Compare commits

...

23 Commits

Author SHA1 Message Date
Michael B
29c3488c14 fix(fonts): fix PP Formula vertical metrics via Transfonter
Rebuilt all PP Formula woff2 files with "Fix vertical metrics" enabled to
normalize OS/2 ascender/descender values across browsers. Removes the need
for the commented-out CSS ascent-override workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 13:04:15 -04:00
Rizumu Ayaka
4972e4d42f fix(form-dropdown): stop cancelling native scroll on slow trackpad wheels (#12334)
## Summary

Follow-up to #12052. The local wheel guard added in FormDropdownMenu was
cancelling native scroll on slow macOS trackpad gestures, leaving image
rows blank in the asset picker until a fast scroll re-pumped samples
through.

## Changes

- **What**: `FormDropdownMenu.onWheel` now only `preventDefault`s on
pinch-zoom (`ctrl/meta + wheel`). The `|deltaX| > |deltaY|` branch of
`isCanvasGestureWheel` is no longer used here.
- **Why**: Slow vertical trackpad scrolls emit small-delta frames with
stray horizontal jitter (e.g. `deltaY=1.2`, `deltaX=1.5`) that satisfied
the horizontal-dominant predicate. Cancelling those frames starved
`VirtualGrid`'s throttled `useScroll`, so `Math.floor(scrollY /
itemHeight)` never advanced and tiles never mounted.
- Horizontal-swipe-to-navigate is still blocked at the page boundary by
`overscroll-behavior: none` on `html, body` (the actual fix from
#12052), so dropping the local horizontal guard does not reintroduce the
back/forward bug.
- The global `isCanvasGestureWheel` is unchanged —
`useCanvasInteractions` still uses it for the canvas wheel handler,
where the semantics are different (forward to canvas pan, not
preventDefault native scroll).

## Review Focus

- Confirm `overscroll-behavior: none` on `html, body` is sufficient on
its own for the horizontal-swipe-to-navigate case inside the teleported
dropdown. (It is — overscroll-behavior is decided per scroll container
at gesture start, independent of where in the page the gesture
originates.)
- Pinch-zoom in the dropdown still preventDefaults correctly.

Tested on macOS trackpad: slow vertical scroll now loads images
progressively; fast scroll unchanged; pinch-zoom in dropdown does not
zoom the page; horizontal swipe in dropdown does not navigate.

Related: this fixes the slow-scroll repro reported in #bug-dump. The
blank-on-reopen class of bug from FE-535 (#11885) is a different issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12334-fix-form-dropdown-stop-cancelling-native-scroll-on-slow-trackpad-wheels-3656d73d3650813a987ffa8d6f0eaa9c)
by [Unito](https://www.unito.io)
2026-07-13 16:08:29 +00:00
Terry Jia
8d8ae7dfc0 feat: share one WebGL context across all 3D views (#13547)
## Summary
Browsers cap live WebGL contexts per page, so each Load3D node owning
its own renderer broke once enough 3D nodes were added. All 3D views now
share a single offscreen WebGLRenderer: each view renders into the
bottom-left region of the shared drawing buffer and blits the frame into
its own plain 2D canvas, which has no context limit.

- Add sharedWebGLRenderer (refcounted singleton, grow-only buffer) and
RendererView (per-view canvas + per-view renderer state + blit)
- Per-view renderer state (tone mapping, color space, clear color) is
applied before each view renders; HDRIManager and SceneModelManager
write to it instead of the renderer
- OrbitControls/TransformControls bind to the node container explicitly
- ViewHelperManager renders the axes helper itself so it can position
and scale independently of renderer DOM layout
- RecordingManager records from the view canvas; capture renders at
exact size on the shared buffer as before
- On last view release the context is force-lost and webglcontextlost is
dispatched synchronously so context-loss handlers still run

## Screenshots (if applicable)
before
<img width="2155" height="1527" alt="image"
src="https://github.com/user-attachments/assets/35a7ab8a-28dd-41ba-8349-525bd2e97977"
/>

after
<img width="918" height="430" alt="屏幕截图 2026-07-09 135025"
src="https://github.com/user-attachments/assets/67328577-bd82-4494-921a-6d298442d081"
/>
<img width="1636" height="1509" alt="image"
src="https://github.com/user-attachments/assets/a3be9215-7cd0-427c-8264-1fd964d3ace1"
/>
2026-07-13 09:50:30 -04:00
Pablo
38c034e1d6 feat(load3d): prototype top-bar chrome for embedded 3D viewer (#13205)
## Summary

Prototype redesign of the embedded 3D viewer controls into a framed
top/bottom-bar chrome. Proof-of-concept for design exploration — not
intended to merge as-is.

## Changes

- **What**: Replaces the floating viewer controls (`Load3DControls`)
with a new `Load3DMenuBar` framed chrome: a black top bar with a
category dropdown (Scene / 3D Model / Camera / Lighting) and the active
category's actions (labels collapse to icons on narrow nodes), plus a
black bottom bar with Record (left) and fit + export (right). Export is
moved out of the menu into the bottom-right button. Adds a "Clay"
material mode that renders meshes with a flat grey material so geometry
is visible without textures.
- **Breaking**: None — `Load3D.vue` swaps the component and hides its
existing right-side button column behind `v-if="false"`; nothing is
deleted.

## Review Focus

- Design prototype: `Load3D.vue` swaps `Load3DControls` →
`Load3DMenuBar` and hides the existing fit/center/expand/record column.
The original components are left untouched.
- The only changes outside the prototype component are for the **Clay**
material mode, which touches shared engine code: `MaterialMode` type
(`interfaces.ts`), the runtime material switch (`SceneModelManager.ts`),
the capability lists (`MeshModelAdapter.ts`, `ModelAdapter.ts`,
`useLoad3d.ts`), and the i18n label.
- Known prototype gaps: **Record** is a visual toggle only (pulsing dot,
not wired to real recording); **fit** reuses the existing
`handleFitToViewer`; **export** reuses the existing format options.

## Screenshots (if applicable)

Prototype walkthrough captured during development (top-bar chrome,
category dropdown, labeled-vs-icon collapse, Clay material, pulsing
Record). Available on request.

---------

Co-authored-by: PabloWiedemann <PabloWiedemann@users.noreply.github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-12 23:09:54 -04:00
Comfy Org PR Bot
b40fad0e75 1.48.2 (#13596)
Patch version increment to 1.48.2

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-11 03:49:03 +00:00
Maanil Verma
01cbfa6a23 feat(templates): replace template search with MiniSearch and usage ranking (#13386)
## Summary

The template picker's search now surfaces the right template for how
people actually type — abbreviations, typos, multi-word intent, and
non-Latin (CJK) titles — and orders results by real popularity instead
of a fuzzy-match score that was being thrown away. Search and ranking
now behave the same here as they do on the workflow hub.

## Changes

**What**

- Searching for the way people phrase things now works: `t2v`, `i2v`,
`cn` expand to their full modality terms, `img2img`/`v2v` expand to
editing (matching how the catalog tags image/video edit templates),
`flux upscale` and `sdxl lora` match across title/model/tag fields
together, prefixes like `vid` match `video`, and typos like `contorlnet`
still find ControlNet. Versioned names tokenize sensibly, so `wan 2.2`
and `wan2.2` both hit, while `2.5` never blurs into `3.5`.
- CJK titles are searchable. Unspaced Han/Hiragana/Katakana runs are
tokenized into character unigrams and bigrams, so a substring a user
types (`放大` inside `图像放大`, or the single trailing `大`) lands on a match.
Korean and other spaced scripts fall to the normal word tokenizer,
unchanged.
- Fuzzy matching is tighter: a term now tolerates edits up to 20% of its
length (down from a flat threshold), so `contorlnet` still finds
ControlNet but `upscale` no longer fuzzy-matches the shorter, unrelated
`scale`. Short (≤3-char) and digit-bearing terms stay exact.
- Results lead with text relevance. Previously the fuzzy match score was
computed and then discarded, and any active sort re-ordered results by
usage — so the best textual match rarely landed on top. Now relevance is
the authoritative order while a query is active, and when two results
match about equally well, the more-used template wins the tie (dampened
so one runaway-popular template can't dominate).
- The ranking is a stable total order. Scores are bucketed before usage
breaks ties, so a cluster of near-equally-relevant results always sorts
the same way — a naive per-pair "within X%" comparison is intransitive
and makes the order depend on internal input order (it can even shuffle
as you type another character).
- "Popular" ranks by raw usage, matching what the hub and the search
index show. It previously blended in a freshness term that pushed newer,
less-used templates above genuinely popular ones.
- The sort dropdown works during search again: it defaults to
"Relevance" but you can switch to Popular/Newest/etc. to re-order the
results, and your browse sort is restored (and never overwritten by a
search-time choice) when you clear the query.
- Alphabetical sort reads correctly: it sorts by the title shown on the
card, trims stray leading whitespace that used to jump templates to the
top, and groups number-prefixed titles after the letters instead of
ahead of them.
- Filter telemetry now reports the sort the user is actually seeing
(relevance while searching) rather than the persisted browse sort, so
analytics reflect the visible ordering.
- Removed the old runtime Fuse-options override path, which is obsolete
under the new engine.

**Breaking**

None. Existing filters (Model / Use Case / Runs On / distribution),
pagination, and persisted sort settings are unchanged; the relevance
mode is search-only and never persisted.

## Review Focus

- The ranking crux is `rankByRelevanceThenUsage` in
`templateSearchConfig.ts`: relevance is primary, usage only re-orders
results in the same score bucket, and bucketing keeps it a stable total
order. That's the one function to review for correctness.
- The CJK tokenizer (`cjkGrams` / `tokenize` in
`templateSearchConfig.ts`): script-matched so only unspaced scripts are
grammed, and a pure-CJK run relies on its grams (no whole-word token).
Splitting by code point is safe here (these scripts are BMP-only; emoji
are excluded by the run regex).
- Deliberately not touched: the "Recommended" sort keeps its curated
blend (usage + editorial rank + freshness) so it stays distinct from
"Popular"; `vram-low-to-high` remains unimplemented exactly as on main.

## Tradeoffs / notes

- Adds `minisearch` (~18 kB gzip). The template selector is where it's
used; accepted for the search-quality gain (a later change could
lazy-load it if bundle size becomes a concern).
- Bucketing means two results just across a bucket boundary don't
tie-break on usage even when their scores are close — the accepted cost
of a transitive, predictable order (this mirrors how the search index
quantizes relevance).
- `img2img` expands to editing (not literal "image to image") because
the catalog labels those templates "Image Edit" — verified against the
real data.
- CJK bigrams roughly double the token count for a pure-CJK title;
negligible at catalog scale (~550 templates, short titles).

## Testing

Behavioral coverage over the real search paths, not the mocks — the
ranking and tokenizer run against actual MiniSearch output; only the
ranking-store math is mocked. Also verified against the full
~550-template catalog end to end (all query types stable, zero
input-order-dependent orderings).

### Behavior matrix (verified on the real catalog)

| Input / action | Now | Previously |
| --- | --- | --- |
| `img2img` | Image-editing templates (Qwen Image Edit, …) | Matched
every "image" template — intent lost |
| `flux upscale` | Flux upscale templates (matches both terms across
fields) | **No results** (single-field fuzzy couldn't span title + tag)
|
| `sdxl lora` | SDXL templates | **No results** |
| `t2v` / `i2v` / `cn` | Expand to text→video / image→video / controlnet
| Only partial slug hits, if any |
| `vid` (prefix) | Matches `video` templates | Unreliable |
| `contorlnet` (typo) | Finds ControlNet | Often dropped by the strict
threshold |
| `upscale` | Matches upscale titles only | Fuzzy-matched the unrelated
substring `scale` |
| `放大` / `大` (CJK) | Matches `图像放大` and other titles containing the run
| No match — CJK titles were unsearchable by substring |
| `wan 2.2` and `wan2.2` | Both match; `2.5` never matches `3.5` | Space
vs no-space degraded the match |
| Near-tied cluster (e.g. `upscale`) | Stable order every time |
Reordered depending on input order (could shuffle as you type) |
| Query active, "Popular" selected | Best textual match still leads;
usage breaks near-ties | Sort re-ordered by usage, burying the best
match |
| Change sort while searching | Re-orders the search results; relevance
is the default | Sort was locked; dropdown had no effect |
| Clear the search | Restores the browse sort you had before | — |
| "Popular" sort | Orders by raw usage (matches hub / index) | Freshness
blend pushed newer low-usage templates up |
| A–Z sort | Letters first (`ACE…`), number-prefixed titles last
(`3x3…`, `360…`); leading whitespace ignored | Leading-space titles
jumped to the top; numbers sorted before letters |

### Unit tests (81 total, all passing)

- `templateSearchConfig.test.ts` (34) — tokenizer identifier/version
splits, CJK unigram/bigram gramming (and Korean left as a spaced word),
per-term fuzziness (`upscale` ≠ `scale`), abbreviation expansion (incl.
`img2img`→edit intent), prefix + typo matching, AND-then-OR,
literal-before-expansion ordering, relevance>tag>description ranking,
and `rankByRelevanceThenUsage` giving a stable order on an intransitive
cluster.
- `useTemplateFiltering.test.ts` (35) — the `img2img` / `flux upscale` /
`sdxl lora` regressions, relevance-default-on-search,
override-sort-while-searching, browse-sort restore on clear, ephemeral
mid-search sort, telemetry reporting the visible sort, Runs-On filter,
empty-result guard, filters preserving relevance order, and alphabetical
trimming + numbers-after-letters.
- `templateRankingStore.test.ts` (12) — freshness and default-score
(recommended) math.

Gate: `pnpm typecheck`, `pnpm lint`, `pnpm knip` clean.

## Screen Recording (if applicable)



https://github.com/user-attachments/assets/6748a3f7-e69d-44ac-826c-71990c8dce90
2026-07-11 01:16:40 +00:00
Christian Byrne
945a143626 fix(ci): harden pr-backport — independent per-target attempts + comment on conflict failures (#13412)
## Symptom

When the auto-backport workflow (`.github/workflows/pr-backport.yaml`)
hits a cherry-pick **conflict**, it is supposed to comment on the
original PR telling the author to backport manually. On PR #13359
(backport to `cloud/1.45`, cherry-pick of `d6c582c39` conflicting on
`useSubscriptionDialog.test.ts`) **no comment was posted** — the author
(@huntcsg) got no notification and had to find the failure by digging
into Actions logs. See run
[28616756256](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/28616756256/job/84862400420).

## Root cause

The "Comment on failures" step actually ran and reached the `conflicts`
branch — the failure reason *was* populated and the `if:` condition
*was* met. The real failure is at the last line of the loop, under the
step's default `bash -e` shell:

```yaml
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
```

The job log ends with:

```
GraphQL: Unable to create comment because issue is locked (addComment)
##[error]Process completed with exit code 1.
```

PR #13359 is `locked: true`, so `gh pr comment` returns non-zero.
Because the call was unguarded under `set -e`, the step aborted on the
spot: the comment was lost and — critically for the general case — any
remaining failed targets in the loop would also be skipped. The step is
then marked failed with no actionable output on the PR.

(Related PR #13167 "attempt each backport target branch independently"
is still open/unmerged; this is a residual gap in the failure-comment
path.)

## Fix

Wrap every `gh pr comment` call in a `post_comment` helper. On failure
it emits a `::warning::` naming the target, the reason, and the
manual-backport branch (`backport-<pr>-to-<target>`) instead of
aborting, so the loop always attempts a comment for each failed target
and surfaces a clear log message when GitHub refuses (e.g. locked
issue). The conflict comment body now also states the manual backport
branch explicitly.

- YAML: `python3 -c 'import yaml; yaml.safe_load(...)'` passes.
- `actionlint`: no new warnings in the changed step (the 2 pre-existing
`SC2016` notes on the intentional single-quoted `envsubst` var lists are
unchanged).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:09:31 -07:00
pythongosssss
193bbaba81 fix: dont remove unowned callbacks when cleaning hooks on unmount (#12380)
## Summary

Minimaps unmounted cleanup blindly restored the callbacks that are
originally captured, even if other systems have chained their own
callbacks onto this, breaking other parts of the system (e.g. vue node
graph manager).
Recreation:    

1. Ensure minimap open
2. Enter subgraph 
3. Exit subgraph
4. Close minimap
5. Try adding a node/unpackign subgraph/etc <--- broken

## Changes

- **What**: 
- only replace callbacks that we own
- else function becomes no-op

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12380-fix-dont-remove-unowned-callbacks-when-cleaning-hooks-on-unmount-3666d73d3650817cbfe0d98ab98528b8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-07-10 20:24:20 +00:00
Alexis Rolland
1eacb224a1 Update commit author login retrieval in CLA workflow (#13544)
## Summary

Make CLA more robust by including commit authors in the allowlist even
if they have no GitHub account. This to ensure only PR authors are
required to sign.

## Changes

- **What**: `cla.yaml`
2026-07-10 20:16:51 +00:00
Terry Jia
4ed2fe70f3 feat: accept bboxes input and add grid snapping to Create Bounding Boxes (#13376)
## Summary
- Seed/override the canvas from an upstream bboxes input
- Add dotted grid background and magnetic snap-to-grid controls
- Darken the canvas well for contrast

BE is https://github.com/Comfy-Org/ComfyUI/pull/14724

## Screenshots (if applicable)



https://github.com/user-attachments/assets/7282f4d5-7cac-46f0-9d73-d0add37f4eb9
2026-07-10 15:40:17 +00:00
Terry Jia
5da5ee5031 feat: wire up Save 3D (Advanced) node family (CORE-329) (#13330)
## Summary
Register the save-side advanced nodes in the Load3D viewer
infrastructure: Save3DAdvanced reuses the mesh advanced extension, while
SaveGaussianSplat and SavePointCloud reuse the splat/point cloud preview
extensions.

Parameterize both extension factories with a loadFolder so save nodes
load the persisted file from the output folder instead of temp, and add
the node types to the lazy-load and viewport-state sets.

BE change https://github.com/Comfy-Org/ComfyUI/pull/14701
## Screenshots (if applicable)
Save 3D (Advanced)
<img width="1328" height="939" alt="image"
src="https://github.com/user-attachments/assets/c5f3cbe0-6e57-463c-9128-67490c2fc89e"
/>

Save Splat
<img width="1296" height="1052" alt="image"
src="https://github.com/user-attachments/assets/f05bbcf8-9794-4861-9dd7-3015d38b11d9"
/>
2026-07-10 15:39:51 +00:00
Maanil Verma
ceb5ae1eba fix(cloud): keep survey footer visible on small screens (#13568)
## Summary

Keep the onboarding survey's Back/Submit footer visible on small screens
by scrolling only the question area instead of the whole survey.

## Changes

- **What**: The survey scrolled as a single block , so on short
viewports the button row slid under the template footer (Terms/Privacy).
Now the outer is bounded to its slot and the question wrapper in
`DynamicSurveyForm` scrolls internally with a responsive cap , so option
lists scroll while the footer buttons stay pinned. The step height
animation is unchanged.

## Screen Recording

https://github.com/user-attachments/assets/6d7f6d50-59b8-4dc0-b20e-8f4ca08167c6
2026-07-10 08:30:50 +05:30
Comfy Org PR Bot
9f880c78cb 1.48.1 (#13559)
Patch version increment to 1.48.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 02:16:50 +00:00
Benjamin Lu
3b2eb50f3b feat(cloud): redeem desktop login codes for web-to-desktop identity stitching (GTM-93) (#13418)
## What

Browser half of GTM-93 macOS web→desktop identity stitching: when the
desktop app opens the system browser at cloud login with
`?desktop_login_code=dlc_…`, the frontend redeems that code against the
cloud backend once a Firebase session exists — after **explicit user
approval**.

Reworked on top of the preserved-query `stripAfterCapture` capability
(#13465):

- The `DESKTOP_LOGIN` namespace opts into strip-on-capture: the code is
stashed and removed from the URL **before any navigation completes**, so
it never reaches history, `previousFullPath`, later guards, or telemetry
— the hand-rolled URL scrubbing this PR previously carried (raw-string
parser + three strip sites) is gone.
- `desktopLoginRedemption.ts` is a plain module with a single export,
`installDesktopLoginRedemption(router)`, installed once in `router.ts`'s
cloud block (replaces six per-view/composable trigger sites). Redemption
reads the code only from the stash: per-code state (approval + 2-attempt
transient budget, so a second code gets its own approval and budget),
approval dialog, `POST /api/auth/desktop-login-codes/redeem` with the
raw Firebase ID token (backend route is Firebase-JWT-only), 10s fetch
timeout.
- Triggers: `router.afterEach` (the cloud auth guard settles Firebase
init before navigations complete) plus a lazy watcher on
`authStore.currentUser` for sessions that appear without a navigation
(OAuth-resume error branch, dialog sign-in). One bounded in-page retry
(5s) guarantees an approved sign-in always ends in a success or failure
toast.
- Terminal rejections (400/403/404/409/410) drop the code with an error
toast; transient failures (401/5xx/timeout/network) retry once; budget
exhaustion now surfaces a failure toast instead of dying silently.

## Why

Windows stitches web→desktop at download time via installer stamping;
macOS DMGs can't be stamped, so we stitch at login. The browser is where
both halves meet: the existing `posthog.identify(uid)` merges the web
anon person into the Firebase uid, and the backend emits
`comfy.cloud.identity.login_attributed` (uid ↔ installation_id) at
redeem. The desktop app polls the backend and receives a one-time custom
token — no auth material posted to a desktop loopback server (the
concern that stalled #12983, which this supersedes).

## Security

- **Approval dialog before redeem** — redemption mints the desktop a
sign-in token for *your* account, so a lured click must not be enough
(device-code phishing mitigation). Cancel clears the stash and does
nothing.
- Only the opaque single-use code ever appears in a URL; the tracker
strips it on first sight, pre-navigation, and it is never logged.

## Testing

Vitest, driven through a real router (createRouter/createMemoryHistory,
no vue-router mocks) and the real preserved-query manager: capture/stash
lifecycle, approval gate (no fetch before approve; decline/dismiss
clears; per-code approval), Bearer/body shape, terminal-vs-transient
statuses, timeout abort, bounded in-page retry + failure toast on budget
exhaustion, per-code regressions (second code after
success/decline/exhaustion redeems independently), auth-watcher trigger
(session appearing without navigation), unauthenticated no-op, trigger
coalescing. Typecheck/lint/format clean.

Types are hand-written with a `TODO(@comfyorg/ingest-types)` — the
generated types land automatically once the cloud PR merges and the
type-gen workflow runs.

## Landing order

1. Cloud backend: https://github.com/Comfy-Org/cloud/pull/4736 (until it
ships, redemption never triggers — this PR is inert)
2. #13465 preserved-query strip-on-capture (base of this PR)
3. #13466 global-prompt FIFO queue (runtime dependency: the approval
confirm must settle even if another prompt is open)
4. **This PR**
5. Desktop (activates the flow):
https://github.com/Comfy-Org/Comfy-Desktop/pull/1222

GTM-93 · Supersedes #12983

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-10 02:11:00 +00:00
Matt Miller
2ef341dcd8 test: E2E for BYOK secret add / list / delete flow (#13510)
## ELI-5

The settings screen now has a "Secrets" panel where you can save API
keys for
model/AI providers. This adds an end-to-end test that plays out the
whole story
like a real user: open the panel, add a key, watch it show up in the
list, then
delete it. It also checks the security promise — the key you type is
sent to the
server but is **never** shown back to you afterward — and that an
account without
access to the gated providers never even sees them in the dropdown.

## What

Adds `browser_tests/tests/cloudSecrets.spec.ts`, a Playwright spec
covering the
secrets (API keys) surface in the cloud app:

- **Entitled account, full CRUD round-trip:** empty state -> add a
provider key
(pick provider, name, secret value, save) -> the key appears in the list
->
  delete it via the confirm dialog -> back to empty state.
- **Secret value is write-only:** asserts the create request carried the
plaintext
value, but the value is never echoed back into the DOM (the
list-response schema
  is metadata-only).
- **Entitlement gate:** an account whose provider allowlist is empty
never sees
  the gated providers anywhere in the add dialog.

Follows the existing cloud E2E conventions: drives a raw `page` and
reuses the
`mockCloudBoot` / `bootCloud` helpers so the app boots signed-in against
fully
mocked endpoints. A small stateful in-memory handler backs the secrets
endpoints
(list / create / delete + the provider allowlist) so the flow is
deterministic
and never touches a real backend.

## Why

Verification capstone for the secrets settings surface — proves the add
/ list /
delete flow works against the documented API behavior
(`GET`/`POST`/`DELETE` on
the secrets collection, `GET` on the provider allowlist) and locks in
the two
contracts that matter: the secret value is never returned after
creation, and the
provider allowlist is the only thing that surfaces gated providers to
the user.

## Tests

- `browser_tests/tests/cloudSecrets.spec.ts` — new, two cases (tagged
`@cloud`).
- Static checks pass locally: oxlint (0 warnings/errors) and oxfmt
formatting.
- The browser run itself needs a served app + the E2E harness (CI), so
it was not
executed in this environment; the spec is self-contained and mocks all
network.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:20:03 +00:00
Mobeen Abdullah
1815c7f7a4 feat(website): add JSON-LD structured data across the site (#13480)
## Summary

This PR adds schema.org **JSON-LD structured data across the whole
marketing site**, built from one shared, CMS-ready module and gated by a
small CI validator. It replaces the old global block (which had a stale
logo, wrong social links, disconnected nodes, and a head slot that
rendered three times) with a single connected `@graph` on every page.
Structured data only — there is no visual or runtime change for users.
Tracks Linear **FE-1170**.

The design goal was that structured data should be impossible to get
subtly wrong: one place builds it, honesty rules are enforced in code,
and a build-time validator fails the build if any page ships a broken
`@id` graph or a fabricated price/rating.

## Changes

- **One builder, one sink.** `utils/jsonLd.ts` holds pure, node-testable
builders; `components/common/JsonLdGraph.astro` is the single escaped
`<script type="application/ld+json">` sink (prevents `</script>`
breakout XSS).
- **The layout owns the page entity.** `BaseLayout` emits a baseline
`Organization` + `WebSite` + `WebPage` graph on every page from its own
`title`/`description`/canonical props. Enriched pages pass only what is
specific to them: `pageType`, `breadcrumbs`, `mainEntityId`, and
`extraJsonLd` nodes. This makes it impossible for a page's meta tags and
its structured data to drift apart.
- **Corrected site-wide entity.** Raster PNG logo (Google does not index
SVG logos), real `sameAs` handles sourced from the footer links,
`@id`-linked `Organization`/`WebSite`, and the triple-rendered head slot
fixed.
- **Honesty is enforced, not just intended.** No fabricated
`Review`/`AggregateRating`/`Offer`. Pricing offers are parsed only from
plain `$N` copy (a future "Contact us" drops the offer instead of
shipping a garbage price). Third-party node packs and listed models do
**not** claim Comfy Org as author/publisher. `noindex` pages (404,
payment) emit no structured data.
- **CI validator.** `pnpm --filter @comfyorg/website validate:jsonld`
runs over `dist/` in the website build workflow and fails on invalid
JSON, an unresolved `@id`, a fake rating, or an empty/non-numeric offer
price.
- **Breaking:** none.

## Coverage (also a manual QA checklist for the preview)

Every public page carries at least `Organization` + `WebSite` +
`WebPage`. The pages below add a page-specific primary entity, in
**English and zh-CN**:

| Page | Path (example) | Adds to the graph |
|---|---|---|
| Home | `/`, `/zh-CN` | `SoftwareApplication` (ComfyUI, free) +
`SoftwareSourceCode` |
| Download | `/download` | `SoftwareApplication` (ComfyUI desktop) |
| Pricing | `/cloud/pricing` | `Product` + 3 monthly `Offer`s
($20/$35/$100) + Breadcrumb |
| Models catalog | `/p/supported-models` | `CollectionPage` + `ItemList`
(313, lean) + Breadcrumb |
| Model detail | `/p/supported-models/4x-ultrasharp` |
`SoftwareApplication` + `FAQPage` + Breadcrumb |
| Nodes catalog | `/cloud/supported-nodes` | `CollectionPage` +
`ItemList` (58 packs) + Breadcrumb |
| Node-pack detail | `/cloud/supported-nodes/ComfyQR` |
`SoftwareApplication` (+ free `Offer`) + Breadcrumb |
| Demos | `/demos/community-workflows` | `LearningResource` + Breadcrumb
|
| About / Contact | `/about`, `/contact` | `AboutPage` / `ContactPage`
(Org as `mainEntity`) + Breadcrumb |
| Careers | `/careers` | `CollectionPage` + `ItemList` of open roles +
Breadcrumb |
| Affiliates | `/affiliates` | `FAQPage` + Breadcrumb |

Pages deliberately left at the baseline `WebPage` (generic landings,
legal, coming-soon) and pages with **no** structured data (`noindex`:
`/404`, `/payment/*`; redirect URLs) are intentional.

## Review Focus

- **Layout-owns-WebPage design.** `BaseLayout` builds the `WebPage`;
pages contribute only extra nodes. This is the main structural decision
and is what removes meta-vs-schema drift by construction.
- **Honesty guardrails.** Worth confirming: pricing offers, third-party
author omission on packs/models, and that `noindex` pages emit nothing.
- **`@id` and URL consistency.** All cross-page links and `@id`s resolve
to the canonical trailing-slash form; zh-CN breadcrumbs are rooted under
`/zh-CN`; the singleton `WebSite`/`#software` entities carry one
consistent definition across pages and locales.
- **The validator.** It is a bespoke ~100-line script scoped to the
website build job (not the prod deploy). Happy to make it non-blocking
or drop it if the team prefers.
- **Coordination with #13468.** Both branches add
`components/common/JsonLdGraph.astro`. Customer pages are intentionally
excluded here; #13468 can converge onto this shared builder.

## Verification

`astro check` 0 errors · 166 unit tests · `knip` 0 · `eslint` 0 · build
497 pages · validator passes across 500 pages · JSON-LD e2e specs 24/24.
(The 3 pre-existing demo e2e timeouts are an external Arcade-embed flake
on one slug, reproduced identically on `main`.)

## Screenshots

Not applicable — head-only structured data, no visual change. Validate
on the Vercel preview with the Rich Results Test and the schema.org
validator. Note: `@id`/URL values render as `comfy.org` (from
`astro.config` `site`) even on the preview host, which is correct.
2026-07-09 21:03:43 +00:00
imick-io
287b9eb980 feat(website): add HeroBackdrop01 block component (#13549)
## Summary

Adds the `HeroBackdrop01` hero block component to the website app. This
lands the component on its own so it can be merged to `main` ahead of
the EDU page work (on another branch) that consumes it.

The component renders a responsive hero with an optional image/video
backdrop (in-flow rounded card on mobile, full-bleed background on
desktop), an optional product badge, title, subtitle, and footnote. It
respects `prefers-reduced-motion` by not autoplaying the looping
backdrop video (WCAG 2.2.2).

## Notes

- No consumer imports the component yet — it is intentionally added
ahead of the page that will use it. The `knip` unused-file check flags
this, which is expected for this staging PR.
- Depends on existing `useReducedMotion` composable and
`ProductHeroBadge` component, both already present on `main`.

## Test plan

- [ ] `pnpm typecheck:website` passes (verified locally via pre-commit
hook)
- [ ] Component renders correctly once wired into a page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:43:49 +00:00
Matt Miller
06b0471257 test: pin unknown-provider flow-through in server-driven provider options (#13546)
## ELI-5

The provider picker for BYOK secrets is now driven by the server's
`availableProviders` list, not a hardcoded frontend list. The point is
that a provider the server offers but the frontend has never heard of
should still show up — using its raw id as the label and no logo. This
adds the one test that actually pins that behavior, so nobody can later
re-add a "only show providers the frontend knows about" filter without a
test going red.

## Summary

Regression test for the server-driven provider options introduced in
#13509: asserts that an unknown provider id passes through
`providerOptions` rather than being filtered against the local
presentational registry.

## Changes

- **What**: Adds one `useSecretForm.test.ts` case asserting that a
create-mode `availableProviders` list containing an id absent from the
local `SECRET_PROVIDERS` registry (`'brand-new-provider'`) yields a
single `providerOptions` entry rendered with the raw id as its label and
`logo: undefined`.

## Review Focus

The existing suite already covers the registry fallback
(`providers.test.ts`) and server-listed *known* ids (`runway`/`gemini`),
but every one of those cases uses an id that exists in the local
registry — so a future change that re-filtered `availableProviders`
against `SECRET_PROVIDERS` could pass all current tests while silently
dropping unknown providers. This test closes that gap by using an id
that is deliberately absent from the registry, so it fails if
pass-through is ever broken. Follow-up to the optional review ask on
#13509. Test-only; no production code changes.
2026-07-09 19:20:56 +00:00
Maanil Verma
8120142f49 feat(cloud): redesign the cloud onboarding survey (#13518)
## Summary

New users get a cleaner, faster onboarding survey: one question per
screen on tappable cards that advance the moment you pick an answer,
with follow-up questions that appear only when they're relevant. The
questions themselves were reworked to learn what people actually want to
do with ComfyUI.

## Changes

**What**

- Replaced the radio/checkbox list with a card-based,
one-question-at-a-time wizard. Choosing a single-select answer advances
automatically — no separate Next click — while multi-select and
free-text steps still wait for you to confirm.
- Reworked the question set: what you want to make, how well you know
ComfyUI, and how you found us. Two questions are now conditional — a
"what are you building?" follow-up appears only for workflow/API
builders, and a "which platform?" follow-up appears only when you say
you found us on social media.
- Added an "other" free-text escape hatch to the intent and source
questions, required before you can move on so we don't capture an empty
"other".
- Polished the whole surface to the comfy-canvas theme with animated
step-height and cross-fade transitions between questions, and hid the
marketing hero on the survey and user-check routes so the form has room.
- Errors now surface only after you've interacted with a field, not on
first paint.
- Extended the telemetry survey-response and remote-config option shapes
to carry the new fields (including per-option icons), leaving the older
fields in place so historical responses still typecheck.

**Breaking** — None. The remote-config survey schema stays
backend-overridable, hidden branch answers are zeroed in the submitted
payload, and the telemetry field names line up 1:1 with the schema. The
backend dynamic config already ships the matching version-3 schema.


## Testing

Behavioral coverage over the wizard's real interactions rather than DOM
structure, since the risk is in navigation/branching/validation, not
markup. `vue-i18n` is mounted for real with the actual locale file so
tests assert on rendered copy.

-
[DynamicSurveyForm.test.ts](src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts)
— auto-advance on single-select, no-advance on multi/other, Back
navigation, branch reveal/hide and its submitted payload,
required-"other" gating, post-interaction error surfacing, and
survey-prop reset.
-
[DynamicSurveyField.test.ts](src/platform/cloud/onboarding/survey/DynamicSurveyField.test.ts)
— card rendering/selection state, stable option ids, multi-select emit,
the conditional "other" input, and label resolution via key / locale map
/ id fallback.
-
[surveySchema.test.ts](src/platform/cloud/onboarding/survey/surveySchema.test.ts)
— default-schema branching (which steps show), hidden-field zeroing and
free-text-over-sentinel in the payload, and the shared
`hasNonEmptyValue` truth table.

Gates: `vue-tsc` typecheck clean; eslint/oxfmt clean on touched files;
survey suite green (68 tests across the three files). Manual: run the
cloud onboarding flow, pick a workflow/apps intent to confirm the
"building" follow-up, pick social to confirm the platform follow-up, and
verify an empty "other" blocks advancing.

## Screen Recording



https://github.com/user-attachments/assets/1908ca18-93d1-41a2-a55b-1f04a6df2268
2026-07-09 18:31:52 +00:00
AustinMroz
3164e6ab61 On workflow swap, restore 'Preview as Text' text (#13536)
Also adds proper typing for `onNodeOutputsUpdated`

See also: #12877 and #13427, which include near equivalent changes for
the bug itself, but different tests. If I had more time and had not
already made my own fix, I would have liked to spend more time getting
either of them cleaned up.
2026-07-09 17:03:19 +00:00
pythongosssss
731512c655 test: remove timeout causing flake (#13543)
## Summary

The timeout resolved 1 second after the test completed sometimes
throwing:
```
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
ReferenceError: window is not defined
 ❯ resolveMessageFormat node_modules/.pnpm/@intlify+core-base@9.14.5/node_modules/@intlify/core-base/dist/core-base.mjs:1357:13
 ❯ translate node_modules/.pnpm/@intlify+core-base@9.14.5/node_modules/@intlify/core-base/dist/core-base.mjs:1216:11
 ❯ node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:581:48
 ❯ wrapWithDeps node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:526:19
 ❯ t node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:581:16
 ❯ onNodePackChange src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue:201:10
    199|   // Add Latest option with actual version number
    200|   const latestLabel = latestVersionNumber
    201|     ? `${t('manager.latestVersion')} (${latestVersionNumber})`
       |          ^

    202|     : t('manager.latestVersion')
    203|

This error originated in "src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
```

The timeout was not required for the test to validate the loading text.

## Changes

- **What**: Remove timeout
2026-07-09 16:27:28 +00:00
imick-io
c0ad1e98c2 test(website): add e2e specs for the learning page (#13529)
## Summary

Add end-to-end test coverage for the `/learning` page, which previously
had none.

## Changes

- **What**: New `e2e/learning.spec.ts` covering the EN page smoke
behaviour (hero, featured workflow, tutorial grid rendered from the
`learningTutorials` data source, per-tutorial Try Workflow links, and
the contact-sales CTA), the tutorial video dialog open/close/Escape
interactions, and the zh-CN localized page.

## Review Focus

Assertions are driven off the `learningTutorials` data source and `t()`
i18n keys rather than hardcoded strings to avoid change-detector tests.
Media is stubbed by the auto-applied `blockExternalMedia` fixture, so
the specs have no network dependency. The tutorial-dialog interaction
uses a `toPass` retry to accommodate `client:visible` hydration.
2026-07-09 16:23:07 +00:00
Comfy Org PR Bot
bd9fab2d2f 1.48.0 (#13541)
Minor version increment to 1.48.0

**Base branch:** `main`

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-07-09 07:21:27 +00:00
199 changed files with 14178 additions and 2680 deletions

View File

@@ -40,3 +40,6 @@ jobs:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build
- name: Validate JSON-LD structured data
run: pnpm --filter @comfyorg/website validate:jsonld

View File

@@ -38,9 +38,11 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"

View File

@@ -278,32 +278,49 @@ jobs:
continue
fi
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Create backport branch. A failure here (e.g. dirty state left
# by a prior target) must not abort the loop and skip remaining
# targets, so fall back to a clean checkout and record the error.
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
git checkout main || git checkout -f main
echo "::endgroup::"
continue
fi
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
git push --force-with-lease origin "${BACKPORT_BRANCH}"
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
else
git push origin "${BACKPORT_BRANCH}"
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# A push failure for one target must not abort the loop and
# prevent remaining targets from being attempted.
if "${PUSH_CMD[@]}"; then
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
else
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
fi
# Return to main (keep the branch, we need it for PR)
git checkout main
git checkout main || git checkout -f main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort
git cherry-pick --abort || true
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main
git branch -D "${BACKPORT_BRANCH}"
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
fi
echo "::endgroup::"
@@ -384,6 +401,10 @@ jobs:
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
The auto-backport could not be completed automatically. Please backport
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
open a PR to `${target}`.
<details>
<summary>📄 Conflicting files</summary>
@@ -416,19 +437,37 @@ jobs:
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
# Post a comment without letting a single failed `gh pr comment` (e.g.
# a locked issue, as happened for PR #13359, or a transient API error)
# abort the step under `set -e` and swallow the remaining failures.
post_comment() {
local body="$1"
local context="$2"
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
fi
}
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
if [ "${reason}" = "branch-missing" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
elif [ "${reason}" = "already-exists" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
elif [ "${reason}" = "branch-create-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
elif [ "${reason}" = "push-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
@@ -444,10 +483,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
fi
done

View File

@@ -76,10 +76,14 @@ test.describe('Affiliates landing — desktop interactions', () => {
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
'@type': string
mainEntity?: unknown[]
}[]
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({

View File

@@ -0,0 +1,158 @@
import { expect } from '@playwright/test'
import { learningTutorials } from '../src/data/learningTutorials'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
test.describe('Learning page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learning')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning — Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
await expect(heading).toContainText('ComfyUI')
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
})
test('featured workflow section shows title and author', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.featured.title', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByText(t('learning.featured.author', 'en'))
).toBeVisible()
})
test('renders every tutorial from the data source', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'en'),
level: 2
})
).toBeVisible()
for (const tutorial of learningTutorials) {
await expect(
page.getByRole('button', {
name: tutorialButtonName(tutorial.title.en, 'en')
})
).toBeVisible()
}
})
test('tutorials with a workflow link expose an external Try Workflow link', async ({
page
}) => {
const linkedTutorials = learningTutorials.filter(
(tutorial) => tutorial.href
)
const workflowLinks = page.getByRole('link', {
name: t('cta.tryWorkflow', 'en')
})
const hrefs = await workflowLinks.evaluateAll((links) =>
links.map((link) => link.getAttribute('href'))
)
for (const tutorial of linkedTutorials) {
expect(hrefs).toContain(tutorial.href)
}
})
test('call to action links to contact sales', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.cta.heading', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
).toHaveAttribute('href', '/contact')
})
})
test.describe('Learning tutorial dialog', () => {
test('opens a tutorial video and dismisses via the close button', async ({
page
}) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
// TutorialsSection is hydrated via `client:visible`; retry the click until
// Vue responds by opening the dialog.
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await expect(
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
).toBeVisible()
await dialog
.getByRole('button', { name: t('gallery.detail.close', 'en') })
.click()
await expect(dialog).toBeHidden()
})
test('dismisses the dialog with the Escape key', async ({ page }) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
})
test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 — Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'zh-CN'),
level: 2
})
).toBeVisible()
const [firstTutorial] = learningTutorials
await expect(
page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
})
).toBeVisible()
})
})

View File

@@ -17,7 +17,8 @@
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts"
"generate:models": "tsx ./scripts/generate-models.ts",
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",

View File

@@ -0,0 +1,129 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { collectGraphIds } from '../src/utils/jsonLd'
const DIST_DIR = join(process.cwd(), 'dist')
const JSON_LD_BLOCK =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
interface Violation {
file: string
message: string
}
function htmlFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true })
.map(String)
.filter((entry) => entry.endsWith('.html'))
.map((entry) => join(dir, entry))
}
function typesOf(node: Record<string, unknown>): string[] {
const type = node['@type']
if (typeof type === 'string') return [type]
if (Array.isArray(type)) {
return type.filter((t): t is string => typeof t === 'string')
}
return []
}
function hasValidPrice(node: Record<string, unknown>): boolean {
const price = node.price
const priceStr = price == null ? '' : String(price).trim()
return priceStr !== '' && !Number.isNaN(Number(priceStr))
}
function checkHonesty(
value: unknown,
file: string,
violations: Violation[]
): void {
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (!node || typeof node !== 'object') return
const record = node as Record<string, unknown>
const types = typesOf(record)
if (types.includes('Review') || types.includes('AggregateRating')) {
violations.push({
file,
message: `dishonest node type ${types.join('/')}`
})
}
if ('aggregateRating' in record || 'review' in record) {
violations.push({
file,
message: 'node carries a review/aggregateRating'
})
}
if (
types.includes('Offer') &&
(!hasValidPrice(record) || !record.priceCurrency)
) {
violations.push({
file,
message: 'Offer missing priceCurrency or a concrete price'
})
}
Object.values(record).forEach(walk)
}
walk(value)
}
function validateFile(file: string): Violation[] {
const html = readFileSync(file, 'utf8')
const violations: Violation[] = []
const definedIds = new Set<string>()
const referencedIds: string[] = []
for (const match of html.matchAll(JSON_LD_BLOCK)) {
let parsed: unknown
try {
parsed = JSON.parse(match[1])
} catch (error) {
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
continue
}
checkHonesty(parsed, file, violations)
const { defined, references } = collectGraphIds(parsed)
defined.forEach((id) => definedIds.add(id))
referencedIds.push(...references)
}
for (const id of referencedIds) {
if (!definedIds.has(id)) {
violations.push({ file, message: `unresolved @id reference: ${id}` })
}
}
return violations
}
function main(): void {
const files = htmlFiles(DIST_DIR)
if (files.length === 0) {
console.error(
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
)
process.exit(1)
}
const violations = files.flatMap(validateFile)
if (violations.length > 0) {
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
for (const { file, message } of violations) {
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
}
process.exit(1)
}
process.stdout.write(
`JSON-LD validation passed across ${files.length} page(s).\n`
)
}
main()

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HeroBackdrop01 from './HeroBackdrop01.vue'
const sampleImage =
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
const meta: Meta<typeof HeroBackdrop01> = {
title: 'Website/Blocks/HeroBackdrop01',
component: HeroBackdrop01,
tags: ['autodocs'],
args: {
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
title: 'Build anything\nwith ComfyUI',
subtitle:
'A powerful, modular visual interface for building and running AI workflows.'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithBadge: Story = {
args: {
badgeText: 'New'
}
}
export const WithFootnote: Story = {
args: {
footnote: 'Available on Windows, macOS, and Linux.'
}
}
export const NoBackdrop: Story = {
args: {
backdrop: undefined
}
}

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
type Backdrop =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
backdrop,
mobileBackdrop,
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
subtitle,
footnote,
class: className
} = defineProps<{
backdrop?: Backdrop
mobileBackdrop?: Backdrop
badgeText?: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
subtitle?: string
footnote?: string
class?: HTMLAttributes['class']
}>()
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
// Removing the reactive `autoplay` attribute only suppresses the *initial*
// play; it can't pause a video the browser has already started. That is
// exactly the SSR case: the server renders `autoplay` (it can't read the
// client's motion preference), the browser begins playback on parse, and the
// post-hydration attribute removal is too late. Pause on mount so
// reduced-motion users get the poster frame instead of a looping video.
const pauseIfReduced = (el: unknown) => {
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
}
// On mobile the backdrop is an in-flow rounded card above the content; on
// desktop it is the full-bleed background behind it. A single element serves
// both roles via responsive classes — mobileBackdrop only swaps the source.
const sharedBackdropClass =
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
// When both breakpoints use images, serve them from a single responsive <img>
// so the browser fetches only the source matching the viewport. Two
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
// does not stop the fetch), doubling the high-priority load on an
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
// way and fall back to breakpoint-toggled layers below.
const responsiveImage = computed(() => {
if (backdrop?.type !== 'image') return null
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
const base = mobileBackdrop ?? backdrop
return {
src: base.src,
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
// Larger-viewport source; omitted when one image serves both breakpoints.
desktopSrc: mobileBackdrop ? backdrop.src : undefined
}
})
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
const backdropLayers = computed(() => {
if (!backdrop) return []
if (mobileBackdrop) {
return [
{
backdrop: mobileBackdrop,
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
},
{
backdrop,
class: 'absolute inset-0 hidden size-full object-cover lg:block'
}
]
}
return [{ backdrop, class: sharedBackdropClass }]
})
const scrimShape = 'farthest-side at 50% 50%'
const scrimStyle = {
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
}
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
>
<div class="relative overflow-hidden rounded-3xl">
<slot name="backdrop">
<picture v-if="responsiveImage" class="contents">
<source
v-if="responsiveImage.desktopSrc"
:srcset="responsiveImage.desktopSrc"
media="(min-width: 1024px)"
/>
<img
:src="responsiveImage.src"
:alt="responsiveImage.alt"
fetchpriority="high"
decoding="async"
:class="sharedBackdropClass"
/>
</picture>
<template v-else>
<template v-for="(layer, i) in backdropLayers" :key="i">
<video
v-if="layer.backdrop.type === 'video'"
:ref="pauseIfReduced"
:src="layer.backdrop.src"
:poster="layer.backdrop.poster"
:aria-label="layer.backdrop.alt"
:aria-hidden="layer.backdrop.alt ? undefined : true"
:autoplay="!reduceMotion"
loop
muted
playsinline
preload="metadata"
:class="layer.class"
/>
<img
v-else
:src="layer.backdrop.src"
:alt="layer.backdrop.alt ?? ''"
fetchpriority="high"
decoding="async"
:class="layer.class"
/>
</template>
</template>
</slot>
<div
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
>
<div class="relative w-full max-w-xl">
<div
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
/>
<div class="relative">
<ProductHeroBadge
v-if="badgeText"
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
>
{{ subtitle }}
</p>
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
{{ footnote }}
</p>
<slot />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,12 @@
---
import type { JsonLdGraph } from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
interface Props {
graph: JsonLdGraph
}
const { graph } = Astro.props
---
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />

View File

@@ -0,0 +1,53 @@
import { t } from '../i18n/translations'
import type { Locale, TranslationKey } from '../i18n/translations'
import { externalLinks } from './routes'
interface PricingTier {
slug: string
labelKey: TranslationKey
priceKey: TranslationKey
}
const tiers: PricingTier[] = [
{
slug: 'standard',
labelKey: 'pricing.plan.standard.label',
priceKey: 'pricing.plan.standard.price'
},
{
slug: 'creator',
labelKey: 'pricing.plan.creator.label',
priceKey: 'pricing.plan.creator.price'
},
{
slug: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price'
}
]
export interface PricingOffer {
name: string
price: string
url: string
}
export function pricingOffers(locale: Locale): PricingOffer[] {
return tiers.flatMap((tier) => {
const display = t(tier.priceKey, locale).trim()
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
if (!match) {
console.warn(
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
)
return []
}
return [
{
name: t(tier.labelKey, locale),
price: match[1],
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
}
]
})
}

View File

@@ -82,14 +82,19 @@ export const externalLinks = {
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
g2ComfyUi: 'https://www.g2.com/products/comfyui',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'

View File

@@ -2190,6 +2190,13 @@ const translations = {
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
@@ -4061,7 +4068,6 @@ const translations = {
en: 'This page is being redesigned. Check back soon.',
'zh-CN': '此页面正在重新设计中,请稍后再来。'
},
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
'customers.story.whatsNext': {
@@ -4157,10 +4163,6 @@ const translations = {
en: "Run the world's leading AI models in ComfyUI",
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
},
'models.breadcrumb.home': {
en: 'Home',
'zh-CN': '首页'
},
'models.breadcrumb.models': {
en: 'Supported Models',
'zh-CN': '支持的模型'

View File

@@ -14,8 +14,10 @@ import {
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { buildPageGraph, pageContext } from '../utils/jsonLd'
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
interface Props {
title: string
@@ -23,6 +25,10 @@ interface Props {
keywords?: string[]
ogImage?: string
noindex?: boolean
pageType?: WebPageType
breadcrumbs?: Crumb[]
mainEntityId?: string
extraJsonLd?: (JsonLdNode | null | undefined)[]
}
const {
@@ -31,15 +37,21 @@ const {
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
pageType,
breadcrumbs,
mainEntityId,
extraJsonLd,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const canonicalURL = new URL(url)
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
@@ -58,28 +70,21 @@ const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org',
logo: 'https://comfy.org/icons/logomark.svg',
sameAs: [
'https://github.com/comfyanonymous/ComfyUI',
'https://discord.gg/comfyorg',
'https://x.com/comaboratory',
'https://reddit.com/r/comfyui',
'https://linkedin.com/company/comfyorg',
'https://instagram.com/comfyorg',
],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Comfy',
url: 'https://comfy.org',
}
const structuredData = noindex
? undefined
: buildPageGraph(
{ siteUrl, locale },
{
url,
name: title,
description,
imageUrl: ogImageURL.href,
type: pageType,
crumbs: breadcrumbs,
mainEntityId,
},
...(extraJsonLd ?? []),
)
---
<!doctype html>
@@ -121,10 +126,7 @@ const websiteJsonLd = {
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
{structuredData && <JsonLdGraph graph={structuredData} />}
<slot name="head" />
<!-- Google Tag Manager -->
@@ -144,7 +146,6 @@ const websiteJsonLd = {
)}
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (

View File

@@ -5,9 +5,25 @@ import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="About Us — Comfy">
<BaseLayout
title="About Us — Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.about', locale) },
]}
>
<HeroSection client:load />
<StorySection />
<OurValuesSection />

View File

@@ -9,34 +9,36 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
const pageTitle = t('affiliate.page.title', 'en')
const pageDescription = t('affiliate.page.description', 'en')
const { locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const faqPage: JsonLdNode = {
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
})),
}
---
<BaseLayout
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
title={pageTitle}
description={pageDescription}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: pageTitle },
]}
extraJsonLd={[faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection />
<HowItWorksSection />

View File

@@ -7,6 +7,13 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
import { t } from '../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -19,11 +26,31 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="Careers — Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection />
<RolesSection departments={departments} client:visible />

View File

@@ -2,9 +2,41 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout title="Pricing — Comfy Cloud">
<BaseLayout
title="Pricing — Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<PriceSection client:load />
<WhatsIncludedSection />
</BaseLayout>

View File

@@ -4,39 +4,44 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../i18n/translations'
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Custom-node packs supported on Comfy Cloud',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
const title = t('cloudNodes.meta.title', 'en')
const description = t('cloudNodes.meta.description', 'en')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
})),
)
---
<BaseLayout
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,7 +7,12 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -29,35 +34,45 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
description: pack.description || undefined,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
authorName: pack.publisher?.name,
isFree: true,
})
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -2,9 +2,25 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import FormSection from '../components/contact/FormSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="Contact — Comfy">
<BaseLayout
title="Contact — Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.contact', locale) },
]}
>
<FormSection client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -7,6 +7,13 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
}
---
@@ -88,25 +61,20 @@ const breadcrumbJsonLd = {
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -8,11 +8,29 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Download Comfy Desktop — Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -9,11 +9,28 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from "../utils/jsonLd";
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
);
---
<BaseLayout
title="Comfy — Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[
comfyUiApplicationNode(siteUrl),
comfyUiSourceCodeNode(siteUrl),
]}
keywords={[
"comfyui app",
"comfyui web app",

View File

@@ -4,6 +4,13 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
@@ -19,7 +26,6 @@ if (model.canonicalSlug) {
}
const { displayName } = model
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
const dirDescriptions: Record<string, string> = {
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
@@ -40,55 +46,31 @@ const dirDescriptions: Record<string, string> = {
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: displayName,
url,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Any',
url: canonicalURL.href,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('models.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('models.breadcrumb.models'),
item: 'https://comfy.org/p/supported-models'
},
{
'@type': 'ListItem',
position: 3,
name: displayName
}
]
}
const faqJsonLd = {
'@context': 'https://schema.org',
})
const faqPage: JsonLdNode = {
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
},
{
'@type': 'Question',
@@ -97,54 +79,44 @@ const faqJsonLd = {
'@type': 'Answer',
text: model.docsUrl
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
}
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
},
},
{
'@type': 'Question',
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
}
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
},
},
{
'@type': 'Question',
name: `Is ${displayName} free to use in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
}
}
]
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
},
},
],
}
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
---
<BaseLayout
title={`${pageTitle} — Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('models.breadcrumb.models', locale),
url: absoluteUrl(Astro.site, '/p/supported-models'),
},
{ name: displayName },
]}
extraJsonLd={[software, faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(softwareAppJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<ModelHeroSection
displayName={displayName}

View File

@@ -2,10 +2,29 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const modelList = itemListNode(
url,
title,
models.map((model) => ({
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
})),
)
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
@@ -26,6 +45,13 @@ const dirLabel: Record<string, string> = {
<BaseLayout
title={`${title} — Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: title },
]}
extraJsonLd={[modelList]}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">

View File

@@ -5,9 +5,29 @@ import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<BaseLayout
title="关于我们 — Comfy"
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.about', locale) },
]}
>
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />

View File

@@ -7,6 +7,13 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -19,11 +26,34 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="招聘 — Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" departments={departments} client:visible />

View File

@@ -2,9 +2,44 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PriceSection from '../../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../../config/pricing'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout title="定价 — Comfy Cloud">
<BaseLayout
title="定价 — Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<PriceSection locale="zh-CN" client:load />
<WhatsIncludedSection locale="zh-CN" />
</BaseLayout>

View File

@@ -4,39 +4,47 @@ import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Comfy Cloud 支持的自定义节点包',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
const title = t('cloudNodes.meta.title', 'zh-CN')
const description = t('cloudNodes.meta.description', 'zh-CN')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
})),
)
---
<BaseLayout
title={t('cloudNodes.meta.title', 'zh-CN')}
description={t('cloudNodes.meta.description', 'zh-CN')}
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection locale="zh-CN" client:visible />
<PackGridSection locale="zh-CN" packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,7 +7,12 @@ import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -29,35 +34,48 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
description: pack.description || undefined,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
authorName: pack.publisher?.name,
isFree: true,
})
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<PackDetail pack={pack} locale="zh-CN" />
</BaseLayout>

View File

@@ -2,9 +2,28 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import FormSection from '../../components/contact/FormSection.vue'
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="联系我们 — Comfy">
<BaseLayout
title="联系我们 — Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.contact', locale) },
]}
>
<FormSection locale="zh-CN" client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -7,6 +7,13 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title, 'zh-CN')
const description = t(demo.description, 'zh-CN')
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home', 'zh-CN'),
item: 'https://comfy.org/zh-CN'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos', 'zh-CN'),
item: 'https://comfy.org/zh-CN/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
}
---
@@ -88,25 +61,23 @@ const breadcrumbJsonLd = {
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -8,11 +8,32 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />

View File

@@ -9,11 +9,25 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
import { t } from '../../i18n/translations'
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Comfy — 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
>
<HeroSection locale="zh-CN" client:load />

View File

@@ -255,9 +255,10 @@
Shifting visually with `top` corrects the appearance without changing the
inline-block's bounding box, so button/badge sizes are unaffected. */
@utility ppformula-text-center {
display: inline-block;
position: relative;
top: 0.1em;
/* display: inline-block; */
/* position: relative; */
/* top: 0.1em; */
--ppformula-text-center: 1;
}
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked

