Compare commits

..

14 Commits

Author SHA1 Message Date
Michael B
5fa74d2d6b refactor(website): wire ModelCreationsSection through the gallery collection
Move the five Grok showcase items previously hardcoded inside
ModelCreationsSection.vue into the canonical gallery Content Collection
as visible: false entries (subway-swan, milos-little-wonder,
amber-passage, neon-revenant, midnight-umami). Curation moves to the
.astro page: featuredCreationSlugs is declared inline and resolved
through getGalleryByIds — the helper added in slice 1, which preserves
input slug order.

ModelCreationsSection.vue is now a generic "render these items" view
that accepts items: readonly GalleryItem[] as a prop. Section copy
(modelName, ctaHref) stays inline — only the gallery-shaped data
moves out.

zh-CN/models.astro queries 'en' to preserve current behavior, since
the original hardcoded titles were English-only plain strings.
Translating these four/five items is explicitly out of scope per the
issue.

Note: the issue body says "four" Grok items, but the inline array has
five — neon-revenant by Eric Solorio. Slice 1's browser verification
confirmed five cards rendering today. The migration includes all five
to preserve current rendering; the AC's "same creations in same order
with same media as before" requires it.

No new tests are added (per the issue's AC #8): this slice reuses the
getGalleryByIds tracer test and gallery schema coverage from slice 1.
2026-06-11 14:08:39 -04:00
Michael B
93ff253625 refactor(website): migrate tutorials to Astro Content Collection
Move the six learning tutorials from src/data/learningTutorials.ts
into a tutorials Astro Content Collection, completing the gallery /
events / tutorials migration started in earlier slices. Frontmatter-
only Markdown is used (not JSON) so a prose body can be added later
without restructuring.

Schema, generated type, and ResolvedTutorial alias (= LearningTutorial
& { slug: string }) live in src/content.config.ts. The pure helper
getTutorialPosterSrc moves to src/utils/tutorial.ts so the function
stops being co-located with data declarations.

src/content/queries.ts gains getTutorialsByLocale(locale), which
filters by locale prefix and sorts by data.order ascending (same fix
as gallery and events).

Tags collapse from the global TranslationKey union to plain locale-
anchored strings in frontmatter — render path drops t(tag, locale)
and emits <Badge>{{ tag }}</Badge> directly. Tag translation keys
tags.partnerNodes and tags.imageToVideo in src/i18n/translations.ts
become orphaned and are left in place; cleanup belongs with the
future tags-as-reference-collection promotion (declared out of scope
in the PRD).

TutorialsSection and TutorialDetailDialog now take resolved tutorials
as a prop and key the active-tutorial state by the stable slug rather
than the old id field. The .astro pages do the entry → { slug, ...data }
merge so the Vue components never see Astro's CollectionEntry shape.

Slug rename: the old _v03-suffixed snake_case ids
(cleanplate_walkthrough_v03, etc.) become clean kebab-case
content-oriented slugs (cleanplate-walkthrough, etc.). No external
consumer references the previous ids, so this is safe; future asset
re-versioning will not force a slug change.

preservePathId generalised from /\.json$/ to /\.[^.]+$/ so it strips
both .json and .md extensions for the shared generateId override.

src/data/learningTutorials.ts is deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 13:50:39 -04:00
Michael B
2b6cc16575 refactor(website): migrate events to Astro Content Collection
Move the three learning-page events from src/data/events.ts into an
events Astro Content Collection, following the pattern established by
the gallery slice. Per-locale JSON files live under
src/content/events/<locale>/<slug>.json. The EventItem type, which
used to live inside EventsSection.vue with LocalizedText fields, is
now inferred from the eventsSchema in src/content.config.ts and
exposes plain string fields — the .astro page resolves locale at the
data boundary.

src/content/queries.ts gains getEventsByLocale(locale), which filters
by locale prefix and sorts by data.order ascending (so the home page
order survives Astro's lexicographic getCollection default — same fix
as gallery).

src/pages/zh-CN/learning.astro now mounts <EventsSection> with the
resolved events. Until now it imported EventsSection and learningEvents
but never used them — typecheck warned on both. Side-effect: events
become visible on /zh-CN/learning where they weren't before.

Schema notes:
- href accepts both URLs and the literal "#" so the placeholder seed
  data parses, while truly malformed input is still rejected.
- The generateId override that preserves the zh-CN/ path verbatim is
  extracted to a shared preservePathId helper used by both collections;
  slice 3 will reuse it.

The English /learning page is untouched — its existing EventsSection
and learningEvents imports are still commented out per the PRD's
explicit out-of-scope note.

src/data/events.ts is deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 13:26:25 -04:00
Michael B
85ad748c06 fix(website): preserve source order and zh-CN locale in gallery collection
Live verification of the previous commit found two regressions:

1. getCollection returned entries lexicographically by id, so the
   gallery rendered alphabetically instead of in the curator-defined
   order. The repeating row layout (full / 2-col / 3-col / large-left /
   large-right) depends on item order.

2. The default glob loader generateId lowercases path segments, so
   `zh-CN/<slug>` became `zh-cn/<slug>` and the locale filter matched
   zero entries. /zh-CN/gallery rendered empty.

Adds an explicit `order: z.number().int()` field to the gallery schema,
backfills order 1-18 in all 36 JSON files matching today's source order,
sorts in getVisibleGalleryByLocale by data.order ascending, and overrides
generateId on the glob loader to preserve the original path verbatim.

Adds a tracer test for the sort behavior. Mark issue 01 done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 12:53:48 -04:00
Michael B
4b81b127dd refactor(website): migrate gallery to Astro Content Collection
Move the gallery from a hand-written src/data/gallery.ts array into an
Astro Content Collection with a Zod schema as the single source of
truth. Per-locale JSON files live under src/content/gallery/<locale>/,
with the filename as the stable cross-locale slug. A new
src/content/queries.ts module exposes getVisibleGalleryByLocale,
getGalleryByIds, and slugOf for use by Astro pages.

Consumers (GalleryCard, GallerySection, GalleryItemAttribution,
GalleryDetailModal, ModelCreationsSection) now import GalleryItem from
src/content.config.ts. GallerySection becomes prop-driven; the
gallery.astro pages resolve items for the current locale and pass them
in. zh-CN/ mirrors en/ content to preserve byte-identical rendering on
/zh-CN/gallery; translators can replace zh-CN files individually.

Also tells ESLint's import-x/no-unresolved to ignore the astro:
virtual-module prefix in the website package, since astro:content and
friends are injected by the Astro build and not seen by the TS
resolver.

First slice of the gallery/events/tutorials content-collection
migration (PRD in apps/website/.scratch/content-collections/PRD.md).
ModelCreationsSection keeps its inline items for now — slice 4 wires
models.astro to query the gallery by slug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 12:31:16 -04:00
Dante
598cf33ab7 [bugfix] Truncate long workspace names in workspace switcher (#12762)
## Summary

Long team workspace names wrapped onto multiple lines in the user-menu
workspace switcher, overflowing the fixed 54px rows and breaking the
dropdown layout. Applies the same single-line ellipsis pattern already
used by the current-workspace header
(`CurrentUserPopoverWorkspace.vue`).

## Changes

- **What**: `truncate` on the switcher name span, `max-w-full` on the
name row, `shrink-0` on avatar/tier badge/check icon so only the name
shrinks (`WorkspaceSwitcherPopover.vue`, 5 lines)
- Regression tests: Vitest component test + `@cloud` Playwright e2e
measuring single-line render height

Fixes
[FE-778](https://linear.app/comfyorg/issue/FE-778/bug-team-workspace-names-wrapping-to-multiple-lines-display-poorly-in)

## Red-Green Verification

| Commit | CI | Result |
|---|---|---|
| `30e04e2` test only | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378157)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378213)
| 🔴 new unit test + cloud e2e fail (proves tests catch the
bug) |
| `d8f9a5c` fix | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508881)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508715)
| 🟢 same tests pass |

## Screenshots

| Before | After |
|---|---|
| <img width="320" alt="before — name wraps to 4 lines, rows collide"
src="https://github.com/user-attachments/assets/90f3286a-5b50-4477-9b5c-9d32d0b026e4"
/> | <img width="320" alt="after — single line with ellipsis, row height
intact"
src="https://github.com/user-attachments/assets/8e47bbb2-b5b1-4945-a008-68491f39dc46"
/> |

## Review Focus

