Compare commits
9 Commits
codex/cove
...
proto/load
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
909e776bd9 | ||
|
|
78e49d9360 | ||
|
|
fbe462143a | ||
|
|
61cb1bcde0 | ||
|
|
9dcab4ee96 | ||
|
|
dc29f30b02 | ||
|
|
fb3350ee0e | ||
|
|
be8e0010ee | ||
|
|
d0e97d6933 |
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -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 |
BIN
apps/website/public/images/mcp/mcp-thumb-asphalt.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-concepts.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-kaiju.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-keyart.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-moodboard.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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
|
||||
|
||||
120
apps/website/src/components/blocks/FeatureGrid01.vue
Normal 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>
|
||||
100
apps/website/src/components/blocks/FeatureGrid02.vue
Normal 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>
|
||||
108
apps/website/src/components/blocks/FeatureRows01.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
59
apps/website/src/components/blocks/ReasonsSplit01.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -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/',
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
// href: '#',
|
||||
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': '联系' },
|
||||
|
||||
24
apps/website/src/pages/mcp.astro
Normal 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>
|
||||
24
apps/website/src/pages/zh-CN/mcp.astro
Normal 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>
|
||||
@@ -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([*]);
|
||||
}
|
||||
|
||||
195
apps/website/src/templates/mcp/ComfyMcpDemo.vue
Normal 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_01–03.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>
|
||||
19
apps/website/src/templates/mcp/FAQSection.vue
Normal 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>
|
||||
27
apps/website/src/templates/mcp/HeroSection.vue
Normal 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>
|
||||
29
apps/website/src/templates/mcp/HowItWorksSection.vue
Normal 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>
|
||||
64
apps/website/src/templates/mcp/SetupSection.vue
Normal 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>
|
||||
66
apps/website/src/templates/mcp/ToolsSection.vue
Normal 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>
|
||||
26
apps/website/src/templates/mcp/WhySection.vue
Normal 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>
|
||||
27
apps/website/src/templates/mcp/ctas.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 41 KiB |
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>)
|
||||
|
||||
|
||||
239
src/components/load3d/Load3DMenuBar.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
329
src/components/load3d/Load3DMenuBar.vue
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
47
src/components/load3d/menubar/CameraMenuGroup.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
86
src/components/load3d/menubar/CameraMenuGroup.vue
Normal 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>
|
||||
72
src/components/load3d/menubar/GizmoMenuGroup.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
105
src/components/load3d/menubar/GizmoMenuGroup.vue
Normal 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>
|
||||
74
src/components/load3d/menubar/HdriMenuGroup.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
130
src/components/load3d/menubar/HdriMenuGroup.vue
Normal 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>
|
||||
74
src/components/load3d/menubar/LightMenuGroup.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
105
src/components/load3d/menubar/LightMenuGroup.vue
Normal 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>
|
||||
69
src/components/load3d/menubar/ModelMenuGroup.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
135
src/components/load3d/menubar/ModelMenuGroup.vue
Normal 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>
|
||||
78
src/components/load3d/menubar/RecordMenuControl.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
88
src/components/load3d/menubar/RecordMenuControl.vue
Normal 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>
|
||||
108
src/components/load3d/menubar/SceneMenuGroup.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
190
src/components/load3d/menubar/SceneMenuGroup.vue
Normal 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>
|
||||
24
src/components/load3d/menubar/menuBarStyles.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -177,6 +177,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const canExport = ref(true)
|
||||
const materialModes = ref<readonly MaterialMode[]>([
|
||||
'original',
|
||||
'clay',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ describe('MeshModelAdapter', () => {
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'clay',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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_'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/**',
|
||||
|
||||