View File

@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest'
import { externalLinks } from '../config/routes'
import { escapeJsonLd } from './escapeJsonLd'
import type { JsonLdGraph } from './jsonLd'
import {
absoluteUrl,
buildPageGraph,
collectGraphIds,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
itemListNode,
jsonLdId,
organizationId,
pageContext,
productNode,
softwareApplicationNode
} from './jsonLd'
const siteUrl = 'https://comfy.org'
const site = new URL('https://comfy.org/')
function typeNames(graph: JsonLdGraph): string[] {
return graph['@graph'].map((node) => node['@type'])
}
describe('absoluteUrl', () => {
it('resolves internal paths to their trailing-slash canonical form', () => {
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
})
})
describe('pageContext', () => {
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
expect(pageContext(site, '/about/', undefined)).toEqual({
siteUrl,
locale: 'en',
url: 'https://comfy.org/about/'
})
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
})
})
describe('itemListNode', () => {
it('counts items and omits per-item names when not supplied', () => {
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
{ url: 'https://jobs.example/1' },
{ url: 'https://jobs.example/2', name: 'Designer' }
])
expect(node.numberOfItems).toBe(2)
const items = node.itemListElement as Record<string, unknown>[]
expect('name' in items[0]).toBe(false)
expect(items[1].name).toBe('Designer')
})
})
describe('softwareApplicationNode', () => {
it('claims Comfy Org as author and publisher only when first-party', () => {
const node = softwareApplicationNode({
siteUrl,
id: jsonLdId(siteUrl, 'software'),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
isFree: true
})
const orgRef = { '@id': organizationId(siteUrl) }
expect(node.author).toEqual(orgRef)
expect(node.publisher).toEqual(orgRef)
expect(node.offers).toEqual({
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: orgRef
})
})
it('does not name Comfy Org as seller on a third-party free offer', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
isFree: true
})
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
})
it('credits a known third-party author without claiming to publish it', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
authorName: 'Jane Dev'
})
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
expect(node.publisher).toBeUndefined()
})
it('claims no author or publisher for third-party software with no author', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.author).toBeUndefined()
expect(node.publisher).toBeUndefined()
})
})
describe('sameAs encyclopedic references', () => {
it('links the organization to its Wikidata entity', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/`, name: 'Home' }
)
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
})
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
const node = comfyUiApplicationNode(siteUrl)
expect(node.sameAs).toEqual([
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
])
})
it('omits sameAs for third-party software', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.sameAs).toBeUndefined()
})
})
describe('productNode', () => {
it('gives every offer a currency and price', () => {
const node = productNode({
siteUrl,
id: 'https://comfy.org/cloud/pricing/#product',
name: 'Comfy Cloud',
url: 'https://comfy.org/cloud/pricing/',
offers: [{ name: 'Standard', price: '20' }]
})
const offers = node.offers as Record<string, unknown>[]
expect(offers[0].price).toBe('20')
expect(offers[0].priceCurrency).toBe('USD')
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
})
})
describe('comfyUiSourceCodeNode', () => {
it('links the source code to the ComfyUI application via targetProduct', () => {
const node = comfyUiSourceCodeNode(siteUrl)
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
})
})
describe('buildPageGraph', () => {
const url = 'https://comfy.org/cloud/pricing/'
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{
url,
name: 'Pricing',
type: 'CollectionPage',
mainEntityId: jsonLdId(url, 'itemlist'),
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
},
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
)
it('always includes the site-wide organization, website and page entity', () => {
expect(typeNames(graph)).toContain('Organization')
expect(typeNames(graph)).toContain('WebSite')
expect(typeNames(graph)).toContain('CollectionPage')
})
it('produces a graph where every @id reference resolves', () => {
const { defined, references } = collectGraphIds(graph)
for (const reference of references) {
expect(defined.has(reference)).toBe(true)
}
})
})
describe('escapeJsonLd on a built graph', () => {
it('neutralizes a </script> breakout in a page name', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
)
const serialized = escapeJsonLd(graph)
expect(serialized).not.toContain('</script>')
expect(serialized).toContain('\\u003c')
})
})

View File

@@ -0,0 +1,377 @@
import { externalLinks } from '../config/routes'
import type { Locale } from '../i18n/translations'
export type JsonLdNode = Record<string, unknown> & { '@type': string }
export interface JsonLdGraph {
'@context': 'https://schema.org'
'@graph': JsonLdNode[]
}
export interface PageContext {
siteUrl: string
locale: Locale
}
export type WebPageType =
| 'WebPage'
| 'AboutPage'
| 'ContactPage'
| 'CollectionPage'
export interface Crumb {
name: string
url?: string
}
const sameAs = [
externalLinks.github,
externalLinks.x,
externalLinks.youtube,
externalLinks.discord,
externalLinks.instagram,
externalLinks.reddit,
externalLinks.linkedin,
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
externalLinks.wikidataComfyOrg
]
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
const comfyUiSameAs = [
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
]
function siteUrlFrom(site: URL | undefined): string {
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
}
export function absoluteUrl(site: URL | undefined, path: string): string {
const resolved = new URL(path, site ?? 'https://comfy.org').href
return resolved.endsWith('/') ? resolved : `${resolved}/`
}
export function pageContext(
site: URL | undefined,
pathname: string,
currentLocale: string | undefined
): PageContext & { url: string } {
return {
siteUrl: siteUrlFrom(site),
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
url: absoluteUrl(site, pathname)
}
}
export function jsonLdId(pageUrl: string, fragment: string): string {
return `${pageUrl}#${fragment}`
}
export function organizationId(siteUrl: string): string {
return `${siteUrl}/#organization`
}
function websiteId(siteUrl: string): string {
return `${siteUrl}/#website`
}
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
return {
'@context': 'https://schema.org',
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
}
}
function organizationNode(siteUrl: string): JsonLdNode {
return {
'@type': 'Organization',
'@id': organizationId(siteUrl),
name: 'Comfy Org',
url: siteUrl,
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/web-app-manifest-512x512.png`,
width: 512,
height: 512
},
sameAs
}
}
function websiteNode(siteUrl: string): JsonLdNode {
return {
'@type': 'WebSite',
'@id': websiteId(siteUrl),
name: 'Comfy',
url: siteUrl,
publisher: { '@id': organizationId(siteUrl) }
}
}
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
return {
'@type': 'BreadcrumbList',
'@id': jsonLdId(pageUrl, 'breadcrumb'),
itemListElement: crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return isLast || !crumb.url
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
: {
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: crumb.url
}
})
}
}
export function itemListNode(
pageUrl: string,
name: string,
items: { url: string; name?: string }[]
): JsonLdNode {
return {
'@type': 'ItemList',
'@id': jsonLdId(pageUrl, 'itemlist'),
name,
numberOfItems: items.length,
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
url: item.url,
...(item.name ? { name: item.name } : {})
}))
}
}
interface WebPageInput {
siteUrl: string
locale: Locale
url: string
name: string
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
return {
'@type': type,
'@id': jsonLdId(input.url, 'webpage'),
url: input.url,
name: input.name,
description: input.description,
isPartOf: { '@id': websiteId(input.siteUrl) },
primaryImageOfPage: input.imageUrl
? { '@type': 'ImageObject', url: input.imageUrl }
: undefined,
breadcrumb: hasCrumbs
? { '@id': jsonLdId(input.url, 'breadcrumb') }
: undefined,
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
inLanguage: input.locale
}
}
export interface SoftwareAppInput {
siteUrl: string
id: string
name: string
url: string
applicationCategory: string
firstParty?: boolean
applicationSubCategory?: string
description?: string
operatingSystem?: string
image?: string
softwareVersion?: string
license?: string
codeRepository?: string
authorName?: string
isFree?: boolean
sameAs?: string[]
}
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
const orgRef = { '@id': organizationId(input.siteUrl) }
const author = input.firstParty
? orgRef
: input.authorName
? { '@type': 'Person', name: input.authorName }
: undefined
return {
'@type': 'SoftwareApplication',
'@id': input.id,
name: input.name,
url: input.url,
applicationCategory: input.applicationCategory,
applicationSubCategory: input.applicationSubCategory,
description: input.description,
operatingSystem: input.operatingSystem,
image: input.image,
softwareVersion: input.softwareVersion,
license: input.license,
codeRepository: input.codeRepository,
author,
publisher: input.firstParty ? orgRef : undefined,
sameAs: input.sameAs,
offers: input.isFree
? {
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: input.firstParty ? orgRef : undefined
}
: undefined
}
}
interface SourceCodeInput {
siteUrl: string
id: string
name: string
codeRepository: string
programmingLanguage?: string
targetProductId?: string
}
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
return {
'@type': 'SoftwareSourceCode',
'@id': input.id,
name: input.name,
codeRepository: input.codeRepository,
programmingLanguage: input.programmingLanguage,
targetProduct: input.targetProductId
? { '@id': input.targetProductId }
: undefined,
author: { '@id': organizationId(input.siteUrl) }
}
}
export function comfyUiSoftwareId(siteUrl: string): string {
return `${siteUrl}/#software`
}
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
return softwareApplicationNode({
siteUrl,
id: comfyUiSoftwareId(siteUrl),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux',
isFree: true,
sameAs: comfyUiSameAs
})
}
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
return softwareSourceCodeNode({
siteUrl,
id: `${siteUrl}/#sourcecode`,
name: 'ComfyUI',
codeRepository: externalLinks.github,
programmingLanguage: 'Python',
targetProductId: comfyUiSoftwareId(siteUrl)
})
}
interface OfferInput {
name: string
price: string | number
url?: string
}
export interface ProductInput {
siteUrl: string
id: string
name: string
url: string
offers: OfferInput[]
}
export function productNode(input: ProductInput): JsonLdNode {
return {
'@type': 'Product',
'@id': input.id,
name: input.name,
url: input.url,
brand: { '@id': organizationId(input.siteUrl) },
offers: input.offers.map((offer) => ({
'@type': 'Offer',
name: offer.name,
price: offer.price,
priceCurrency: 'USD',
url: offer.url,
seller: { '@id': organizationId(input.siteUrl) },
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: offer.price,
priceCurrency: 'USD',
unitText: 'MONTH'
}
}))
}
}
export interface PageGraphInput {
url: string
name: string
type?: WebPageType
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
export function buildPageGraph(
ctx: PageContext,
page: PageGraphInput,
...extraNodes: (JsonLdNode | null | undefined)[]
): JsonLdGraph {
const { type = 'WebPage', ...rest } = page
const input: WebPageInput = {
...rest,
siteUrl: ctx.siteUrl,
locale: ctx.locale
}
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
return buildGraph(
organizationNode(ctx.siteUrl),
websiteNode(ctx.siteUrl),
webPageNode(input, type),
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
...extraNodes
)
}
export function collectGraphIds(value: unknown): {
defined: Set<string>
references: string[]
} {
const defined = new Set<string>()
const references: string[] = []
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (node && typeof node === 'object') {
const record = node as Record<string, unknown>
const id = record['@id']
if (typeof id === 'string') {
if (Object.keys(record).length === 1) references.push(id)
else defined.add(id)
}
Object.values(record).forEach(walk)
}
}
walk(value)
return { defined, references }
}