- Truncation chain: the name span is a flex item, so `truncate`
(overflow-hidden) zeroes its automatic min size; `max-w-full` caps the
`items-start` row at the container width. Mirrors the header pattern —
no new component.
- Figma `Team Plan - Workspaces` (Workspaces Menu component, node
2045-14413) specifies compact single-line rows; long-name overflow was
undesigned, truncation preserves the spec'd layout.
2026-06-10 14:45:36 +00:00
jaeone94
1b14f4df8a Simplify missing node pack error presentation (#12735)
## Summary

Simplify the Missing Node Packs error card so it follows the new
error-tab item-row direction, with clearer pack rows, predictable locate
behavior, and focused E2E coverage.

This is the third PR in the staged error-tab simplification plan:

1. Merged: execution/prompt/validation error presentation and catalog
grouping in #12683.
2. Merged: missing media presentation simplification in #12705.
3. This PR: missing node pack presentation simplification.
4. Planned next: swap-node presentation simplification.
5. Planned later: missing model presentation and action-flow
simplification.

## Changes

- **What**: Refactors Missing Node Packs rows so pack-level and
node-level actions are easier to scan and more consistent with the rest
of the refreshed Errors tab.
- **What**: Removes the node-id badge from missing node pack rows,
matching the simplified item-row direction.
- **What**: Makes a single-node known pack row directly locatable from
the pack label, rather than rendering an extra child row.
- **What**: Keeps multi-node packs collapsed by default, with both the
chevron and pack title toggling the child node list.
- **What**: Keeps unknown packs expanded by default, including the
single-node unknown-pack case, so users can still see the unresolved
node type immediately.
- **What**: Keeps per-node child rows clickable for locate-on-canvas
behavior when a pack contains multiple affected nodes.
- **What**: Replaces missing-node-pack action labels with shared
`g.install` and `g.search` copy and removes now-unused English locale
keys.
- **What**: Adds targeted Playwright coverage for the simplified
missing-node-pack card, including unknown-pack default rows, row-label
locate behavior, and chevron/title expansion behavior.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Please focus on the missing-node-pack row behavior:

- Single known pack with one affected node should stay compact and
locate the node from the pack label or locate icon.
- Known packs with multiple affected nodes should show a count, start
collapsed, and expand/collapse from either the chevron or title.
- Unknown packs should expose the affected node rows immediately,
including when there is only one affected node.
- Locate actions should remain attached to the affected node rows, not
to the parent pack when there are multiple nodes.
- The E2E fixture intentionally uses two missing nodes with the same
`cnr_id` and node sizes of `[400, 200]` to follow browser-test asset
guidance.

## Validation

- `pnpm format:check`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm knip --cache` via pre-push hook
- `pnpm test:unit
src/components/rightSidePanel/errors/MissingPackGroupRow.test.ts
src/components/rightSidePanel/errors/MissingNodeCard.test.ts --run`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts
--project=chromium`
- Pre-commit hook: staged formatting, linting, `pnpm typecheck`, and
`pnpm typecheck:browser`

## Screenshots

This PR 
<img width="531" height="598" alt="스크린샷 2026-06-10 오전 1 54 31"
src="https://github.com/user-attachments/assets/9c0addeb-92d2-4cef-a4f3-35a87bbad308"
/>

old (Main)
<img width="509" height="807" alt="스크린샷 2026-06-10 오전 1 53 51"
src="https://github.com/user-attachments/assets/b8488f73-d8ed-4356-bd4c-fc678ea205f7"
/>
2026-06-10 09:59:50 +00:00
Dante
ef93b4696c feat(billing): team-plan CreditSlider component — 5 fixed stops (FE-935) (#12644)
## What

https://a46f3266.comfy-storybook.pages.dev/?path=/docs/components-creditslider--docs&globals=theme:dark
<img width="560" height="288" alt="cs12644_dark"
src="https://github.com/user-attachments/assets/c06b5244-d178-4fa5-8bb9-61fd8595fe9b"
/>
<img width="560" height="288" alt="cs12644_light"
src="https://github.com/user-attachments/assets/16626333-43ba-4541-bd11-faaa8513b1e8"
/>


Adds **CreditSlider** — the team-plan credit-subscription slider from
Figma **DES-197** — as a standalone presentational component. **B4
standalone slice (FE-935)**; parent **FE-934**.

## Why it can ship now (no backend dependency)
The slider's 5 stops are **locked in DES-197**, so this component is
built and reviewable independently of the (still-TBD) backend slider
contract. Wiring it into the pricing table is deferred to FE-934.

## How it works
- 5 fixed stops — **200 / 400 / 700 / 1,400 / 2,500 USD** ↔ 42,200 /
84,400 / 147,700 / 295,400 / 527,500 credits; default **$700**.
- Snap-to-stop is guaranteed by driving the shared
`src/components/ui/slider/Slider.vue` (reka-ui) in **index space**
(`:min="0" :max="4" :step="1"`) — the thumb can only land on the 5
stops, with free keyboard-arrow support + ARIA from reka-ui.
- `v-model` carries the selected **USD** value; a `change` event also
emits `{ index, usd, credits }` for the future pricing-table wiring.
- Thresholds live in a typed constant `teamPlanCreditStops.ts` (sibling
to `tierPricing.ts`), **hardcoded per DES-197** with a `TODO(FE-934)` to
source from `GET /api/billing/plans` once the BE contract lands. The
credit figures equal `usdToCredits(usd)` (rate 211); a test guards
against rate drift.

## Files
- `src/platform/cloud/subscription/components/CreditSlider.vue` (+
`CreditSlider.stories.ts`, `CreditSlider.test.ts`)
- `src/platform/cloud/subscription/constants/teamPlanCreditStops.ts`

## Verification
- `vue-tsc --noEmit`: clean.
- `oxlint --type-aware`: 0 errors / 0 warnings.
- `vitest run`: **11/11 pass** — default stop, ArrowRight/ArrowLeft snap
to the adjacent stop (never in between), `change` payload, disabled
state, BE-sourced stops override, empty `stops` renders nothing, all 5
labels render, and the credit-rate-drift guard.

## Not in scope
- Wiring into `PricingTableWorkspace` / the team-plan card (FE-934,
blocked on the BE slider contract).
- The marketing caption and card layout around the slider (parent
component's concern).

Design source: Figma **DES-197** (Team Plan / Workspaces).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:05:34 +00:00
Alexis Rolland
24c512d144 Bumping search ranks (#12750)
## Summary

Bumping the ranking of native nodes to improve their discoverability

## Changes

- **What**: Updated ranking of native nodes in
`public/assets/sorted-custom-node-map.json`
2026-06-10 07:22:10 +00:00
Comfy Org PR Bot
6f6141a8e4 1.46.12 (#12745)
Patch version increment to 1.46.12

**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-06-10 04:48:29 +00:00
jaeone94
14f8fdebdd fix: refresh promoted combo host options after missing model reload (#12692)
## Summary

Fixes a Vue node subgraph case where the missing-model refresh flow
clears the missing-model error, but the promoted combo widget remains in
an invalid visual state because its hosted options snapshot is stale.

## Changes

- **What**: After `reloadNodeDefs()` refreshes combo option lists and
extension `refreshComboInNodes` hooks run, resync hosted options
snapshots for promoted combo widgets so Vue-rendered subgraph nodes see
the newly available model option.
- **What**: Adds a focused E2E regression for the missing-model refresh
path on a subgraph with a promoted `ckpt_name` widget.
- **What**: Hardens the E2E by cleaning up its `/object_info` route
override and asserting the widget's `aria-invalid` state rather than a
Tailwind implementation class.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

This PR is intentionally a minimal patch for the missing-model refresh
path, not a broader subgraph architecture change.

Root cause: `reloadNodeDefs()` updates the live LiteGraph combo widget
options, including widgets inside subgraphs. However, Vue nodes render
promoted widgets from a hosted `WidgetState.options` snapshot. When a
missing model is downloaded and the missing-model refresh button reloads
node definitions, the source combo receives the updated model list, but
the promoted host snapshot can still contain the old option list. The
missing-model error and node-level state are cleared, while the Vue
combo still computes itself as invalid from stale options.

The fix keeps the existing host snapshot model intact. It simply resyncs
promoted combo host options after the normal combo refresh pipeline
finishes. This avoids changing promoted-widget ownership,
`useProcessedWidgets` merge precedence, or broader subgraph internals
while addressing the reported stale invalid state.

Why the helper is in `app.ts`: this sync is currently a single-call-site
post-step of `reloadNodeDefs()`, and the ordering is load-bearing. It
must run after the core combo refresh loop and after extension
`refreshComboInNodes` hooks so it captures both built-in and
extension-provided option changes. Keeping the small private helper next
to the refresh orchestration makes that sequence explicit and avoids
adding a new public subgraph helper or introducing a more visible
dependency cycle through `promotionUtils` for a narrow patch.

This is stacked on
`jaeone/fe-942-bug-error-indicators-persist-after-resolving-missing-model`,
so it is opened as a draft until the base PR lands.

## Test Plan

- `pnpm knip`
- `pnpm exec oxfmt --check src/scripts/app.ts
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts`
- `pnpm exec eslint --cache --no-warn-ignored src/scripts/app.ts
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts`
- `pnpm typecheck`
- `pnpm test:browser:local --grep "Refreshing a resolved promoted
missing model clears the combo invalid state"`
- First local run hit a `beforeEach` timeout while the dev server was
still warming custom-node/conflict-detection output.
  - Re-running against the warmed dev server passed.

## Screenshots (if applicable)

N/A. The behavioral E2E covers the visible invalid-state regression.
2026-06-10 04:36:45 +00:00
Deep Mehta
73e3aead16 refactor: recolor comfy-logo-single to new brand (dark, not blue) (#12748)
## Summary

`comfy-logo-single.svg` is still the old **yellow-on-blue** (`#172DD7`)
ComfyUI mark, while the favicon and the rest of the brand have moved to
**dark `#211927` + yellow `#F2FF59`**. This refreshes it to match.

## Changes

- **What**: Pure color swap on
`public/assets/images/comfy-logo-single.svg` (`#172DD7`→`#211927`,
yellows normalized to `#F2FF59`). Geometry/mask unchanged.
- **Breaking**: none.

## Review Focus

- This logo is the **in-app header mark** (`ComfyOrgHeader.vue`) and the
**PWA/cloud manifest icon**, so it's a visible brand change across OSS +
cloud + desktop — flagging for **brand/design sign-off** before merge.
- `comfy-logo-mono.svg` (black/white) is intentionally left as-is.
2026-06-10 04:35:44 +00:00
Benjamin Lu
c190784307 Add share id attribution across share and run telemetry (#12741)
## Summary
- Thread `share_id` through shared workflow open/import, link creation,
auth completion, and run success telemetry
- Persist share attribution on loaded workflows and queued jobs so
shared runs can be joined back to the source link
- Add provider support for `share_link_opened` and `shared_workflow_run`
events across telemetry backends

## Behavior notes
- `execution_success` is now keyed off the success event's own
`prompt_id` (looked up in `queuedJobs`) instead of `activeJobId`. This
fixes successes for non-active jobs being reported with the wrong job
id, but may slightly shift `execution_success` event volume: successes
for jobs this client never queued or saw start are no longer tracked.
- Share auth attribution (`share_auth` preserved query) is cleared if
the user cancels the shared workflow dialog, so only users who proceed
past the dialog have signups attributed to the share link.

## Testing
- Added and updated unit tests for shared workflow loading, link
creation, auth attribution, workflow service loading, and execution
success
- Unit tests, `pnpm test:unit`, and repository checks for formatting,
linting, and type coverage passed
2026-06-09 20:56:31 -07:00
Simon Pinfold
38458c518e feat(assets): include previews in bulk asset export (#12746)
## Summary

Set `include_previews: true` on bulk asset export so exported zips
include preview files, using the new option added to the cloud export
API.

## Changes

- **What**: Added `include_previews` to `AssetExportOptions` and pass
`true` from the bulk export path in `useMediaAssetActions`.

## Review Focus

Field name matches the updated cloud `AssetExportOptions` contract.
2026-06-10 03:03:00 +00:00
175 changed files with 4829 additions and 3302 deletions

View File

@@ -1,20 +1,10 @@
<script setup lang="ts">
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import type { EventItem } from '../../content.config'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
export type EventItem = {
label: LocalizedText
title: LocalizedText
cta: LocalizedText
href: string
}
const {
locale = 'en',
headingKey,
@@ -40,12 +30,12 @@ const {
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
@@ -66,20 +56,20 @@ const {
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
>
<span
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
>
{{ event.label[locale] }}
{{ event.label }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title[locale] }}
{{ event.title }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta[locale] }}
{{ event.cta }}
<svg
class="size-4 transition-transform group-hover:translate-x-0.5"
viewBox="0 0 24 24"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../data/gallery'
import type { GalleryItem } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import GalleryItemAttribution from './GalleryItemAttribution.vue'
@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
<div class="flex w-full items-end justify-between p-4">
<div class="gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<GalleryItemAttribution :item :locale />
</p>
</div>
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
<!-- Mobile metadata -->
<div v-if="mobile" class="mt-2 gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<GalleryItemAttribution :item :locale />
</p>
</div>

View File

@@ -12,7 +12,7 @@ import {
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import type { GalleryItem } from '../../data/gallery'
import type { GalleryItem } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
@@ -123,11 +123,11 @@ onUnmounted(() => {
<!-- Close button -->
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:top-8 lg:right-26"
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:top-8 lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
@@ -136,7 +136,7 @@ onUnmounted(() => {
<div class="relative hidden min-h-0 w-full flex-1 pt-12 lg:flex">
<!-- Left: info card -->
<div
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
class="bg-primary-comfy-yellow rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8 text-primary-comfy-ink"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"
@@ -170,7 +170,7 @@ onUnmounted(() => {
<!-- Right: large image -->
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 p-4"
class="border-primary-comfy-yellow rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-4"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -197,7 +197,7 @@ onUnmounted(() => {
>
<!-- Image -->
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
class="border-primary-comfy-yellow flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 bg-primary-comfy-ink p-3"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -223,7 +223,7 @@ onUnmounted(() => {
<!-- Info card -->
<div
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
class="bg-primary-comfy-yellow w-full rounded-4xl p-6 text-primary-comfy-ink"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../data/gallery'
import type { GalleryItem } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'

View File

@@ -2,13 +2,15 @@
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import { visibleGalleryItems as items } from '../../data/gallery'
import type { GalleryItem } from '../../data/gallery'
import type { GalleryItem } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import GalleryCard from './GalleryCard.vue'
import GalleryDetailModal from './GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { items, locale = 'en' } = defineProps<{
items: GalleryItem[]
locale?: Locale
}>()
const modalOpen = ref(false)
const modalIndex = ref(0)

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { ResolvedTutorial } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
@@ -9,7 +9,7 @@ import { t } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: LearningTutorial
tutorial: ResolvedTutorial
locale?: Locale
}>()
@@ -39,7 +39,7 @@ onUnmounted(() => {
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title[locale]"
:aria-label="tutorial.title"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@@ -60,7 +60,7 @@ onUnmounted(() => {
class="border-primary-comfy-yellow rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-3 lg:p-4"
>
<VideoPlayer
:key="tutorial.id"
:key="tutorial.slug"
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
@@ -73,7 +73,7 @@ onUnmounted(() => {
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
{{ tutorial.title }}
</h2>
</dialog>
</Teleport>

View File

@@ -1,22 +1,23 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ResolvedTutorial } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import { getTutorialPosterSrc } from '../../utils/tutorial'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { tutorials, locale = 'en' } = defineProps<{
tutorials: readonly ResolvedTutorial[]
locale?: Locale
}>()
const activeTutorialId = ref<string | null>(null)
const activeTutorialSlug = ref<string | null>(null)
const activeTutorial = () =>
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
tutorials.find((tutorial) => tutorial.slug === activeTutorialSlug.value)
</script>
<template>
@@ -31,15 +32,15 @@ const activeTutorial = () =>
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<li
v-for="tutorial in learningTutorials"
:key="tutorial.id"
v-for="tutorial in tutorials"
:key="tutorial.slug"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
@click="activeTutorialId = tutorial.id"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title}`"
@click="activeTutorialSlug = tutorial.slug"
>
<video
:src="getTutorialPosterSrc(tutorial)"
@@ -74,7 +75,7 @@ const activeTutorial = () =>
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
{{ tutorial.title }}
</h3>
<MaskRevealButton
v-if="tutorial.href"
@@ -103,7 +104,7 @@ const activeTutorial = () =>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ t(tag, locale) }}</Badge>
<Badge>{{ tag }}</Badge>
</li>
</ul>
</div>
@@ -114,7 +115,7 @@ const activeTutorial = () =>
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialId = null"
@close="activeTutorialSlug = null"
/>
</section>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../data/gallery'
import type { GalleryItem } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -9,64 +9,14 @@ import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { items, locale = 'en' } = defineProps<{
items: readonly GalleryItem[]
locale?: Locale
}>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
const items: GalleryItem[] = [
{
id: 'subway-swan',
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
title: 'Subway Swan',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'milos-little-wonder',
video:
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
title: 'Milos Little Wonder',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'amber-passage',
image:
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
title: 'Amber Passage',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats',
objectPosition: 'bottom'
},
{
id: 'neon-revenant',
video:
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
title: 'Neon Revenant',
userAlias: 'Eric Solorio',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.instagram.com/enigmatic_e'
},
{
id: 'midnight-umami',
image:
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
title: 'Midnight Umami',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
}
]
const modalOpen = ref(false)
const modalIndex = ref(0)

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('astro:content', () => ({
defineCollection: (config: unknown) => config
}))
vi.mock('astro/loaders', () => ({
glob: () => ({})
}))
import { eventsSchema, gallerySchema, tutorialsSchema } from './content.config'
const validEntry = {
order: 1,
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
href: 'https://www.thinkdiffusion.com/studio'
}
describe('gallerySchema', () => {
it('accepts a valid entry', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEntry
const result = gallerySchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('defaults visible to true when omitted', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(true)
}
})
it('preserves an explicit visible: false', () => {
const result = gallerySchema.safeParse({ ...validEntry, visible: false })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(false)
}
})
it('rejects an invalid URL in image/video/href', () => {
const result = gallerySchema.safeParse({
...validEntry,
href: 'not a url'
})
expect(result.success).toBe(false)
})
})
const validEvent = {
order: 1,
label: 'Live Stream:',
title: 'Zero to Node: Building Your First Workflow',
cta: 'Link',
href: 'https://example.com/event'
}
describe('eventsSchema', () => {
it('accepts a valid entry', () => {
const result = eventsSchema.safeParse(validEvent)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEvent
const result = eventsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('accepts "#" as a placeholder href', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: '#' })
expect(result.success).toBe(true)
})
it('rejects a truly malformed href that is neither a URL nor "#"', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: 'not a url' })
expect(result.success).toBe(false)
})
})
const validTutorial = {
order: 1,
tags: ['Partner Nodes', 'Image To Video'],
title: 'Cleanplate Walkthrough',
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg'
}
describe('tutorialsSchema', () => {
it('accepts a valid entry', () => {
const result = tutorialsSchema.safeParse(validTutorial)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validTutorial
const result = tutorialsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('rejects an invalid videoSrc URL', () => {
const result = tutorialsSchema.safeParse({
...validTutorial,
videoSrc: 'not a url'
})
expect(result.success).toBe(false)
})
})

View File

@@ -0,0 +1,78 @@
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'
export const gallerySchema = z.object({
order: z.number().int(),
image: z.string().url().optional(),
video: z.string().url().optional(),
title: z.string(),
userAlias: z.string(),
teamAlias: z.string(),
tool: z.string(),
href: z.string().url().optional(),
objectPosition: z.string().optional(),
objectFit: z.string().optional(),
visible: z.boolean().default(true)
})
export type GalleryItem = z.infer<typeof gallerySchema>
export const eventsSchema = z.object({
order: z.number().int(),
label: z.string(),
title: z.string(),
cta: z.string(),
href: z.string().url().or(z.literal('#'))
})
export type EventItem = z.infer<typeof eventsSchema>
export const tutorialsSchema = z.object({
order: z.number().int(),
tags: z.array(z.string()),
title: z.string(),
videoSrc: z.string().url(),
href: z.string().url().optional(),
poster: z.string().url().optional(),
posterTime: z.number().optional()
})
export type LearningTutorial = z.infer<typeof tutorialsSchema>
export type ResolvedTutorial = LearningTutorial & { slug: string }
// The default `generateId` lowercases path segments (e.g. `zh-CN/foo` becomes
// `zh-cn/foo`), which collides with the BCP-47 locale codes Astro's i18n
// config uses elsewhere. Strip the file extension to keep the path — and
// therefore the locale prefix — verbatim.
const preservePathId = ({ entry }: { entry: string }): string =>
entry.replace(/\.[^.]+$/, '')
const gallery = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/gallery',
generateId: preservePathId
}),
schema: gallerySchema
})
const events = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/events',
generateId: preservePathId
}),
schema: eventsSchema
})
const tutorials = defineCollection({
loader: glob({
pattern: '**/*.md',
base: './src/content/tutorials',
generateId: preservePathId
}),
schema: tutorialsSchema
})
export const collections = { gallery, events, tutorials }

View File

@@ -0,0 +1,7 @@
{
"order": 2,
"label": "Event 1",
"title": "Lorem ipsum dollar sita met",
"cta": "London, UK",
"href": "#"
}

View File

@@ -0,0 +1,7 @@
{
"order": 3,
"label": "Event 2",
"title": "Lorem ipsum dollar sita met",
"cta": "San Francisco",
"href": "#"
}

View File

@@ -0,0 +1,7 @@
{
"order": 1,
"label": "Live Stream:",
"title": "Zero to Node: Building Your First Workflow",
"cta": "Link",
"href": "#"
}

View File

@@ -0,0 +1,7 @@
{
"order": 2,
"label": "活动 1",
"title": "此处为活动描述的占位文本",
"cta": "英国伦敦",
"href": "#"
}

View File

@@ -0,0 +1,7 @@
{
"order": 3,
"label": "活动 2",
"title": "此处为活动描述的占位文本",
"cta": "旧金山",
"href": "#"
}

View File

@@ -0,0 +1,7 @@
{
"order": 1,
"label": "直播:",
"title": "从零到节点:构建你的第一个工作流",
"cta": "链接",
"href": "#"
}

View File

@@ -0,0 +1,9 @@
{
"order": 17,
"image": "https://media.comfy.org/website/gallery/gallery.webp",
"title": "Amber Astronaut",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -0,0 +1,11 @@
{
"order": 21,
"image": "https://media.comfy.org/website/gallery/amber-passage_compressed.jpg",
"title": "Amber Passage",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"objectPosition": "bottom",
"visible": false
}

View File

@@ -0,0 +1,9 @@
{
"order": 16,
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
"title": "Animation Reel",
"userAlias": "Andidea",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
}

View File

@@ -0,0 +1,9 @@
{
"order": 5,
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
"title": "Autopoiesis",
"userAlias": "Yogo",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/visualfrisson/?hl=en"
}

View File

@@ -0,0 +1,9 @@
{
"order": 13,
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
"title": "Cuco - A Love Letter To LA",
"userAlias": "Paul Trillo",
"teamAlias": "CoffeeVectors",
"tool": "ComfyUI",
"href": "https://vimeo.com/1062859798"
}

View File

@@ -0,0 +1,9 @@
{
"order": 12,
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
"title": "DDU-DU DDU-DU",
"userAlias": "Purz",
"teamAlias": "Andidea",
"tool": "Animatediff",
"href": "https://vimeo.com/1019924290"
}

View File

@@ -0,0 +1,9 @@
{
"order": 18,
"image": "https://media.comfy.org/website/gallery/desert.webp",
"title": "Desert Landing",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -0,0 +1,9 @@
{
"order": 6,
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
"title": "Eat It - Dance",
"userAlias": "Johana Lyu",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.joannalyu.com/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 7,
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
"title": "Fall",
"userAlias": "Nathan Shipley",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 11,
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
"title": "It's gonna be a good good summer",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685900"
}

View File

@@ -0,0 +1,9 @@
{
"order": 15,
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
"title": "Goodbye Beijing",
"userAlias": "Rui",
"teamAlias": "makeitrad",
"tool": "Animatediff",
"href": "https://x.com/rui40000"
}

View File

@@ -0,0 +1,10 @@
{
"order": 23,
"image": "https://media.comfy.org/website/gallery/midnight_umami_compressed.png",
"title": "Midnight Umami",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -0,0 +1,10 @@
{
"order": 20,
"video": "https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4",
"title": "Milos Little Wonder",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -0,0 +1,9 @@
{
"order": 3,
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
"title": "Neon Nights",
"userAlias": "ShaneF Motion Design",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
}

View File

@@ -0,0 +1,10 @@
{
"order": 22,
"video": "https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4",
"title": "Neon Revenant",
"userAlias": "Eric Solorio",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.instagram.com/enigmatic_e",
"visible": false
}

View File

@@ -0,0 +1,9 @@
{
"order": 9,
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
"title": "Origami world",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 2,
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
"title": "Origins - Kyrie Irving",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://vimeo.com/1021360563"
}

View File

@@ -0,0 +1,9 @@
{
"order": 10,
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
"title": "Shot on InstaX",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 14,
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
"title": "Show you my garden",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685479"
}

View File

@@ -0,0 +1,10 @@
{
"order": 19,
"image": "https://media.comfy.org/website/gallery/subway-swan_compressed.png",
"title": "Subway Swan",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -0,0 +1,9 @@
{
"order": 1,
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
"title": "Until Our Eye Interlink harajuku",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
}

View File

@@ -0,0 +1,9 @@
{
"order": 8,
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
"title": "Untitled",
"userAlias": "Nathan Shipley",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 4,
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
"title": "Untitled",
"userAlias": "MidJourney man",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
}

View File

@@ -0,0 +1,9 @@
{
"order": 17,
"image": "https://media.comfy.org/website/gallery/gallery.webp",
"title": "Amber Astronaut",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -0,0 +1,9 @@
{
"order": 16,
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
"title": "Animation Reel",
"userAlias": "Andidea",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
}

View File

@@ -0,0 +1,9 @@
{
"order": 5,
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
"title": "Autopoiesis",
"userAlias": "Yogo",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/visualfrisson/?hl=en"
}

View File

@@ -0,0 +1,9 @@
{
"order": 13,
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
"title": "Cuco - A Love Letter To LA",
"userAlias": "Paul Trillo",
"teamAlias": "CoffeeVectors",
"tool": "ComfyUI",
"href": "https://vimeo.com/1062859798"
}

View File

@@ -0,0 +1,9 @@
{
"order": 12,
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
"title": "DDU-DU DDU-DU",
"userAlias": "Purz",
"teamAlias": "Andidea",
"tool": "Animatediff",
"href": "https://vimeo.com/1019924290"
}

View File

@@ -0,0 +1,9 @@
{
"order": 18,
"image": "https://media.comfy.org/website/gallery/desert.webp",
"title": "Desert Landing",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -0,0 +1,9 @@
{
"order": 6,
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
"title": "Eat It - Dance",
"userAlias": "Johana Lyu",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.joannalyu.com/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 7,
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
"title": "Fall",
"userAlias": "Nathan Shipley",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 11,
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
"title": "It's gonna be a good good summer",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685900"
}

View File

@@ -0,0 +1,9 @@
{
"order": 15,
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
"title": "Goodbye Beijing",
"userAlias": "Rui",
"teamAlias": "makeitrad",
"tool": "Animatediff",
"href": "https://x.com/rui40000"
}

View File

@@ -0,0 +1,9 @@
{
"order": 3,
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
"title": "Neon Nights",
"userAlias": "ShaneF Motion Design",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 9,
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
"title": "Origami world",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 2,
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
"title": "Origins - Kyrie Irving",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://vimeo.com/1021360563"
}

View File

@@ -0,0 +1,9 @@
{
"order": 10,
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
"title": "Shot on InstaX",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 14,
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
"title": "Show you my garden",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685479"
}

View File

@@ -0,0 +1,9 @@
{
"order": 1,
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
"title": "Until Our Eye Interlink harajuku",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
}

View File

@@ -0,0 +1,9 @@
{
"order": 8,
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
"title": "Untitled",
"userAlias": "Nathan Shipley",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
}

View File

@@ -0,0 +1,9 @@
{
"order": 4,
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
"title": "Untitled",
"userAlias": "MidJourney man",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
}

View File

@@ -0,0 +1,264 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCollection } from 'astro:content'
import {
getEventsByLocale,
getGalleryByIds,
getTutorialsByLocale,
getVisibleGalleryByLocale,
slugOf
} from './queries'
vi.mock('astro:content', () => ({
getCollection: vi.fn()
}))
const getCollectionMock = vi.mocked(getCollection)
interface FixtureEntry {
id: string
collection: 'gallery'
data: {
order: number
title: string
userAlias: string
teamAlias: string
tool: string
image?: string
video?: string
href?: string
objectPosition?: string
objectFit?: string
visible: boolean
}
}
function entry(
id: string,
overrides: Partial<FixtureEntry['data']> = {}
): FixtureEntry {
return {
id,
collection: 'gallery',
data: {
order: 0,
title: 'Title',
userAlias: 'User',
teamAlias: 'Team',
tool: 'ComfyUI',
visible: true,
...overrides
}
}
}
beforeEach(() => {
getCollectionMock.mockReset()
})
describe('slugOf', () => {
it('strips the locale prefix from an entry id', () => {
const entry = { id: 'en/until-our-eye-interlink-harajuku' }
expect(slugOf(entry)).toBe('until-our-eye-interlink-harajuku')
})
it('handles zh-CN prefix', () => {
const entry = { id: 'zh-CN/desert-landing' }
expect(slugOf(entry)).toBe('desert-landing')
})
})
describe('getVisibleGalleryByLocale', () => {
it('returns only entries whose id starts with the requested locale prefix', async () => {
getCollectionMock.mockResolvedValue([
entry('en/alpha'),
entry('en/beta'),
entry('zh-CN/alpha'),
entry('zh-CN/gamma')
] as never)
const en = await getVisibleGalleryByLocale('en')
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
const zh = await getVisibleGalleryByLocale('zh-CN')
expect(zh.map((e) => e.id)).toEqual(['zh-CN/alpha', 'zh-CN/gamma'])
})
it('excludes entries with visible: false', async () => {
getCollectionMock.mockResolvedValue([
entry('en/shown', { visible: true }),
entry('en/hidden', { visible: false }),
entry('en/also-shown', { visible: true })
] as never)
const result = await getVisibleGalleryByLocale('en')
expect(result.map((e) => e.id)).toEqual(['en/shown', 'en/also-shown'])
})
it('sorts entries by the order field ascending, not by id', async () => {
getCollectionMock.mockResolvedValue([
entry('en/charlie', { order: 1 }),
entry('en/alpha', { order: 3 }),
entry('en/bravo', { order: 2 })
] as never)
const result = await getVisibleGalleryByLocale('en')
expect(result.map((e) => e.id)).toEqual([
'en/charlie',
'en/bravo',
'en/alpha'
])
})
})
describe('getGalleryByIds', () => {
it('returns entries in the order of the input slug array', async () => {
getCollectionMock.mockResolvedValue([
entry('en/alpha'),
entry('en/beta'),
entry('en/gamma'),
entry('zh-CN/alpha')
] as never)
const result = await getGalleryByIds(['gamma', 'alpha', 'beta'], 'en')
expect(result.map((e) => e.id)).toEqual(['en/gamma', 'en/alpha', 'en/beta'])
})
it('drops slugs with no matching entry in the requested locale', async () => {
getCollectionMock.mockResolvedValue([
entry('en/alpha'),
entry('zh-CN/beta')
] as never)
const result = await getGalleryByIds(['alpha', 'missing', 'beta'], 'en')
expect(result.map((e) => e.id)).toEqual(['en/alpha'])
})
})
interface EventsFixtureEntry {
id: string
collection: 'events'
data: {
order: number
label: string
title: string
cta: string
href: string
}
}
function eventEntry(
id: string,
overrides: Partial<EventsFixtureEntry['data']> = {}
): EventsFixtureEntry {
return {
id,
collection: 'events',
data: {
order: 0,
label: 'Label',
title: 'Title',
cta: 'CTA',
href: '#',
...overrides
}
}
}
describe('getEventsByLocale', () => {
it('returns only entries whose id starts with the requested locale prefix', async () => {
getCollectionMock.mockResolvedValue([
eventEntry('en/alpha'),
eventEntry('en/beta'),
eventEntry('zh-CN/alpha')
] as never)
const en = await getEventsByLocale('en')
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
})
it('sorts entries by the order field ascending, not by id', async () => {
getCollectionMock.mockResolvedValue([
eventEntry('en/charlie', { order: 1 }),
eventEntry('en/alpha', { order: 3 }),
eventEntry('en/bravo', { order: 2 })
] as never)
const result = await getEventsByLocale('en')
expect(result.map((e) => e.id)).toEqual([
'en/charlie',
'en/bravo',
'en/alpha'
])
})
it('returns an empty array for a locale with no entries', async () => {
getCollectionMock.mockResolvedValue([
eventEntry('en/alpha'),
eventEntry('en/beta')
] as never)
const result = await getEventsByLocale('zh-CN')
expect(result).toEqual([])
})
})
interface TutorialsFixtureEntry {
id: string
collection: 'tutorials'
data: {
order: number
tags: string[]
title: string
videoSrc: string
href?: string
poster?: string
posterTime?: number
}
}
function tutorialEntry(
id: string,
overrides: Partial<TutorialsFixtureEntry['data']> = {}
): TutorialsFixtureEntry {
return {
id,
collection: 'tutorials',
data: {
order: 0,
tags: ['Tag'],
title: 'Title',
videoSrc: 'https://example.com/video.mp4',
...overrides
}
}
}
describe('getTutorialsByLocale', () => {
it('returns only entries whose id starts with the requested locale prefix', async () => {
getCollectionMock.mockResolvedValue([
tutorialEntry('en/alpha'),
tutorialEntry('en/beta'),
tutorialEntry('zh-CN/alpha')
] as never)
const en = await getTutorialsByLocale('en')
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
})
it('sorts entries by the order field ascending, not by id', async () => {
getCollectionMock.mockResolvedValue([
tutorialEntry('en/charlie', { order: 1 }),
tutorialEntry('en/alpha', { order: 3 }),
tutorialEntry('en/bravo', { order: 2 })
] as never)
const result = await getTutorialsByLocale('en')
expect(result.map((e) => e.id)).toEqual([
'en/charlie',
'en/bravo',
'en/alpha'
])
})
})

View File

@@ -0,0 +1,61 @@
import { getCollection } from 'astro:content'
import type { CollectionEntry } from 'astro:content'
import type { Locale } from '../i18n/translations'
export type GalleryEntry = CollectionEntry<'gallery'>
export type EventsEntry = CollectionEntry<'events'>
export type TutorialsEntry = CollectionEntry<'tutorials'>
export function slugOf(entry: { id: string }): string {
const slash = entry.id.indexOf('/')
return slash === -1 ? entry.id : entry.id.slice(slash + 1)
}
export async function getVisibleGalleryByLocale(
locale: Locale
): Promise<GalleryEntry[]> {
const prefix = `${locale}/`
const entries: GalleryEntry[] = await getCollection('gallery')
return entries
.filter(
(entry) => entry.id.startsWith(prefix) && entry.data.visible !== false
)
.sort((a, b) => a.data.order - b.data.order)
}
export async function getGalleryByIds(
slugs: readonly string[],
locale: Locale
): Promise<GalleryEntry[]> {
const entries: GalleryEntry[] = await getCollection('gallery')
const bySlug = new Map<string, GalleryEntry>()
for (const entry of entries) {
if (entry.id.startsWith(`${locale}/`)) {
bySlug.set(slugOf(entry), entry)
}
}
return slugs
.map((slug) => bySlug.get(slug))
.filter((entry): entry is GalleryEntry => entry !== undefined)
}
export async function getEventsByLocale(
locale: Locale
): Promise<EventsEntry[]> {
const prefix = `${locale}/`
const entries: EventsEntry[] = await getCollection('events')
return entries
.filter((entry) => entry.id.startsWith(prefix))
.sort((a, b) => a.data.order - b.data.order)
}
export async function getTutorialsByLocale(
locale: Locale
): Promise<TutorialsEntry[]> {
const prefix = `${locale}/`
const entries: TutorialsEntry[] = await getCollection('tutorials')
return entries
.filter((entry) => entry.id.startsWith(prefix))
.sort((a, b) => a.data.order - b.data.order)
}

View File

@@ -0,0 +1,9 @@
---
order: 1
tags:
- Partner Nodes
- Image To Video
title: Cleanplate Walkthrough
videoSrc: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4
poster: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg
---

View File

@@ -0,0 +1,10 @@
---
order: 2
tags:
- Partner Nodes
- Image To Video
title: Deaging Workflow
videoSrc: https://media.comfy.org/website/learning/deaging_workflow_v03.mp4
poster: https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=93f286fbc2c8
---

View File

@@ -0,0 +1,10 @@
---
order: 3
tags:
- Partner Nodes
- Image To Video
title: Frame Adjustments Demo
videoSrc: https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4
poster: https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=7dca0438edf4
---

View File

@@ -0,0 +1,10 @@
---
order: 4
tags:
- Partner Nodes
- Image To Video
title: Mattes and Utilities
videoSrc: https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4
poster: https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=be0889296f65
---

View File

@@ -0,0 +1,10 @@
---
order: 5
tags:
- Partner Nodes
- Image To Video
title: Seedance Demo ComfyUI
videoSrc: https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4
poster: https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=ef543bd4a773
---

View File

@@ -0,0 +1,10 @@
---
order: 6
tags:
- Partner Nodes
- Image To Video
title: Sky Replacement
videoSrc: https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4
poster: https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg
href: https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/
---

View File

@@ -0,0 +1,9 @@
---
order: 1
tags:
- 合作伙伴节点
- 图像生成视频
title: 净板演练
videoSrc: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4
poster: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg
---

View File

@@ -0,0 +1,10 @@
---
order: 2
tags:
- 合作伙伴节点
- 图像生成视频
title: 减龄工作流
videoSrc: https://media.comfy.org/website/learning/deaging_workflow_v03.mp4
poster: https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=93f286fbc2c8
---

View File

@@ -0,0 +1,10 @@
---
order: 3
tags:
- 合作伙伴节点
- 图像生成视频
title: 帧调整演示
videoSrc: https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4
poster: https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=7dca0438edf4
---

View File

@@ -0,0 +1,10 @@
---
order: 4
tags:
- 合作伙伴节点
- 图像生成视频
title: 遮罩与实用工具
videoSrc: https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4
poster: https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=be0889296f65
---

View File

@@ -0,0 +1,10 @@
---
order: 5
tags:
- 合作伙伴节点
- 图像生成视频
title: Seedance ComfyUI 演示
videoSrc: https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4
poster: https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=ef543bd4a773
---

View File

@@ -0,0 +1,10 @@
---
order: 6
tags:
- 合作伙伴节点
- 图像生成视频
title: 天空替换
videoSrc: https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4
poster: https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg
href: https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/
---

View File

@@ -1,31 +0,0 @@
import type { EventItem } from '../components/common/EventsSection.vue'
export const learningEvents: readonly EventItem[] = [
{
label: { en: 'Live Stream:', 'zh-CN': '直播:' },
title: {
en: 'Zero to Node: Building Your First Workflow',
'zh-CN': '从零到节点:构建你的第一个工作流'
},
cta: { en: 'Link', 'zh-CN': '链接' },
href: '#'
},
{
label: { en: 'Event 1', 'zh-CN': '活动 1' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'London, UK', 'zh-CN': '英国伦敦' },
href: '#'
},
{
label: { en: 'Event 2', 'zh-CN': '活动 2' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'San Francisco', 'zh-CN': '旧金山' },
href: '#'
}
] as const

View File

@@ -1,191 +0,0 @@
export interface GalleryItem {
id: string
image?: string
video?: string
title: string
userAlias: string
teamAlias: string
tool: string
href?: string
objectPosition?: string
objectFit?: string
/** Defaults to true. Set to false to hide this item from rendered lists. */
visible?: boolean
}
const galleryItems: GalleryItem[] = [
{
id: 'until-our-eye-interlink-harajuku',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
},
{
id: 'origins-kyrie-irving',
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
title: 'Origins - Kyrie Irving',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://vimeo.com/1021360563'
},
{
id: 'neon-nights',
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
title: 'Neon Nights',
userAlias: 'ShaneF Motion Design',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
},
{
id: 'untitled-dusk-mountains',
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
title: 'Untitled',
userAlias: 'MidJourney man',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
},
{
id: 'autopoiesis',
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
title: 'Autopoiesis',
userAlias: 'Yogo',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/visualfrisson/?hl=en'
},
{
id: 'eat-it-dance',
video:
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
title: 'Eat It - Dance',
userAlias: 'Johana Lyu',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.joannalyu.com/'
},
{
id: 'fall',
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
title: 'Fall',
userAlias: 'Nathan Shipley',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
},
{
id: 'untitled-buildings',
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
title: 'Untitled',
userAlias: 'Nathan Shipley',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
},
{
id: 'origami-world',
video:
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
title: 'Origami world',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'shot-on-instax',
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
title: 'Shot on InstaX',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'good-good-summer',
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
title: "It's gonna be a good good summer",
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685900'
},
{
id: 'ddu-du-ddu-du',
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
title: 'DDU-DU DDU-DU',
userAlias: 'Purz',
teamAlias: 'Andidea',
tool: 'Animatediff',
href: 'https://vimeo.com/1019924290'
},
{
id: 'cuco-love-letter-to-la',
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
title: 'Cuco - A Love Letter To LA',
userAlias: 'Paul Trillo',
teamAlias: 'CoffeeVectors',
tool: 'ComfyUI',
href: 'https://vimeo.com/1062859798'
},
{
id: 'show-you-my-garden',
video:
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
title: 'Show you my garden',
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685479'
},
{
id: 'goodbye-beijing',
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
title: 'Goodbye Beijing',
userAlias: 'Rui',
teamAlias: 'makeitrad',
tool: 'Animatediff',
href: 'https://x.com/rui40000'
},
{
id: 'animation-reel',
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
title: 'Animation Reel',
userAlias: 'Andidea',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
},
{
id: 'amber-astronaut',
image: 'https://media.comfy.org/website/gallery/gallery.webp',
title: 'Amber Astronaut',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
},
{
id: 'desert-landing',
image: 'https://media.comfy.org/website/gallery/desert.webp',
title: 'Desert Landing',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
}
]
export const visibleGalleryItems: GalleryItem[] = galleryItems.filter(
(item) => item.visible !== false
)
/** @knipIgnoreUsedByStackedPR */
export function getGalleryItemById(id: string): GalleryItem | undefined {
return galleryItems.find((item) => item.id === id)
}

View File

@@ -1,84 +0,0 @@
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
export const learningTutorials: readonly LearningTutorial[] = [
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'deaging_workflow_v03',
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
videoSrc:
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'frame_adjustments_demo_v03',
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
videoSrc:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'mattes_and_utilities_v03',
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
videoSrc:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'seedance_demo_comfyui_v03',
title: { en: 'Seedance Demo ComfyUI', 'zh-CN': 'Seedance ComfyUI 演示' },
videoSrc:
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'skyreplacement_smaller_v06',
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
videoSrc:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -3,10 +3,14 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/gallery/HeroSection.vue'
import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
import { getVisibleGalleryByLocale } from '../content/queries'
const entries = await getVisibleGalleryByLocale('en')
const items = entries.map((entry) => entry.data)
---
<BaseLayout title="Gallery — Comfy">
<HeroSection />
<GallerySection client:load />
<GallerySection items={items} client:load />
<ContactSection />
</BaseLayout>

View File

@@ -8,14 +8,20 @@ import CallToActionSection from '../components/common/CallToActionSection.vue'
import { getRoutes } from '../config/routes'
import { externalLinks } from '../config/routes'
// import { learningEvents } from '../data/events'
import { getTutorialsByLocale, slugOf } from '../content/queries'
const routes = getRoutes('en')
const tutorialEntries = await getTutorialsByLocale('en')
const tutorials = tutorialEntries.map((entry) => ({
slug: slugOf(entry),
...entry.data
}))
---
<BaseLayout title="Learning — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />
<TutorialsSection tutorials={tutorials} client:visible />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"

View File

@@ -3,7 +3,18 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
import { getGalleryByIds } from '../content/queries'
const featuredCreationSlugs = [
'subway-swan',
'milos-little-wonder',
'amber-passage',
'neon-revenant',
'midnight-umami'
]
const featuredEntries = await getGalleryByIds(featuredCreationSlugs, 'en')
const featuredItems = featuredEntries.map((entry) => entry.data)
---
<BaseLayout
@@ -16,7 +27,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection client:load />
<ModelCreationsSection items={featuredItems} client:load />
<AIModelsSection client:load />
<ProductShowcaseSection client:load />
</BaseLayout>

View File

@@ -3,10 +3,14 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/gallery/HeroSection.vue'
import GallerySection from '../../components/gallery/GallerySection.vue'
import ContactSection from '../../components/gallery/ContactSection.vue'
import { getVisibleGalleryByLocale } from '../../content/queries'
const entries = await getVisibleGalleryByLocale('zh-CN')
const items = entries.map((entry) => entry.data)
---
<BaseLayout title="作品集 — Comfy">
<HeroSection locale="zh-CN" />
<GallerySection locale="zh-CN" client:load />
<GallerySection locale="zh-CN" items={items} client:load />
<ContactSection locale="zh-CN" />
</BaseLayout>

View File

@@ -6,15 +6,34 @@ import TutorialsSection from '../../components/learning/TutorialsSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
import EventsSection from '../../components/common/EventsSection.vue'
import { getRoutes, externalLinks } from '../../config/routes'
import { learningEvents } from '../../data/events'
import {
getEventsByLocale,
getTutorialsByLocale,
slugOf
} from '../../content/queries'
const routes = getRoutes('zh-CN')
const eventEntries = await getEventsByLocale('zh-CN')
const events = eventEntries.map((entry) => entry.data)
const tutorialEntries = await getTutorialsByLocale('zh-CN')
const tutorials = tutorialEntries.map((entry) => ({
slug: slugOf(entry),
...entry.data
}))
---
<BaseLayout title="学习 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" tutorials={tutorials} client:visible />
<EventsSection
locale="zh-CN"
headingKey="learning.events.heading"
descriptionKey="learning.events.description"
notifyLabelKey="learning.events.getNotified"
events={events}
client:visible
/>
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"

View File

@@ -4,6 +4,17 @@ import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
import { getGalleryByIds } from '../../content/queries'
const featuredCreationSlugs = [
'subway-swan',
'milos-little-wonder',
'amber-passage',
'neon-revenant',
'midnight-umami'
]
const featuredEntries = await getGalleryByIds(featuredCreationSlugs, 'en')
const featuredItems = featuredEntries.map((entry) => entry.data)
---
<BaseLayout
@@ -17,7 +28,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
/>
<ModelCreationsSection client:load locale="zh-CN" />
<ModelCreationsSection items={featuredItems} client:load locale="zh-CN" />
<AIModelsSection client:load locale="zh-CN" />
<ProductShowcaseSection client:load locale="zh-CN" />
</BaseLayout>

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { getTutorialPosterSrc } from './tutorial'
describe('getTutorialPosterSrc', () => {
it('returns the explicit poster URL when provided', () => {
expect(
getTutorialPosterSrc({
order: 1,
tags: [],
title: 'T',
videoSrc: 'https://example.com/v.mp4',
poster: 'https://example.com/poster.jpg'
})
).toBe('https://example.com/poster.jpg')
})
it('falls back to videoSrc#t=<posterTime> when poster is missing', () => {
expect(
getTutorialPosterSrc({
order: 1,
tags: [],
title: 'T',
videoSrc: 'https://example.com/v.mp4',
posterTime: 7
})
).toBe('https://example.com/v.mp4#t=7')
})
it('uses the default poster time when neither poster nor posterTime is set', () => {
expect(
getTutorialPosterSrc({
order: 1,
tags: [],
title: 'T',
videoSrc: 'https://example.com/v.mp4'
})
).toBe('https://example.com/v.mp4#t=1')
})
})

View File

@@ -0,0 +1,8 @@
import type { LearningTutorial } from '../content.config'
const DEFAULT_POSTER_TIME_SECONDS = 1
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`

View File

@@ -0,0 +1,48 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "TEST_MISSING_PACK_NODE_A",
"pos": [48, 86],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
},
{
"id": 2,
"type": "TEST_MISSING_PACK_NODE_B",
"pos": [520, 86],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -45,6 +45,8 @@ export const TestIds = {
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
missingNodePackExpand: 'missing-node-pack-expand',
missingNodePackCount: 'missing-node-pack-count',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',

View File

@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
@@ -12,27 +12,39 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
)
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
})
test('Should show missing node packs group', async ({ comfyPage }) => {
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should expand pack group to reveal node type names', async ({
test('Should show unknown pack node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
test('Should show subgraph missing node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -43,66 +55,72 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
missingNodeCard.getByRole('button', {
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
})
).toBeVisible()
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
test('Should locate missing node from the row label', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeHidden()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Locate node button is visible for expanded pack nodes', async ({
test('Should toggle grouped pack nodes from chevron and title', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
'missing/missing_nodes_same_pack'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
const packTitle = missingNodeCard.getByRole('button', {
name: 'test-missing-node-pack'
})
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
const expandButton = missingNodeCard.getByTestId(
TestIds.dialogs.missingNodePackExpand
)
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()
await expect(
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
).toHaveText('2')
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await expandButton.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await packTitle.click()
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await packTitle.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
})
})

View File

@@ -425,6 +425,56 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
}
)
test(
'Refreshing a resolved promoted missing model clears the combo invalid state',
{ tag: ['@widget', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
await comfyPage.vueNodes.waitForNodes()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')
.getByRole('combobox', { name: 'ckpt_name', exact: true })
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
const objectInfoRoute = /\/object_info$/
try {
await comfyPage.page.route(objectInfoRoute, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
await comfyPage.page
.getByTestId(TestIds.dialogs.missingModelRefresh)
.click()
await expect(missingModelGroup).toBeHidden()
await expect(promotedModelCombo).toBeVisible()
await expect(promotedModelCombo).not.toHaveAttribute(
'aria-invalid',
'true'
)
} finally {
await comfyPage.page.unroute(objectInfoRoute)
}
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
// text-sm rows render a single 20px line; a wrapped name is 40px+.
const SINGLE_LINE_MAX_HEIGHT_PX = 28
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
},
{
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await use(page)
}
})
test.describe('Workspace switcher', { tag: '@cloud' }, () => {
test('renders a long team workspace name on a single line', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByText(PERSONAL_WORKSPACE_NAME).click()
const longName = page.getByText(LONG_WORKSPACE_NAME)
await expect(longName).toBeVisible()
const box = await longName.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
})

View File

@@ -421,6 +421,15 @@ export default defineConfig([
}
},
// Astro virtual modules (astro:content, astro:assets, etc.) are not
// resolvable by the TS resolver — they are injected by the Astro build.
{
files: ['apps/website/**/*.{ts,vue,astro}'],
rules: {
'import-x/no-unresolved': ['error', { ignore: ['^astro:'] }]
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.11",
"version": "1.46.12",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -1,9 +1,9 @@
<svg width="520" height="520" viewBox="0 0 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_227_285" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="520" height="520">
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#EEFF30"/>
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#F2FF59"/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#172DD7"/>
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F0FF41"/>
<rect y="0.751831" width="520" height="520" fill="#211927"/>
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F2FF59"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -2,8 +2,8 @@
"PreviewImage": 4314,
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImageAdvanced": 1763,
"SaveImage": 1762,
"SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,
@@ -14,6 +14,7 @@
"UpscaleModelLoader": 629,
"UNETLoader": 606,
"VAELoader": 604,
"PreviewAny": 528,
"ShowText|pysssss": 527.5526981023964,
"ImageUpscaleWithModel": 523,
"ControlNetApplyAdvanced": 513,
@@ -24,10 +25,12 @@
"VHS_LoadVideo": 440,
"ImpactSwitch": 349,
"Reroute": 348,
"ResizeImageMaskNode": 337,
"ResizeAndPadImage": 336,
"ImageResizeKJv2": 335,
"StringConcatenate": 326,
"Text Concatenate": 325.7030402103206,
"SaveVideo": 321,
"PreviewAny": 319,
"KSamplerAdvanced": 304,
"SDXLPromptStyler": 297.0913411304729,
"Note": 291,
@@ -52,6 +55,7 @@
"CLIPLoader": 202,
"GeminiNode": 202,
"KSampler (Efficient)": 194.01083622636423,
"RemoveBackground": 187,
"ImageRemoveBackground+": 186,
"IPAdapterModelLoader": 184,
"PrimitiveInt": 183,
@@ -59,7 +63,9 @@
"LoadVideo": 179,
"Text Concatenate (JPS)": 175.98154639522735,
"PrimitiveNode": 175,
"Text Multiline": 163.04749064680308,
"PrimitiveStringMultiline": 166,
"Text Multiline": 165,
"GetImageSize": 164,
"GetImageSize+": 163,
"ImageScaleToTotalPixels": 157,
"String Literal": 150.11343489837878,
@@ -68,15 +74,14 @@
"DownloadAndLoadFlorence2Model": 144,
"LoadImageOutput": 143,
"IPAdapterUnifiedLoader": 141,
"FluxGuidance": 133,
"BatchImagesNode": 134,
"ImageBatchMulti": 133,
"FluxGuidance": 132,
"ByteDanceSeedreamNode": 130,
"CR Text Input Switch": 128.16473423438606,
"IPAdapterAdvanced": 128,
"If ANY execute A else B": 127.77279315110049,
"GeminiImage2Node": 124,
"GetImageSize": 121,
"PrimitiveStringMultiline": 120,
"IPAdapter": 118,
"CreateVideo": 116,
"ConditioningZeroOut": 115,
@@ -102,6 +107,7 @@
"DepthAnythingPreprocessor": 100,
"CR Apply LoRA Stack": 96.02556540496816,
"Image Filter Adjustments": 95.24168323839699,
"ComfyMathExpression": 96,
"SimpleMath+": 95,
"GroundingDinoSAMSegment (segment anything)": 93.28197782196906,
"Image Overlay": 93.28197782196906,
@@ -147,7 +153,6 @@
"Image Resize": 63.494455492264656,
"Automatic CFG": 63.494455492264656,
"Canny": 63,
"StringConcatenate": 63,
"DepthAnything_V2": 61,
"ImageCrop+": 60,
"ModelSamplingSD3": 59,
@@ -199,6 +204,7 @@
"BNK_CLIPTextEncodeAdvanced": 45.857106744413365,
"CR SDXL Aspect Ratio": 45.46516566112778,
"LoadAudio": 45,
"ResolutionSelector": 45,
"smZ CLIPTextEncode": 44.68128349455661,
"Bus Node": 44.68128349455661,
"PreviewTextNode": 44.68128349455661,
@@ -389,7 +395,6 @@
"SD_4XUpscale_Conditioning": 21,
"UltimateSDUpscaleCustomSample": 21,
"StyleModelLoader": 21,
"ResizeAndPadImage": 21,
"Text Random Prompt": 20.77287741413597,
"INPAINT_VAEEncodeInpaintConditioning": 20.77287741413597,
"BrushNet": 20.77287741413597,

View File

@@ -71,12 +71,11 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
name: 'MissingPackGroupRow',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
props: ['group', 'showInfoButton'],
emits: ['locate-node', 'open-manager-info']
}
}))
@@ -122,7 +121,6 @@ function makePackGroups(count = 2): MissingPackGroup[] {
function renderCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
@@ -130,7 +128,6 @@ function renderCard(
const result = render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
@@ -169,12 +166,10 @@ describe('MissingNodeCard', () => {
it('passes props correctly to MissingPackGroupRow children', () => {
renderCard({
showInfoButton: true,
showNodeIdBadge: true
showInfoButton: true
})
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
})
})
@@ -256,7 +251,6 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
@@ -279,7 +273,6 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},

View File

@@ -56,27 +56,29 @@
>
</template>
</i18n-t>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
<div class="flex flex-col gap-1 overflow-hidden py-2">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="primary"
variant="secondary"
size="sm"
:disabled="isRestarting"
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
<i
v-else
aria-hidden="true"
@@ -105,9 +107,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
const { showInfoButton, missingPackGroups } = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()

View File

@@ -61,16 +61,16 @@ const i18n = createI18n({
messages: {
en: {
g: {
loading: 'Loading'
install: 'Install',
loading: 'Loading',
search: 'Search'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
@@ -100,7 +100,6 @@ function renderRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
const user = userEvent.setup()
@@ -110,7 +109,6 @@ function renderRow(
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
onLocateNode,
onOpenManagerInfo,
...props
@@ -118,7 +116,6 @@ function renderRow(
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
@@ -156,9 +153,22 @@ describe('MissingPackGroupRow', () => {
expect(screen.getByText(/Loading/)).toBeInTheDocument()
})
it('does not render header locate while pack metadata is resolving', () => {
renderRow({
group: makeGroup({
isResolving: true,
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders node count', () => {
renderRow()
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('renders count of 5 for 5 nodeTypes', () => {
@@ -171,38 +181,29 @@ describe('MissingPackGroupRow', () => {
}))
})
})
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
it('expands when chevron is clicked', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('collapses when chevron is clicked again', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
})
describe('Node Type List', () => {
async function expand(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Expand' }))
}
it('hides multiple nodeTypes behind the expand control by default', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
})
it('renders all nodeTypes when expanded', async () => {
it('shows unknown pack nodeTypes by default', () => {
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('renders all nodeTypes after expanding', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [
@@ -212,40 +213,87 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('NodeA')).toBeInTheDocument()
expect(screen.getByText('NodeB')).toBeInTheDocument()
expect(screen.getByText('NodeC')).toBeInTheDocument()
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { user } = renderRow({ showNodeIdBadge: true })
await expand(user)
expect(screen.getByText('#10')).toBeInTheDocument()
it('hides multiple nodeTypes again after collapsing', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { user } = renderRow({ showNodeIdBadge: false })
await expand(user)
expect(screen.queryByText('#10')).not.toBeInTheDocument()
it('hides a single nodeType without an expand control', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
})
})
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Expand' })
).not.toBeInTheDocument()
})
it('emits locateNode when Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
await expand(user)
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(screen.getByRole('button', { name: 'my-pack' }))
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('moves locate to the header when there is one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('emits locateNode when expanded child Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', async () => {
const { user } = renderRow({
it('emits locateNode when node label is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: 'MissingA' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(user)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
@@ -253,7 +301,6 @@ describe('MissingPackGroupRow', () => {
it('handles mixed nodeTypes with and without nodeId', async () => {
const { user } = renderRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
@@ -261,7 +308,7 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
@@ -274,21 +321,25 @@ describe('MissingPackGroupRow', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
renderRow()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
renderRow({ group: makeGroup({ packId: null }) })
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
it('shows Search when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
renderRow()
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
})
it('shows "Installed" state when pack is installed', () => {
@@ -312,7 +363,9 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(screen.getByText('Install node pack')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Install' })
).toBeInTheDocument()
})
it('calls installAllPacks when Install button is clicked', async () => {
@@ -320,9 +373,7 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const { user } = renderRow()
await user.click(
screen.getByRole('button', { name: /Install node pack/ })
)
await user.click(screen.getByRole('button', { name: 'Install' }))
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
@@ -369,7 +420,7 @@ describe('MissingPackGroupRow', () => {
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
renderRow({ group: makeGroup({ nodeTypes: [] }) })
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
})
})

View File

@@ -1,187 +1,221 @@
<template>
<div class="mb-2 flex w-full flex-col">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 w-full items-center">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</p>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="showInfoButton && group.packId !== null"
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
size="unset"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
<i
v-if="isUnknownPack"
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes && !group.isResolving"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
:aria-expanded="expanded"
@click="toggleExpand"
>
#{{ nodeType.nodeId }}
{{ packDisplayName }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ packDisplayName }}
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal"
:class="
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{ packDisplayName }}
</span>
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="icon-sm"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--locate] size-3" />
<i class="icon-[lucide--info] size-4" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
/>
<i
v-else
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
<span
v-if="showNodeCount"
data-testid="missing-node-pack-count"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ group.nodeTypes.length }}
</span>
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex w-full items-start py-1"
>
</span>
<div v-if="showInstallAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: isPackInstalled
? t('rightSidePanel.missingNodePacks.installed')
: t('g.install')
}}
</span>
</Button>
</div>
<div
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
v-else-if="showLoadingAction"
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('g.loading') }}
</span>
</div>
</div>
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex w-full items-start py-1"
>
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<span class="text-foreground min-w-0 truncate">
{{ t('g.search') }}
</span>
</Button>
</div>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none space-y-1 p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
"
>
<li
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
:class="
cn(
packTextButtonClass,
'text-muted-foreground hover:text-base-foreground'
)
"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
</span>
<Button
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -193,10 +227,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
const { group, showInfoButton } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -205,6 +238,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const expandedOverride = ref<boolean | null>(null)
const packTextButtonClass =
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
@@ -219,17 +256,73 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
const isUnknownPack = computed(
() => group.packId === null && !group.isResolving
)
const packDisplayName = computed(() => {
if (group.packId === null) {
return t('rightSidePanel.missingNodePacks.unknownPack')
}
return nodePack.value?.name ?? group.packId
})
const isPackInstalled = computed(
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
)
const showInstallAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
(nodePack.value !== null || isPackInstalled.value)
)
const showLoadingAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
isLoading.value
)
const showSearchAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
!showLoadingAction.value
)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
const expanded = computed(
() =>
expandedOverride.value ??
(isUnknownPack.value && hasMultipleNodeTypes.value)
)
const showNodeTypeList = computed(
() =>
(isUnknownPack.value && group.nodeTypes.length === 1) ||
(hasMultipleNodeTypes.value && expanded.value)
)
const primaryLocatableNodeType = computed(() => {
if (group.isResolving) return null
if (isUnknownPack.value) return null
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function handlePackInstallClick() {
if (!group.packId) return
if (!comfyManagerStore.isPackInstalled(group.packId)) {
if (!isPackInstalled.value) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
expandedOverride.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
@@ -241,10 +334,14 @@ function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function isLocatableNodeType(
nodeType: MissingNodeType
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
return typeof nodeType !== 'string' && nodeType.nodeId != null
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locateNode', String(nodeType.nodeId))
}
if (!isLocatableNodeType(nodeType)) return
emit('locateNode', String(nodeType.nodeId))
}
</script>

View File

@@ -148,7 +148,6 @@
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:show-node-id-badge="showNodeIdBadge"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"

View File

@@ -1,103 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
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 type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
import type {
LinkReleaseNodeCategory,
LinkReleaseSearchResultGroup
} from './linkReleaseMenuModel'
const { groups } = vi.hoisted(() => ({
groups: {
suggestions: [] as ComfyNodeDefImpl[],
categories: [] as LinkReleaseNodeCategory[],
searchResultGroups: [] as LinkReleaseSearchResultGroup[]
}
}))
vi.mock('./linkReleaseMenuModel', () => ({
getLinkReleaseHeaderLabel: () => '',
getLinkReleaseSuggestions: () => groups.suggestions,
buildLinkReleaseNodeCategories: () => groups.categories,
groupLinkReleaseSearchResults: () => groups.searchResultGroups,
searchLinkReleaseNodes: () =>
groups.searchResultGroups.flatMap((group) =>
group.nodes.map((node) => ({ category: group.category, node }))
),
filterNodesByName: () => []
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
const stubs = {
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuLabel: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div role="menuitem"><slot /></div>' },
DropdownMenuSeparator: { template: '<hr data-testid="menu-separator" />' },
LinkReleaseNodeSubmenu: { template: '<div data-testid="submenu" />' },
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
}
function suggestion(name: string): ComfyNodeDefImpl {
return { name, display_name: name } as ComfyNodeDefImpl
}
function nodeCategory(
key: 'comfy' | 'extensions' | 'partner',
labelKey: string = key
): LinkReleaseNodeCategory {
return { key, labelKey, icon: '', nodes: [suggestion('Node')] }
}
function renderMenu() {
return render(LinkReleaseContextMenu, {
props: { context: null },
global: { plugins: [i18n, createTestingPinia()], stubs }
})
}
describe('LinkReleaseContextMenu group divider', () => {
it('renders a divider between the suggestions and categories groups', () => {
groups.suggestions = [suggestion('KSampler')]
groups.categories = [nodeCategory('comfy')]
renderMenu()
expect(screen.getAllByTestId('menu-separator')).toHaveLength(3)
})
it('omits the group divider when only one group is present', () => {
groups.suggestions = []
groups.categories = [nodeCategory('comfy')]
renderMenu()
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
})
it('renders a divider between search result groups', async () => {
groups.suggestions = []
groups.categories = []
groups.searchResultGroups = [
{
category: nodeCategory('extensions', 'contextMenu.Extensions'),
nodes: [suggestion('Ext Node')]
},
{
category: nodeCategory('partner', 'contextMenu.Partner Nodes'),
nodes: [suggestion('Partner Node')]
}
]
renderMenu()
await userEvent.type(screen.getByRole('textbox'), 'na')
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
})
})

View File

@@ -1,379 +0,0 @@
<template>
<DropdownMenuRoot :open="open" :modal="false" @update:open="onOpenChange">
<DropdownMenuTrigger as-child>
<div
aria-hidden="true"
class="pointer-events-none fixed size-0"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
/>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
align="start"
:side-offset="SIDE_OFFSET"
:collision-padding="VIEWPORT_MARGIN"
:avoid-collisions="false"
:class="contentClass"
:style="menuMaxHeight ? { maxHeight: `${menuMaxHeight}px` } : undefined"
@open-auto-focus.prevent="focusSearch"
@close-auto-focus.prevent
@entry-focus="onEntryFocus"
@keydown.capture="redirectTypingToSearch"
>
<DropdownMenuLabel
v-if="headerLabel"
class="flex shrink-0 items-center gap-2 p-2 text-xs font-medium text-muted-foreground uppercase"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<span
class="size-4 rounded-full"
:style="{ backgroundColor: slotColor }"
/>
</span>
<span class="truncate">{{ headerLabel }}</span>
</DropdownMenuLabel>
<div data-search-field class="p-.5 shrink-0">
<div
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
/>
<input
ref="searchInput"
v-model="query"
type="text"
:placeholder="t('contextMenu.Search')"
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
@keydown="onRootSearchKeydown"
/>
</div>
</div>
<DropdownMenuSeparator
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<div :class="scrollClass">
<template v-if="trimmedQuery">
<template
v-for="(group, groupIndex) in searchResultGroups"
:key="group.category.key"
>
<DropdownMenuSeparator
v-if="groupIndex > 0"
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<DropdownMenuItem
v-for="node in group.nodes"
:key="node.name"
:class="itemClass"
@select="selectNode(node)"
>
<i
:class="cn(group.category.icon, 'size-4 shrink-0 opacity-80')"
/>
<span class="flex min-w-0 flex-1 items-center gap-1">
<span class="shrink-0 text-muted-foreground">
{{ t(group.category.labelKey) }}:
</span>
<MiddleTruncate
:text="node.display_name"
class="min-w-0 flex-1"
/>
</span>
</DropdownMenuItem>
</template>
<div
v-if="searchResults.length === 0"
class="p-2 text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
</div>
</template>
<template v-else>
<template v-if="suggestions.length">
<DropdownMenuLabel
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
>
{{ t('contextMenu.Most Relevant') }}
</DropdownMenuLabel>
<DropdownMenuItem
v-for="nodeDef in suggestions"
:key="nodeDef.name"
:class="itemClass"
@select="selectNode(nodeDef)"
>
<MiddleTruncate
:text="nodeDef.display_name"
class="min-w-0 flex-1"
/>
</DropdownMenuItem>
</template>
<DropdownMenuSeparator
v-if="suggestions.length && categories.length"
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<template v-if="categories.length">
<DropdownMenuLabel
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
>
{{ t('contextMenu.Compatible Nodes') }}
</DropdownMenuLabel>
<LinkReleaseNodeSubmenu
v-for="category in categories"
:key="category.key"
:category
:item-class="itemClass"
:content-class="submenuContentClass"
:scroll-class="submenuScrollClass"
@select="selectNode"
/>
</template>
</template>
</div>
<template v-if="!trimmedQuery">
<DropdownMenuSeparator
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<DropdownMenuItem
:class="cn(itemClass, 'shrink-0')"
@select="addReroute"
>
<i class="icon-[lucide--git-fork] size-4 shrink-0 opacity-80" />
<span class="flex-1 truncate">
{{ t('contextMenu.Add Reroute') }}
</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getSlotColor } from '@/constants/slotColors'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import MiddleTruncate from './MiddleTruncate.vue'
import {
buildLinkReleaseNodeCategories,
computeContextMenuTop,
estimateLinkReleaseMenuHeight,
getLinkReleaseHeaderLabel,
getLinkReleaseSuggestions,
groupLinkReleaseSearchResults,
searchLinkReleaseNodes
} from './linkReleaseMenuModel'
import type {
LinkReleaseContext,
LinkReleaseNodeMatch
} from './linkReleaseMenuModel'
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
const emit = defineEmits<{
selectNode: [nodeDef: ComfyNodeDefImpl]
addReroute: []
dismiss: []
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const open = ref(false)
const position = ref({ x: 0, y: 0 })
const searchInput = ref<HTMLInputElement>()
const query = ref('')
const menuMaxHeight = ref<number>()
let actionTaken = false
const VIEWPORT_MARGIN = 8
const SIDE_OFFSET = 4
const MENU_WIDTH = 384
const contentClass =
'z-1700 flex min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const scrollClass =
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
const submenuContentClass =
'z-1700 flex w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const submenuScrollClass =
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
const itemClass =
'flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
const headerLabel = computed(() =>
context ? getLinkReleaseHeaderLabel(context) : ''
)
const slotColor = computed(() => getSlotColor(context?.dataType?.split(',')[0]))
const trimmedQuery = computed(() => query.value.trim())
const typeFilter = computed(() => {
if (!context) return null
const svc = nodeDefStore.nodeSearchService
return {
filterDef: context.isFromOutput
? svc.inputTypeFilter
: svc.outputTypeFilter,
value: context.dataType
}
})
const compatibleNodes = computed<ComfyNodeDefImpl[]>(() => {
if (!typeFilter.value) return []
return nodeDefStore.nodeSearchService.searchNode('', [typeFilter.value], {
limit: 500
})
})
const defaultNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
if (!context?.dataType) return []
const table = context.isFromOutput
? LiteGraph.slot_types_default_out
: LiteGraph.slot_types_default_in
const types = table?.[context.dataType] ?? []
return types
.map((type) => nodeDefStore.allNodeDefsByName[type])
.filter((nodeDef): nodeDef is ComfyNodeDefImpl => Boolean(nodeDef))
})
const suggestions = computed(() =>
getLinkReleaseSuggestions(defaultNodeDefs.value)
)
const categories = computed(() =>
buildLinkReleaseNodeCategories(compatibleNodes.value)
)
const searchResultGroups = computed(() =>
groupLinkReleaseSearchResults(categories.value, trimmedQuery.value)
)
const searchResults = computed<LinkReleaseNodeMatch[]>(() =>
searchLinkReleaseNodes(categories.value, trimmedQuery.value)
)
function selectNode(nodeDef: ComfyNodeDefImpl) {
actionTaken = true
emit('selectNode', nodeDef)
hide()
}
function addReroute() {
actionTaken = true
emit('addReroute')
hide()
}
function focusSearch() {
searchInput.value?.focus()
}
function isPrintableKey(event: KeyboardEvent) {
return (
event.key.length === 1 &&
event.key !== ' ' &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
)
}
// When the keyboard focus is on a menu item, funnel printable keystrokes into
// the search field instead of letting Reka run its item type-ahead.
function redirectTypingToSearch(event: KeyboardEvent) {
if (event.target === searchInput.value || !isPrintableKey(event)) return
event.preventDefault()
event.stopPropagation()
query.value += event.key
focusSearch()
}
// Reka refocuses the first item (scrolling the list to the top) whenever the
// menu regains focus, which fires as the pointer leaves an item while scrolling.
function onEntryFocus(event: Event) {
event.preventDefault()
}
function focusFirstItem(target: HTMLElement) {
const menu = target.closest<HTMLElement>('[role="menu"]')
menu
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
?.focus()
}
function onRootSearchKeydown(event: KeyboardEvent) {
// Let Reka close the menu natively on Escape.
if (event.key === 'Escape') return
event.stopPropagation()
if (event.key === 'ArrowDown') {
event.preventDefault()
focusFirstItem(event.currentTarget as HTMLElement)
} else if (event.key === 'Enter' && trimmedQuery.value) {
const first = searchResults.value[0]
if (first) selectNode(first.node)
}
}
function show(event: MouseEvent) {
actionTaken = false
query.value = ''
const menuHeight = estimateLinkReleaseMenuHeight({
hasHeader: Boolean(headerLabel.value),
suggestionCount: suggestions.value.length,
categoryCount: categories.value.length,
searchResultCount: 0,
showReroute: true
})
const menuTop = computeContextMenuTop({
cursorY: event.clientY,
menuHeight,
viewportHeight: window.innerHeight,
margin: VIEWPORT_MARGIN,
sideOffset: SIDE_OFFSET
})
menuMaxHeight.value = window.innerHeight - menuTop - VIEWPORT_MARGIN
const maxX = window.innerWidth - MENU_WIDTH - VIEWPORT_MARGIN
position.value = {
x: Math.min(event.clientX, Math.max(VIEWPORT_MARGIN, maxX)),
y: menuTop - SIDE_OFFSET
}
void nextTick(() => {
open.value = true
})
}
function hide() {
open.value = false
}
function onOpenChange(value: boolean) {
open.value = value
if (value) return
if (!actionTaken) emit('dismiss')
actionTaken = false
}
defineExpose({ show, hide })
</script>

View File

@@ -1,120 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
const contentClass =
'z-1700 flex max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const submenuContentClass =
'z-1700 flex w-sm max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const submenuScrollClass =
'overflow-y-auto scrollbar-custom max-h-[min(calc(var(--reka-dropdown-menu-content-available-height)-3.5rem),80vh)]'
const itemClass =
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
function node(name: string, display_name = name): ComfyNodeDefImpl {
return { name, display_name } as ComfyNodeDefImpl
}
const category: LinkReleaseNodeCategory = {
key: 'comfy',
labelKey: 'contextMenu.Comfy Nodes',
icon: 'icon-[lucide--box]',
nodes: [
node('KSampler'),
node('VAEDecode', 'VAE Decode'),
node('VAEEncode', 'VAE Encode'),
node('CLIPTextEncode', 'CLIP Text Encode'),
node('LoadImage', 'Load Image'),
node('SaveImage', 'Save Image'),
node('EmptyLatentImage', 'Empty Latent Image'),
node(
'StableCascade_StageB_Conditioning',
'StableCascade_StageB_Conditioning'
)
]
}
const meta: Meta<typeof LinkReleaseNodeSubmenu> = {
title: 'Components/Searchbox/LinkReleaseNodeSubmenu',
component: LinkReleaseNodeSubmenu
}
export default meta
type Story = StoryObj<typeof meta>
function renderAnchored(side: 'left' | 'right'): Story['render'] {
return () => ({
components: {
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuLabel,
LinkReleaseNodeSubmenu
},
setup() {
const anchorStyle =
side === 'right'
? 'position: fixed; top: 64px; right: 16px;'
: 'position: fixed; top: 64px; left: 16px;'
return {
anchorStyle,
contentClass,
submenuContentClass,
submenuScrollClass,
itemClass,
category,
side
}
},
template: `
<div style="height: 480px;">
<DropdownMenuRoot default-open>
<DropdownMenuTrigger as-child>
<button :style="anchorStyle" class="rounded-md border border-interface-menu-stroke bg-interface-menu-surface px-3 py-1.5 text-sm text-base-foreground">
Compatible Nodes
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:class="contentClass"
:side="side === 'right' ? 'bottom' : 'bottom'"
:align="side === 'right' ? 'end' : 'start'"
:side-offset="4"
>
<DropdownMenuLabel class="block truncate px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase">
Compatible Nodes
</DropdownMenuLabel>
<LinkReleaseNodeSubmenu
:category="category"
:item-class="itemClass"
:content-class="submenuContentClass"
:scroll-class="submenuScrollClass"
/>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
`
})
}
/** Anchored near the LEFT edge: the submenu opens to the RIGHT (normal). */
export const OpensRight: Story = { render: renderAnchored('left') }
/**
* Anchored near the RIGHT edge: with no room on the right, Floating UI flips the
* submenu to the LEFT, landing flush against the parent menu's left edge.
*/
export const FlipsLeft: Story = { render: renderAnchored('right') }

View File

@@ -1,67 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
const category: LinkReleaseNodeCategory = {
key: 'comfy',
labelKey: 'Comfy Nodes',
icon: 'icon-[lucide--box]',
nodes: [{ name: 'KSampler', display_name: 'KSampler' } as ComfyNodeDefImpl]
}
const stubs = {
DropdownMenuSub: { template: '<div><slot /></div>' },
DropdownMenuSubTrigger: {
template: '<button data-testid="sub-trigger"><slot /></button>'
},
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuSubContent: { template: '<div role="menu"><slot /></div>' },
DropdownMenuSeparator: { template: '<hr />' },
DropdownMenuItem: { template: '<div role="menuitem"><slot /></div>' },
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
}
function renderSubmenu() {
return render(LinkReleaseNodeSubmenu, {
props: { category, itemClass: '', contentClass: '', scrollClass: '' },
global: { plugins: [i18n], stubs }
})
}
describe('LinkReleaseNodeSubmenu keyboard handling', () => {
it('steps into the submenu search on ArrowRight', async () => {
renderSubmenu()
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('{ArrowRight}')
await nextTick()
expect(screen.getByRole('textbox')).toHaveFocus()
})
it('steps into the submenu search on Enter', async () => {
renderSubmenu()
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('{Enter}')
await nextTick()
expect(screen.getByRole('textbox')).toHaveFocus()
})
it('does not move focus to the search on other keys', async () => {
renderSubmenu()
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('a')
await nextTick()
expect(screen.getByRole('textbox')).not.toHaveFocus()
})
})

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