Compare commits

..

9 Commits

Author SHA1 Message Date
Terry Jia
909e776bd9 refactor(load3d): split menu bar into per-responsibility group components 2026-06-30 23:27:08 -04:00
PabloWiedemann
78e49d9360 feat(load3d): prototype top-bar chrome for embedded 3D viewer
Replace the floating viewer controls with a framed chrome: a black top bar
holding a category dropdown (Scene/3D Model/Camera/Lighting) plus the active
category's actions (labels collapse to icons on narrow nodes), and a black
bottom bar with Record and fit/export. Move export out of the menu into a
bottom-right button.

Add a 'Clay' material mode that renders meshes with a flat grey material so
geometry is visible without textures.

Proof-of-concept for design exploration; not intended to merge as-is.
2026-06-29 20:24:10 -04:00
Denis
fbe462143a fix: re-export GroupNodeHandler for custom node compat (#13299)
Fixes #13175

#12931 slimmed groupNode.ts down to migration-only and dropped the
export on GroupNodeHandler.

ComfyUI-Manager still imports it (import { GroupNodeConfig,
GroupNodeHandler } from "../../extensions/core/groupNode.js" in
components-manager.js), so the legacy shim no longer providing that
export throws "does not provide an export named 'GroupNodeHandler'" at
module load. That kills the whole Manager extension before setup() runs
— which is why the Manager button vanished from the toolbar since 1.47.3
(backend loads fine, frontend JS dies).

Just re-adds the export (class is still there, only the keyword was
lost) plus the existing @knipIgnoreUnusedButUsedByCustomNodes tag since
nothing in src imports it.

Tested by loading with ComfyUI-Manager installed: the groupNode.js
import error is gone and the Manager button shows again.
typecheck/knip/lint pass.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:04:59 +00:00
nav-tej
61cb1bcde0 fix(website): point launches Comfy MCP CTA to /mcp (#13287)
*PR Created by the Glary-Bot Agent*

---

## Summary

Update the `EXPLORE` CTA on the Comfy MCP card on
[/launches](https://comfy.org/launches) to link to
[/mcp](https://comfy.org/mcp) instead of the docs
(`docs.comfy.org/agent-tools/cloud`).

## Change

Single line in `apps/website/src/data/drops.ts`:

```diff
     cta: {
       label: EXPLORE,
-      href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
+      href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
     }
```

Matches the locale-aware pattern used by sibling cards (`/download` /
`/zh-CN/download`, `/api` / `/zh-CN/api`). Both `src/pages/mcp.astro`
and `src/pages/zh-CN/mcp.astro` already exist, so neither link is dead.
The `externalLinks.docsMcp` constant is retained because the MCP page
itself still uses it.

## Verification

- `pnpm typecheck` / `pnpm typecheck:website` clean.
- `oxfmt`, `oxlint`, `eslint` clean (all ran via lint-staged on commit).
- Manually loaded `/launches` and `/zh-CN/launches` in the dev server
and confirmed the Comfy MCP card now points to `/mcp` and `/zh-CN/mcp`
respectively.
- Loaded `/mcp` and confirmed the destination page renders ("Comfy MCP —
Drive ComfyUI from any AI agent").
- Code review by Oracle: no issues.

Screenshot shows the updated MCP card on /launches.

## Screenshots

![Comfy MCP card on /launches with EXPLORE button now linking to
/mcp](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9f315ccc2692129159ae31efab9464684ff2f6db3e144feae6dd52fd314c0b47/pr-images/1782765166237-5e83667b-8dc3-4182-9891-609385a1dae5.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-29 22:29:38 +00:00
AustinMroz
9dcab4ee96 Essentials Cleanup (#13183)
Address several followup comments from #12744
2026-06-29 22:15:54 +00:00
Benjamin Lu
dc29f30b02 Track theme setting changes via telemetry (#13142)
Fix Color Palette changes not getting tracked, requested by design team.

Capture theme changes as `app:setting_changed` telemetry. The only
existing hook lived in `SettingItem.vue`, which renders *visible*
settings; `Comfy.ColorPalette` is hidden and changed through bespoke
theme UI, so it was never tracked.

Open to opinions here, we can also remove the hook in SettingItem.vue,
and just make everything that was visible opt in.

Linear:
https://linear.app/comfyorg/issue/GTM-158/track-theme-usage-with-posthog-events
2026-06-29 22:05:05 +00:00
imick-io
fb3350ee0e feat(website): redesign Comfy MCP setup steps and add button variant (#13285)
## Summary

Reworks the Comfy MCP page's **"Set up Comfy MCP in three steps"**
section to match the new design, and adds a per-action button `variant`
option to `FeatureGrid01`.

The three steps are now:

| Step | Title | Action |
| --- | --- | --- |
| 1 | Copy the MCP URL | Copy field showing
`https://cloud.comfy.org/mcp` |
| 2 | Add the connector | Filled button **"COMFY CLOUD MCP DOCS" ↗** →
MCP docs |
| 3 | Connect and sign in | Filled button **"COMFY CLOUD SKILLS" ↗** →
comfy-skills repo |

## Changes

- **`FeatureGrid01.vue`** — add `variant?: 'default' | 'outline'` to the
link card action; button now uses `card.action.variant ?? 'outline'`
instead of a hardcoded outline, so callers can opt into the filled
style.
- **`config/routes.ts`** — add `mcpSkills` external link
(`https://github.com/Comfy-Org/comfy-skills`).
- **`i18n/translations.ts`** — refresh the `mcp.setup.*` copy (en +
zh-CN): new subtitle, reworded steps, new `step2.cta` / `step3.cta`,
drop the now-unused `step1.cta`.
- **`SetupSection.vue`** — re-map cards: step 1 → copy field, steps 2 &
3 → filled link buttons.

## Test plan

- [x] `pnpm typecheck` — 0 errors
- [x] Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck)
pass
- [ ] Visual check on `/mcp` and `/zh-CN/mcp` (copy field on step 1; two
filled yellow CTAs with up-right arrows on steps 2 & 3)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:08:03 +00:00
imick-io
be8e0010ee feat(website): rebuild Comfy MCP page on the design system (+ zh-CN) (#13283)
Rebuilds the **Comfy MCP** marketing page on the website design-system
stack and adds the missing zh-CN page.

## What's here
- Replaces the bespoke `components/product/mcp/` section silo with thin
`templates/mcp/*` wrappers over reusable `blocks/` + `common/`
components.
- Adds `src/pages/zh-CN/mcp.astro` and threads `locale` through every
section (was English-only).
- New/extended design-system blocks:
- `FeatureGrid01` — setup steps, with a reusable `ui/CopyableField`
(uses `@vueuse/core` `useClipboard`).
- `FeatureGrid02` — how-it-works steps with `NodeUnionIcon` connectors +
a CTA pair via `ui/button`.
- `FeatureRows01` — alternating media rows; `ReasonsSplit01` — "why"
list.
- `HeroSplit01` gained `subtitle`, a `media` slot, and a `class`
passthrough; `SectionHeader` gained `align`.
- Standardized block section spacing on `px-6 py-16 lg:py-24`.
- Refreshed all 8 MCP FAQ answers (en + zh-CN) and hydrated the FAQ
section so the accordion is interactive.

## Notes
- Stacked on the original MCP landing-page commits (previously PR
#13095); those ride along here.
- `typecheck` and `build` are green; `/mcp` and `/zh-CN/mcp` both render
in both locales.

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

---------

Co-authored-by: Balpreet Brar <balpreet.brar@growthnatives.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-29 10:04:24 -07:00
imick-io
d0e97d6933 fix(website): move launches nav item and add cleanplate workflow link (#13282)
## Summary
- Move the `/launches` nav item from **Company → More** to **Products →
Features** in the main navbar
- Add the workflow link to the **Cleanplate Walkthrough** learning
tutorial (`https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/`)

## Changes
- `apps/website/src/data/mainNavigation.ts`
- `apps/website/src/data/learningTutorials.ts`

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:24:10 +00:00
78 changed files with 3646 additions and 600 deletions

View File

@@ -55,6 +55,3 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Enforce critical coverage gate
run: pnpm test:coverage:critical

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
</svg>

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -26,7 +26,7 @@ function toggle(index: number) {
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
variant?: 'default' | 'outline'
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
type ColumnCount = 2 | 3 | 4
const {
cards,
columns = 3,
copiedLabel,
copyLabel,
eyebrow,
heading,
subtitle
} = defineProps<{
cards: readonly FeatureCard[]
columns?: ColumnCount
copiedLabel?: string
copyLabel?: string
eyebrow?: string
heading: string
subtitle?: string
}>()
const columnClass: Record<ColumnCount, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
:variant="card.action.variant ?? 'outline'"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import SectionHeader from '../common/SectionHeader.vue'
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
type Cta = { label: string; href: string; target?: '_blank' }
export interface FeatureStep {
id: string
number: string
title: string
description: string
}
defineProps<{
heading: string
steps: readonly FeatureStep[]
primaryCta?: Cta
secondaryCta?: Cta
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader>{{ heading }}</SectionHeader>
<!-- Step cards in a row, joined by node-union connectors on desktop -->
<div
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
>
<template v-for="(step, i) in steps" :key="step.id">
<div
v-if="i > 0"
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
aria-hidden="true"
>
<NodeUnionIcon
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
/>
</div>
<div
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
>
<div class="flex flex-1 flex-col gap-4 p-8">
<div>
<p
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ step.number }}
</p>
<h3
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
>
{{ step.title }}
</h3>
</div>
<p class="text-primary-comfy-canvas">
{{ step.description }}
</p>
</div>
</div>
</template>
</div>
<div
v-if="primaryCta || secondaryCta"
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<Button
v-if="primaryCta"
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
variant="outline"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ secondaryCta.label }}
</Button>
</div>
</section>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Locale } from '../../i18n/translations'
import GlassCard from '../common/GlassCard.vue'
import SectionHeader from '../common/SectionHeader.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
// <video> has no native alt; used as the player's accessible label.
alt?: string
poster?: string
tracks?: readonly VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean
hideControls?: boolean
}
export interface FeatureRow {
id: string
title: string
description: string
media: RowMedia
}
const {
heading,
eyebrow,
locale = 'en',
rows
} = defineProps<{
heading: string
eyebrow?: string
locale?: Locale
rows: readonly FeatureRow[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" max-width="xl">
{{ heading }}
</SectionHeader>
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
<GlassCard
v-for="(row, i) in rows"
:key="row.id"
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
>
<!-- Text -->
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
>
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
{{ row.title }}
</h3>
<p class="text-sm text-smoke-700 lg:text-base">
{{ row.description }}
</p>
</div>
<!-- Media: image or video -->
<div
:class="
cn(
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
>
<img
v-if="row.media.type === 'image'"
:src="row.media.src"
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
:locale="locale"
:aria-label="row.media.alt ?? row.title"
:src="row.media.src"
:poster="row.media.poster"
:tracks="row.media.tracks"
:autoplay="row.media.autoplay"
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
class="w-full"
/>
</div>
</GlassCard>
</div>
</section>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { Locale } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
@@ -27,6 +29,7 @@ const {
badgeLogoAlt,
title,
titleHighlight,
subtitle,
features = [],
primaryCta,
secondaryCta,
@@ -41,14 +44,17 @@ const {
videoAutoplay = false,
videoLoop = false,
videoMinimal = false,
videoHideControls = false
videoHideControls = false,
class: className
} = defineProps<{
locale?: Locale
class?: HTMLAttributes['class']
badgeText: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
titleHighlight?: string
subtitle?: string
features?: string[]
primaryCta: Cta
secondaryCta?: Cta
@@ -72,7 +78,8 @@ const {
:class="
cn(
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
className
)
"
>
@@ -84,7 +91,7 @@ const {
/>
<h1
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
>
<template v-if="titleHighlight">
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
@@ -93,6 +100,13 @@ const {
<template v-else>{{ title }}</template>
</h1>
<p
v-if="subtitle"
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
>
{{ subtitle }}
</p>
<ul v-if="features.length" class="mt-8 space-y-3">
<li
v-for="feature in features"
@@ -127,27 +141,29 @@ const {
</div>
<div class="order-first w-full lg:order-last lg:flex-1">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
<slot name="media">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</slot>
</div>
</section>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
export interface Reason {
id: string
title: string
description: string
}
const { highlightClass = 'text-white' } = defineProps<{
heading: string
headingHighlight?: string
highlightClass?: string
subtitle?: string
reasons: readonly Reason[]
}>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
>
<!-- Left heading -->
<div
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
>
<h2
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
>
{{ heading
}}<span v-if="headingHighlight" :class="highlightClass">{{
headingHighlight
}}</span>
</h2>
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
{{ subtitle }}
</p>
</div>
<!-- Right reasons list -->
<div class="flex-1">
<div
v-for="reason in reasons"
:key="reason.id"
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
>
<div class="shrink-0 xl:w-84">
<h3
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ reason.title }}
</h3>
<slot name="reason-extra" :reason="reason" />
</div>
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
{{ reason.description }}
</p>
</div>
</div>
</section>
</template>

View File

@@ -7,12 +7,14 @@ const {
label,
headingTag = 'h2',
maxWidth = 'lg',
headingSize = 'section'
headingSize = 'section',
align = 'center'
} = defineProps<{
label?: string
headingTag?: 'h1' | 'h2' | 'h3'
maxWidth?: 'md' | 'lg' | 'xl'
headingSize?: 'section' | 'hero'
align?: 'center' | 'start'
}>()
const maxWidthClass = {
@@ -28,7 +30,14 @@ const headingSizeClass = {
</script>
<template>
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
<div
:class="
cn(
maxWidthClass[maxWidth],
align === 'center' ? 'mx-auto text-center' : 'text-left'
)
"
>
<SectionLabel v-if="label">{{ label }}</SectionLabel>
<component
:is="headingTag"

View File

@@ -37,7 +37,8 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{ label: t('nav.comfyApi', locale), href: routes.api },
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
{ label: t('nav.mcpServer', locale), href: routes.mcp }
]
},
{

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
const {
value,
copyLabel = 'Copy',
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
void copy(value)
}
</script>
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />
</button>
</div>
</template>

View File

@@ -19,7 +19,8 @@ const baseRoutes = {
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models'
models: '/p/supported-models',
mcp: '/mcp'
} as const
type Routes = typeof baseRoutes
@@ -65,6 +66,8 @@ export const externalLinks = {
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
mcpServer: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',

View File

@@ -127,7 +127,7 @@ export const drops: readonly Drop[] = [
},
cta: {
label: EXPLORE,
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{

View File

@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
label: 'English'
}
],
// href: '#',
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
tags: [partnerNodesTag, imageToVideoTag]
},
{

View File

@@ -69,10 +69,19 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
header: t('nav.colFeatures', locale),
items: [
{
label: t('nav.mcpServer', locale),
href: routes.mcp,
badge: 'new'
},
// TODO: no page yet — re-enable when landing pages ship
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
// { label: t('nav.appMode', locale), href: '#' },
// { label: t('nav.agentSkills', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.docs', locale),
href: externalLinks.docs,
@@ -180,11 +189,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.blogs', locale),
href: externalLinks.blog,

View File

@@ -11,6 +11,16 @@ const translations = {
'zh-CN': '图像生成视频'
},
// UI (global, reusable across sections)
'ui.copy': {
en: 'Copy',
'zh-CN': '复制'
},
'ui.copied': {
en: 'Copied',
'zh-CN': '已复制'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
@@ -1825,6 +1835,311 @@ const translations = {
'我们尽力为经历面试流程的候选人提供有意义的反馈。由于申请量较大,在简历筛选阶段可能无法提供详细反馈。'
},
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP — Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP — 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎,可在 Claude Code、Claude Desktop 及任何兼容 MCP 的客户端中生成图像、视频、音频和 3D 内容。'
},
// MCP HeroSection
'mcp.hero.heading': {
en: 'Drive ComfyUI from\nany AI agent.',
'zh-CN': '让任何 AI 智能体\n驱动 ComfyUI。'
},
'mcp.hero.subtitle': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol — so your assistant can access the ecosystem, build workflows, and generate images, video, audio, or 3D.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
},
'mcp.hero.demoPrompt': {
en: "match this frame's palette, make the hero key art",
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
},
'mcp.hero.viewDocs': {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
},
'mcp.hero.demoGenerate': {
en: 'GENERATE',
'zh-CN': '生成'
},
'mcp.hero.demoActionGenerateImage': {
en: 'GENERATE-IMAGE',
'zh-CN': '生成图像'
},
'mcp.hero.demoActionGenerate3d': {
en: 'GENERATE-3D ASSET',
'zh-CN': '生成 3D 资产'
},
'mcp.hero.demoActionUpscale': {
en: 'UPSCALE-IMAGE',
'zh-CN': '放大图像'
},
// MCP SetupStepsSection
'mcp.setup.label': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'mcp.setup.heading': {
en: 'Set up Comfy MCP in three steps',
'zh-CN': '三步完成 Comfy MCP 配置'
},
'mcp.setup.subtitle': {
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
},
'mcp.setup.step1.description': {
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the connector',
'zh-CN': '添加连接器'
},
'mcp.setup.step2.description': {
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
'zh-CN': 'COMFY CLOUD MCP 文档'
},
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
'mcp.setup.step3.title': {
en: 'Connect and sign in',
'zh-CN': '连接并登录'
},
'mcp.setup.step3.description': {
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
},
'mcp.setup.step3.cta': {
en: 'COMFY CLOUD SKILLS',
'zh-CN': 'COMFY CLOUD 技能'
},
// MCP WhyBuildSection
'mcp.why.heading': {
en: 'Why build on\n',
'zh-CN': '为什么选择\n'
},
'mcp.why.headingHighlight': {
en: 'Comfy MCP?',
'zh-CN': 'Comfy MCP'
},
'mcp.why.subtitle': {
en: 'A trusted infrastructure that lets engineers and professionals ship faster.',
'zh-CN': '一套值得信赖的基础设施,让工程师和专业人士交付更快。'
},
'mcp.why.1.title': {
en: 'Open protocol,\nany client.',
'zh-CN': '开放协议,\n任意客户端。'
},
'mcp.why.1.description': {
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
'zh-CN':
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop更多客户端即将推出。'
},
'mcp.why.2.title': {
en: 'The full engine,\nnot a sandbox.',
'zh-CN': '完整引擎,\n非沙箱环境。'
},
'mcp.why.2.description': {
en: 'Same tool your team uses. Fully connected multi-step, multi-GPU workflows. Everything available now and in the future.',
'zh-CN':
'与你团队使用的相同工具。完整连接的多步骤、多 GPU 工作流。当前及未来的所有功能均可使用。'
},
'mcp.why.3.title': {
en: 'Outputs you keep.',
'zh-CN': '输出归你所有。'
},
'mcp.why.3.description': {
en: 'Downloads go to your Comfy library — store, reuse, remix, and share without leaving the ecosystem.',
'zh-CN':
'下载内容保存到你的 Comfy 库——在生态系统内存储、复用、二次创作和分享。'
},
'mcp.why.4.title': {
en: 'Powered by\nComfy Cloud.',
'zh-CN': '由 Comfy Cloud\n提供支持。'
},
'mcp.why.4.description': {
en: 'Run without a local GPU through the same infrastructure your team already trusts.',
'zh-CN': '无需本地 GPU通过你团队信赖的相同基础设施运行。'
},
// MCP ToolsSection
'mcp.tools.heading': {
en: 'Everything ComfyUI can do,\nnow available as tools.',
'zh-CN': 'ComfyUI 能做的一切,\n现在都可作为工具调用。'
},
'mcp.tools.1.title': {
en: 'Generate anything',
'zh-CN': '生成任意内容'
},
'mcp.tools.1.description': {
en: 'Generate images, video, audio, 3D, upscale, or remove backgrounds. Add or remove elements in images, create or modify any visual, audio, or 3D asset at any scale.',
'zh-CN':
'生成图像、视频、音频、3D 内容,放大分辨率或移除背景。添加或删除图像元素,以任意规模创建或修改任何视觉、音频或 3D 资产。'
},
'mcp.tools.1.alt': {
en: 'Comfy MCP generating images, video, audio, and 3D assets from a single prompt',
'zh-CN': 'Comfy MCP 通过单个提示生成图像、视频、音频和 3D 资产'
},
'mcp.tools.2.title': {
en: 'Search the ecosystem',
'zh-CN': '搜索生态系统'
},
'mcp.tools.2.description': {
en: 'Query thousands of models, browse rankings, and choose workflow templates straight from your response.',
'zh-CN': '查询数千个模型,浏览排名,直接在对话中选择工作流模板。'
},
'mcp.tools.2.alt': {
en: 'Comfy MCP searching the ecosystem of models, rankings, and workflow templates',
'zh-CN': 'Comfy MCP 搜索模型、排名和工作流模板的生态系统'
},
'mcp.tools.3.title': {
en: 'Run real workflows',
'zh-CN': '运行真实工作流'
},
'mcp.tools.3.description': {
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
'zh-CN':
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
},
'mcp.tools.3.alt': {
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
},
// MCP HowItWorksSection
'mcp.howItWorks.heading': {
en: 'How it works',
'zh-CN': '工作原理'
},
'mcp.howItWorks.step1.number': { en: '01', 'zh-CN': '01' },
'mcp.howItWorks.step1.title': {
en: 'CONNECT',
'zh-CN': '连接'
},
'mcp.howItWorks.step1.description': {
en: 'Add the Comfy Cloud MCP server to Claude Code or Claude Desktop and sign in once with OAuth. No API keys to manage.',
'zh-CN':
'将 Comfy Cloud MCP 服务器添加到 Claude Code 或 Claude Desktop通过 OAuth 一次性登录。无需管理 API 密钥。'
},
'mcp.howItWorks.step2.number': { en: '02', 'zh-CN': '02' },
'mcp.howItWorks.step2.title': {
en: 'DISCOVER',
'zh-CN': '发现'
},
'mcp.howItWorks.step2.description': {
en: "Your agent gets Comfy's tools: search, generate, submit, and retrieve — everything it needs to create.",
'zh-CN':
'你的智能体获得 Comfy 的工具:搜索、生成、提交和获取——一切所需,应有尽有。'
},
'mcp.howItWorks.step3.number': { en: '03', 'zh-CN': '03' },
'mcp.howItWorks.step3.title': {
en: 'CREATE',
'zh-CN': '创作'
},
'mcp.howItWorks.step3.description': {
en: 'Request what you want, the agent queues and runs the workflow, and returns the finished result.',
'zh-CN': '描述你的需求,智能体排队执行工作流,并返回最终结果。'
},
// MCP FAQSection
'mcp.faq.heading': {
en: 'Q&As',
'zh-CN': '常见问答'
},
'mcp.faq.1.q': {
en: 'Which clients are supported?',
'zh-CN': '支持哪些客户端?'
},
'mcp.faq.1.a': {
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
'zh-CN':
'目前支持 Claude Code 和 Claude Desktop均通过 OAuth 登录。更多客户端的支持即将推出。'
},
'mcp.faq.2.q': {
en: 'Do I need an API key?',
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.2.a': {
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
'zh-CN':
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
},
'mcp.faq.3.q': {
en: 'Do the slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.3.a': {
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
},
'mcp.faq.4.q': {
en: "The sign-in didn't open a browser.",
'zh-CN': '登录时没有打开浏览器。'
},
'mcp.faq.4.a': {
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
'zh-CN':
'在 Claude Code 中,运行 /mcp选择 comfy-cloud然后选择 Authenticate授权。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
},
'mcp.faq.5.q': {
en: 'How do I connect in Claude Code?',
'zh-CN': '如何在 Claude Code 中连接?'
},
'mcp.faq.5.a': {
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
'zh-CN':
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate授权。一步即可添加连接和斜杠命令。'
},
'mcp.faq.6.q': {
en: "What's the server URL for Claude Desktop?",
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
},
'mcp.faq.6.a': {
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
'zh-CN':
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
},
'mcp.faq.7.q': {
en: 'What can my agent do once connected?',
'zh-CN': '连接后我的智能体能做什么?'
},
'mcp.faq.7.a': {
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
'zh-CN':
'生成图像、视频、音频和 3D搜索模型、节点和模板并运行 ComfyUI 工作流——全部在对话中完成。'
},
'mcp.faq.8.q': {
en: 'Is it generally available?',
'zh-CN': '现已正式发布了吗?'
},
'mcp.faq.8.a': {
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
},
// SiteNav
'nav.products': { en: 'Products', 'zh-CN': '产品' },
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
@@ -1867,6 +2182,7 @@ const translations = {
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },

View File

@@ -0,0 +1,24 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ProductCardsSection from '../components/common/ProductCardsSection.vue'
import HeroSection from '../templates/mcp/HeroSection.vue'
import SetupSection from '../templates/mcp/SetupSection.vue'
import WhySection from '../templates/mcp/WhySection.vue'
import ToolsSection from '../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../templates/mcp/FAQSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'en')}
description={t('mcp.meta.description', 'en')}
>
<HeroSection locale="en" client:load />
<SetupSection locale="en" client:visible />
<WhySection locale="en" />
<ToolsSection locale="en" />
<HowItWorksSection locale="en" />
<ProductCardsSection locale="en" label-key="products.labelProducts" />
<FAQSection client:visible locale="en" />
</BaseLayout>

View File

@@ -0,0 +1,24 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
import HeroSection from '../../templates/mcp/HeroSection.vue'
import SetupSection from '../../templates/mcp/SetupSection.vue'
import WhySection from '../../templates/mcp/WhySection.vue'
import ToolsSection from '../../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../../templates/mcp/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'zh-CN')}
description={t('mcp.meta.description', 'zh-CN')}
>
<HeroSection locale="zh-CN" client:load />
<SetupSection locale="zh-CN" client:visible />
<WhySection locale="zh-CN" />
<ToolsSection locale="zh-CN" />
<HowItWorksSection locale="zh-CN" />
<ProductCardsSection locale="zh-CN" label-key="products.labelProducts" />
<FAQSection client:visible locale="zh-CN" />
</BaseLayout>

View File

@@ -162,6 +162,45 @@
animation: ripple-effect 4s linear infinite;
}
@keyframes cursor-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@utility animate-cursor-blink {
animation: cursor-blink 1s step-end infinite;
}
.card-slide-enter-active {
transition:
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.4s ease;
}
.card-slide-enter-from {
transform: translateX(56px);
opacity: 0;
}
/* Existing cards slide down smoothly when a new card is prepended. */
.card-slide-move {
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.card-slide-leave-active {
transition: opacity 0.2s ease;
}
.card-slide-leave-to {
opacity: 0;
}
@utility animate-delay-* {
animation-delay: --value([*]);
}

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { Check } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const PROMPT = t('mcp.hero.demoPrompt', locale)
const generateLabel = t('mcp.hero.demoGenerate', locale)
const cards = [
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'moodboard_v1.png · 6-up',
tag: 'Gmail',
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'concepts_0103.png',
tag: 'Notion',
thumb: '/images/mcp/mcp-thumb-concepts.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'hero_keyart.png',
tag: 'Figma',
thumb: '/images/mcp/mcp-thumb-keyart.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerate3d',
file: 'asphalt_pbr/ · 5 maps',
tag: 'Blender',
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
},
{
actionKey: 'mcp.hero.demoActionUpscale',
file: 'kaiju_neon_4k.png · 4096',
tag: null,
thumb: '/images/mcp/mcp-thumb-kaiju.webp'
}
] as const
const visibleCount = ref(0)
const displayedPrompt = ref('')
const promptDone = ref(false)
const displayedCards = computed(() =>
cards
.slice(0, visibleCount.value)
.map((card) => ({ ...card, action: t(card.actionKey, locale) }))
// Newest card first — it slides in right below the prompt box and pushes
// the rest down.
.reverse()
)
let timer: ReturnType<typeof setTimeout> | null = null
let active = false
function schedule(fn: () => void, ms: number) {
timer = setTimeout(() => {
if (active) fn()
}, ms)
}
function typePrompt(onDone: () => void) {
displayedPrompt.value = ''
promptDone.value = false
let i = 0
function step() {
i++
displayedPrompt.value = PROMPT.slice(0, i)
if (i < PROMPT.length) {
schedule(step, 35)
} else {
promptDone.value = true
schedule(onDone, 350)
}
}
schedule(step, 50)
}
function revealNextCard() {
if (visibleCount.value >= cards.length) {
// All done — pause then reset
schedule(() => {
visibleCount.value = 0
schedule(revealNextCard, 500)
}, 2500)
return
}
// Type the prompt, then slide in the next card
typePrompt(() => {
visibleCount.value++
schedule(revealNextCard, 400)
})
}
onMounted(() => {
active = true
schedule(revealNextCard, 600)
})
onUnmounted(() => {
active = false
if (timer) clearTimeout(timer)
})
</script>
<template>
<div class="flex flex-col gap-6 max-lg:h-[50vh]">
<!-- Prompt panel -->
<div
class="rounded-5xl flex flex-col justify-between gap-8 overflow-hidden bg-white/4 p-8"
>
<p
class="font-formula text-[17px] leading-relaxed font-light text-primary-comfy-canvas"
>
{{ displayedPrompt
}}<span
class="bg-primary-comfy-yellow ml-0.5 inline-block h-5.5 w-2 translate-y-0.5"
:class="promptDone ? 'animate-cursor-blink' : ''"
/>
</p>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-white/10" />
<div
class="bg-primary-comfy-yellow font-formula rounded-2xl px-4 py-3 text-sm font-extrabold tracking-[0.7px] text-primary-comfy-ink uppercase"
>
{{ generateLabel }}
</div>
</div>
</div>
<!-- Cards accumulate each slides in from the right after its prompt cycle -->
<div class="relative overflow-hidden max-lg:min-h-0 max-lg:flex-1">
<TransitionGroup
name="card-slide"
tag="div"
class="flex flex-col gap-2.5 max-lg:absolute max-lg:inset-x-0 max-lg:top-0"
>
<div
v-for="(card, i) in displayedCards"
:key="card.file"
class="flex items-center gap-3.5 overflow-hidden rounded-3xl px-4 py-3.5"
:class="i === 0 ? 'bg-white/8' : 'bg-white/4'"
>
<img
:src="card.thumb"
:alt="card.action"
class="size-13.5 shrink-0 rounded-[14px] object-cover"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p
class="font-formula text-primary-comfy-yellow text-xs font-extrabold tracking-[0.7px] uppercase"
>
{{ card.action }}
</p>
<p
class="font-formula truncate text-sm font-light text-primary-comfy-canvas"
>
{{ card.file }}
</p>
</div>
<span
v-if="card.tag"
class="font-formula relative isolate inline-flex h-8 shrink-0 items-center justify-center overflow-visible bg-transparent px-5 text-sm font-extrabold tracking-[0.7px] text-white/60 uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm before:bg-white/20"
>
<span class="ppformula-text-center">
{{ card.tag }}
</span>
</span>
<Check
class="size-4 shrink-0 text-primary-comfy-canvas/60"
:stroke-width="1.5"
/>
</div>
</TransitionGroup>
<!-- Bottom fade so accumulating cards dissolve into the page background -->
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-32 bg-linear-to-t from-primary-comfy-ink to-transparent lg:hidden"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
const faqs = faqNumbers.map((n) => ({
id: String(n),
question: t(`mcp.faq.${n}.q`, locale),
answer: t(`mcp.faq.${n}.a`, locale)
}))
</script>
<template>
<FAQSplit01 :heading="t('mcp.faq.heading', locale)" :faqs="faqs" />
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import ComfyMcpDemo from './ComfyMcpDemo.vue'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
</script>
<template>
<HeroSplit01
:locale="locale"
class="min-h-screen"
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
>
<template #media>
<ComfyMcpDemo :locale="locale" />
</template>
</HeroSplit01>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import FeatureGrid02 from '../../components/blocks/FeatureGrid02.vue'
import type { FeatureStep } from '../../components/blocks/FeatureGrid02.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
const stepNumbers = [1, 2, 3] as const
const steps: FeatureStep[] = stepNumbers.map((n) => ({
id: String(n),
number: t(`mcp.howItWorks.step${n}.number`, locale),
title: t(`mcp.howItWorks.step${n}.title`, locale),
description: t(`mcp.howItWorks.step${n}.description`, locale)
}))
</script>
<template>
<FeatureGrid02
:heading="t('mcp.howItWorks.heading', locale)"
:steps="steps"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
/>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards: FeatureCard[] = [
{
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
}
},
{
id: 'step2',
label: t('mcp.setup.step2.label', locale),
title: t('mcp.setup.step2.title', locale),
description: t('mcp.setup.step2.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step2.cta', locale),
href: externalLinks.docsMcp,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
},
{
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step3.cta', locale),
href: externalLinks.mcpSkills,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
}
]
</script>
<template>
<FeatureGrid01
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import FeatureRows01 from '../../components/blocks/FeatureRows01.vue'
import type { FeatureRow } from '../../components/blocks/FeatureRows01.vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type ToolMedia =
| { type: 'image'; src: string }
| {
type: 'video'
src: string
autoplay?: boolean
loop?: boolean
hideControls?: boolean
}
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
{
n: 1,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-everything.gif'
},
altKey: 'mcp.tools.1.alt'
},
{
n: 2,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png'
},
altKey: 'mcp.tools.2.alt'
},
{
n: 3,
media: {
type: 'video',
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
autoplay: true,
loop: true,
hideControls: true
},
altKey: 'mcp.tools.3.alt'
}
]
const rows: FeatureRow[] = tools.map(({ n, media, altKey }) => {
const alt = altKey ? t(altKey, locale) : undefined
return {
id: String(n),
title: t(`mcp.tools.${n}.title`, locale),
description: t(`mcp.tools.${n}.description`, locale),
media: { ...media, alt }
}
})
</script>
<template>
<FeatureRows01
:locale="locale"
:heading="t('mcp.tools.heading', locale)"
:rows="rows"
/>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import ReasonsSplit01 from '../../components/blocks/ReasonsSplit01.vue'
import type { Reason } from '../../components/blocks/ReasonsSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasonNumbers = [1, 2, 3, 4] as const
const reasons: Reason[] = reasonNumbers.map((n) => ({
id: String(n),
title: t(`mcp.why.${n}.title`, locale),
description: t(`mcp.why.${n}.description`, locale)
}))
</script>
<template>
<ReasonsSplit01
:heading="t('mcp.why.heading', locale)"
:heading-highlight="t('mcp.why.headingHighlight', locale)"
highlight-class="text-primary-comfy-yellow"
:subtitle="t('mcp.why.subtitle', locale)"
:reasons="reasons"
/>
</template>

View File

@@ -0,0 +1,27 @@
import { externalLinks, getRoutes } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
export interface McpCta {
label: string
href: string
target?: '_blank'
}
/**
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -53,7 +53,6 @@
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -70,8 +70,6 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<!-- Items declaring an icon key (even empty) keep the slot so labels align
within icon-bearing menus; icon-less menus render labels flush-left. -->
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />

View File

@@ -24,7 +24,7 @@ function toggleCategory(category: string) {
}
</script>
<template>
<DropdownMenu button-class="icon-[lucide--list-filter]">
<DropdownMenu>
<template #button>
<Button size="icon" :aria-label="$t('g.filter')">
<i class="icon-[lucide--list-filter]" />
@@ -52,7 +52,7 @@ function toggleCategory(category: string) {
>
<span
class="flex-1"
v-text="$t(filterLabels?.[filter] ?? '') ?? filter"
v-text="filterLabels?.[filter] ? $t(filterLabels[filter]) : filter"
/>
<DropdownMenuItemIndicator class="size-4 shrink-0">
<i class="icon-[lucide--check]" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
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 RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
type Props = {
isRecording?: boolean
hasRecording?: boolean
recordingDuration?: number
onStartRecording?: () => void
onStopRecording?: () => void
onExportRecording?: () => void
onClearRecording?: () => void
}
function renderControl(props: Props = {}) {
const result = render(RecordMenuControl, {
props,
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
describe('RecordMenuControl', () => {
it('starts recording when idle', async () => {
const onStartRecording = vi.fn()
const { user } = renderControl({ isRecording: false, onStartRecording })
await user.click(screen.getByRole('button', { name: 'Record' }))
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('stops recording when active', async () => {
const onStopRecording = vi.fn()
const { user } = renderControl({ isRecording: true, onStopRecording })
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
expect(onStopRecording).toHaveBeenCalledOnce()
})
it('exposes export, clear and duration once a recording exists', async () => {
const onExportRecording = vi.fn()
const onClearRecording = vi.fn()
const { user } = renderControl({
isRecording: false,
hasRecording: true,
recordingDuration: 65,
onExportRecording,
onClearRecording
})
expect(screen.getByText('01:05')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Export Recording' }))
await user.click(screen.getByRole('button', { name: 'Clear Recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('hides export and clear while recording is in progress', () => {
renderControl({ isRecording: true, hasRecording: true })
expect(
screen.queryByRole('button', { name: 'Export Recording' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,88 @@
<template>
<button
v-tooltip.top="tip(recordLabel)"
:class="chipClass"
type="button"
:aria-label="compact ? recordLabel : undefined"
@click="toggleRecording"
>
<span
v-if="isRecording"
class="size-2 animate-pulse rounded-full bg-red-500"
/>
<i v-else class="icon-[lucide--video] size-4" />
<span v-if="!compact">{{ recordLabel }}</span>
</button>
<template v-if="hasRecording && !isRecording">
<button
v-tooltip.top="tip(t('load3d.exportRecording'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.exportRecording')"
@click="emit('exportRecording')"
>
<i class="icon-[lucide--download] size-4" />
</button>
<button
v-tooltip.top="tip(t('load3d.clearRecording'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.clearRecording')"
@click="emit('clearRecording')"
>
<i class="icon-[lucide--trash-2] size-4" />
</button>
<span
v-if="recordingDuration && recordingDuration > 0"
class="px-1 text-sm text-base-foreground"
>
{{ formatDuration(recordingDuration) }}
</span>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
chipClass,
iconBtnClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
const { compact = false } = defineProps<{
compact?: boolean
}>()
const isRecording = defineModel<boolean>('isRecording')
const hasRecording = defineModel<boolean>('hasRecording')
const recordingDuration = defineModel<number>('recordingDuration')
const emit = defineEmits<{
(e: 'startRecording'): void
(e: 'stopRecording'): void
(e: 'exportRecording'): void
(e: 'clearRecording'): void
}>()
const { t } = useI18n()
const recordLabel = computed(() =>
isRecording.value
? t('load3d.menuBar.stopRecording')
: t('load3d.menuBar.record')
)
function toggleRecording() {
if (isRecording.value) emit('stopRecording')
else emit('startRecording')
}
function formatDuration(seconds: number) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}
</script>

View File

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

View File

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

View File

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

View File

@@ -117,8 +117,8 @@
</template>
<script setup lang="ts">
import { mapValues } from 'es-toolkit'
import { useEventListener, useLocalStorage } from '@vueuse/core'
import { mapValues } from 'es-toolkit'
import type { MenuItem } from 'primevue/menuitem'
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
import {

View File

@@ -12,12 +12,11 @@ export function resolveEssentialTileNodeDef(
): ComfyNodeDefImpl | undefined {
const name = tile.nodeName
if (!name) return undefined
const byName = nodeDefStore.allNodeDefsByName[name]
if (byName) return byName
const target = name.startsWith(BLUEPRINT_TYPE_PREFIX)
? name.slice(BLUEPRINT_TYPE_PREFIX.length)
: name
return nodeDefStore.nodeDefs.find((d) => d.display_name === target)
if (!name.startsWith(BLUEPRINT_TYPE_PREFIX))
return nodeDefStore.allNodeDefsByName[name]
const subgraphName = name.slice(BLUEPRINT_TYPE_PREFIX.length)
return nodeDefStore.allNodeDefsByDisplayName[subgraphName]
}
export function useEssentialTileNodeDef(tile: MaybeRefOrGetter<EssentialTile>) {

View File

@@ -177,6 +177,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const canExport = ref(true)
const materialModes = ref<readonly MaterialMode[]>([
'original',
'clay',
'normal',
'wireframe'
])

View File

@@ -815,8 +815,10 @@ export class GroupNodeConfig {
* `configure`. The load-time migration unpacks each instance via
* {@link convertToNodes} and {@link LGraph.convertToSubgraph} repackages the
* result as a subgraph.
*
* @knipIgnoreUnusedButUsedByCustomNodes
*/
class GroupNodeHandler {
export class GroupNodeHandler {
node: LGraphNode
groupData: GroupNodeConfig

View File

@@ -108,6 +108,7 @@ describe('MeshModelAdapter', () => {
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([
'original',
'clay',
'normal',
'wireframe'
])

View File

@@ -24,7 +24,7 @@ export class MeshModelAdapter implements ModelAdapter {
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
materialModes: ['original', 'clay', 'normal', 'wireframe'],
fitTargetSize: 5
}

View File

@@ -19,6 +19,7 @@ describe('DEFAULT_MODEL_CAPABILITIES', () => {
expect(DEFAULT_MODEL_CAPABILITIES.exportable).toBe(true)
expect([...DEFAULT_MODEL_CAPABILITIES.materialModes]).toEqual([
'original',
'clay',
'normal',
'wireframe'
])

View File

@@ -60,7 +60,7 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
materialModes: ['original', 'clay', 'normal', 'wireframe'],
fitTargetSize: 5
}

View File

@@ -29,6 +29,7 @@ export class SceneModelManager implements ModelManagerInterface {
standardMaterial: THREE.MeshStandardMaterial
wireframeMaterial: THREE.MeshBasicMaterial
depthMaterial: THREE.MeshDepthMaterial
clayMaterial: THREE.MeshStandardMaterial
originalFileName: string | null = null
originalURL: string | null = null
appliedTexture: THREE.Texture | null = null
@@ -98,8 +99,44 @@ export class SceneModelManager implements ModelManagerInterface {
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
this.depthMaterial.onBeforeCompile = (shader) => {
shader.uniforms.cameraType = {
value: this.activeCamera instanceof THREE.OrthographicCamera ? 1.0 : 0.0
}
shader.fragmentShader = `
uniform float cameraType;
${shader.fragmentShader}
`
shader.fragmentShader = shader.fragmentShader.replace(
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
`
float depth = 1.0 - fragCoordZ;
if (cameraType > 0.5) {
depth = pow(depth, 400.0);
} else {
depth = pow(depth, 0.6);
}
gl_FragColor = vec4(vec3(depth), opacity);
`
)
}
this.depthMaterial.customProgramCacheKey = () => {
return this.activeCamera instanceof THREE.OrthographicCamera
? 'ortho'
: 'persp'
}
this.standardMaterial = this.createSTLMaterial()
this.clayMaterial = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.0,
roughness: 0.9,
flatShading: false,
side: THREE.DoubleSide
})
}
init(): void {}
@@ -110,6 +147,7 @@ export class SceneModelManager implements ModelManagerInterface {
this.standardMaterial.dispose()
this.wireframeMaterial.dispose()
this.depthMaterial.dispose()
this.clayMaterial.dispose()
if (this.appliedTexture) {
this.appliedTexture.dispose()
@@ -212,68 +250,25 @@ export class SceneModelManager implements ModelManagerInterface {
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
const depthMat = new THREE.MeshDepthMaterial({
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
depthMat.onBeforeCompile = (shader) => {
shader.uniforms.cameraType = {
value:
this.activeCamera instanceof THREE.OrthographicCamera
? 1.0
: 0.0
}
shader.fragmentShader = `
uniform float cameraType;
${shader.fragmentShader}
`
shader.fragmentShader = shader.fragmentShader.replace(
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
`
float depth = 1.0 - fragCoordZ;
if (cameraType > 0.5) {
depth = pow(depth, 400.0);
} else {
depth = pow(depth, 0.6);
}
gl_FragColor = vec4(vec3(depth), opacity);
`
)
}
depthMat.customProgramCacheKey = () => {
return this.activeCamera instanceof THREE.OrthographicCamera
? 'ortho'
: 'persp'
}
child.material = depthMat
child.material = this.depthMaterial
break
case 'normal':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1, 1),
transparent: false,
opacity: 1.0
})
child.material = this.normalMaterial
break
case 'wireframe':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true,
transparent: false,
opacity: 1.0
})
child.material = this.wireframeMaterial
break
case 'clay':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = this.clayMaterial
break
case 'original':
case 'pointCloud':

View File

@@ -11,6 +11,7 @@ export type MaterialMode =
| 'normal'
| 'wireframe'
| 'depth'
| 'clay'
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'

View File

@@ -2061,6 +2061,7 @@
"centerCameraOnModel": "Center Camera on Model",
"scene": "Scene",
"model": "Model",
"model3d": "3D Model",
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode...",
@@ -2071,13 +2072,30 @@
"reloadingModel": "Reloading model...",
"uploadTexture": "Upload Texture",
"applyingTexture": "Applying Texture...",
"menuBar": {
"showGrid": "Show grid",
"bgColor": "BG Color",
"bgImage": "BG Image",
"panorama": "Panorama",
"removeBackground": "Remove BG",
"upDirection": "Up Direction",
"material": "Material",
"skeleton": "Skeleton",
"fov": "FOV",
"intensity": "Intensity",
"record": "Record",
"stopRecording": "Stop recording",
"switchProjection": "Switch projection",
"originalMaterialOnly": "Original material only"
},
"materialModes": {
"normal": "Normal",
"wireframe": "Wireframe",
"original": "Original",
"pointCloud": "Point Cloud",
"depth": "Depth",
"lineart": "Lineart"
"lineart": "Lineart",
"clay": "Clay"
},
"upDirections": {
"original": "Original"
@@ -2109,10 +2127,10 @@
"uploadingModel": "Uploading 3D model...",
"loadingHDRI": "Loading HDRI...",
"hdri": {
"label": "HDRI Environment",
"uploadFile": "Upload HDRI (.hdr, .exr)",
"changeFile": "Change HDRI",
"removeFile": "Remove HDRI",
"label": "HDRI",
"uploadFile": "Upload",
"changeFile": "Change",
"removeFile": "Remove",
"showAsBackground": "Show as Background",
"intensity": "Intensity"
},
@@ -2122,7 +2140,7 @@
"translate": "Translate",
"rotate": "Rotate",
"scale": "Scale",
"reset": "Reset Transform"
"reset": "Reset"
}
},
"imageCrop": {

View File

@@ -9,13 +9,6 @@ import { i18n } from '@/i18n'
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0))
const trackSettingChanged = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackSettingChanged
}))
}))
const mockGet = vi.fn()
const mockSet = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
@@ -40,7 +33,7 @@ const FormItemUpdateStub = defineComponent({
template: '<div data-testid="form-item-stub" />'
})
describe('SettingItem (telemetry UI tracking)', () => {
describe('SettingItem', () => {
beforeEach(() => {
vi.clearAllMocks()
emitFormValue = null
@@ -61,15 +54,15 @@ describe('SettingItem (telemetry UI tracking)', () => {
})
}
it('tracks telemetry when value changes via UI (uses normalized value)', async () => {
it('persists setting updates through the setting store', async () => {
const settingParams: SettingParams = {
id: 'main.sub.setting.name',
name: 'Telemetry Visible',
name: 'Visible Setting',
type: 'text',
defaultValue: 'default'
}
mockGet.mockReturnValueOnce('default').mockReturnValueOnce('normalized')
mockGet.mockReturnValue('default')
mockSet.mockResolvedValue(undefined)
renderComponent(settingParams)
@@ -78,33 +71,6 @@ describe('SettingItem (telemetry UI tracking)', () => {
await flushPromises()
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
expect(trackSettingChanged).toHaveBeenCalledWith(
expect.objectContaining({
setting_id: 'main.sub.setting.name',
previous_value: 'default',
new_value: 'normalized'
})
)
})
it('does not track telemetry when normalized value does not change', async () => {
const settingParams: SettingParams = {
id: 'main.sub.setting.name',
name: 'Telemetry Visible',
type: 'text',
defaultValue: 'same'
}
mockGet.mockReturnValueOnce('same').mockReturnValueOnce('same')
mockSet.mockResolvedValue(undefined)
renderComponent(settingParams)
emitFormValue!('same')
await flushPromises()
expect(trackSettingChanged).not.toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith('main.sub.setting.name', 'newvalue')
})
})

View File

@@ -31,7 +31,6 @@ import FormItem from '@/components/common/FormItem.vue'
import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingOption, SettingParams } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import type { Settings } from '@/schemas/apiSchema'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -81,19 +80,6 @@ const settingValue = computed(() => settingStore.get(props.setting.id))
const updateSettingValue = async <K extends keyof Settings>(
newValue: Settings[K]
) => {
const telemetry = useTelemetry()
const settingId = props.setting.id
const previousValue = settingValue.value
await settingStore.set(settingId, newValue)
const normalizedValue = settingStore.get(settingId)
if (previousValue !== normalizedValue) {
telemetry?.trackSettingChanged({
setting_id: settingId,
previous_value: previousValue,
new_value: normalizedValue
})
}
await settingStore.set(props.setting.id, newValue)
}
</script>

View File

@@ -945,6 +945,7 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: 'dark',
versionModified: '1.6.7',
telemetry: { trackChanges: true, includeValues: true },
migrateDeprecatedValue(val: unknown) {
const value = val as string
// Legacy custom palettes were prefixed with 'custom_'

View File

@@ -11,6 +11,16 @@ import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
const { trackSettingChanged } = vi.hoisted(() => ({
trackSettingChanged: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackSettingChanged
}))
}))
// Mock the api
vi.mock('@/scripts/api', () => ({
api: {
@@ -399,11 +409,6 @@ describe('useSettingStore', () => {
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
expect(api.storeSetting).toHaveBeenCalledWith('test.setting', 'newvalue')
// Set the same value again, it should not trigger onChange
await store.set('test.setting', 'newvalue')
expect(onChangeMock).toHaveBeenCalledTimes(2)
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
// Set a different value, it should trigger onChange
await store.set('test.setting', 'differentvalue')
expect(onChangeMock).toHaveBeenCalledWith('differentvalue', 'newvalue')
@@ -415,6 +420,120 @@ describe('useSettingStore', () => {
)
})
it('tracks visible settings with values by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default'
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting',
previous_value: 'default',
new_value: 'newvalue'
})
})
it('does not track hidden settings by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'hidden',
defaultValue: 'default'
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).not.toHaveBeenCalled()
})
it('does not track visible settings that opt out', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { trackChanges: false }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).not.toHaveBeenCalled()
})
it('tracks visible settings without values when values opt out', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { includeValues: false }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting'
})
})
it('tracks hidden settings that opt in, without shipping values by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'hidden',
defaultValue: 'default',
telemetry: { trackChanges: true }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting'
})
// Setting the same value again is a no-op and should not re-emit
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
})
it('ships previous/new values when the setting opts into includeValues', async () => {
store.addSetting({
id: 'Comfy.ColorPalette',
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
telemetry: { trackChanges: true, includeValues: true }
})
await store.set('Comfy.ColorPalette', 'light')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'Comfy.ColorPalette',
previous_value: 'dark',
new_value: 'light'
})
})
it('does not track telemetry when persistence fails', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { trackChanges: true }
})
vi.mocked(api.storeSetting).mockRejectedValueOnce(new Error('failed'))
await expect(store.set('test.setting', 'newvalue')).rejects.toThrow(
'failed'
)
expect(trackSettingChanged).not.toHaveBeenCalled()
})
describe('object mutation prevention', () => {
beforeEach(() => {
const setting: SettingParams = {
@@ -542,6 +661,34 @@ describe('useSettingStore', () => {
expect(api.storeSetting).not.toHaveBeenCalled()
})
it('tracks only the settings in a batch that opt in', async () => {
store.addSetting({
id: 'Comfy.ColorPalette',
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
telemetry: { trackChanges: true, includeValues: true }
})
store.addSetting({
id: 'Comfy.Release.Version',
name: 'Release Version',
type: 'hidden',
defaultValue: ''
})
await store.setMany({
'Comfy.ColorPalette': 'light',
'Comfy.Release.Version': '1.0.0'
})
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'Comfy.ColorPalette',
previous_value: 'dark',
new_value: 'light'
})
})
it('should skip unchanged values', async () => {
store.addSetting({
id: 'Comfy.Release.Version',
@@ -581,6 +728,7 @@ describe('useSettingStore', () => {
await store.setMany({ 'Comfy.Release.Version': 'existing' })
expect(api.storeSettings).not.toHaveBeenCalled()
expect(trackSettingChanged).not.toHaveBeenCalled()
})
})
})

View File

@@ -6,6 +6,8 @@ import { compare, valid } from 'semver'
import { ref } from 'vue'
import type { SettingParams } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SettingChangedMetadata } from '@/platform/telemetry/types'
import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -23,6 +25,11 @@ export interface SettingTreeNode extends TreeNode {
data?: SettingParams
}
interface AppliedSetting<TValue> {
previousValue: TValue
newValue: TValue
}
function tryMigrateDeprecatedValue(
setting: SettingParams | undefined,
value: unknown
@@ -45,6 +52,28 @@ function onChange(
}
}
function settingChangedEvent<K extends keyof Settings>(
setting: SettingParams | undefined,
key: K,
applied: AppliedSetting<Settings[K]>
): SettingChangedMetadata | undefined {
if (!setting) return undefined
const telemetry = setting.telemetry
const isVisible = setting.type !== 'hidden'
const trackChanges = telemetry?.trackChanges ?? isVisible
if (!trackChanges) return undefined
const includeValues = telemetry?.includeValues ?? isVisible
return includeValues
? {
setting_id: key,
previous_value: applied.previousValue,
new_value: applied.newValue
}
: { setting_id: key }
}
export const useSettingStore = defineStore('setting', () => {
const settingValues = ref<Partial<Settings>>({})
const settingsById = ref<Record<string, SettingParams>>({})
@@ -99,7 +128,7 @@ export const useSettingStore = defineStore('setting', () => {
function applySettingLocally<K extends keyof Settings>(
key: K,
value: Settings[K]
): Settings[K] | undefined {
): AppliedSetting<Settings[K]> | undefined {
const clonedValue = _.cloneDeep(value)
const newValue = tryMigrateDeprecatedValue(
settingsById.value[key],
@@ -109,8 +138,12 @@ export const useSettingStore = defineStore('setting', () => {
if (newValue === oldValue) return undefined
onChange(settingsById.value[key], newValue, oldValue)
settingValues.value[key] = newValue
return newValue as Settings[K]
const typedNewValue = newValue as Settings[K]
settingValues.value[key] = typedNewValue
return {
previousValue: oldValue,
newValue: typedNewValue
}
}
/**
@@ -121,7 +154,10 @@ export const useSettingStore = defineStore('setting', () => {
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
const applied = applySettingLocally(key, value)
if (applied === undefined) return
await api.storeSetting(key, applied)
await api.storeSetting(key, applied.newValue)
const event = settingChangedEvent(settingsById.value[key], key, applied)
if (event) useTelemetry()?.trackSettingChanged(event)
}
/**
@@ -130,6 +166,7 @@ export const useSettingStore = defineStore('setting', () => {
*/
async function setMany(settings: Partial<Settings>) {
const updatedSettings: Partial<Settings> = {}
const telemetryEvents: SettingChangedMetadata[] = []
for (const key of Object.keys(settings) as (keyof Settings)[]) {
const applied = applySettingLocally(
@@ -137,12 +174,18 @@ export const useSettingStore = defineStore('setting', () => {
settings[key] as Settings[typeof key]
)
if (applied !== undefined) {
updatedSettings[key] = applied
updatedSettings[key] = applied.newValue
const event = settingChangedEvent(settingsById.value[key], key, applied)
if (event) telemetryEvents.push(event)
}
}
if (Object.keys(updatedSettings).length > 0) {
await api.storeSettings(updatedSettings)
const telemetry = useTelemetry()
for (const event of telemetryEvents) {
telemetry?.trackSettingChanged(event)
}
}
}

View File

@@ -26,11 +26,22 @@ export interface SettingOption {
value?: string | number
}
type SettingTelemetryOptions =
| {
trackChanges: false
includeValues?: never
}
| {
trackChanges?: true
includeValues?: boolean
}
export interface SettingParams<TValue = unknown> extends FormItem {
id: keyof Settings
defaultValue: TValue | (() => TValue)
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
onChange?(newValue: TValue, oldValue?: TValue): void
telemetry?: SettingTelemetryOptions
// By default category is id.split('.'). However, changing id to assign
// new category has poor backward compatibility. Use this field to overwrite
// default category from id.

View File

@@ -374,6 +374,9 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
}
return map
})
const allNodeDefsByDisplayName = computed(() => {
return Object.fromEntries(nodeDefs.value.map((d) => [d.display_name, d]))
})
const visibleNodeDefs = computed(() => {
return nodeDefs.value.filter((nodeDef) =>
@@ -508,6 +511,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
nodeDefsByName,
nodeDefsByDisplayName,
allNodeDefsByName,
allNodeDefsByDisplayName,
showDeprecated,
showExperimental,
showDevOnly,

View File

@@ -29,23 +29,6 @@ const VITE_REMOTE_DEV = process.env.VITE_REMOTE_DEV === 'true'
const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'
const COVERAGE_CRITICAL = process.env.COVERAGE_CRITICAL === 'true'
const CRITICAL_COVERAGE_INCLUDE = [
'src/base/**/*.{ts,vue}',
'src/composables/**/*.{ts,vue}',
'src/scripts/**/*.{ts,vue}',
'src/stores/**/*.{ts,vue}',
'src/utils/**/*.{ts,vue}',
'src/workbench/extensions/manager/composables/**/*.{ts,vue}'
]
const CRITICAL_COVERAGE_THRESHOLDS = {
statements: 66,
branches: 56,
functions: 64,
lines: 68
}
// Open Graph / Twitter Meta Tags Constants
const VITE_OG_URL = 'https://cloud.comfy.org'
@@ -685,9 +668,7 @@ export default defineConfig({
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: COVERAGE_CRITICAL
? CRITICAL_COVERAGE_INCLUDE
: ['src/**/*.{ts,vue}'],
include: ['src/**/*.{ts,vue}'],
exclude: [
'src/**/*.test.ts',
'src/**/*.spec.ts',
@@ -696,8 +677,7 @@ export default defineConfig({
'src/locales/**',
'src/lib/litegraph/**',
'src/assets/**'
],
...(COVERAGE_CRITICAL ? { thresholds: CRITICAL_COVERAGE_THRESHOLDS } : {})
]
},
exclude: [
'**/node_modules/**',