View File

@@ -276,6 +276,7 @@ export const TestIds = {
overlay: 'loading-overlay'
},
load3d: {
categoryMenu: 'load3d-category-menu',
recordingDuration: 'load3d-recording-duration'
},
load3dViewer: {

View File

@@ -0,0 +1,279 @@
import { expect } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
* add a provider key, see it listed, delete it — the full CRUD round-trip —
* plus the entitlement contract that a non-entitled account never sees the
* gated providers.
*
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
* `/secrets` backend on top so the flow is deterministic and never touches a
* real server.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// `/api/features` is the remote-config source. Enabling user secrets is what
// surfaces the Secrets settings panel for a signed-in user.
const BOOT_FEATURES = {
user_secrets_enabled: true
} satisfies RemoteConfig
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
// The plaintext key a user types in. It must be sent on create but NEVER echoed
// back by the API or rendered anywhere in the UI.
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
interface SecretRecord {
id: string
name: string
provider?: string
created_at: string
updated_at: string
last_used_at?: string
}
interface CreateCapture {
name?: string
provider?: string
secret_value?: string
}
interface SecretsBackend {
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
createRequests: CreateCapture[]
/** Current server-side store — for asserting delete actually removed a row. */
store: SecretRecord[]
}
/**
* Stateful mock of the ingest `/secrets` surface. A single route handler
* branches on path + method so registration order can never make a specific
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
*
* `providerIds` models entitlement: an entitled account sees runway/gemini,
* a non-entitled account gets an empty list (the server omits them).
*/
async function mockSecretsBackend(
page: Page,
providerIds: string[]
): Promise<SecretsBackend> {
const backend: SecretsBackend = { createRequests: [], store: [] }
let idSeq = 0
const respondList = (route: Route) =>
route.fulfill(jsonRoute({ data: backend.store }))
await page.route('**/api/secrets**', async (route) => {
const request = route.request()
const { pathname } = new URL(request.url())
const method = request.method()
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
// contains the `/api/secrets` substring. Fulfilling that dev-server module
// request with JSON breaks the dynamic import and the panel never mounts.
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
// routes are handled; everything else falls through to the real Vite server.
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
return route.continue()
}
// GET /secrets/providers — the entitlement-gated provider allowlist.
if (pathname.endsWith('/secrets/providers')) {
return route.fulfill(
jsonRoute({ data: providerIds.map((id) => ({ id })) })
)
}
// /secrets/:id — item routes (only DELETE is exercised by this flow).
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
if (itemMatch) {
const id = itemMatch[1]
if (method === 'DELETE') {
backend.store = backend.store.filter((s) => s.id !== id)
return route.fulfill({ status: 204, body: '' })
}
return respondList(route)
}
// /secrets — collection routes.
if (method === 'POST') {
const body = (request.postDataJSON() ?? {}) as CreateCapture
backend.createRequests.push(body)
idSeq += 1
const created: SecretRecord = {
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
name: body.name ?? '',
provider: body.provider,
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T00:00:00Z'
}
backend.store.push(created)
// Response echoes metadata ONLY — the schema has no secret_value field.
return route.fulfill(jsonRoute(created))
}
// GET /secrets (list).
return respondList(route)
})
return backend
}
/**
* Open the settings dialog and land on the Secrets panel, waiting for both the
* provider allowlist and the secret list to resolve so subsequent assertions
* are not racing the panel's on-mount fetches.
*/
async function openSecretsPanel(page: Page) {
const settingsDialog = page.getByTestId('settings-dialog')
await page.evaluate(() => {
const app = window.app
if (!app) throw new Error('window.app is not available')
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
})
await settingsDialog.waitFor({ state: 'visible' })
const providersResolved = page.waitForResponse((r) =>
r.url().includes('/api/secrets/providers')
)
const listResolved = page.waitForResponse(
(r) =>
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
)
await settingsDialog
.locator('nav')
.getByRole('button', { name: 'Secrets' })
.click()
await Promise.all([providersResolved, listResolved])
return settingsDialog
}
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
test('an entitled account can add, list, and delete a provider key', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
// Empty state before anything is added.
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// --- ADD -------------------------------------------------------------
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
// Pick the entitled Runway provider from the server-driven dropdown.
await formDialog.locator('#secret-provider').click()
await page.getByRole('option', { name: 'Runway' }).click()
await formDialog.locator('#secret-name').fill('My Runway Key')
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
await expect(formDialog).toBeHidden()
// --- LIST ------------------------------------------------------------
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
// The create request carried the plaintext value + provider...
expect(backend.createRequests).toHaveLength(1)
expect(backend.createRequests[0]).toMatchObject({
name: 'My Runway Key',
provider: 'runway',
secret_value: RUNWAY_KEY_VALUE
})
// ...but the value must never be echoed back into the list — the API
// response carries metadata only, so nothing should render it as text.
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
// --- DELETE ----------------------------------------------------------
await settingsDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
const confirmDialog = page
.getByRole('dialog')
.filter({ hasText: 'Delete Secret' })
await confirmDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
expect(backend.store).toHaveLength(0)
})
test('a non-entitled account never sees the gated providers', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
// Non-entitled: the server omits runway/gemini from the allowlist.
await mockSecretsBackend(page, [])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// The add form opens, but its provider dropdown is empty — the gated
// providers must not appear anywhere.
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
await formDialog.locator('#secret-provider').click()
// Anchor on the opened listbox so the absence assertions below can't pass
// vacuously against a dropdown that never opened.
const providerListbox = page.getByRole('listbox')
await expect(providerListbox).toBeVisible()
// An empty allowlist must yield an empty dropdown. Asserting zero options
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
// where `availableProviders` is null and the default providers would show.
await expect(providerListbox.getByRole('option')).toHaveCount(0)
})
})

View File

@@ -11,39 +11,47 @@ export class Load3DHelper {
}
get menuButton(): Locator {
return this.node.getByRole('button', { name: /show menu/i })
return this.node.getByTestId(TestIds.load3d.categoryMenu)
}
private get menuPanel(): Locator {
return this.node.page().getByRole('dialog')
}
get recordingButton(): Locator {
return this.node.getByRole('button', { name: /start recording/i })
return this.node.getByRole('button', { name: 'Record', exact: true })
}
get stopRecordingButton(): Locator {
return this.node.getByRole('button', { name: /stop recording/i })
}
get exportRecordingButton(): Locator {
return this.node.getByRole('button', { name: /export recording/i })
}
get clearRecordingButton(): Locator {
return this.node.getByRole('button', { name: /clear recording/i })
}
get recordingDuration(): Locator {
get recordingMenuButton(): Locator {
return this.node.getByTestId(TestIds.load3d.recordingDuration)
}
get downloadRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
}
get startNewRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
}
get deleteRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
}
get gridToggleButton(): Locator {
return this.node.getByRole('button', { name: /show grid/i })
}
get uploadBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: /upload background image/i })
return this.node.getByRole('button', { name: 'BG Image' })
}
get removeBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: /remove background image/i })
return this.node.getByRole('button', { name: 'Remove BG' })
}
get panoramaModeButton(): Locator {
@@ -54,6 +62,10 @@ export class Load3DHelper {
return this.node.locator('input[type="color"]')
}
get exportButton(): Locator {
return this.node.getByRole('button', { name: 'Export', exact: true })
}
get openViewerButton(): Locator {
return this.node.getByRole('button', { name: /open in 3d viewer/i })
}
@@ -63,11 +75,15 @@ export class Load3DHelper {
}
getMenuCategory(name: string): Locator {
return this.node.getByText(name, { exact: true })
return this.menuPanel.getByRole('button', { name, exact: true })
}
get gizmoToggleButton(): Locator {
return this.node.getByRole('button', { name: 'Gizmo' })
// The category chip is also named "Gizmo" once that category is active;
// only the toggle carries aria-pressed.
return this.node
.getByRole('button', { name: 'Gizmo' })
.and(this.node.locator('[aria-pressed]'))
}
get gizmoTranslateButton(): Locator {
@@ -83,13 +99,17 @@ export class Load3DHelper {
}
get gizmoResetButton(): Locator {
return this.node.getByRole('button', { name: 'Reset Transform' })
return this.node.getByRole('button', { name: 'Reset', exact: true })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async openRecordingMenu(): Promise<void> {
await this.recordingMenuButton.click()
}
async openGizmoCategory(): Promise<void> {
await this.openMenu()
await this.getMenuCategory('Gizmo').click()

View File

@@ -47,10 +47,12 @@ test.describe('Load3D', () => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
await expect(load3d.getMenuCategory('Model')).toBeVisible()
await expect(load3d.getMenuCategory('3D Model')).toBeVisible()
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
await expect(load3d.getMenuCategory('Light')).toBeVisible()
await expect(load3d.getMenuCategory('Export')).toBeVisible()
await expect(load3d.getMenuCategory('HDRI')).toBeVisible()
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
await expect(load3d.exportButton).toBeVisible()
await expect(load3d.node).toHaveScreenshot(
'load3d-controls-menu-open.png',
@@ -253,7 +255,7 @@ test.describe('Load3D', () => {
}
)
test('Recording controls show stop/export/clear buttons after a recording', async ({
test('Recording controls collapse into a duration chip with a menu after a recording', async ({
comfyPage,
load3d
}) => {
@@ -265,20 +267,25 @@ test.describe('Load3D', () => {
await expect(load3d.stopRecordingButton).toBeVisible()
})
await test.step('Stop recording surfaces export/clear controls and a 1s duration', async () => {
await test.step('Stop recording surfaces the duration chip with a 1s duration', async () => {
// Record for 1s wall-clock so the duration display settles on 00:01.
await comfyPage.delay(1000)
await load3d.stopRecordingButton.click()
await expect(load3d.recordingButton).toBeVisible()
await expect(load3d.exportRecordingButton).toBeVisible()
await expect(load3d.clearRecordingButton).toBeVisible()
await expect(load3d.recordingDuration).toHaveText('00:01')
await expect(load3d.recordingMenuButton).toBeVisible()
await expect(load3d.recordingMenuButton).toHaveText('00:01')
})
await test.step('Clear recording removes export and clear controls', async () => {
await load3d.clearRecordingButton.click()
await expect(load3d.exportRecordingButton).toHaveCount(0)
await expect(load3d.clearRecordingButton).toHaveCount(0)
await test.step('Chip menu offers download, re-record and delete actions', async () => {
await load3d.openRecordingMenu()
await expect(load3d.downloadRecordingMenuItem).toBeVisible()
await expect(load3d.startNewRecordingMenuItem).toBeVisible()
await expect(load3d.deleteRecordingMenuItem).toBeVisible()
})
await test.step('Deleting the recording restores the record button', async () => {
await load3d.deleteRecordingMenuItem.click()
await expect(load3d.recordingMenuButton).toHaveCount(0)
await expect(load3d.recordingButton).toBeVisible()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -476,6 +476,37 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
.toBe(true)
})
test(
'Closing minimap after subgraph navigation keeps Vue render in sync',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
// Round-trip layers Vue's onNodeAdded wrapper on top of the minimap's.
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await comfyPage.subgraph.exitViaBreadcrumb()
// Minimap unmount must not clobber the Vue wrapper layered above it.
await comfyPage.page
.getByTestId(TestIds.canvas.closeMinimapButton)
.click()
const subgraphFixture =
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await comfyPage.contextMenu.openForVueNode(subgraphFixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
await comfyPage.contextMenu.waitForHidden()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(2)
await expect(
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
).toHaveCount(0)
}
)
})
test.describe('Minimap mobile', { tag: ['@mobile', '@canvas'] }, () => {

View File

@@ -1,7 +1,13 @@
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
@@ -39,4 +45,34 @@ test.describe('Preview as Text node', () => {
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
wstest(
'restoring workflow restores state',
{ tag: '@vue-nodes' },
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview as Text')
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
const preview = node.root.locator('textarea')
await test.step('node previews execution result', async () => {
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
execution.executed('', id, { text: 'massive fennec ears' })
await expect(preview).toHaveValue('massive fennec ears')
})
await test.step('swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.getTab(0).click()
await expect(node.root).toBeHidden()
await comfyPage.menu.topbar.getTab(1).click()
await expect(node.root).toBeVisible()
})
await expect(preview, 'previous output is restored').toHaveValue(
'massive fennec ears'
)
}
)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.47.7",
"version": "1.48.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -114,6 +114,7 @@
"jsonata": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"minisearch": "catalog:",
"pinia": "catalog:",
"posthog-js": "catalog:",
"primeicons": "catalog:",

19
pnpm-lock.yaml generated
View File

@@ -282,6 +282,9 @@ catalogs:
markdown-table:
specifier: ^3.0.4
version: 3.0.4
minisearch:
specifier: ^7.2.0
version: 7.2.0
mixpanel-browser:
specifier: ^2.71.0
version: 2.71.0
@@ -591,6 +594,9 @@ importers:
marked:
specifier: ^15.0.11
version: 15.0.11
minisearch:
specifier: 'catalog:'
version: 7.2.0
pinia:
specifier: 'catalog:'
version: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
@@ -7024,6 +7030,9 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
minisearch@7.2.0:
resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
@@ -8884,8 +8893,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
vue-component-type-helpers@3.3.6:
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -11668,7 +11677,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.5
vue-component-type-helpers: 3.3.6
'@swc/helpers@0.5.21':
dependencies:
@@ -15817,6 +15826,8 @@ snapshots:
minipass@7.1.3: {}
minisearch@7.2.0: {}
mitt@3.0.1: {}
mixpanel-browser@2.71.0:
@@ -18138,7 +18149,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.5: {}
vue-component-type-helpers@3.3.6: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -103,6 +103,7 @@ catalog:
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
minisearch: ^7.2.0
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
oxfmt: ^0.54.0

View File

@@ -4,37 +4,67 @@
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
<div class="flex flex-col">
<div
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
>
<Button
variant="textonly"
size="unset"
:aria-pressed="grid"
:class="
cn(
actionBtnClass,
grid && 'bg-component-node-widget-background-selected'
)
"
@click="grid = !grid"
>
<i class="icon-[lucide--grid-3x3] size-4" />
<span>{{ $t('boundingBoxes.grid') }}</span>
</Button>
<Button
variant="textonly"
size="unset"
:class="cn(actionBtnClass, 'ml-auto')"
@click="clearAll"
>
<i class="icon-[lucide--undo-2] size-4" />
<span>{{ $t('boundingBoxes.clearAll') }}</span>
</Button>
</div>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
</div>
<div
@@ -122,16 +152,6 @@
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
@@ -147,6 +167,9 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { NodeId } from '@/types/nodeId'
const actionBtnClass =
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
const { nodeId } = defineProps<{ nodeId: NodeId }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
@@ -172,7 +195,8 @@ const {
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,

View File

@@ -97,7 +97,7 @@
<!-- Sort Options -->
<div>
<SingleSelect
v-model="sortBy"
v-model="sortSelection"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
:content-style="selectContentStyle"
@@ -556,7 +556,8 @@ const {
selectedModels,
selectedUseCases,
selectedRunsOn,
sortBy,
sortSelection,
hasActiveQuery,
activeModels,
activeUseCases,
filteredTemplates,
@@ -565,14 +566,13 @@ const {
availableRunsOn,
filteredCount,
totalCount,
resetFilters,
loadFuseOptions
resetFilters
} = useTemplateFiltering(navigationFilteredTemplates)
/**
* Raw search input bound to the search box. The actual `searchQuery` consumed
* by the filtering composable is only updated via `applySearchQuery` after the
* debounce settles, keeping Fuse/grid re-renders off the keystroke critical path.
* debounce settles, keeping search/grid re-renders off the keystroke critical path.
*/
const searchInput = ref(searchQuery.value)
@@ -595,15 +595,13 @@ watch(searchQuery, (value) => {
*/
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
const isPopularNav = selectedNavItem.value === 'popular'
const isPopularSort = sortBy.value === 'popular'
const isPopularSort = sortSelection.value === 'popular'
if (source === 'nav') {
if (isPopularNav && !isPopularSort) {
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
sortBy.value = 'popular'
sortSelection.value = 'popular'
} else if (!isPopularNav && isPopularSort) {
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
sortBy.value = 'default'
sortSelection.value = 'default'
}
} else if (source === 'sort') {
// When sort is changed away from 'Popular' while in the 'Popular' category,
@@ -616,7 +614,7 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
watch(sortBy, () => coordinateNavAndSort('sort'))
watch(sortSelection, () => coordinateNavAndSort('sort'))
// Convert between string array and object array for MultiSelect component
// Only show selected items that exist in the current scope
@@ -726,8 +724,15 @@ const runsOnFilterLabel = computed(() => {
}
})
// Sort options
const sortOptions = computed(() => [
...(hasActiveQuery.value
? [
{
name: t('templateWorkflows.sort.relevance', 'Relevance'),
value: 'relevance'
}
]
: []),
{
name: t('templateWorkflows.sort.default', 'Default'),
value: 'default'
@@ -789,7 +794,7 @@ watch(
[
filteredTemplates,
selectedNavItem,
sortBy,
sortSelection,
selectedModels,
selectedUseCases,
selectedRunsOn
@@ -839,8 +844,7 @@ const { isLoading } = useAsyncState(
async () => {
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates(),
loadFuseOptions()
workflowTemplatesStore.loadWorkflowTemplates()
])
return true
},

View File

@@ -449,6 +449,12 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
expect(event.defaultPrevented).toBe(true)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')

View File

@@ -32,7 +32,9 @@
dialogStore.activeKey === item.key
)
"
@focus-outside="onRekaFocusOutside"
@focus-outside="
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<template v-if="item.dialogComponentProps.headless">

View File

@@ -53,7 +53,22 @@ export function onRekaPointerDownOutside(
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
export function onRekaFocusOutside(event: OutsideEvent) {
//
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
// also lose focus to an ordinary app element — not just a portal — when a
// nested dialog closes and the element it focused was removed (deleting the
// selected row). That programmatic focus shift is not a dismiss intent, so such
// a dialog opts out of focus-outside dismissal entirely via
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
// pointer.
export function onRekaFocusOutside(
event: OutsideEvent,
options: { dismissOnFocusOutside?: boolean } = {}
) {
if (options.dismissOnFocusOutside === false) {
event.preventDefault()
return
}
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}

View File

@@ -128,9 +128,9 @@ function renderLoad3D(options: RenderOptions = {}) {
name: 'AnimationControls',
template: '<div data-testid="animation-controls" />'
},
RecordingControls: {
name: 'RecordingControls',
template: '<div data-testid="recording-controls" />'
RecordMenuControl: {
name: 'RecordMenuControl',
template: '<div data-testid="record-menu-control" />'
},
ViewerControls: {
name: 'ViewerControls',
@@ -232,14 +232,16 @@ describe('Load3D', () => {
})
describe('recording controls', () => {
it('renders RecordingControls in regular (non-preview) mode', () => {
it('renders the record control in regular (non-preview) mode', () => {
renderLoad3D({ stateOverrides: { isPreview: ref(false) } })
expect(screen.getByTestId('recording-controls')).toBeInTheDocument()
expect(screen.getByTestId('record-menu-control')).toBeInTheDocument()
})
it('hides RecordingControls in preview mode', () => {
it('hides the record control in preview mode', () => {
renderLoad3D({ stateOverrides: { isPreview: ref(true) } })
expect(screen.queryByTestId('recording-controls')).not.toBeInTheDocument()
expect(
screen.queryByTestId('record-menu-control')
).not.toBeInTheDocument()
})
})

View File

@@ -15,25 +15,39 @@
:is-preview="isPreview"
/>
<div class="pointer-events-none absolute top-0 left-0 size-full">
<Load3DControls
<Load3DMenuBar
v-model:scene-config="sceneConfig"
v-model:model-config="modelConfig"
v-model:camera-config="cameraConfig"
v-model:light-config="lightConfig"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:can-fit-to-viewer="canFitToViewer"
:can-center-camera-on-model="canCenterCameraOnModel"
:node="node as LGraphNode"
:enable-viewer="enable3DViewer"
:can-use-recording="canUseRecording && !isPreview"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
:source-format="sourceFormat"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
@export-model="handleExportModel"
@fit-to-viewer="handleFitToViewer"
@center-camera="handleCenterCameraOnModel"
@toggle-gizmo="handleToggleGizmo"
@set-gizmo-mode="handleSetGizmoMode"
@reset-gizmo-transform="handleResetGizmoTransform"
@start-recording="handleStartRecording"
@stop-recording="handleStopRecording"
@export-recording="handleExportRecording"
@clear-recording="handleClearRecording"
/>
<AnimationControls
v-if="animations && animations.length > 0"
@@ -46,59 +60,6 @@
@seek="handleSeek"
/>
</div>
<div
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
>
<div
v-if="canFitToViewer || canCenterCameraOnModel"
class="flex flex-col rounded-lg bg-backdrop/30"
>
<Button
v-if="canFitToViewer"
v-tooltip.left="{
value: $t('load3d.fitToViewer'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.fitToViewer')"
@click="handleFitToViewer"
>
<i class="pi pi-window-maximize text-lg text-base-foreground" />
</Button>
<Button
v-if="canCenterCameraOnModel"
v-tooltip.left="{
value: $t('load3d.centerCameraOnModel'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.centerCameraOnModel')"
@click="handleCenterCameraOnModel"
>
<i class="pi pi-compass text-lg text-base-foreground" />
</Button>
</div>
<ViewerControls
v-if="enable3DViewer && node"
:node="node as LGraphNode"
/>
<RecordingControls
v-if="canUseRecording && !isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@start-recording="handleStartRecording"
@stop-recording="handleStopRecording"
@export-recording="handleExportRecording"
@clear-recording="handleClearRecording"
/>
</div>
</div>
</template>
@@ -106,12 +67,9 @@
import { computed, onMounted, ref } from 'vue'
import type { Ref } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import Button from '@/components/ui/button/Button.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -192,11 +150,11 @@ const {
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
handleFitToViewer,
handleCenterCameraOnModel,
handleToggleGizmo,
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)

View File

@@ -0,0 +1,242 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
import type {
CameraConfig,
LightConfig,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeSceneConfig(): SceneConfig {
return {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
}
}
function makeModelConfig(): ModelConfig {
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}
function makeCameraConfig(): CameraConfig {
return { cameraType: 'perspective', fov: 75 }
}
function makeLightConfig(): LightConfig {
return {
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
}
}
type RenderProps = Partial<ComponentProps<typeof Load3DMenuBar>>
function renderMenuBar(overrides: RenderProps = {}) {
const result = render(Load3DMenuBar, {
props: {
sceneConfig: makeSceneConfig(),
modelConfig: makeModelConfig(),
cameraConfig: makeCameraConfig(),
lightConfig: makeLightConfig(),
...overrides
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...result, user: userEvent.setup() }
}
async function selectCategory(
user: ReturnType<typeof userEvent.setup>,
label: string
) {
await openCategoryMenu(user)
await user.click(screen.getByRole('button', { name: label }))
}
async function openCategoryMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /Scene/ }))
}
describe('Load3DMenuBar', () => {
it('shows scene controls by default', () => {
renderMenuBar()
expect(
screen.getByRole('button', { name: 'Show grid' })
).toBeInTheDocument()
})
it('toggles showGrid on the bound config when the grid button is clicked', async () => {
const sceneConfig = makeSceneConfig()
const { user } = renderMenuBar({ sceneConfig })
await user.click(screen.getByRole('button', { name: 'Show grid' }))
expect(sceneConfig.showGrid).toBe(false)
})
it('emits fitToViewer when the fit button is clicked', async () => {
const onFitToViewer = vi.fn()
const { user } = renderMenuBar({ onFitToViewer })
await user.click(screen.getByRole('button', { name: 'Fit to Viewer' }))
expect(onFitToViewer).toHaveBeenCalledOnce()
})
it('emits centerCamera when the center button is clicked', async () => {
const onCenterCamera = vi.fn()
const { user } = renderMenuBar({ onCenterCamera })
await user.click(
screen.getByRole('button', { name: 'Center Camera on Model' })
)
expect(onCenterCamera).toHaveBeenCalledOnce()
})
it('hides the center button when canCenterCameraOnModel is false', () => {
renderMenuBar({ canCenterCameraOnModel: false })
expect(
screen.queryByRole('button', { name: 'Center Camera on Model' })
).not.toBeInTheDocument()
})
it('toggles the gizmo and reveals the mode controls inline', async () => {
const onToggleGizmo = vi.fn()
const onSetGizmoMode = vi.fn()
const { user } = renderMenuBar({ onToggleGizmo, onSetGizmoMode })
await selectCategory(user, 'Gizmo')
// The chip and the enable toggle share the 'Gizmo' name; click the toggle.
const gizmoButtons = screen.getAllByRole('button', { name: 'Gizmo' })
await user.click(gizmoButtons[gizmoButtons.length - 1])
expect(onToggleGizmo).toHaveBeenCalledWith(true)
await user.click(screen.getByRole('button', { name: 'Rotate' }))
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
})
it('shows the hdri upload inline without an extra popover', async () => {
const { user } = renderMenuBar()
await selectCategory(user, 'HDRI')
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
})
it('forwards removeHdri as updateHdriFile(null) when a file is loaded', async () => {
const onUpdateHdriFile = vi.fn()
const lightConfig = makeLightConfig()
lightConfig.hdri = {
enabled: true,
hdriPath: 'env.hdr',
showAsBackground: false,
intensity: 1
}
const { user } = renderMenuBar({ lightConfig, onUpdateHdriFile })
await selectCategory(user, 'HDRI')
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
})
it('emits startRecording when the record button is clicked', async () => {
const onStartRecording = vi.fn()
const { user } = renderMenuBar({ onStartRecording })
await user.click(screen.getByRole('button', { name: 'Record' }))
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('forwards exportRecording from the recording menu once a recording exists', async () => {
const onExportRecording = vi.fn()
const { user } = renderMenuBar({
hasRecording: true,
isRecording: false,
onExportRecording
})
await user.click(
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
)
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
})
it('omits the gizmo category when canUseGizmo is false', async () => {
const { user } = renderMenuBar({ canUseGizmo: false })
await openCategoryMenu(user)
expect(
screen.queryByRole('button', { name: 'Gizmo' })
).not.toBeInTheDocument()
})
it('switches to the camera category and shows its controls', async () => {
const { user } = renderMenuBar()
await openCategoryMenu(user)
await user.click(screen.getByRole('button', { name: 'Camera' }))
expect(
screen.getByRole('button', { name: 'Perspective' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show grid' })
).not.toBeInTheDocument()
})
it('omits the light category when canUseLighting is false', async () => {
const { user } = renderMenuBar({ canUseLighting: false })
await openCategoryMenu(user)
expect(
screen.queryByRole('button', { name: 'Light' })
).not.toBeInTheDocument()
})
it('hides scene controls when sceneConfig is undefined', () => {
renderMenuBar({ sceneConfig: undefined })
expect(
screen.queryByRole('button', { name: 'Show grid' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,337 @@
<template>
<div class="pointer-events-none absolute inset-0 flex flex-col">
<div
ref="topBarRef"
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<Popover v-model:open="catMenuOpen">
<PopoverTrigger as-child>
<button
:class="chipClass"
type="button"
data-testid="load3d-category-menu"
>
{{ activeLabel }}
<i class="icon-[lucide--chevron-down] size-4 opacity-70" />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
v-for="c in categoryDefs"
:key="c.key"
type="button"
:class="
cn(
rowClass,
activeCategory === c.key && 'bg-button-active-surface'
)
"
@click="selectCategory(c.key)"
>
{{ c.label }}
</button>
</PopoverContent>
</Popover>
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
<SceneMenuGroup
v-if="activeCategory === 'scene' && sceneConfig"
v-model:config="sceneConfig"
v-model:fov="cameraFov"
:compact
:can-use-background-image="canUseBackgroundImage"
:hdri-active="hdriActive"
@update-background-image="emit('updateBackgroundImage', $event)"
/>
<ModelMenuGroup
v-else-if="activeCategory === 'model' && modelConfig"
v-model:config="modelConfig"
:compact
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
/>
<CameraMenuGroup
v-else-if="activeCategory === 'camera' && cameraConfig"
v-model:config="cameraConfig"
:compact
/>
<LightMenuGroup
v-else-if="activeCategory === 'light' && lightConfig && modelConfig"
v-model:config="lightConfig"
:compact
:is-original-material="isOriginalMaterial"
/>
<HdriMenuGroup
v-else-if="activeCategory === 'hdri' && lightConfig"
v-model:config="lightConfig"
:compact
:scene-has-image="sceneHasImage"
@update-hdri-file="emit('updateHdriFile', $event)"
/>
<GizmoMenuGroup
v-else-if="activeCategory === 'gizmo' && modelConfig"
v-model:config="modelConfig"
:compact
@toggle-gizmo="emit('toggleGizmo', $event)"
@set-gizmo-mode="emit('setGizmoMode', $event)"
@reset-gizmo-transform="emit('resetGizmoTransform')"
/>
</div>
<div
:class="
cn('flex-1', isRecording && 'border-2 border-node-component-executing')
"
/>
<div
class="pointer-events-auto flex h-10 items-center justify-between gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<div class="flex items-center gap-1">
<RecordMenuControl
v-if="canUseRecording"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
:compact
@start-recording="emit('startRecording')"
@stop-recording="emit('stopRecording')"
@export-recording="emit('exportRecording')"
@clear-recording="emit('clearRecording')"
/>
</div>
<div class="flex items-center gap-1">
<ViewerControls
v-if="enableViewer && node"
:node="node as LGraphNode"
/>
<button
v-if="canFitToViewer"
v-tooltip.top="tip(t('load3d.fitToViewer'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.fitToViewer')"
@click="emit('fitToViewer')"
>
<i class="icon-[lucide--scan] size-4" />
</button>
<button
v-if="canCenterCameraOnModel"
v-tooltip.top="tip(t('load3d.centerCameraOnModel'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.centerCameraOnModel')"
@click="emit('centerCamera')"
>
<i class="icon-[lucide--crosshair] size-4" />
</button>
<Popover v-if="canExport" v-model:open="exportOpen">
<PopoverTrigger as-child>
<button
v-tooltip.top="tip(t('load3d.export'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.export')"
>
<i class="icon-[lucide--download] size-4" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
:side-offset="8"
:class="panelClass"
>
<button
v-for="format in exportFormats"
:key="format.value"
type="button"
:class="rowClass"
@click="onExport(format.value)"
>
{{ format.label }}
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { PopoverTrigger } from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
import {
chipClass,
iconBtnClass,
panelClass,
rowClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
CameraConfig,
GizmoMode,
LightConfig,
MaterialMode,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const {
canUseLighting = true,
canUseHdri = true,
canUseGizmo = true,
canExport = true,
canUseBackgroundImage = true,
canFitToViewer = true,
canCenterCameraOnModel = true,
canUseRecording = true,
enableViewer = false,
node = null,
materialModes = ['original', 'clay', 'normal', 'wireframe'],
hasSkeleton = false,
sourceFormat = null
} = defineProps<{
canUseLighting?: boolean
canUseHdri?: boolean
canUseGizmo?: boolean
canExport?: boolean
canUseBackgroundImage?: boolean
canFitToViewer?: boolean
canCenterCameraOnModel?: boolean
canUseRecording?: boolean
enableViewer?: boolean
node?: LGraphNode | null
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
sourceFormat?: string | null
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
const modelConfig = defineModel<ModelConfig>('modelConfig')
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
const lightConfig = defineModel<LightConfig>('lightConfig')
const isRecording = defineModel<boolean>('isRecording')
const hasRecording = defineModel<boolean>('hasRecording')
const recordingDuration = defineModel<number>('recordingDuration')
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'updateHdriFile', file: File | null): void
(e: 'exportModel', format: string): void
(e: 'fitToViewer'): void
(e: 'centerCamera'): void
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
(e: 'startRecording'): void
(e: 'stopRecording'): void
(e: 'exportRecording'): void
(e: 'clearRecording'): void
}>()
const { t } = useI18n()
const categoryDefs = computed(() =>
[
{ key: 'scene', label: t('load3d.scene'), show: !!sceneConfig.value },
{
key: 'model',
label: t('load3d.model3d'),
show: !!modelConfig.value
},
{ key: 'camera', label: t('load3d.camera'), show: !!cameraConfig.value },
{
key: 'light',
label: t('load3d.light'),
show: canUseLighting && !!lightConfig.value && !!modelConfig.value
},
{
key: 'hdri',
label: t('load3d.hdri.label'),
show: canUseHdri && !!lightConfig.value
},
{
key: 'gizmo',
label: t('load3d.gizmo.label'),
show: canUseGizmo && !!modelConfig.value
}
].filter((c) => c.show)
)
const activeCategory = ref('scene')
const activeLabel = computed(
() =>
categoryDefs.value.find((c) => c.key === activeCategory.value)?.label ?? ''
)
watch(categoryDefs, (defs) => {
if (!defs.some((c) => c.key === activeCategory.value)) {
activeCategory.value = defs[0]?.key ?? 'scene'
}
})
const catMenuOpen = ref(false)
const exportOpen = ref(false)
const sceneHasImage = computed(
() =>
!!sceneConfig.value?.backgroundImage &&
sceneConfig.value.backgroundImage !== ''
)
const hdriActive = computed(
() =>
!!lightConfig.value?.hdri?.hdriPath && !!lightConfig.value?.hdri?.enabled
)
const isOriginalMaterial = computed(
() => modelConfig.value?.materialMode === 'original'
)
const cameraFov = computed({
get: () => cameraConfig.value?.fov ?? 0,
set: (value) => {
if (cameraConfig.value) cameraConfig.value.fov = value
}
})
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const topBarRef = ref<HTMLElement | null>(null)
const { width: topW } = useElementSize(topBarRef)
const compactWidthThreshold = 480
const compact = computed(
() => topW.value > 0 && topW.value < compactWidthThreshold
)
function selectCategory(key: string) {
activeCategory.value = key
catMenuOpen.value = false
}
function onExport(format: string) {
emit('exportModel', format)
exportOpen.value = false
}
</script>

View File

@@ -1,205 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
startRecording: 'Start recording',
stopRecording: 'Stop recording',
exportRecording: 'Export recording',
clearRecording: 'Clear recording'
}
}
}
})
type RenderOpts = {
hasRecording?: boolean
isRecording?: boolean
recordingDuration?: number
onStartRecording?: () => void
onStopRecording?: () => void
onExportRecording?: () => void
onClearRecording?: () => void
}
function renderComponent(opts: RenderOpts = {}) {
const hasRecording = ref<boolean>(opts.hasRecording ?? false)
const isRecording = ref<boolean>(opts.isRecording ?? false)
const recordingDuration = ref<number>(opts.recordingDuration ?? 0)
const utils = render(RecordingControls, {
props: {
hasRecording: hasRecording.value,
'onUpdate:hasRecording': (v: boolean | undefined) => {
if (v !== undefined) hasRecording.value = v
},
isRecording: isRecording.value,
'onUpdate:isRecording': (v: boolean | undefined) => {
if (v !== undefined) isRecording.value = v
},
recordingDuration: recordingDuration.value,
'onUpdate:recordingDuration': (v: number | undefined) => {
if (v !== undefined) recordingDuration.value = v
},
onStartRecording: opts.onStartRecording,
onStopRecording: opts.onStopRecording,
onExportRecording: opts.onExportRecording,
onClearRecording: opts.onClearRecording
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, user: userEvent.setup() }
}
describe('RecordingControls', () => {
it('shows the start-recording button initially', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Start recording' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Stop recording' })
).not.toBeInTheDocument()
})
it('shows the stop-recording button while recording is in progress', () => {
renderComponent({ isRecording: true })
expect(
screen.getByRole('button', { name: 'Stop recording' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Start recording' })
).not.toBeInTheDocument()
})
it('emits startRecording when the button is clicked from a stopped state', async () => {
const onStartRecording = vi.fn()
const onStopRecording = vi.fn()
const { user } = renderComponent({
isRecording: false,
onStartRecording,
onStopRecording
})
await user.click(screen.getByRole('button', { name: 'Start recording' }))
expect(onStartRecording).toHaveBeenCalledOnce()
expect(onStopRecording).not.toHaveBeenCalled()
})
it('emits stopRecording when the button is clicked from a recording state', async () => {
const onStartRecording = vi.fn()
const onStopRecording = vi.fn()
const { user } = renderComponent({
isRecording: true,
onStartRecording,
onStopRecording
})
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
expect(onStopRecording).toHaveBeenCalledOnce()
expect(onStartRecording).not.toHaveBeenCalled()
})
it('hides the export and clear buttons when there is no recording', () => {
renderComponent({ hasRecording: false, isRecording: false })
expect(
screen.queryByRole('button', { name: 'Export recording' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Clear recording' })
).not.toBeInTheDocument()
})
it('shows the export and clear buttons once a recording exists', () => {
renderComponent({ hasRecording: true, isRecording: false })
expect(
screen.getByRole('button', { name: 'Export recording' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Clear recording' })
).toBeInTheDocument()
})
it('hides the export and clear buttons during a new recording even if a previous one exists', () => {
renderComponent({ hasRecording: true, isRecording: true })
expect(
screen.queryByRole('button', { name: 'Export recording' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Clear recording' })
).not.toBeInTheDocument()
})
it('emits exportRecording and clearRecording from their respective buttons', async () => {
const onExportRecording = vi.fn()
const onClearRecording = vi.fn()
const { user } = renderComponent({
hasRecording: true,
isRecording: false,
onExportRecording,
onClearRecording
})
await user.click(screen.getByRole('button', { name: 'Export recording' }))
await user.click(screen.getByRole('button', { name: 'Clear recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('renders the formatted duration as MM:SS once a recording exists', () => {
renderComponent({
hasRecording: true,
isRecording: false,
recordingDuration: 75
})
expect(screen.getByTestId('load3d-recording-duration')).toHaveTextContent(
'01:15'
)
})
it('hides the duration display while a recording is in progress', () => {
renderComponent({
hasRecording: true,
isRecording: true,
recordingDuration: 30
})
expect(
screen.queryByTestId('load3d-recording-duration')
).not.toBeInTheDocument()
})
it('hides the duration display when recordingDuration is zero', () => {
renderComponent({
hasRecording: true,
isRecording: false,
recordingDuration: 0
})
expect(
screen.queryByTestId('load3d-recording-duration')
).not.toBeInTheDocument()
})
})

View File

@@ -1,126 +0,0 @@
<template>
<div class="relative rounded-lg bg-backdrop/30">
<div class="flex flex-col gap-2">
<Button
v-tooltip.right="{
value: isRecording
? $t('load3d.stopRecording')
: $t('load3d.startRecording'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="
cn(
'rounded-full',
isRecording && 'recording-button-blink text-red-500'
)
"
:aria-label="
isRecording ? $t('load3d.stopRecording') : $t('load3d.startRecording')
"
@click="toggleRecording"
>
<i
:class="[
'pi',
isRecording ? 'pi-circle-fill' : 'pi-video',
'text-lg text-base-foreground'
]"
/>
</Button>
<Button
v-if="hasRecording && !isRecording"
v-tooltip.right="{
value: $t('load3d.exportRecording'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.exportRecording')"
@click="handleExportRecording"
>
<i class="pi pi-download text-lg text-base-foreground" />
</Button>
<Button
v-if="hasRecording && !isRecording"
v-tooltip.right="{
value: $t('load3d.clearRecording'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.clearRecording')"
@click="handleClearRecording"
>
<i class="pi pi-trash text-lg text-base-foreground" />
</Button>
<div
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
class="mt-1 text-center text-xs text-base-foreground"
data-testid="load3d-recording-duration"
>
{{ formatDuration(recordingDuration) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
const hasRecording = defineModel<boolean>('hasRecording')
const isRecording = defineModel<boolean>('isRecording')
const recordingDuration = defineModel<number>('recordingDuration')
const emit = defineEmits<{
(e: 'startRecording'): void
(e: 'stopRecording'): void
(e: 'exportRecording'): void
(e: 'clearRecording'): void
}>()
function toggleRecording() {
if (isRecording.value) {
emit('stopRecording')
} else {
emit('startRecording')
}
}
function handleExportRecording() {
emit('exportRecording')
}
function handleClearRecording() {
emit('clearRecording')
}
function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}
</script>
<style scoped>
.recording-button-blink {
animation: blink 1s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>

View File

@@ -0,0 +1,47 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(overrides: Partial<CameraConfig> = {}): CameraConfig {
return { cameraType: 'perspective', fov: 75, ...overrides }
}
function renderGroup(config = makeConfig()) {
const result = render(CameraMenuGroup, {
props: { config },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup(), config }
}
describe('CameraMenuGroup', () => {
it('switches the projection type', async () => {
const { user, config } = renderGroup()
await user.click(screen.getByRole('button', { name: 'Perspective' }))
expect(config.cameraType).toBe('orthographic')
})
it('offers the FOV control only for a perspective camera', () => {
renderGroup(makeConfig({ cameraType: 'orthographic' }))
expect(
screen.queryByRole('button', { name: 'FOV' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Orthographic' })
).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,86 @@
<template>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.switchProjection'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.switchProjection') : undefined"
@click="switchCamera"
>
<i class="icon-[lucide--camera] size-4" />
<span v-if="!compact">{{ cameraTypeLabel }}</span>
</button>
<Popover v-if="isPerspective">
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
>
<i class="icon-[lucide--focus] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="cn(panelClass, 'w-56')"
>
<div class="flex flex-col gap-2 p-1">
<span class="text-sm text-base-foreground">{{ t('load3d.fov') }}</span>
<Slider
:model-value="[fov]"
:min="10"
:max="150"
:step="1"
class="w-full"
@update:model-value="setFov"
/>
</div>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
panelClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
import { PopoverTrigger } from 'reka-ui'
const { compact = false } = defineProps<{
compact?: boolean
}>()
const config = defineModel<CameraConfig>('config')
const { t } = useI18n()
const cameraType = computed(() => config.value?.cameraType)
const isPerspective = computed(() => cameraType.value === 'perspective')
const cameraTypeLabel = computed(() =>
cameraType.value ? t(`load3d.cameraType.${cameraType.value}`) : ''
)
const fov = computed(() => config.value?.fov ?? 0)
function switchCamera() {
if (!config.value) return
config.value.cameraType =
config.value.cameraType === 'perspective' ? 'orthographic' : 'perspective'
}
function setFov(value?: number[]) {
if (config.value && value?.length) config.value.fov = value[0]
}
</script>

View File

@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(enabled: boolean): ModelConfig {
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}
type Props = {
config: ModelConfig
onToggleGizmo?: (enabled: boolean) => void
onSetGizmoMode?: (mode: string) => void
}
function renderGroup(props: Props) {
const result = render(GizmoMenuGroup, {
props,
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
describe('GizmoMenuGroup', () => {
it('enables the gizmo and reveals the mode controls', async () => {
const config = makeConfig(false)
const onToggleGizmo = vi.fn()
const { user } = renderGroup({ config, onToggleGizmo })
expect(
screen.queryByRole('button', { name: 'Rotate' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
expect(onToggleGizmo).toHaveBeenCalledWith(true)
expect(config.gizmo?.enabled).toBe(true)
expect(screen.getByRole('button', { name: 'Rotate' })).toBeInTheDocument()
})
it('sets the transform mode', async () => {
const config = makeConfig(true)
const onSetGizmoMode = vi.fn()
const { user } = renderGroup({ config, onSetGizmoMode })
await user.click(screen.getByRole('button', { name: 'Rotate' }))
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
expect(config.gizmo?.mode).toBe('rotate')
})
})

View File

@@ -0,0 +1,105 @@
<template>
<button
v-tooltip.bottom="tip(t('load3d.gizmo.toggle'))"
:class="actionClass(gizmoEnabled)"
:aria-pressed="gizmoEnabled"
type="button"
:aria-label="compact ? t('load3d.gizmo.toggle') : undefined"
@click="toggleGizmo"
>
<i class="icon-[lucide--axis-3d] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.toggle') }}</span>
</button>
<template v-if="gizmoEnabled">
<button
v-tooltip.bottom="tip(t('load3d.gizmo.translate'))"
:class="actionClass(gizmoMode === 'translate')"
:aria-pressed="gizmoMode === 'translate'"
type="button"
:aria-label="compact ? t('load3d.gizmo.translate') : undefined"
@click="setGizmoMode('translate')"
>
<i class="icon-[lucide--move] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.translate') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.gizmo.rotate'))"
:class="actionClass(gizmoMode === 'rotate')"
:aria-pressed="gizmoMode === 'rotate'"
type="button"
:aria-label="compact ? t('load3d.gizmo.rotate') : undefined"
@click="setGizmoMode('rotate')"
>
<i class="icon-[lucide--rotate-3d] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.rotate') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.gizmo.scale'))"
:class="actionClass(gizmoMode === 'scale')"
:aria-pressed="gizmoMode === 'scale'"
type="button"
:aria-label="compact ? t('load3d.gizmo.scale') : undefined"
@click="setGizmoMode('scale')"
>
<i class="icon-[lucide--scale-3d] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.scale') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.gizmo.reset'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.gizmo.reset') : undefined"
@click="resetGizmoTransform"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
<span v-if="!compact">{{ t('load3d.gizmo.reset') }}</span>
</button>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
import type {
GizmoMode,
ModelConfig
} from '@/extensions/core/load3d/interfaces'
const { compact = false } = defineProps<{
compact?: boolean
}>()
const config = defineModel<ModelConfig>('config')
const emit = defineEmits<{
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
}>()
const { t } = useI18n()
const gizmoEnabled = computed(() => config.value?.gizmo?.enabled ?? false)
const gizmoMode = computed(() => config.value?.gizmo?.mode ?? 'translate')
function toggleGizmo() {
const gizmo = config.value?.gizmo
if (!gizmo) return
gizmo.enabled = !gizmo.enabled
emit('toggleGizmo', gizmo.enabled)
}
function setGizmoMode(mode: GizmoMode) {
const gizmo = config.value?.gizmo
if (!gizmo) return
gizmo.mode = mode
emit('setGizmoMode', mode)
}
function resetGizmoTransform() {
emit('resetGizmoTransform')
}
</script>

View File

@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
import type {
HDRIConfig,
LightConfig
} from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(hdri?: Partial<HDRIConfig>): LightConfig {
return {
intensity: 5,
hdri: hdri
? {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1,
...hdri
}
: undefined
}
}
type Props = {
config?: LightConfig
sceneHasImage?: boolean
onUpdateHdriFile?: (file: File | null) => void
}
function renderGroup(props: Props = {}) {
const result = render(HdriMenuGroup, {
props: { config: makeConfig({}), ...props },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
describe('HdriMenuGroup', () => {
it('shows the upload button when no HDRI is loaded', () => {
renderGroup()
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
})
it('hides the upload when a background image is set and no HDRI exists', () => {
renderGroup({ config: makeConfig({ hdriPath: '' }), sceneHasImage: true })
expect(
screen.queryByRole('button', { name: 'Upload' })
).not.toBeInTheDocument()
})
it('toggles enabled and forwards removal once a file is loaded', async () => {
const onUpdateHdriFile = vi.fn()
const config = makeConfig({ hdriPath: 'env.hdr', enabled: false })
const { user } = renderGroup({ config, onUpdateHdriFile })
await user.click(screen.getByRole('button', { name: 'HDRI' }))
expect(config.hdri?.enabled).toBe(true)
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
})
})

View File

@@ -0,0 +1,130 @@
<template>
<template v-if="!sceneHasImage || hdriPath">
<button
v-tooltip.bottom="
tip(
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
)
"
:class="actionClass(false)"
type="button"
:aria-label="
compact
? hdriPath
? t('load3d.hdri.changeFile')
: t('load3d.hdri.uploadFile')
: undefined
"
@click="hdriFileRef?.click()"
>
<i class="icon-[lucide--upload] size-4" />
<span v-if="!compact">{{
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
}}</span>
</button>
<input
ref="hdriFileRef"
type="file"
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
class="pointer-events-none absolute size-0 opacity-0"
@change="onHdriFilePicked"
/>
</template>
<template v-if="hdriPath">
<button
v-tooltip.bottom="tip(t('load3d.hdri.label'))"
:class="actionClass(hdriEnabled)"
:aria-pressed="hdriEnabled"
type="button"
:aria-label="compact ? t('load3d.hdri.label') : undefined"
@click="toggleHdriEnabled"
>
<i class="icon-[lucide--globe] size-4" />
<span v-if="!compact">{{ t('load3d.hdri.label') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.hdri.showAsBackground'))"
:class="actionClass(hdriShowAsBackground)"
:aria-pressed="hdriShowAsBackground"
type="button"
:aria-label="compact ? t('load3d.hdri.showAsBackground') : undefined"
@click="toggleHdriShowAsBackground"
>
<i class="icon-[lucide--image] size-4" />
<span v-if="!compact">{{ t('load3d.hdri.showAsBackground') }}</span>
</button>
<button
v-tooltip.bottom="tip(t('load3d.hdri.removeFile'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.hdri.removeFile') : undefined"
@click="removeHdri"
>
<i class="icon-[lucide--x] size-4" />
<span v-if="!compact">{{ t('load3d.hdri.removeFile') }}</span>
</button>
</template>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
import {
SUPPORTED_HDRI_EXTENSIONS,
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
const { compact = false, sceneHasImage = false } = defineProps<{
compact?: boolean
sceneHasImage?: boolean
}>()
const config = defineModel<LightConfig>('config')
const emit = defineEmits<{
(e: 'updateHdriFile', file: File | null): void
}>()
const { t } = useI18n()
const hdriPath = computed(() => config.value?.hdri?.hdriPath ?? '')
const hdriEnabled = computed(() => config.value?.hdri?.enabled ?? false)
const hdriShowAsBackground = computed(
() => config.value?.hdri?.showAsBackground ?? false
)
const hdriFileRef = ref<HTMLInputElement | null>(null)
function onHdriFilePicked(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
input.value = ''
if (file) {
const ext = `.${file.name.split('.').pop()?.toLowerCase() ?? ''}`
if (!SUPPORTED_HDRI_EXTENSIONS.has(ext)) {
useToastStore().addAlert(t('toastMessages.unsupportedHDRIFormat'))
return
}
}
emit('updateHdriFile', file)
}
function toggleHdriEnabled() {
const hdri = config.value?.hdri
if (hdri) hdri.enabled = !hdri.enabled
}
function toggleHdriShowAsBackground() {
const hdri = config.value?.hdri
if (hdri) hdri.showAsBackground = !hdri.showAsBackground
}
function removeHdri() {
emit('updateHdriFile', null)
}
</script>

View File

@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const settingValues: Record<string, number> = {
'Comfy.Load3D.LightIntensityMinimum': 1,
'Comfy.Load3D.LightIntensityMaximum': 10,
'Comfy.Load3D.LightAdjustmentIncrement': 0.1
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (key: string) => settingValues[key] })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderGroup(isOriginalMaterial: boolean) {
const config: LightConfig = { intensity: 5 }
return render(LightMenuGroup, {
props: { config, isOriginalMaterial },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
}
describe('LightMenuGroup', () => {
it('shows the intensity control for the original material', () => {
renderGroup(true)
expect(
screen.getByRole('button', { name: 'Intensity' })
).toBeInTheDocument()
})
it('explains intensity is unavailable for other materials', () => {
renderGroup(false)
expect(
screen.queryByRole('button', { name: 'Intensity' })
).not.toBeInTheDocument()
expect(screen.getByText('Original material only')).toBeInTheDocument()
})
it('drives HDRI intensity (0-5) when an HDRI environment is active', async () => {
const config: LightConfig = {
intensity: 5,
hdri: {
enabled: true,
hdriPath: 'env.hdr',
showAsBackground: false,
intensity: 2
}
}
render(LightMenuGroup, {
props: { config, isOriginalMaterial: true },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Intensity' }))
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '5')
expect(slider).toHaveAttribute('aria-valuenow', '2')
})
})

View File

@@ -0,0 +1,105 @@
<template>
<Popover v-if="isOriginalMaterial">
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.intensity'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.intensity') : undefined"
>
<i class="icon-[lucide--sun] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.intensity') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="cn(panelClass, 'w-56')"
>
<div class="flex flex-col gap-2 p-1">
<span class="text-sm text-base-foreground">{{
t('load3d.lightIntensity')
}}</span>
<Slider
:model-value="[sliderValue]"
:min="sliderMin"
:max="sliderMax"
:step="sliderStep"
class="w-full"
@update:model-value="onIntensityUpdate"
/>
</div>
</PopoverContent>
</Popover>
<span v-else class="px-2 text-sm text-muted">{{
t('load3d.menuBar.originalMaterialOnly')
}}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
panelClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
import { PopoverTrigger } from 'reka-ui'
const { compact = false, isOriginalMaterial = false } = defineProps<{
compact?: boolean
isOriginalMaterial?: boolean
}>()
const config = defineModel<LightConfig>('config')
const { t } = useI18n()
const settingStore = useSettingStore()
const lightIntensityMinimum = settingStore.get(
'Comfy.Load3D.LightIntensityMinimum'
)
const lightIntensityMaximum = settingStore.get(
'Comfy.Load3D.LightIntensityMaximum'
)
const lightAdjustmentIncrement = settingStore.get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
const usesHdriIntensity = computed(
() => !!config.value?.hdri?.hdriPath?.length && !!config.value?.hdri?.enabled
)
const sliderMin = computed(() =>
usesHdriIntensity.value ? 0 : lightIntensityMinimum
)
const sliderMax = computed(() =>
usesHdriIntensity.value ? 5 : lightIntensityMaximum
)
const sliderStep = computed(() =>
usesHdriIntensity.value ? 0.1 : lightAdjustmentIncrement
)
const sliderValue = computed(() =>
usesHdriIntensity.value
? (config.value?.hdri?.intensity ?? 1)
: (config.value?.intensity ?? lightIntensityMinimum)
)
function onIntensityUpdate(value?: number[]) {
if (!value?.length || !config.value) return
const next = value[0]
if (usesHdriIntensity.value) {
if (config.value.hdri) config.value.hdri.intensity = next
} else {
config.value.intensity = next
}
}
</script>

View File

@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(overrides: Partial<ModelConfig> = {}): ModelConfig {
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
...overrides
}
}
function renderGroup(
props: { config?: ModelConfig; hasSkeleton?: boolean } = {}
) {
const result = render(ModelMenuGroup, {
props: { config: makeConfig(), ...props },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
describe('ModelMenuGroup', () => {
it('sets the up direction from the popover', async () => {
const config = makeConfig()
const { user } = renderGroup({ config })
await user.click(screen.getByRole('button', { name: 'Up Direction' }))
await user.click(screen.getByRole('button', { name: '+Y' }))
expect(config.upDirection).toBe('+y')
})
it('sets the material mode from the popover', async () => {
const config = makeConfig()
const { user } = renderGroup({ config })
await user.click(screen.getByRole('button', { name: 'Material' }))
await user.click(screen.getByRole('button', { name: 'Wireframe' }))
expect(config.materialMode).toBe('wireframe')
})
it('toggles the skeleton only when supported', async () => {
const config = makeConfig({ showSkeleton: false })
const { user, rerender } = renderGroup({ config, hasSkeleton: false })
expect(
screen.queryByRole('button', { name: 'Skeleton' })
).not.toBeInTheDocument()
await rerender({ config, hasSkeleton: true })
await user.click(screen.getByRole('button', { name: 'Skeleton' }))
expect(config.showSkeleton).toBe(true)
})
})

View File

@@ -0,0 +1,135 @@
<template>
<Popover>
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.upDirection'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.upDirection') : undefined"
>
<i class="icon-[lucide--move-3d] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.upDirection') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
v-for="d in upDirections"
:key="d"
type="button"
:class="cn(rowClass, upDirection === d && 'bg-button-active-surface')"
@click="setUpDirection(d)"
>
{{ d.toUpperCase() }}
</button>
</PopoverContent>
</Popover>
<Popover v-if="materialModes.length">
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.material'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.material') : undefined"
>
<i class="icon-[lucide--box] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.material') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
v-for="m in materialModes"
:key="m"
type="button"
:class="cn(rowClass, materialMode === m && 'bg-button-active-surface')"
@click="setMaterialMode(m)"
>
{{ t(`load3d.materialModes.${m}`) }}
</button>
</PopoverContent>
</Popover>
<button
v-if="hasSkeleton"
v-tooltip.bottom="tip(t('load3d.menuBar.skeleton'))"
:class="actionClass(showSkeleton)"
:aria-pressed="showSkeleton"
type="button"
:aria-label="compact ? t('load3d.menuBar.skeleton') : undefined"
@click="toggleSkeleton"
>
<i class="icon-[lucide--bone] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.skeleton') }}</span>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
panelClass,
rowClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import type {
MaterialMode,
ModelConfig,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
import { PopoverTrigger } from 'reka-ui'
const {
compact = false,
hasSkeleton = false,
materialModes = ['original', 'clay', 'normal', 'wireframe']
} = defineProps<{
compact?: boolean
hasSkeleton?: boolean
materialModes?: readonly MaterialMode[]
}>()
const config = defineModel<ModelConfig>('config')
const { t } = useI18n()
const upDirection = computed(() => config.value?.upDirection)
const materialMode = computed(() => config.value?.materialMode)
const showSkeleton = computed(() => config.value?.showSkeleton ?? false)
const upDirections: UpDirection[] = [
'original',
'-x',
'+x',
'-y',
'+y',
'-z',
'+z'
]
function setUpDirection(direction: UpDirection) {
if (config.value) config.value.upDirection = direction
}
function setMaterialMode(mode: MaterialMode) {
if (config.value) config.value.materialMode = mode
}
function toggleSkeleton() {
if (config.value) config.value.showSkeleton = !config.value.showSkeleton
}
</script>

View File

@@ -0,0 +1,138 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
type Props = {
isRecording?: boolean
hasRecording?: boolean
recordingDuration?: number
onStartRecording?: () => void
onStopRecording?: () => void
onExportRecording?: () => void
onClearRecording?: () => void
}
function renderControl(props: Props = {}) {
const result = render(RecordMenuControl, {
props,
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
async function openRecordingMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
)
}
describe('RecordMenuControl', () => {
it('starts recording when idle', async () => {
const onStartRecording = vi.fn()
const { user } = renderControl({ isRecording: false, onStartRecording })
await user.click(screen.getByRole('button', { name: 'Record' }))
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('stops recording when active', async () => {
const onStopRecording = vi.fn()
const { user } = renderControl({ isRecording: true, onStopRecording })
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
expect(onStopRecording).toHaveBeenCalledOnce()
})
it('shows the recording duration once a recording exists', () => {
renderControl({
isRecording: false,
hasRecording: true,
recordingDuration: 65
})
expect(screen.getByText('01:05')).toBeInTheDocument()
})
it('downloads the recording from the menu', async () => {
const onExportRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onExportRecording
})
await openRecordingMenu(user)
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
})
it('starts a new recording from the menu', async () => {
const onStartRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onStartRecording
})
await openRecordingMenu(user)
await user.click(
screen.getByRole('button', { name: 'Start New Recording' })
)
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('deletes the recording from the menu', async () => {
const onClearRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onClearRecording
})
await openRecordingMenu(user)
await user.click(
within(screen.getByRole('dialog')).getByRole('button', {
name: 'Delete Recording'
})
)
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('deletes the recording via the chip dismiss button', async () => {
const onClearRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onClearRecording
})
await user.click(screen.getByRole('button', { name: 'Delete Recording' }))
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('shows only the stop control while recording is in progress', () => {
renderControl({ isRecording: true, hasRecording: true })
expect(
screen.queryByRole('button', {
name: 'Video recording of the scene [mp4]'
})
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,141 @@
<template>
<button
v-if="isRecording"
v-tooltip.top="tip(t('load3d.menuBar.stopRecording'))"
:class="chipClass"
type="button"
:aria-label="t('load3d.menuBar.stopRecording')"
@click="emit('stopRecording')"
>
<span class="size-2 animate-pulse rounded-full bg-red-500" />
<span v-if="!compact">{{ t('load3d.menuBar.recording') }}</span>
</button>
<div
v-else-if="hasRecording"
class="flex shrink-0 items-center gap-0.5 rounded-lg bg-button-active-surface py-0.5 pr-0.5 pl-1 text-sm text-base-foreground"
>
<Popover v-model:open="menuOpen">
<PopoverTrigger as-child>
<button
v-tooltip.top="tip(t('load3d.menuBar.videoRecordingTooltip'))"
class="focus-visible:ring-ring flex items-center gap-1.5 rounded-md border-0 bg-transparent px-1 py-0.5 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
type="button"
:aria-label="t('load3d.menuBar.videoRecordingTooltip')"
data-testid="load3d-recording-duration"
>
<i class="icon-[lucide--film] size-4" />
{{ formatDuration(recordingDuration ?? 0) }}
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
type="button"
:class="cn(rowClass, 'gap-2')"
@click="downloadRecording"
>
<i class="icon-[lucide--download] size-4" />
{{ t('load3d.menuBar.downloadRecording') }}
</button>
<button
type="button"
:class="cn(rowClass, 'gap-2')"
@click="startNewRecording"
>
<i class="icon-[lucide--video] size-4" />
{{ t('load3d.menuBar.startNewRecording') }}
</button>
<button
type="button"
:class="cn(rowClass, 'gap-2')"
@click="deleteRecording"
>
<i class="icon-[lucide--trash-2] size-4" />
{{ t('load3d.menuBar.deleteRecording') }}
</button>
</PopoverContent>
</Popover>
<button
v-tooltip.top="tip(t('load3d.menuBar.deleteRecording'))"
class="focus-visible:ring-ring flex size-6 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
type="button"
:aria-label="t('load3d.menuBar.deleteRecording')"
@click="emit('clearRecording')"
>
<i class="icon-[lucide--x] size-3.5" />
</button>
</div>
<button
v-else
v-tooltip.top="tip(t('load3d.menuBar.record'))"
:class="chipClass"
type="button"
:aria-label="compact ? t('load3d.menuBar.record') : undefined"
@click="emit('startRecording')"
>
<i class="icon-[lucide--video] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.record') }}</span>
</button>
</template>
<script setup lang="ts">
import { PopoverTrigger } from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
chipClass,
panelClass,
rowClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import { cn } from '@comfyorg/tailwind-utils'
const { compact = false } = defineProps<{
compact?: boolean
}>()
const isRecording = defineModel<boolean>('isRecording')
const hasRecording = defineModel<boolean>('hasRecording')
const recordingDuration = defineModel<number>('recordingDuration')
const emit = defineEmits<{
(e: 'startRecording'): void
(e: 'stopRecording'): void
(e: 'exportRecording'): void
(e: 'clearRecording'): void
}>()
const { t } = useI18n()
const menuOpen = ref(false)
function downloadRecording() {
menuOpen.value = false
emit('exportRecording')
}
function startNewRecording() {
menuOpen.value = false
emit('startRecording')
}
function deleteRecording() {
menuOpen.value = false
emit('clearRecording')
}
function formatDuration(seconds: number) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}
</script>

View File

@@ -0,0 +1,108 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeConfig(overrides: Partial<SceneConfig> = {}): SceneConfig {
return {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled',
...overrides
}
}
type Props = {
config?: SceneConfig
fov?: number
hdriActive?: boolean
canUseBackgroundImage?: boolean
onUpdateBackgroundImage?: (file: File | null) => void
}
function renderGroup(props: Props = {}) {
const result = render(SceneMenuGroup, {
props: { config: makeConfig(), ...props },
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
describe('SceneMenuGroup', () => {
it('toggles showGrid on the bound config', async () => {
const config = makeConfig({ showGrid: true })
const { user } = renderGroup({ config })
await user.click(screen.getByRole('button', { name: 'Show grid' }))
expect(config.showGrid).toBe(false)
})
it('hides background color and image controls while HDRI is active', () => {
renderGroup({ hdriActive: true })
expect(
screen.queryByRole('button', { name: 'BG Color' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'BG Image' })
).not.toBeInTheDocument()
})
it('hides the image upload when background images are not allowed', () => {
renderGroup({ canUseBackgroundImage: false })
expect(screen.getByRole('button', { name: 'BG Color' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'BG Image' })
).not.toBeInTheDocument()
})
it('shows panorama and remove once a background image exists', async () => {
const onUpdateBackgroundImage = vi.fn()
const { user } = renderGroup({
config: makeConfig({ backgroundImage: 'bg.png' }),
onUpdateBackgroundImage
})
expect(screen.getByRole('button', { name: 'Panorama' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Remove BG' }))
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
})
it('exposes the FOV control while a panorama background is active', () => {
renderGroup({
config: makeConfig({
backgroundImage: 'bg.png',
backgroundRenderMode: 'panorama'
}),
fov: 75
})
expect(screen.getByRole('button', { name: 'FOV' })).toBeInTheDocument()
})
it('clears the file input so the same image can be re-picked', async () => {
const onUpdateBackgroundImage = vi.fn()
const { user } = renderGroup({ onUpdateBackgroundImage })
const input = screen.getByTestId<HTMLInputElement>('scene-bg-image-input')
const file = new File(['x'], 'bg.png', { type: 'image/png' })
await user.upload(input, file)
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(file)
expect(input.value).toBe('')
})
})

View File

@@ -0,0 +1,190 @@
<template>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.showGrid'))"
:class="actionClass(showGrid)"
:aria-pressed="showGrid"
type="button"
:aria-label="compact ? t('load3d.menuBar.showGrid') : undefined"
@click="toggleGrid"
>
<i class="icon-[lucide--grid-3x3] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.showGrid') }}</span>
</button>
<template v-if="!hasImage && !hdriActive">
<button
v-tooltip.bottom="tip(t('load3d.menuBar.bgColor'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.bgColor') : undefined"
@click="colorRef?.click()"
>
<i class="icon-[lucide--palette] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.bgColor') }}</span>
</button>
<input
ref="colorRef"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
:value="bgColor"
@input="setBackgroundColor"
/>
<template v-if="canUseBackgroundImage">
<button
v-tooltip.bottom="tip(t('load3d.menuBar.bgImage'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.bgImage') : undefined"
@click="bgImageRef?.click()"
>
<i class="icon-[lucide--image] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.bgImage') }}</span>
</button>
<input
ref="bgImageRef"
type="file"
accept="image/*"
class="pointer-events-none absolute size-0 opacity-0"
data-testid="scene-bg-image-input"
@change="onBackgroundImagePicked"
/>
</template>
</template>
<template v-if="hasImage">
<button
v-tooltip.bottom="tip(t('load3d.menuBar.panorama'))"
:class="actionClass(isPanorama)"
:aria-pressed="isPanorama"
type="button"
:aria-label="compact ? t('load3d.menuBar.panorama') : undefined"
@click="togglePanorama"
>
<i class="icon-[lucide--globe] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.panorama') }}</span>
</button>
<Popover v-if="isPanorama">
<PopoverTrigger as-child>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
>
<i class="icon-[lucide--focus] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="cn(panelClass, 'w-56')"
>
<div class="flex flex-col gap-2 p-1">
<span class="text-sm text-base-foreground">{{
t('load3d.fov')
}}</span>
<Slider
:model-value="[fovValue]"
:min="10"
:max="150"
:step="1"
class="w-full"
@update:model-value="setFov"
/>
</div>
</PopoverContent>
</Popover>
<button
v-tooltip.bottom="tip(t('load3d.menuBar.removeBackground'))"
:class="actionClass(false)"
type="button"
:aria-label="compact ? t('load3d.menuBar.removeBackground') : undefined"
@click="removeBackgroundImage"
>
<i class="icon-[lucide--x] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.removeBackground') }}</span>
</button>
</template>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
panelClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
import { PopoverTrigger } from 'reka-ui'
const {
compact = false,
canUseBackgroundImage = true,
hdriActive = false
} = defineProps<{
compact?: boolean
canUseBackgroundImage?: boolean
hdriActive?: boolean
}>()
const config = defineModel<SceneConfig>('config')
const fov = defineModel<number>('fov')
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
}>()
const { t } = useI18n()
const showGrid = computed(() => config.value?.showGrid ?? false)
const bgColor = computed(() => config.value?.backgroundColor ?? '#000000')
const hasImage = computed(
() => !!config.value?.backgroundImage && config.value.backgroundImage !== ''
)
const isPanorama = computed(
() => config.value?.backgroundRenderMode === 'panorama'
)
const fovValue = computed(() => fov.value ?? 10)
const colorRef = ref<HTMLInputElement | null>(null)
const bgImageRef = ref<HTMLInputElement | null>(null)
function toggleGrid() {
if (config.value) config.value.showGrid = !config.value.showGrid
}
function setBackgroundColor(event: Event) {
if (config.value) {
config.value.backgroundColor = (event.target as HTMLInputElement).value
}
}
function onBackgroundImagePicked(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) emit('updateBackgroundImage', file)
}
function removeBackgroundImage() {
emit('updateBackgroundImage', null)
}
function togglePanorama() {
if (!config.value) return
config.value.backgroundRenderMode =
config.value.backgroundRenderMode === 'panorama' ? 'tiled' : 'panorama'
}
function setFov(value?: number[]) {
if (value?.length) fov.value = value[0]
}
</script>

View File

@@ -0,0 +1,24 @@
import { cn } from '@comfyorg/tailwind-utils'
export const chipClass =
'flex shrink-0 items-center gap-1.5 rounded-lg border-0 bg-interface-menu-surface px-2.5 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-button-active-surface focus-visible:ring-1 focus-visible:ring-ring'
export const iconBtnClass =
'flex size-8 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground outline-none transition-colors hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring'
export const panelClass =
'w-48 max-h-80 overflow-y-auto flex flex-col gap-0.5 p-1.5 rounded-lg border-border-default bg-interface-menu-surface shadow-interface'
export const rowClass =
'flex w-full cursor-pointer items-center rounded-md border-0 bg-transparent px-2 py-1.5 text-left text-sm text-base-foreground outline-none hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset'
export function actionClass(active: boolean) {
return cn(
'focus-visible:ring-ring flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1',
active && 'bg-button-active-surface'
)
}
export function tip(label: string) {
return { value: label, showDelay: 300 }
}

View File

@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
await userEvent.click(screen.getByRole('button', { name: '+' }))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
@@ -44,18 +44,14 @@ describe('PaletteSwatchRow', () => {
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
it('opens the color picker when a swatch is clicked', async () => {
const { container } = renderRow(['#ff0000'])
const swatch = container.querySelector('[data-index="0"]')!
await userEvent.click(swatch)
expect(swatch.getAttribute('data-state')).toBe('open')
})
it('starts a drag on pointer down without emitting', async () => {

View File

@@ -1,17 +1,25 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
<ColorPicker
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
:key="i"
:model-value="hex"
:alpha="false"
@update:model-value="(value) => updateAt(i, value)"
>
<template #trigger>
<button
type="button"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
</template>
</ColorPicker>
<button
v-if="modelValue.length < max"
type="button"
@@ -21,12 +29,6 @@
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
@@ -34,6 +36,7 @@
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
@@ -41,8 +44,9 @@ const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
modelValue,
container
})
</script>

View File

@@ -14,20 +14,27 @@ import { cn } from '@comfyorg/tailwind-utils'
import ColorPickerPanel from './ColorPickerPanel.vue'
defineProps<{
const { alpha = true } = defineProps<{
class?: string
disabled?: boolean
alpha?: boolean
}>()
const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
function readHsva(hex: string): HSVA {
const next = hexToHsva(hex || '#000000')
if (!alpha) next.a = 100
return next
}
const hsva = ref<HSVA>(readHsva(modelValue.value))
const displayMode = ref<'hex' | 'rgba'>('hex')
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
hsva.value = hexToHsva(newVal || '#000000')
hsva.value = readHsva(newVal)
}
})
@@ -67,49 +74,51 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
<slot name="trigger">
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</slot>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
@@ -123,6 +132,7 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
:alpha
/>
</PopoverContent>
</PopoverPortal>

View File

@@ -13,6 +13,8 @@ import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const { alpha = true } = defineProps<{ alpha?: boolean }>()
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
@@ -37,6 +39,7 @@ const { t } = useI18n()
/>
<ColorPickerSlider v-model="hsva.h" type="hue" />
<ColorPickerSlider
v-if="alpha"
v-model="hsva.a"
type="alpha"
:hue="hsva.h"
@@ -72,7 +75,7 @@ const { t } = useI18n()
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
</template>
<span class="shrink-0 border-l border-border-subtle pl-1"
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
>
</div>

View File

@@ -156,7 +156,7 @@ describe('fromBoundingBoxes', () => {
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
@@ -167,10 +167,31 @@ describe('fromBoundingBoxes', () => {
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#fff']
palette: ['#ffffff']
})
})
it('normalizes palette entries and drops invalid colors', () => {
const boxes: BoundingBox[] = [
{
x: 0,
y: 0,
width: 10,
height: 10,
metadata: {
type: 'obj',
text: '',
desc: '',
palette: ['#FF0000', '#abc', 'red', '', 123] as unknown as string[]
}
}
]
expect(fromBoundingBoxes(boxes, 100, 100)[0].palette).toEqual([
'#ff0000',
'#aabbcc'
])
})
it('fills defaults when metadata is missing or partial', () => {
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({

View File

@@ -202,6 +202,22 @@ function isBoundingBox(b: unknown): b is BoundingBox {
)
}
function normalizeHexColor(color: unknown): string | null {
if (typeof color !== 'string') return null
const hex = color.trim().toLowerCase()
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex)
if (short) {
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
}
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null
}
function normalizePalette(palette: unknown): string[] {
return Array.isArray(palette)
? palette.map(normalizeHexColor).filter((c): c is string => c !== null)
: []
}
export function fromBoundingBoxes(
boxes: readonly BoundingBox[],
width: number,
@@ -219,9 +235,7 @@ export function fromBoundingBoxes(
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
palette: normalizePalette(meta.palette)
}
})
}

View File

@@ -8,14 +8,32 @@ import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
const { appState, outputState } = vi.hoisted(() => ({
appState: { node: null as unknown },
outputState: {
outputs: undefined as unknown,
nodeOutputs: null as { value: Record<string, unknown> } | null
}
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
vi.mock('@/stores/nodeOutputStore', async () => {
const { ref } = await import('vue')
const nodeOutputs = ref<Record<string, unknown>>({})
outputState.nodeOutputs = nodeOutputs
return {
useNodeOutputStore: () => ({
nodeOutputs,
nodePreviewImages: ref({}),
getNodeImageUrls: () => undefined,
getNodeOutputs: () => outputState.outputs
})
}
})
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
@@ -27,6 +45,9 @@ const ctx = {
save: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
arc: () => {},
fill: () => {},
rect: () => {},
clip: () => {},
font: '',
@@ -58,17 +79,32 @@ function makeCanvas(): HTMLCanvasElement {
return el
}
function makeNode() {
interface MockNode {
widgets: { name: string; value: unknown }[]
findInputSlot: (name: string) => number
getInputNode: () => null
isInputConnected?: () => boolean
}
function makeNode(): MockNode {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
{ name: 'height', value: 512 },
{ name: 'last_incoming', value: [] }
],
findInputSlot: () => -1,
getInputNode: () => null
}
}
const lastIncomingOf = (node: MockNode) =>
node.widgets.find((w) => w.name === 'last_incoming')!.value
const setLastIncomingOf = (node: MockNode, value: BoundingBox[]) => {
node.widgets.find((w) => w.name === 'last_incoming')!.value = value
}
const pe = (
clientX: number,
clientY: number,
@@ -96,6 +132,8 @@ interface Captured extends Api {
modelValue: Ref<BoundingBox[]>
}
const modelBoxes = (c: Captured) => c.modelValue.value
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
@@ -128,9 +166,19 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
...over
})
function makeConnectedNode(): MockNode {
return {
...makeNode(),
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
isInputConnected: () => true
}
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
outputState.outputs = undefined
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
@@ -168,8 +216,8 @@ describe('useBoundingBoxes drawing', () => {
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
@@ -177,7 +225,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
@@ -185,7 +233,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(modelBoxes(c)).toHaveLength(1)
})
})
@@ -194,7 +242,7 @@ describe('useBoundingBoxes region editing', () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(c.modelValue.value[0].metadata.type).toBe('text')
expect(modelBoxes(c)[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
@@ -205,14 +253,18 @@ describe('useBoundingBoxes region editing', () => {
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
})
it('clears all regions', async () => {
it('clears all regions and invalidates the applied upstream input', async () => {
const node = makeNode()
setLastIncomingOf(node, [box()])
appState.node = node
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
expect(lastIncomingOf(node)).toEqual([])
})
})
@@ -226,7 +278,7 @@ describe('useBoundingBoxes inline editor', () => {
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
@@ -239,6 +291,168 @@ describe('useBoundingBoxes inline editor', () => {
})
})
describe('useBoundingBoxes incoming bboxes input', () => {
it('adopts cached outputs on mount without overwriting existing edits', () => {
const node = makeConnectedNode()
appState.node = node
const incoming = [box({ x: 0, width: 100 })]
outputState.outputs = { input_bboxes: incoming }
const c = setup([box({ x: 200, width: 300 })])
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(300)
expect(lastIncomingOf(node)).toEqual(incoming)
})
it('does not re-apply an already applied output after a remount', async () => {
const node = makeConnectedNode()
const incoming = [box({ x: 0, width: 100 })]
setLastIncomingOf(node, incoming)
appState.node = node
outputState.outputs = { input_bboxes: incoming }
const c = setup([box({ x: 200, width: 300 })])
outputState.nodeOutputs!.value = { updated: true }
await flush()
expect(modelBoxes(c)[0].width).toBe(300)
})
it('ignores incoming output when the input is not connected', () => {
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
const c = setup([])
expect(modelBoxes(c)).toHaveLength(0)
})
it('repopulates from the next run after clearing the canvas', async () => {
appState.node = makeConnectedNode()
const c = setup([])
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
c.clearAll()
await flush()
expect(modelBoxes(c)).toHaveLength(0)
outputState.nodeOutputs!.value = { n: 2 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(100)
})
it('does not apply output updates while the input is disconnected', async () => {
let connected = true
appState.node = {
...makeConnectedNode(),
isInputConnected: () => connected
}
const c = setup([])
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
c.clearAll()
await flush()
connected = false
outputState.nodeOutputs!.value = { n: 2 }
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
it('does not apply incoming boxes while the user is drawing', async () => {
appState.node = makeConnectedNode()
const c = setup([])
c.grid.value = false
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(50, 50))
outputState.outputs = {
input_bboxes: [box({ x: 0, width: 100, height: 100 })]
}
outputState.nodeOutputs!.value = { n: 1 }
await flush()
c.onDocPointerUp(pe(50, 50))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(205)
})
it('applies incoming boxes when outputs stream in after mount', async () => {
const node = makeConnectedNode()
appState.node = node
const c = setup([])
expect(modelBoxes(c)).toHaveLength(0)
const incoming = [box({ x: 0, width: 100 })]
outputState.outputs = { input_bboxes: incoming }
outputState.nodeOutputs!.value = { updated: true }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(100)
expect(lastIncomingOf(node)).toEqual(incoming)
})
it('re-seeds the canvas over user edits when the upstream value changes', async () => {
const node = makeConnectedNode()
setLastIncomingOf(node, [box({ x: 0, width: 100 })])
appState.node = node
const c = setup([box({ x: 200, width: 300 })])
const changed = [box({ x: 64, width: 128 })]
outputState.outputs = { input_bboxes: changed }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)[0].width).toBe(128)
expect(lastIncomingOf(node)).toEqual(changed)
})
})
describe('useBoundingBoxes grid snapping', () => {
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].x).toBe(64)
expect(modelBoxes(c)[0].width).toBe(256)
})
it('does not snap when grid is disabled', async () => {
const c = setup()
c.grid.value = false
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(55, 55))
c.onDocPointerUp(pe(55, 55))
await flush()
expect(modelBoxes(c)[0].width).toBe(230)
})
it('keeps the anchored edge fixed when resizing a single edge', async () => {
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
c.onPointerDown(pe(60, 30))
c.onCanvasPointerMove(pe(80, 30))
c.onDocPointerUp(pe(80, 30))
await flush()
expect(modelBoxes(c)[0].x).toBe(51)
})
it('removes a box that a resize collapses to zero size', async () => {
const c = setup([box({ x: 64, y: 64, width: 128, height: 128 })])
c.onPointerDown(pe(37, 25))
c.onCanvasPointerMove(pe(14, 25))
c.onDocPointerUp(pe(14, 25))
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])

View File

@@ -1,4 +1,5 @@
import { useElementSize } from '@vueuse/core'
import { cloneDeep, isEqual } from 'es-toolkit'
import { storeToRefs } from 'pinia'
import type { Ref, ShallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
@@ -15,6 +16,7 @@ import type {
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
@@ -25,6 +27,10 @@ const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
const GRID_PX = 32
const MAX_GRID_CELLS = 64
const DOT_ALPHA = 0.18
const DOT_RADIUS = 1
interface InlineEditorState {
value: string
@@ -57,6 +63,7 @@ export function useBoundingBoxes(
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const grid = ref(true)
const { width: containerWidth } = useElementSize(canvasContainer)
@@ -96,6 +103,89 @@ export function useBoundingBoxes(
return Math.max(0, Math.min(1, n))
}
function gridSpec() {
const axisFraction = (size: number) =>
Math.max(GRID_PX, Math.ceil(size / MAX_GRID_CELLS)) / size
return {
fx: axisFraction(widthValue.value),
fy: axisFraction(heightValue.value)
}
}
function snapFraction(value: number, step: number) {
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
}
function snapRegion(region: Region, mode: HitMode): Region {
if (!grid.value) return region
const { fx, fy } = gridSpec()
if (mode === 'move') {
return {
...region,
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
}
}
const snapLeft =
mode === 'draw' ||
mode === 'resize-l' ||
mode === 'resize-tl' ||
mode === 'resize-bl'
const snapRight =
mode === 'draw' ||
mode === 'resize-r' ||
mode === 'resize-tr' ||
mode === 'resize-br'
const snapTop =
mode === 'draw' ||
mode === 'resize-t' ||
mode === 'resize-tl' ||
mode === 'resize-tr'
const snapBottom =
mode === 'draw' ||
mode === 'resize-b' ||
mode === 'resize-bl' ||
mode === 'resize-br'
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
const x2 = snapRight
? snapFraction(region.x + region.w, fx)
: region.x + region.w
const y2 = snapBottom
? snapFraction(region.y + region.h, fy)
: region.y + region.h
return {
...region,
x: x1,
y: y1,
w: Math.max(0, x2 - x1),
h: Math.max(0, y2 - y1)
}
}
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
const el = canvasEl.value
if (!el) return
const { fx, fy } = gridSpec()
if (fx <= 0 || fy <= 0) return
const cols = Math.round(1 / fx)
const rows = Math.round(1 / fy)
ctx.save()
ctx.globalAlpha = DOT_ALPHA
ctx.fillStyle = getComputedStyle(el).color
ctx.beginPath()
for (let i = 0; i <= cols; i++) {
const cx = Math.min(1, i * fx) * W
for (let j = 0; j <= rows; j++) {
const cy = Math.min(1, j * fy) * H
ctx.moveTo(cx + DOT_RADIUS, cy)
ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2)
}
}
ctx.fill()
ctx.restore()
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
@@ -146,6 +236,8 @@ export function useBoundingBoxes(
ctx.fillRect(0, 0, W, H)
}
if (grid.value) drawDots(ctx, W, H)
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
@@ -366,7 +458,7 @@ export function useBoundingBoxes(
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
requestDraw()
}
@@ -375,7 +467,7 @@ export function useBoundingBoxes(
drawing.value = false
canvasEl.value?.releasePointerCapture?.(e.pointerId)
const b = state.value.regions[activeIndex.value]
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
if (b && (b.w < 0.005 || b.h < 0.005)) {
removeRegion(activeIndex.value)
}
syncState()
@@ -510,6 +602,7 @@ export function useBoundingBoxes(
function clearAll() {
state.value.regions = []
activeIndex.value = -1
setLastIncoming([])
syncState()
}
@@ -530,6 +623,23 @@ export function useBoundingBoxes(
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
watch(
litegraphNode,
(node) => {
const props = node?.properties as { bboxGrid?: unknown } | undefined
if (props && typeof props.bboxGrid === 'boolean')
grid.value = props.bboxGrid
},
{ immediate: true }
)
watch(grid, (enabled) => {
const props = litegraphNode.value?.properties as
| Record<string, unknown>
| undefined
if (props) props.bboxGrid = enabled
requestDraw()
})
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
@@ -580,10 +690,63 @@ export function useBoundingBoxes(
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
function lastIncomingWidget() {
return litegraphNode.value?.widgets?.find((w) => w.name === 'last_incoming')
}
function lastIncomingValue(): BoundingBox[] {
const value = lastIncomingWidget()?.value
return Array.isArray(value) ? (value as BoundingBox[]) : []
}
function setLastIncoming(boxes: BoundingBox[]) {
const widget = lastIncomingWidget()
if (!widget) return
const next = cloneDeep(boxes)
widget.value = next
widget.callback?.(next)
}
function applyIncomingBoxes(apply = true) {
if (drawing.value) return
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('bboxes')
if (slot < 0 || !node.isInputConnected(slot)) return
const outputs = nodeOutputStore.getNodeOutputs(node) as
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
| undefined
const incoming = outputs?.input_bboxes
if (!incoming?.length) return
const applied = lastIncomingValue()
if (isEqual(incoming, applied)) return
if (!apply) {
if (!applied.length && state.value.regions.length)
setLastIncoming(incoming)
return
}
state.value.regions = fromBoundingBoxes(
incoming,
widthValue.value,
heightValue.value
)
activeIndex.value = state.value.regions.length ? 0 : -1
setLastIncoming(incoming)
syncState()
}
watch(
() => nodeOutputStore.nodeOutputs,
() => {
updateBgImage()
applyIncomingBoxes()
},
{ deep: true }
)
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
applyIncomingBoxes(false)
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
@@ -608,6 +771,7 @@ export function useBoundingBoxes(
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
}
}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
@@ -13,17 +13,12 @@ afterEach(() => {
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
return { modelValue, container, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
@@ -37,31 +32,17 @@ describe('usePaletteSwatchRow', () => {
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
it('updates the color at an index', () => {
const { modelValue, updateAt } = setup(['#a', '#b'])
updateAt(1, '#123456')
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
it('ignores an update that does not change the color', () => {
const { modelValue, updateAt } = setup(['#a'])
const before = modelValue.value
updateAt(0, '#a')
expect(modelValue.value).toBe(before)
})
it('reorders via drag when the pointer crosses another swatch', () => {

View File

@@ -5,30 +5,16 @@ import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
container
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
function updateAt(i: number, value: string) {
if (modelValue.value[i] === value) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
next[i] = value
modelValue.value = next
}
@@ -105,8 +91,7 @@ export function usePaletteSwatchRow({
})
return {
openPicker,
onPickerInput,
updateAt,
remove,
addColor,
onPointerDown

View File

@@ -0,0 +1,316 @@
import { describe, expect, it } from 'vitest'
import type { SearchResult } from 'minisearch'
import {
createTemplateSearchIndex,
expandAbbreviation,
expandQuery,
rankByRelevanceThenUsage,
searchTemplates,
termFuzziness,
tokenize
} from '@/composables/templateSearchConfig'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const buildTemplate = (
overrides: Partial<TemplateInfo> & { name: string }
): TemplateInfo => ({
description: '',
mediaType: 'image',
mediaSubtype: 'png',
...overrides
})
describe('tokenize', () => {
it('splits identifiers on hyphen and underscore', () => {
expect(tokenize('video_ltx2_3_t2v')).toEqual([
'video_ltx2_3_t2v',
'video',
'ltx2',
'3',
't2v'
])
})
it('splits a trailing version off its name so "wan 2.7" matches "wan2.7"', () => {
expect(tokenize('wan2.7')).toEqual(['wan2.7', 'wan', '2.7'])
})
it('keeps a mid-digit abbreviation whole', () => {
expect(tokenize('t2v')).toEqual(['t2v'])
})
it('emits a unigram and bigram for each char of an unspaced CJK run', () => {
expect(tokenize('图像放大')).toEqual([
'图',
'像',
'放',
'大',
'图像',
'像放',
'放大'
])
})
it('grams katakana including the prolonged-sound mark', () => {
expect(tokenize('データ')).toEqual(['デ', 'ー', 'タ', 'デー', 'ータ'])
})
it('treats Korean as a spaced script, not an unspaced CJK run', () => {
expect(tokenize('업스케일')).toEqual(['업스케일'])
})
it('grams the CJK part of a word glued to latin, keeping the whole word', () => {
expect(tokenize('flux图像')).toEqual(['图', '像', '图像', 'flux图像'])
})
it('lowercases and drops empty tokens', () => {
expect(tokenize(' Flux Kontext ')).toEqual(['flux', 'kontext'])
})
})
describe('termFuzziness', () => {
it('is exact for short terms (≤3 chars)', () => {
expect(termFuzziness('t2v')).toBe(false)
expect(termFuzziness('cn')).toBe(false)
})
it('is exact for any term containing a digit so versions do not blur', () => {
expect(termFuzziness('2.5')).toBe(false)
expect(termFuzziness('flux2')).toBe(false)
})
it('allows edits for longer alphabetic terms', () => {
expect(termFuzziness('control')).not.toBe(false)
})
})
describe('expandAbbreviation', () => {
it('expands cross-modality shorthand', () => {
expect(expandAbbreviation('t2i')).toBe('text image')
expect(expandAbbreviation('i2v')).toBe('image video')
expect(expandAbbreviation('txt2img')).toBe('text image')
})
it('expands same-modality transforms to editing', () => {
expect(expandAbbreviation('img2img')).toBe('image edit')
expect(expandAbbreviation('v2v')).toBe('video edit')
})
it('expands known acronyms', () => {
expect(expandAbbreviation('cn')).toBe('controlnet')
})
it('returns null for unknown tokens and unknown modalities', () => {
expect(expandAbbreviation('flux')).toBeNull()
expect(expandAbbreviation('x2y')).toBeNull()
})
})
describe('expandQuery', () => {
it('expands shorthand tokens within a multi-word query', () => {
expect(expandQuery('wan i2v')).toBe('wan image video')
})
it('returns null when nothing expands', () => {
expect(expandQuery('flux upscale')).toBeNull()
})
})
describe('searchTemplates', () => {
const buildIndex = (templates: TemplateInfo[]) =>
createTemplateSearchIndex(templates)
it('returns an empty array for a blank query without touching the index', () => {
const index = buildIndex([buildTemplate({ name: 'a', title: 'Alpha' })])
expect(searchTemplates(index, ' ')).toEqual([])
})
it('matches a prefix ("vid" → "video")', () => {
const index = buildIndex([
buildTemplate({ name: 'video', title: 'Video Generator' }),
buildTemplate({ name: 'audio', title: 'Audio Studio' })
])
expect(searchTemplates(index, 'vid')).toContain('video')
expect(searchTemplates(index, 'vid')).not.toContain('audio')
})
it('tolerates a typo in a longer term ("contorlnet")', () => {
const index = buildIndex([
buildTemplate({
name: 'cn',
title: 'Union ControlNet',
tags: ['ControlNet']
})
])
expect(searchTemplates(index, 'contorlnet')).toContain('cn')
})
it('does not fuzzy-match a long word onto its shorter substring', () => {
const index = buildIndex([
buildTemplate({ name: 'real', title: 'SeedVR2 Image Upscale' }),
buildTemplate({
name: 'junk',
title: 'Anime Text to Image',
description: 'configure CFG scale and steps'
})
])
expect(searchTemplates(index, 'upscale')).toContain('real')
expect(searchTemplates(index, 'upscale')).not.toContain('junk')
})
it('matches a CJK term inside an unspaced CJK title', () => {
const index = buildIndex([
buildTemplate({ name: 'zh_upscale', title: '图像放大' }), // "image upscale"
buildTemplate({ name: 'zh_video', title: '视频补帧' }) // "video interpolation"
])
// 放大 = "upscale"
expect(searchTemplates(index, '放大')).toContain('zh_upscale')
expect(searchTemplates(index, '放大')).not.toContain('zh_video')
})
it('matches a single CJK character that ends an unspaced run', () => {
const index = buildIndex([buildTemplate({ name: 'zh', title: '图像放大' })])
// 大 is only the trailing half of the last bigram; the unigram reaches it.
expect(searchTemplates(index, '大')).toContain('zh')
})
it('requires all words to match (AND) before falling back to OR', () => {
const index = buildIndex([
buildTemplate({
name: 'both',
title: 'Flux Upscale',
models: ['Flux'],
tags: ['Upscale']
}),
buildTemplate({ name: 'flux_only', title: 'Flux Text to Image' })
])
expect(searchTemplates(index, 'flux upscale')[0]).toBe('both')
})
it('breaks a near-tie by higher usage', () => {
const index = buildIndex([
buildTemplate({ name: 'low', title: 'Alpha Upscale', usage: 1 }),
buildTemplate({ name: 'high', title: 'Beta Upscale', usage: 5000 })
])
expect(searchTemplates(index, 'upscale')[0]).toBe('high')
})
it('does not let usage override a clearly stronger text match', () => {
const index = buildIndex([
buildTemplate({ name: 'exact', title: 'Outpaint', usage: 1 }),
buildTemplate({
name: 'weak',
title: 'Portrait',
description: 'has an outpaint option somewhere',
usage: 9000
})
])
expect(searchTemplates(index, 'outpaint')[0]).toBe('exact')
})
it('deduplicates literal and expansion matches, keeping the literal first', () => {
const index = buildIndex([
buildTemplate({ name: 'literal', title: 'Wan T2V', tags: ['T2V'] }),
buildTemplate({ name: 'expanded', title: 'Text to Video Studio' })
])
const results = searchTemplates(index, 't2v')
expect(results[0]).toBe('literal')
expect(new Set(results).size).toBe(results.length)
})
it('indexes localized title/description over raw english', () => {
const index = buildIndex([
buildTemplate({
name: 'localized',
title: 'raw',
localizedTitle: 'aquarela',
description: 'raw',
localizedDescription: 'pintura'
})
])
expect(searchTemplates(index, 'aquarela')).toEqual(['localized'])
expect(searchTemplates(index, 'pintura')).toEqual(['localized'])
})
it('falls back to the name when a template has no title or description', () => {
const index = buildIndex([buildTemplate({ name: 'flux_kontext_edit' })])
expect(searchTemplates(index, 'kontext')).toEqual(['flux_kontext_edit'])
})
it('ranks an editing template above text-to-image for "img2img"', () => {
const index = buildIndex([
buildTemplate({
name: 'text_to_image',
title: 'Qwen Text to Image',
tags: ['Text to Image']
}),
buildTemplate({
name: 'image_edit',
title: 'Qwen Image Edit',
tags: ['Image Edit']
})
])
expect(searchTemplates(index, 'img2img')[0]).toBe('image_edit')
})
it('ranks a title match above a tag match above a description-only match', () => {
const index = buildIndex([
buildTemplate({
name: 'in_description',
title: 'Something Else',
description: 'mentions upscale in passing'
}),
buildTemplate({ name: 'in_tag', title: 'Something', tags: ['Upscale'] }),
buildTemplate({ name: 'in_title', title: 'Upscale Studio' })
])
expect(searchTemplates(index, 'upscale')).toEqual([
'in_title',
'in_tag',
'in_description'
])
})
it('ranks an exact title above a title with extra words', () => {
const index = buildIndex([
buildTemplate({ name: 'with_extra', title: 'ControlNet Guidance' }),
buildTemplate({ name: 'exact', title: 'ControlNet' })
])
expect(searchTemplates(index, 'controlnet')[0]).toBe('exact')
})
})
describe('rankByRelevanceThenUsage', () => {
const hit = (id: string, score: number, usage: number): SearchResult =>
({ id, score, usage }) as unknown as SearchResult
// Scores 0.93/0.965/1.0 with usages 100/50/1 form an intransitive cycle under
// a pairwise relative-band compare (A>B, B>C, but A<C), which makes Array.sort
// input-order-dependent. Bucketing must give one stable order for any input.
it('produces a stable order for an intransitive cluster', () => {
const a = hit('a', 0.93, 100)
const b = hit('b', 0.965, 50)
const c = hit('c', 1.0, 1)
const order = (hits: SearchResult[]) =>
rankByRelevanceThenUsage(hits).map((h) => h.id)
const expected = order([a, b, c])
expect(order([c, b, a])).toEqual(expected)
expect(order([b, a, c])).toEqual(expected)
expect(order([c, a, b])).toEqual(expected)
})
it('breaks ties within a band by usage but not across bands', () => {
const strong = hit('strong', 1.0, 1)
const nearStrong = hit('near', 0.98, 500)
const weak = hit('weak', 0.5, 9000)
const ids = rankByRelevanceThenUsage([weak, strong, nearStrong]).map(
(h) => h.id
)
// near (higher usage, same band as strong) leads; weak stays last on score.
expect(ids).toEqual(['near', 'strong', 'weak'])
})
})

View File

@@ -0,0 +1,204 @@
import MiniSearch from 'minisearch'
import type { SearchResult } from 'minisearch'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
// MiniSearch serializes the index but not the search options, so the tokenizer
// and field list live here and are used at both index and query time.
const SEARCH_FIELDS = [
'title',
'description',
'tags',
'models',
'name'
] as const
// Usage only reorders hits within this fraction of the top score, so popularity
// never overrides a clearly-better text match. 5% tuned empirically.
const USAGE_TIEBREAK_BAND = 0.05
// Script-matched so spaced neighbors like Korean fall to the word tokenizer.
const CJK = /[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}]/u
const CJK_RUN = new RegExp(`${CJK.source}+`, 'gu')
// Unigrams + bigrams so any substring of an unspaced run lands on a token.
function cjkGrams(word: string): string[] {
const grams: string[] = []
for (const run of word.match(CJK_RUN) ?? []) {
const characters = run.split('')
grams.push(...characters)
for (let i = 1; i < characters.length; i++) {
grams.push(characters[i - 1] + characters[i])
}
}
return grams
}
/**
* Emits sub-parts so a term matches however it's typed: `-`/`_` splits, a
* trailing version (`wan2.7` → `wan`, `2.7`), and CJK character grams.
*/
export function tokenize(text: string): string[] {
const tokens = new Set<string>()
for (const word of text.toLowerCase().split(/\s+/).filter(Boolean)) {
for (const gram of cjkGrams(word)) tokens.add(gram)
// A pure-CJK run has no whole-word token — its grams already cover it.
if (CJK.test(word) && !/[a-z0-9]/.test(word)) continue
tokens.add(word)
for (const part of word.split(/[-_]/)) {
if (part) tokens.add(part)
}
const version = word.match(/^([a-z][a-z.]*?)(\d+(?:\.\d+)*)$/)
if (version) {
tokens.add(version[1].replace(/\.$/, ''))
tokens.add(version[2])
}
}
return [...tokens]
}
// Exact for ≤3-char and digit-bearing terms; otherwise 20% of length, so a typo
// is forgiven but `upscale` can't fuzzy-match the shorter `scale`.
export function termFuzziness(term: string): number | false {
return term.length <= 3 || /\d/.test(term) ? false : 0.2
}
function searchOptions(combineWith: 'AND' | 'OR' = 'AND') {
// Description demoted below default so an incidental prose mention never
// outranks a real title/model match.
return {
boost: { title: 3, models: 2, tags: 2, description: 0.5 },
prefix: true,
fuzzy: termFuzziness,
combineWith,
tokenize
}
}
// `{X}2{Y}` shorthand expanded by structure (t2i, txt2img, …) rather than one
// entry per spelling.
const MODALITY_STEMS: Record<string, string> = {
t: 'text',
txt: 'text',
text: 'text',
i: 'image',
img: 'image',
image: 'image',
v: 'video',
vid: 'video',
video: 'video',
a: 'audio',
s: 'audio',
m: 'music'
}
const SAME_MODALITY_EDIT: Record<string, string> = {
image: 'image edit',
video: 'video edit',
audio: 'audio edit'
}
const ACRONYMS: Record<string, string> = {
cn: 'controlnet'
}
export function expandAbbreviation(token: string): string | null {
const lower = token.trim().toLowerCase()
if (ACRONYMS[lower]) return ACRONYMS[lower]
const match = lower.match(/^([a-z]+)2([a-z]+)$/)
if (!match) return null
const left = MODALITY_STEMS[match[1]]
const right = MODALITY_STEMS[match[2]]
if (!left || !right) return null
if (left === right) return SAME_MODALITY_EDIT[left] ?? left
return `${left} ${right}`
}
/** Expands shorthand tokens (`wan i2v` → `wan image video`); null if none expand. */
export function expandQuery(query: string): string | null {
let changed = false
const out = query
.split(/\s+/)
.filter(Boolean)
.map((token) => {
const expansion = expandAbbreviation(token.toLowerCase())
if (expansion) changed = true
return expansion ?? token
})
.join(' ')
return changed ? out : null
}
export function createTemplateSearchIndex(
templates: TemplateInfo[]
): MiniSearch<TemplateInfo> {
const index = new MiniSearch<TemplateInfo>({
idField: 'name',
fields: [...SEARCH_FIELDS],
// Returned on each hit so the tiebreak can read usage without a second lookup.
storeFields: ['usage'],
// Index the localized strings the card actually shows, so a match explains
// a visible result.
extractField: (template, field) => {
if (field === 'title') return template.localizedTitle ?? template.title
if (field === 'description') {
return template.localizedDescription ?? template.description ?? ''
}
const value = template[field as keyof TemplateInfo]
return Array.isArray(value) ? value.join(' ') : ((value as string) ?? '')
},
tokenize,
searchOptions: searchOptions('AND')
})
index.addAll(templates)
return index
}
// Rank by relevance, with usage breaking ties inside a score band. Scores are
// bucketed so the ordering is a stable total order (a pairwise relative-band
// compare is intransitive). log1p dampens heavy-tailed usage.
export function rankByRelevanceThenUsage(hits: SearchResult[]): SearchResult[] {
const bandSize =
hits.reduce((max, hit) => Math.max(max, hit.score), 0) * USAGE_TIEBREAK_BAND
const bucket = (score: number) =>
bandSize > 0 ? Math.round(score / bandSize) : 0
return [...hits].sort((a, b) => {
if (bucket(a.score) !== bucket(b.score)) return b.score - a.score
return Math.log1p(Number(b.usage ?? 0)) - Math.log1p(Number(a.usage ?? 0))
})
}
/** Ordered template names for a query: literal matches first, then dedup'd expansion matches. */
export function searchTemplates(
index: MiniSearch<TemplateInfo>,
query: string
): string[] {
const trimmed = query.trim()
if (!trimmed) return []
const andThenOr = (q: string): SearchResult[] => {
const and = index.search(q, searchOptions('AND'))
const hits = and.length > 0 ? and : index.search(q, searchOptions('OR'))
return rankByRelevanceThenUsage(hits)
}
const ordered: string[] = []
const seen = new Set<string>()
const collect = (hits: SearchResult[]) => {
for (const hit of hits) {
const id = String(hit.id)
if (!seen.has(id)) {
seen.add(id)
ordered.push(id)
}
}
}
collect(andThenOr(trimmed))
const expanded = expandQuery(trimmed)
if (expanded) collect(andThenOr(expanded))
return ordered
}

View File

@@ -77,6 +77,14 @@ vi.mock('pinia', async (importOriginal) => {
}
})
const { settingGetMock } = vi.hoisted(() => ({
settingGetMock: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: settingGetMock })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
@@ -95,6 +103,9 @@ describe('useLoad3d', () => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
settingGetMock.mockImplementation((key: string) =>
key === 'Comfy.Load3D.BackgroundColor' ? '282828' : undefined
)
mockNode = createMockLGraphNode({
properties: {
@@ -203,9 +214,7 @@ describe('useLoad3d', () => {
getModelInfo: vi.fn().mockReturnValue(null),
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
setAnimationTime: vi.fn(),
renderer: {
domElement: mockCanvas
} as Partial<Load3d['renderer']> as Load3d['renderer']
domElement: mockCanvas
}
vi.mocked(Load3d).mockImplementation(function (this: Load3d) {
@@ -292,7 +301,7 @@ describe('useLoad3d', () => {
mockNode.flags.collapsed = true
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
expect(mockLoad3d.domElement!.hidden).toBe(true)
})
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
@@ -356,6 +365,20 @@ describe('useLoad3d', () => {
expect(composable.isPreview.value).toBe(true)
})
it('should set preview mode for save-viewer nodes despite width/height widgets', async () => {
Object.defineProperty(mockNode, 'constructor', {
value: { comfyClass: 'Save3DAdvanced' },
configurable: true
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.isPreview.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
@@ -383,7 +406,37 @@ describe('useLoad3d', () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
expect(composable.sceneConfig.value.backgroundColor).toBe('#282828')
})
it('defaults background color from the Comfy.Load3D.BackgroundColor setting', () => {
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
reactive({ appScalePercentage: 100 }) as unknown as ReturnType<
typeof useCanvasStore
>
)
settingGetMock.mockImplementation((key: string) =>
key === 'Comfy.Load3D.BackgroundColor' ? '123456' : undefined
)
const composable = useLoad3d(mockNode)
expect(composable.sceneConfig.value.backgroundColor).toBe('#123456')
})
it('attaches event listeners before running queued ready callbacks', async () => {
const composable = useLoad3d(mockNode)
let listenersAttachedWhenCallbackRan = false
composable.waitForLoad3d(() => {
listenersAttachedWhenCallbackRan =
vi.mocked(mockLoad3d.addEventListener!).mock.calls.length > 0
})
await composable.initializeLoad3d(document.createElement('div'))
expect(listenersAttachedWhenCallbackRan).toBe(true)
})
it('passes getZoomScale callback to createLoad3d', async () => {

View File

@@ -8,6 +8,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
import {
isAssetPreviewSupported,
persistThumbnail
@@ -118,7 +119,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const sceneConfig = ref<SceneConfig>({
showGrid: true,
backgroundColor: '#000000',
backgroundColor: getActivePinia()
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
: '#282828',
backgroundImage: '',
backgroundRenderMode: 'tiled'
})
@@ -177,6 +180,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const canExport = ref(true)
const materialModes = ref<readonly MaterialMode[]>([
'original',
'clay',
'normal',
'wireframe'
])
@@ -192,6 +196,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (
isLoad3dResultViewerNode(node.constructor.comfyClass ?? '') ||
node.constructor.comfyClass?.startsWith('Preview') ||
!(widthWidget && heightWidget)
) {
@@ -239,7 +244,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
node.onDrawBackground,
function (this: LGraphNode) {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
load3d.domElement.hidden = this.flags.collapsed ?? false
}
}
)
@@ -248,6 +253,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeToLoad3dMap.set(node, load3d)
handleEvents('add')
const callbacks = pendingCallbacks.get(node)
if (callbacks && load3d) {
@@ -263,8 +270,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) invokeReadyCallback(callback, load3d)
})
}
handleEvents('add')
} catch (error) {
console.error('Error initializing Load3d:', error)
useToastStore().addAlert(

View File

@@ -4,7 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
| LightConfig
| undefined
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
if (sceneConfig) {
backgroundColor.value =

View File

@@ -1,7 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
@@ -24,11 +23,11 @@ const defaultSettingStore = {
}
const defaultRankingStore = {
computeDefaultScore: vi.fn(() => 0),
computePopularScore: vi.fn(() => 0),
getUsageScore: vi.fn(() => 0),
computeDefaultScore: vi.fn(
(_date?: string, _rank?: number, usage: number = 0) => usage
),
computeFreshness: vi.fn(() => 0.5),
isLoaded: { value: false }
largestUsageScore: 0
}
const mockSystemStatsStore = {
@@ -51,9 +50,10 @@ vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn(() => mockSystemStatsStore)
}))
const trackTemplateFilterChanged = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackTemplateFilterChanged: vi.fn(),
trackTemplateFilterChanged,
trackSearchQuery: vi.fn()
}))
}))
@@ -62,20 +62,12 @@ vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
useSearchQueryTracking: vi.fn()
}))
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
getFuseOptions: mockGetFuseOptions
}
}))
describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.stubGlobal('__DISTRIBUTION__', 'localhost')
mockSystemStatsStore.systemStats.system.os = 'linux'
mockGetFuseOptions.mockResolvedValue(null)
})
afterEach(() => {
@@ -248,117 +240,423 @@ describe('useTemplateFiltering', () => {
])
})
describe('loadFuseOptions', () => {
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'test-template',
description: 'Test template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const customFuseOptions: IFuseOptions<TemplateInfo> = {
keys: [
{ name: 'name', weight: 0.5 },
{ name: 'description', weight: 0.5 }
],
threshold: 0.4,
includeScore: true
const usageRankedTemplates = () =>
ref<TemplateInfo[]>([
{
name: 'low',
title: 'Low',
description: '',
mediaType: 'image',
mediaSubtype: 'png',
usage: 10
},
{
name: 'high',
title: 'High',
description: '',
mediaType: 'image',
mediaSubtype: 'png',
usage: 900
}
])
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
it('ranks "recommended" via computeDefaultScore', async () => {
const { sortBy, filteredTemplates } = useTemplateFiltering(
usageRankedTemplates()
)
const { loadFuseOptions, filteredTemplates } =
useTemplateFiltering(templates)
sortBy.value = 'recommended'
await nextTick()
await loadFuseOptions()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'high',
'low'
])
expect(defaultRankingStore.computeDefaultScore).toHaveBeenCalled()
})
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
expect(filteredTemplates.value).toBeDefined()
})
it('ranks "popular" by raw usage without the recommended score', async () => {
const { sortBy, filteredTemplates } = useTemplateFiltering(
usageRankedTemplates()
)
it('does not update fuseOptions when getFuseOptions returns null', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'test-template',
description: 'Test template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
sortBy.value = 'popular'
await nextTick()
mockGetFuseOptions.mockResolvedValueOnce(null)
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'high',
'low'
])
expect(defaultRankingStore.computeDefaultScore).not.toHaveBeenCalled()
})
const { loadFuseOptions, filteredTemplates } =
useTemplateFiltering(templates)
const initialResults = filteredTemplates.value
await loadFuseOptions()
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
expect(filteredTemplates.value).toEqual(initialResults)
})
it('handles errors when getFuseOptions fails', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'test-template',
description: 'Test template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
mockGetFuseOptions.mockRejectedValueOnce(new Error('Network error'))
const { loadFuseOptions, filteredTemplates } =
useTemplateFiltering(templates)
const initialResults = filteredTemplates.value
await expect(loadFuseOptions()).rejects.toThrow('Network error')
expect(filteredTemplates.value).toEqual(initialResults)
})
it('recreates Fuse instance when fuseOptions change', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'searchable-template',
description: 'This is a searchable template',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'another-template',
description: 'Another template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { loadFuseOptions, searchQuery, filteredTemplates } =
useTemplateFiltering(templates)
const customFuseOptions = {
keys: [{ name: 'name', weight: 1.0 }],
threshold: 0.2,
includeScore: true,
includeMatches: true
it('filters to ComfyUI templates via the Runs On filter', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'open',
title: 'Open',
description: '',
mediaType: 'image',
mediaSubtype: 'png',
openSource: true
},
{
name: 'partner',
title: 'Partner',
description: '',
mediaType: 'image',
mediaSubtype: 'png',
openSource: false
}
])
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
const { selectedRunsOn, filteredTemplates } =
useTemplateFiltering(templates)
selectedRunsOn.value = ['ComfyUI']
await nextTick()
await loadFuseOptions()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'open'
])
})
it('sorts alphabetically by the localized title shown on the card', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'z-raw',
title: 'Apple', // raw title would sort first
localizedTitle: 'Zebra', // but the card shows this
description: '',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'a-raw',
title: 'Zulu',
localizedTitle: 'Ant',
description: '',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'alphabetical'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'a-raw', // Ant
'z-raw' // Zebra
])
})
it('A-Z trims whitespace, groups numbers after letters, and orders them naturally', async () => {
const make = (name: string, title: string): TemplateInfo => ({
name,
title,
description: '',
mediaType: 'image',
mediaSubtype: 'png'
})
const templates = ref<TemplateInfo[]>([
make('ten', '1.10 Model'),
make('two', '1.2 Model'),
make('spaced', ' Apple'), // leading space must not jump to the top
make('zebra', 'Zebra')
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'alphabetical'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'spaced', // " Apple" trimmed → sorts as a letter, first
'zebra',
'two', // numbers grouped after letters; 1.2 before 1.10 (numeric)
'ten'
])
})
describe('Search relevance (MiniSearch)', () => {
const names = (templates: { name: string }[]) =>
templates.map((template) => template.name)
const buildTemplate = (
overrides: Partial<TemplateInfo> & { name: string }
): TemplateInfo => ({
description: '',
mediaType: 'image',
mediaSubtype: 'png',
...overrides
})
async function searchFor(templates: TemplateInfo[], query: string) {
const composable = useTemplateFiltering(ref(templates))
composable.searchQuery.value = query
await nextTick()
return composable
}
it('matches "img2img" via abbreviation expansion', async () => {
const templates = [
buildTemplate({
name: 'z_image_t2i',
title: 'Z-Image: Text to Image',
tags: ['Text to Image']
}),
buildTemplate({ name: 'video_gen', title: 'LTX Text to Video' })
]
const { filteredTemplates } = await searchFor(templates, 'img2img')
expect(names(filteredTemplates.value)).toContain('z_image_t2i')
expect(names(filteredTemplates.value)).not.toContain('video_gen')
})
it('matches multi-word cross-field queries like "flux upscale"', async () => {
const templates = [
buildTemplate({
name: 'flux_upscale',
title: 'Flux.1 Creative Upscale',
models: ['Flux.1'],
tags: ['Image Upscale']
}),
buildTemplate({
name: 'flux_txt2img',
title: 'Flux.1 Text to Image',
models: ['Flux.1']
}),
buildTemplate({
name: 'seedvr_upscale',
title: 'SeedVR2 Upscale',
tags: ['Image Upscale']
})
]
const { filteredTemplates } = await searchFor(templates, 'flux upscale')
expect(names(filteredTemplates.value)[0]).toBe('flux_upscale')
})
it('breaks near-tied relevance by usage, dampened so it cannot override a better match', async () => {
const templates = [
buildTemplate({
name: 'low_usage_upscale',
title: 'Alpha Image Upscale',
tags: ['Image Upscale'],
usage: 5
}),
buildTemplate({
name: 'high_usage_upscale',
title: 'Beta Image Upscale',
tags: ['Image Upscale'],
usage: 5000
})
]
const { filteredTemplates } = await searchFor(templates, 'upscale')
// Near-identical text scores → the far more used template ranks first.
expect(names(filteredTemplates.value)[0]).toBe('high_usage_upscale')
})
it('keeps relevance order even when a usage sort is persisted', async () => {
const templates = [
buildTemplate({
name: 'exact_match',
title: 'Outpaint Studio',
tags: ['Outpaint'],
usage: 1
}),
buildTemplate({
name: 'popular_weak_match',
title: 'Portrait Generator',
description: 'supports outpaint as a minor feature',
usage: 9000
})
]
const composable = useTemplateFiltering(ref(templates))
composable.sortBy.value = 'popular'
composable.searchQuery.value = 'outpaint'
await nextTick()
searchQuery.value = 'searchable'
// Search defaults to relevance regardless of the persisted browse sort,
// so the exact title match wins over the high-usage weak match.
expect(composable.sortSelection.value).toBe('relevance')
expect(names(composable.filteredTemplates.value)[0]).toBe('exact_match')
})
it('lets the user override relevance with another sort while searching', async () => {
const templates = [
buildTemplate({
name: 'exact_low_usage',
title: 'Outpaint Studio',
usage: 1
}),
buildTemplate({
name: 'weak_high_usage',
title: 'Portrait',
description: 'outpaint mentioned once',
usage: 9000
})
]
const composable = useTemplateFiltering(ref(templates))
composable.searchQuery.value = 'outpaint'
await nextTick()
expect(composable.sortSelection.value).toBe('relevance')
expect(names(composable.filteredTemplates.value)[0]).toBe(
'exact_low_usage'
)
composable.sortSelection.value = 'popular'
await nextTick()
expect(composable.sortSelection.value).toBe('popular')
expect(names(composable.filteredTemplates.value)[0]).toBe(
'weak_high_usage'
)
})
it('restores the browse sort when the search is cleared and keeps relevance ephemeral', async () => {
const composable = useTemplateFiltering(
ref([buildTemplate({ name: 'only', title: 'Only' })])
)
composable.sortBy.value = 'popular'
composable.searchQuery.value = 'only'
await nextTick()
expect(composable.sortSelection.value).toBe('relevance')
composable.searchQuery.value = ''
await nextTick()
// Browse sort is untouched by the search; relevance is never persisted.
expect(composable.sortSelection.value).toBe('popular')
expect(composable.sortBy.value).toBe('popular')
})
it('keeps a browse sort chosen mid-search ephemeral', async () => {
const composable = useTemplateFiltering(
ref([buildTemplate({ name: 'only', title: 'Only' })])
)
composable.sortBy.value = 'newest'
composable.searchQuery.value = 'only'
await nextTick()
// Simulates the nav coordinator picking Popular during a search.
composable.sortSelection.value = 'popular'
await nextTick()
expect(composable.sortBy.value).toBe('newest') // persisted sort untouched
composable.searchQuery.value = ''
await nextTick()
expect(composable.sortSelection.value).toBe('newest')
})
it('returns no results for a query that matches nothing', async () => {
const templates = [
buildTemplate({ name: 'flux_image', title: 'Flux Image' })
]
const { filteredTemplates, filteredCount } = await searchFor(
templates,
'zzzznomatch'
)
expect(filteredTemplates.value).toEqual([])
expect(filteredCount.value).toBe(0)
})
it('matches the localized title the card displays', async () => {
const templates = [
buildTemplate({
name: 'localized_only',
title: 'raw english',
localizedTitle: 'aquarela'
})
]
const { filteredTemplates } = await searchFor(templates, 'aquarela')
expect(names(filteredTemplates.value)).toEqual(['localized_only'])
})
it('reports the visible sort to telemetry, not the persisted browse sort', async () => {
vi.useFakeTimers()
try {
const composable = useTemplateFiltering(
ref([buildTemplate({ name: 'only', title: 'Only' })])
)
composable.sortBy.value = 'popular'
composable.searchQuery.value = 'only'
await nextTick()
await vi.runOnlyPendingTimersAsync()
// Searching shows relevance, so telemetry must report relevance, not popular.
expect(trackTemplateFilterChanged).toHaveBeenLastCalledWith(
expect.objectContaining({ sort_by: 'relevance' })
)
} finally {
vi.useRealTimers()
}
})
it('preserves relevance order after a model filter narrows the results', async () => {
const templates = [
buildTemplate({
name: 'strong',
title: 'Flux Upscale Pro',
models: ['Flux'],
tags: ['Upscale']
}),
buildTemplate({
name: 'weak',
title: 'Flux Portrait',
models: ['Flux'],
description: 'mentions upscale once'
})
]
const composable = useTemplateFiltering(ref(templates))
composable.searchQuery.value = 'upscale'
composable.selectedModels.value = ['Flux']
await nextTick()
expect(filteredTemplates.value.length).toBeGreaterThan(0)
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
// The filter keeps the search order; the stronger match stays first.
expect(names(composable.filteredTemplates.value)).toEqual([
'strong',
'weak'
])
})
it('narrows to the query then restores the full set when cleared', async () => {
const templates = [
buildTemplate({ name: 'alpha_one', title: 'Alpha' }),
buildTemplate({ name: 'beta_two', title: 'Beta' })
]
const composable = useTemplateFiltering(ref(templates))
composable.searchQuery.value = 'alpha'
await nextTick()
expect(names(composable.filteredTemplates.value)).toEqual(['alpha_one'])
composable.searchQuery.value = ''
await nextTick()
expect(names(composable.filteredTemplates.value)).toHaveLength(2)
})
it('records a non-negative largest usage score after an empty-result search', async () => {
const templates = [
buildTemplate({ name: 'gamma', title: 'Gamma', usage: 3 }),
buildTemplate({ name: 'delta', title: 'Delta', usage: 7 })
]
const { filteredTemplates } = await searchFor(templates, 'zzzznomatch')
expect(filteredTemplates.value).toEqual([])
// Empty results must yield 0, not -Infinity, which would corrupt
// usage-normalized ranking scores.
expect(defaultRankingStore.largestUsageScore).toBe(0)
})
})

View File

@@ -1,9 +1,11 @@
import { refDebounced, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { watchDebounced } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import {
createTemplateSearchIndex,
searchTemplates
} from '@/composables/templateSearchConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
@@ -12,7 +14,40 @@ import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import { debounce } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
type TemplateBrowseSort =
| 'default'
| 'recommended'
| 'popular'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
type TemplateSortMode = TemplateBrowseSort | 'relevance'
/** The title shown on the card, trimmed for stable sorting. */
function displayTitle(template: TemplateInfo): string {
return (
template.localizedTitle ||
template.title ||
template.name ||
''
).trim()
}
/** A→Z by displayed title, with number-prefixed titles grouped after letters. */
function compareAlphabetical(a: TemplateInfo, b: TemplateInfo): number {
const titleA = displayTitle(a)
const titleB = displayTitle(b)
const numericA = /^\d/.test(titleA)
const numericB = /^\d/.test(titleB)
if (numericA !== numericB) return numericA ? 1 : -1
return titleA.localeCompare(titleB, undefined, {
numeric: true,
sensitivity: 'base'
})
}
/**
* Checks whether a template is visible for the given set of distributions.
@@ -26,20 +61,6 @@ function isTemplateVisibleForDistributions(
return distributions.some((d) => template.includeOnDistributions!.includes(d))
}
// Fuse.js configuration for fuzzy search
const defaultFuseOptions: IFuseOptions<TemplateInfo> = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'description', weight: 0.1 },
{ name: 'tags', weight: 0.2 },
{ name: 'models', weight: 0.3 }
],
threshold: 0.33,
includeScore: true,
includeMatches: true
}
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
@@ -57,17 +78,9 @@ export function useTemplateFiltering(
const selectedRunsOn = ref<string[]>(
settingStore.get('Comfy.Templates.SelectedRunsOn')
)
const sortBy = ref<
| 'default'
| 'recommended'
| 'popular'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
>(settingStore.get('Comfy.Templates.SortBy'))
const fuseOptions = ref<IFuseOptions<TemplateInfo>>(defaultFuseOptions)
const sortBy = ref<TemplateBrowseSort>(
settingStore.get('Comfy.Templates.SortBy')
)
const templatesArray = computed(() => {
const templateData = 'value' in templates ? templates.value : templates
@@ -101,8 +114,8 @@ export function useTemplateFiltering(
)
})
const fuse = computed(
() => new Fuse(visibleTemplates.value, fuseOptions.value)
const searchIndex = computed(() =>
createTemplateSearchIndex(visibleTemplates.value)
)
const availableModels = computed(() => {
@@ -154,15 +167,36 @@ export function useTemplateFiltering(
)
)
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const hasActiveQuery = computed(() => searchQuery.value.trim().length > 0)
const searchSort = ref<TemplateSortMode>('relevance')
watch(hasActiveQuery, (searching) => {
if (searching) searchSort.value = 'relevance'
})
const activeSort = computed(() =>
hasActiveQuery.value ? searchSort.value : sortBy.value
)
const sortSelection = computed<TemplateSortMode>({
get: () => activeSort.value,
set: (value) => {
// relevance is search-only; a browse sort chosen mid-search stays ephemeral.
if (value === 'relevance' || hasActiveQuery.value)
searchSort.value = value
else sortBy.value = value
}
})
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
if (!hasActiveQuery.value) {
return visibleTemplates.value
}
const results = fuse.value.search(debouncedSearchQuery.value)
return results.map((result) => result.item)
const templatesByName = new Map(
visibleTemplates.value.map((template) => [template.name, template])
)
return searchTemplates(searchIndex.value, searchQuery.value)
.map((name) => templatesByName.get(name))
.filter((template): template is TemplateInfo => template !== undefined)
})
const filteredByModels = computed(() => {
@@ -224,8 +258,9 @@ export function useTemplateFiltering(
watch(
filteredByRunsOn,
(templates) => {
rankingStore.largestUsageScore = Math.max(
...templates.map((t) => t.usage || 0)
rankingStore.largestUsageScore = templates.reduce(
(max, template) => Math.max(max, template.usage ?? 0),
0
)
},
{ immediate: true }
@@ -234,7 +269,9 @@ export function useTemplateFiltering(
const sortedTemplates = computed(() => {
const templates = [...filteredByRunsOn.value]
switch (sortBy.value) {
switch (activeSort.value) {
case 'relevance':
return templates
case 'recommended':
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
return templates.sort((a, b) => {
@@ -251,18 +288,9 @@ export function useTemplateFiltering(
return scoreB - scoreA
})
case 'popular':
// User-driven: usage × 0.9 + freshness × 0.1
return templates.sort((a, b) => {
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
return scoreB - scoreA
})
return templates.sort((a, b) => (b.usage ?? 0) - (a.usage ?? 0))
case 'alphabetical':
return templates.sort((a, b) => {
const nameA = a.title || a.name || ''
const nameB = b.title || b.name || ''
return nameA.localeCompare(nameB)
})
return templates.sort(compareAlphabetical)
case 'newest':
return templates.sort((a, b) => {
const dateA = new Date(a.date || '1970-01-01')
@@ -292,6 +320,7 @@ export function useTemplateFiltering(
selectedUseCases.value = []
selectedRunsOn.value = []
sortBy.value = 'default'
searchSort.value = 'relevance'
}
const removeModelFilter = (model: string) => {
@@ -317,22 +346,15 @@ export function useTemplateFiltering(
selected_models: selectedModels.value,
selected_use_cases: selectedUseCases.value,
selected_runs_on: selectedRunsOn.value,
sort_by: sortBy.value,
sort_by: activeSort.value,
filtered_count: filteredCount.value,
total_count: totalCount.value
})
}, 500)
const loadFuseOptions = async () => {
const fetchedOptions = await api.getFuseOptions()
if (fetchedOptions) {
fuseOptions.value = fetchedOptions
}
}
// Watch for filter changes and track them
watch(
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, sortBy],
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, activeSort],
() => {
// Only track if at least one filter is active (to avoid tracking initial state)
const hasActiveFilters =
@@ -340,7 +362,7 @@ export function useTemplateFiltering(
selectedModels.value.length > 0 ||
selectedUseCases.value.length > 0 ||
selectedRunsOn.value.length > 0 ||
sortBy.value !== 'default'
activeSort.value !== 'default'
if (hasActiveFilters) {
debouncedTrackFilterChange()
@@ -389,6 +411,8 @@ export function useTemplateFiltering(
selectedUseCases,
selectedRunsOn,
sortBy,
sortSelection,
hasActiveQuery,
// Computed - Active filters (actually applied)
activeModels,
@@ -410,7 +434,6 @@ export function useTemplateFiltering(
resetFilters,
removeModelFilter,
removeUseCaseFilter,
removeRunsOnFilter,
loadFuseOptions
removeRunsOnFilter
}
}

View File

@@ -32,7 +32,8 @@ function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
const widgets: MockWidget[] = [
{ name: 'width', hidden: false, options: {} },
{ name: 'height', hidden: false, options: {} },
{ name: 'other', hidden: false, options: {} }
{ name: 'other', hidden: false, options: {} },
{ name: 'last_incoming', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
@@ -73,6 +74,15 @@ describe('Comfy.CreateBoundingBoxes extension', () => {
expect(node.widgets[0].options.hidden).toBe(false)
})
it('always hides the internal last_incoming widget', () => {
for (const connected of [true, false]) {
const node = makeNode(connected)
state.extension!.nodeCreated(node)
expect(node.widgets[3].hidden).toBe(true)
expect(node.widgets[3].options.hidden).toBe(true)
}
})
it('writes visibility through the widget value store when present', () => {
state.widgetState = { options: {} }
const node = makeNode(true)

Some files were not shown because too many files have changed in this diff Show More