Compare commits
18 Commits
codex/cove
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2fe1c3f26 | ||
|
|
690e0e3590 | ||
|
|
01738b7b19 | ||
|
|
be9de941c9 | ||
|
|
f4e0430072 | ||
|
|
c78592c1ec | ||
|
|
00b0c6b434 | ||
|
|
da34fa3944 | ||
|
|
c8ed15da31 | ||
|
|
b132abc64a | ||
|
|
55c52a730a | ||
|
|
fbe462143a | ||
|
|
61cb1bcde0 | ||
|
|
9dcab4ee96 | ||
|
|
dc29f30b02 | ||
|
|
fb3350ee0e | ||
|
|
be8e0010ee | ||
|
|
d0e97d6933 |
@@ -9,6 +9,7 @@
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"public/materialdesignicons.min.css",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/__fixtures__/**/*.json"
|
||||
"**/__fixtures__/**/*.json",
|
||||
"apps/website/src/content/**/*.mdx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import mdx from '@astrojs/mdx'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
@@ -24,6 +25,9 @@ export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
prefetch: { prefetchAll: true },
|
||||
// Keep MDX punctuation verbatim; SmartyPants would turn the source's straight
|
||||
// quotes into curly ones and drift from the rest of the site's copy.
|
||||
markdown: { smartypants: false },
|
||||
redirects: {
|
||||
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
|
||||
'/customers/moment-factory/',
|
||||
@@ -37,6 +41,7 @@ export default defineConfig({
|
||||
devToolbar: { enabled: !process.env.NO_TOOLBAR },
|
||||
integrations: [
|
||||
vue(),
|
||||
mdx(),
|
||||
sitemap({
|
||||
filter: (page) => !isExcludedFromSitemap(page)
|
||||
})
|
||||
|
||||
73
apps/website/e2e/customers-detail.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customer story detail @smoke', () => {
|
||||
test('renders the migrated article: hero, section nav, and body', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /Series Entertainment Rebuilt Game and Video Production/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
|
||||
await expect(nav.getByRole('button', { name: 'CONCLUSION' })).toBeVisible()
|
||||
|
||||
// Section title rendered from the MDX <Section title> wrapper.
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: 'The Output Series Achieved Using ComfyUI'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('section nav highlights the section the reader selects', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
const intro = nav.getByRole('button', { name: 'INTRO' })
|
||||
const problem = nav.getByRole('button', { name: 'THE PROBLEM' })
|
||||
|
||||
await expect(intro).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await problem.click()
|
||||
await expect(problem).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('shows the read-more link only when an external source exists', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/open-story-movement')
|
||||
await expect(
|
||||
page.getByRole('link', { name: /read more on this topic/i })
|
||||
).toBeVisible()
|
||||
|
||||
// series-entertainment only redirected back to itself, so the link is gone.
|
||||
await page.goto('/customers/series-entertainment')
|
||||
await expect(
|
||||
page.getByRole('link', { name: /read more on this topic/i })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('links to the next story in the what-is-next section', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
const nextLink = page.getByRole('link', { name: /view article/i })
|
||||
await expect(nextLink).toBeVisible()
|
||||
// Links to another customer story, without coupling the test to the
|
||||
// specific slug or sort order.
|
||||
await expect(nextLink).toHaveAttribute('href', /^\/customers\/[a-z0-9-]+$/)
|
||||
await expect(nextLink).not.toHaveAttribute(
|
||||
'href',
|
||||
'/customers/series-entertainment'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
"@astrojs/mdx": "catalog:",
|
||||
"@astrojs/vue": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
@@ -48,6 +49,7 @@
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
"vitest": "catalog:",
|
||||
"vue-component-type-helpers": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
100
apps/website/src/components/customers/ArticleNav.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useIntersectionObserver } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import { scrollTo } from '../../scripts/smoothScroll'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
|
||||
type Category = ComponentProps<typeof CategoryNav>['categories'][number]
|
||||
|
||||
const { categories } = defineProps<{
|
||||
categories: Category[]
|
||||
}>()
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
let isScrolling = false
|
||||
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function clearScrollLock() {
|
||||
isScrolling = false
|
||||
if (scrollSafetyTimer !== undefined) {
|
||||
clearTimeout(scrollSafetyTimer)
|
||||
scrollSafetyTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
const scrollBottom = window.scrollY + window.innerHeight
|
||||
return (
|
||||
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
|
||||
)
|
||||
}
|
||||
|
||||
function activateLastIfAtBottom() {
|
||||
if (isScrolling) return
|
||||
if (!isAtBottom()) return
|
||||
const lastId = categories[categories.length - 1]?.value
|
||||
if (lastId) activeSection.value = lastId
|
||||
}
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id
|
||||
clearScrollLock()
|
||||
isScrolling = true
|
||||
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
})
|
||||
return
|
||||
}
|
||||
clearScrollLock()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The section anchors live in the statically rendered article body, so the
|
||||
// observer targets are resolved from the DOM by id rather than template refs.
|
||||
const elements = categories
|
||||
.map((category) => document.getElementById(category.value))
|
||||
.filter((el): el is HTMLElement => el !== null)
|
||||
|
||||
useIntersectionObserver(
|
||||
elements,
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
if (isAtBottom()) return
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top)
|
||||
best = entry
|
||||
}
|
||||
if (best) activeSection.value = best.target.id
|
||||
},
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
activateLastIfAtBottom()
|
||||
})
|
||||
|
||||
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CategoryNav
|
||||
:categories
|
||||
:model-value="activeSection"
|
||||
@update:model-value="scrollToSection"
|
||||
/>
|
||||
</template>
|
||||
68
apps/website/src/components/customers/CustomerArticle.astro
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import { render } from 'astro:content'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { CustomerStoryEntry } from '../../utils/customers'
|
||||
import ArticleNav from './ArticleNav.vue'
|
||||
import BulletList from './content/BulletList.astro'
|
||||
import Contributors from './content/Contributors.astro'
|
||||
import Figure from './content/Figure.astro'
|
||||
import Heading from './content/Heading.astro'
|
||||
import ListItem from './content/ListItem.astro'
|
||||
import Paragraph from './content/Paragraph.astro'
|
||||
import Quote from './content/Quote.astro'
|
||||
import ReadMore from './content/ReadMore.vue'
|
||||
import Section from './content/Section.astro'
|
||||
import Steps from './content/Steps.astro'
|
||||
|
||||
interface Props {
|
||||
entry: CustomerStoryEntry
|
||||
locale?: Locale
|
||||
}
|
||||
|
||||
const { entry, locale = 'en' } = Astro.props
|
||||
const { Content } = await render(entry)
|
||||
|
||||
// The sidebar nav mirrors the section outline declared in frontmatter so it is
|
||||
// server-rendered, exactly like the previous ContentSection sidebar.
|
||||
const categories = entry.data.sections.map((section) => ({
|
||||
label: section.label,
|
||||
value: section.id
|
||||
}))
|
||||
|
||||
// Markdown elements are mapped to the ported block styles; the named
|
||||
// components (Section, Figure, ...) are used directly inside the MDX body.
|
||||
const contentComponents = {
|
||||
p: Paragraph,
|
||||
h3: Heading,
|
||||
ul: BulletList,
|
||||
li: ListItem,
|
||||
Section,
|
||||
Figure,
|
||||
Quote,
|
||||
Contributors,
|
||||
Steps
|
||||
}
|
||||
---
|
||||
|
||||
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
<ArticleNav
|
||||
categories={categories}
|
||||
client:media="(min-width: 1024px)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1">
|
||||
<Content components={contentComponents} />
|
||||
{
|
||||
entry.data.readMore && (
|
||||
<ReadMore href={entry.data.readMore} locale={locale} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { customerStories } from '../../config/customerStories'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { StoryCard } from '../../utils/customers'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const { stories, locale = 'en' } = defineProps<{
|
||||
stories: StoryCard[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
</script>
|
||||
@@ -13,7 +16,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
|
||||
>
|
||||
<a
|
||||
v-for="story in customerStories"
|
||||
v-for="story in stories"
|
||||
:key="story.slug"
|
||||
:href="`${prefix}/customers/${story.slug}`"
|
||||
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
|
||||
@@ -22,7 +25,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
<div class="m-2 aspect-video overflow-hidden rounded-2xl">
|
||||
<div
|
||||
class="size-full rounded-2xl bg-white/5 bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${story.image})` }"
|
||||
:style="{ backgroundImage: `url(${story.cover})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,12 +35,12 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(story.category, locale) }}
|
||||
{{ story.category }}
|
||||
</span>
|
||||
<h3
|
||||
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
|
||||
>
|
||||
{{ t(story.title, locale) }}
|
||||
{{ story.title }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<ul class="mt-4 space-y-1 pl-5 text-sm"><slot /></ul>
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
interface Person {
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
people: Person[]
|
||||
}
|
||||
|
||||
const { label, people } = Astro.props
|
||||
---
|
||||
|
||||
<div class="mt-8 rounded-2xl bg-(--site-bg-soft) p-6">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{
|
||||
people.map((person, i) => (
|
||||
<>
|
||||
<p
|
||||
class={cn(
|
||||
'text-sm font-semibold text-primary-comfy-canvas',
|
||||
i === 0 ? 'mt-2' : 'mt-4'
|
||||
)}
|
||||
>
|
||||
{person.name}
|
||||
</p>
|
||||
<p class="text-xs text-primary-comfy-canvas">{person.role}</p>
|
||||
</>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
20
apps/website/src/components/customers/content/Figure.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<img src={src} alt={alt} class="w-full rounded-2xl object-cover" />
|
||||
{
|
||||
caption && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h3 class="text-primary-comfy-yellow mt-6 mb-2 text-lg font-semibold italic">
|
||||
<slot />
|
||||
</h3>
|
||||
@@ -0,0 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
@@ -0,0 +1 @@
|
||||
<p class="mt-4 text-sm/relaxed text-primary-comfy-canvas"><slot /></p>
|
||||
16
apps/website/src/components/customers/content/Quote.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
interface Props {
|
||||
name: string
|
||||
}
|
||||
|
||||
const { name } = Astro.props
|
||||
---
|
||||
|
||||
<blockquote
|
||||
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-(--site-bg-soft) p-8"
|
||||
>
|
||||
<p class="text-lg/relaxed font-light text-primary-comfy-canvas italic">
|
||||
"<slot />"
|
||||
</p>
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
|
||||
</blockquote>
|
||||
21
apps/website/src/components/customers/content/ReadMore.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import Button from '../../ui/button/Button.vue'
|
||||
|
||||
const { href, locale = 'en' } = defineProps<{
|
||||
href: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-8 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ t('customers.story.readMore', locale) }}
|
||||
<template #append>
|
||||
<span class="text-base" aria-hidden="true">↗</span>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
17
apps/website/src/components/customers/content/Section.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
interface Props {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const { id, title } = Astro.props
|
||||
---
|
||||
|
||||
<div id={id} class="mb-16 scroll-mt-24 lg:scroll-mt-36">
|
||||
{
|
||||
title && (
|
||||
<h2 class="mb-6 text-2xl font-light text-primary-comfy-canvas">{title}</h2>
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
17
apps/website/src/components/customers/content/Steps.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
interface Props {
|
||||
items: string[]
|
||||
}
|
||||
|
||||
const { items } = Astro.props
|
||||
---
|
||||
|
||||
<ol class="mt-4 space-y-1 pl-1 text-sm [counter-reset:step]">
|
||||
{
|
||||
items.map((item) => (
|
||||
<li class="flex items-start gap-3 text-primary-comfy-canvas [counter-increment:step] before:shrink-0 before:font-semibold before:tabular-nums before:text-primary-comfy-yellow before:content-[counter(step,_decimal-leading-zero)]">
|
||||
{item}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
@@ -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>
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
interface CustomerStory {
|
||||
slug: string
|
||||
image: string
|
||||
category: TranslationKey
|
||||
title: TranslationKey
|
||||
body: TranslationKey
|
||||
detailPrefix: string
|
||||
readMoreHref?: string
|
||||
}
|
||||
|
||||
export const customerStories: CustomerStory[] = [
|
||||
{
|
||||
slug: 'series-entertainment',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/series-entertainment/cover.webp',
|
||||
category: 'customers.story.series-entertainment.category',
|
||||
title: 'customers.story.series-entertainment.title',
|
||||
body: 'customers.story.series-entertainment.body',
|
||||
detailPrefix: 'customers.detail.series-entertainment',
|
||||
readMoreHref:
|
||||
'https://comfy.org/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui'
|
||||
},
|
||||
{
|
||||
slug: 'open-story-movement',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/open-story-movement/cover.webp',
|
||||
category: 'customers.story.open-story-movement.category',
|
||||
title: 'customers.story.open-story-movement.title',
|
||||
body: 'customers.story.open-story-movement.body',
|
||||
detailPrefix: 'customers.detail.open-story-movement',
|
||||
readMoreHref: 'https://blog.comfy.org/p/how-open-source-is-fueling-the-open'
|
||||
},
|
||||
{
|
||||
slug: 'moment-factory',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/moment-factory/cover.webp',
|
||||
category: 'customers.story.moment-factory.category',
|
||||
title: 'customers.story.moment-factory.title',
|
||||
body: 'customers.story.moment-factory.body',
|
||||
detailPrefix: 'customers.detail.moment-factory',
|
||||
readMoreHref:
|
||||
'https://comfy.org/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping'
|
||||
},
|
||||
{
|
||||
slug: 'ubisoft-chord',
|
||||
image: 'https://media.comfy.org/website/customers/ubisoft/cover.webp',
|
||||
category: 'customers.story.ubisoft-chord.category',
|
||||
title: 'customers.story.ubisoft-chord.title',
|
||||
body: 'customers.story.ubisoft-chord.body',
|
||||
detailPrefix: 'customers.detail.ubisoft-chord',
|
||||
readMoreHref:
|
||||
'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model'
|
||||
},
|
||||
{
|
||||
slug: 'groove-jones',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp',
|
||||
category: 'customers.story.groove-jones.category',
|
||||
title: 'customers.story.groove-jones.title',
|
||||
body: 'customers.story.groove-jones.body',
|
||||
detailPrefix: 'customers.detail.groove-jones'
|
||||
}
|
||||
]
|
||||
|
||||
export function getStoryBySlug(slug: string): CustomerStory | undefined {
|
||||
return customerStories.find((s) => s.slug === slug)
|
||||
}
|
||||
|
||||
export function getNextStory(slug: string): CustomerStory {
|
||||
const index = customerStories.findIndex((s) => s.slug === slug)
|
||||
return customerStories[(index + 1) % customerStories.length]
|
||||
}
|
||||
@@ -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/',
|
||||
|
||||
17
apps/website/src/content.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineCollection } from 'astro:content'
|
||||
import { glob } from 'astro/loaders'
|
||||
|
||||
import { customerStorySchema } from './content/customers.schema'
|
||||
|
||||
const customers = defineCollection({
|
||||
// Preserve the exact path as the id (default slugification lowercases the
|
||||
// `zh-CN` locale folder, which would break locale filtering).
|
||||
loader: glob({
|
||||
base: './src/content/customers',
|
||||
pattern: '**/*.mdx',
|
||||
generateId: ({ entry }) => entry.replace(/\.mdx$/, '')
|
||||
}),
|
||||
schema: customerStorySchema
|
||||
})
|
||||
|
||||
export const collections = { customers }
|
||||
64
apps/website/src/content/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Website content collections
|
||||
|
||||
How we keep editable marketing content in code, using Astro Content Collections.
|
||||
Customer stories (`/customers`) are the first content type moved over, and this is
|
||||
the pattern to follow for the rest of the marketing content.
|
||||
|
||||
## Which kind of collection to use
|
||||
|
||||
- **Article / prose content** (case studies, blog-style pages): use an **MDX**
|
||||
collection. One MDX file per entry, frontmatter for the metadata, prose body with
|
||||
a few small components for images, quotes, etc.
|
||||
- **Structured / list content** (pricing tiers, feature grids, model lists): use a
|
||||
**data** collection (`file()` loader + JSON/YAML + a zod schema). Do not force this
|
||||
kind of content into MDX.
|
||||
|
||||
## How customer stories are set up (the article pattern)
|
||||
|
||||
- The collection is defined in `src/content.config.ts` (a `glob` loader over
|
||||
`src/content/customers`).
|
||||
- One folder per locale: `src/content/customers/en` and `.../zh-CN`. The same
|
||||
filename is the same story in both languages. A custom `generateId` keeps the exact
|
||||
path as the id, so the `zh-CN` folder is not lower-cased (that silently breaks
|
||||
locale filtering otherwise).
|
||||
- The schema lives in `src/content/customers.schema.ts` (title, category,
|
||||
description, cover, order, section list, optional read-more link).
|
||||
- The body components are in `components/customers/content` (`Section`, `Figure`,
|
||||
`Quote`, `Contributors`, `Steps`, plus styled paragraph/heading/list). These are
|
||||
generic article blocks. When a second article type is added, move them to a shared
|
||||
folder so both can use them.
|
||||
- The detail page renders the body with `<Content components={...} />` and a small
|
||||
scroll-spy sidebar island (`ArticleNav.vue`). The article body itself is static
|
||||
HTML; only the sidebar ships JavaScript.
|
||||
|
||||
## Adding a new article type (quick version)
|
||||
|
||||
1. Add a collection to `src/content.config.ts` with a `glob` loader and a zod schema.
|
||||
2. Put the content under `src/content/<type>/<locale>/<slug>.mdx`.
|
||||
3. Build the listing and detail pages that read it with `getCollection`.
|
||||
4. Reuse the block components above.
|
||||
|
||||
## Gotchas worth knowing
|
||||
|
||||
- `src/env.d.ts` must reference `../.astro/types.d.ts`, otherwise `getCollection` is
|
||||
untyped and entry data comes back empty.
|
||||
- `astro.config.ts` sets `markdown.smartypants: false` so punctuation stays exactly
|
||||
as written (otherwise straight quotes become curly and drift from the rest of the
|
||||
site). This option is deprecated in Astro 7 and moves onto the markdown processor;
|
||||
handle that as part of the Astro 7 upgrade.
|
||||
- ESLint: `apps/website` files ignore the `astro:` virtual modules in
|
||||
`import-x/no-unresolved` (they are real at build time but the resolver cannot see
|
||||
them).
|
||||
- `ui/button/Button.vue` cannot take an `href` inside a `.astro` file (its props do
|
||||
not declare it). Wrap it in a small `.vue` when you need a link button, see
|
||||
`components/customers/content/ReadMore.vue`.
|
||||
- Content MDX is excluded from `oxfmt` in `.oxfmtrc.json`. The formatter rewraps
|
||||
component slots and changes the rendered output (it broke blockquotes). Keep one
|
||||
logical block per line when editing.
|
||||
- `components/common/ContentSection.vue` and `config/contentSections.ts` still power
|
||||
the legal and privacy pages. Do not delete them.
|
||||
- The MDX `components` map styles the block elements (paragraphs, `###`, lists) and the
|
||||
named block components (`Figure`, `Quote`, etc.). Inline `a`/`strong`/`em` typed
|
||||
directly in prose render with browser defaults, so route prose through the block
|
||||
components; if styled inline links are ever needed, add them to the map with design
|
||||
sign-off.
|
||||
97
apps/website/src/content/customers.content.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const customersDir = join(dirname(fileURLToPath(import.meta.url)), 'customers')
|
||||
const locales = ['en', 'zh-CN'] as const
|
||||
|
||||
interface Story {
|
||||
file: string
|
||||
frontmatter: string
|
||||
body: string
|
||||
}
|
||||
|
||||
function loadStories(): Story[] {
|
||||
const stories: Story[] = []
|
||||
for (const locale of locales) {
|
||||
const dir = join(customersDir, locale)
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (!name.endsWith('.mdx')) continue
|
||||
const raw = readFileSync(join(dir, name), 'utf8')
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||||
if (!match) throw new Error(`No frontmatter block in ${locale}/${name}`)
|
||||
stories.push({
|
||||
file: `${locale}/${name}`,
|
||||
frontmatter: match[1],
|
||||
body: match[2]
|
||||
})
|
||||
}
|
||||
}
|
||||
return stories
|
||||
}
|
||||
|
||||
// The TOC sidebar is built from frontmatter `sections`, but the scroll-spy
|
||||
// anchors come from `<Section id="...">` in the body. Nothing binds the two but
|
||||
// matching strings, so this guards against silent drift (a renamed body id or a
|
||||
// missing frontmatter entry would leave the nav pointing at a dead anchor).
|
||||
function frontmatterSections(
|
||||
frontmatter: string
|
||||
): { id: string; label: string }[] {
|
||||
const sections: { id: string; label: string }[] = []
|
||||
const pattern = /-\s*id:\s*(\S+)\s*\n\s*label:\s*(.+)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(frontmatter)) !== null) {
|
||||
sections.push({
|
||||
id: match[1].trim(),
|
||||
label: match[2].trim().replace(/^["']|["']$/g, '')
|
||||
})
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
function bodySectionIds(body: string): string[] {
|
||||
const ids: string[] = []
|
||||
const pattern = /<Section\b[^>]*\bid="([^"]*)"/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(body)) !== null) {
|
||||
ids.push(match[1])
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const stories = loadStories()
|
||||
|
||||
it('finds all ten customer stories', () => {
|
||||
expect(stories).toHaveLength(10)
|
||||
})
|
||||
|
||||
describe.for(stories)('$file', ({ frontmatter, body }) => {
|
||||
const sections = frontmatterSections(frontmatter)
|
||||
const bodyIds = bodySectionIds(body)
|
||||
|
||||
it('declares at least one section', () => {
|
||||
expect(sections.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('has a non-empty id and label for every section', () => {
|
||||
for (const section of sections) {
|
||||
expect(section.id).not.toBe('')
|
||||
expect(section.label).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every body <Section> an id', () => {
|
||||
expect(bodyIds).not.toContain('')
|
||||
expect(bodyIds.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('matches frontmatter section ids to body <Section> ids', () => {
|
||||
const fromFrontmatter = [
|
||||
...new Set(sections.map((section) => section.id))
|
||||
].sort()
|
||||
const fromBody = [...new Set(bodyIds)].sort()
|
||||
expect(fromBody).toEqual(fromFrontmatter)
|
||||
})
|
||||
})
|
||||
15
apps/website/src/content/customers.schema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
// strictObject so a misspelled frontmatter key (e.g. readMoreHref) fails the
|
||||
// content build instead of being silently dropped.
|
||||
export const customerStorySchema = z.strictObject({
|
||||
title: z.string(),
|
||||
category: z.string(),
|
||||
description: z.string(),
|
||||
cover: z.url(),
|
||||
readMore: z.url().optional(),
|
||||
order: z.number().int().nonnegative(),
|
||||
sections: z.array(z.object({ id: z.string(), label: z.string() }))
|
||||
})
|
||||
|
||||
export type CustomerStoryFrontmatter = z.infer<typeof customerStorySchema>
|
||||
106
apps/website/src/content/customers/en/groove-jones.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "How Groove Jones Delivered a Holiday FOOH Campaign for Dick's Sporting Goods with Comfy"
|
||||
category: "CASE STUDY"
|
||||
description: "Groove Jones, a Dallas-based creative studio, used Comfy to deliver a hyper-realistic FOOH holiday campaign for the Crocs x NFL collection on a fast-approaching deadline."
|
||||
cover: "https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp"
|
||||
order: 4
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE OUTPUT"
|
||||
- id: topic-3
|
||||
label: "THE PROBLEM"
|
||||
- id: topic-4
|
||||
label: "HOW COMFY SOLVED THE PROBLEM"
|
||||
- id: topic-5
|
||||
label: "BRAND-TRAINED LORAS"
|
||||
- id: topic-6
|
||||
label: "MULTI-MODEL ORCHESTRATION"
|
||||
- id: topic-7
|
||||
label: "THE PIPELINE"
|
||||
- id: topic-8
|
||||
label: "VERSION CONTROL"
|
||||
- id: topic-9
|
||||
label: "FINISHING IN NUKE"
|
||||
- id: topic-10
|
||||
label: "THE TAKEAWAY"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Groove Jones, a Dallas-based creative studio, builds AI-driven campaigns and immersive experiences for major brands where photoreal polish, creative ambition, and social-ready speed all have to land together. As their work expanded across AI Video, AR, VR, and WebGL for clients like Crocs, the NFL, and Dick’s Sporting Goods, they faced a recurring challenge: delivering feature-film-quality VFX on commercial timelines and budgets.
|
||||
|
||||
For the Crocs x NFL collection holiday launch, that challenge came to a head. The brief called for hyper-realistic video of giant NFL-licensed Crocs parachuting into real Dick’s Sporting Goods parking lots, across multiple locations, delivered on a fast-approaching holiday deadline. A live-action shoot plus a traditional CG pipeline was off the table.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="The Output Groove Jones Achieved Using Comfy">
|
||||
|
||||
- A full FOOH (faux out-of-home) social campaign delivered on a tight holiday deadline
|
||||
- Hyper-realistic videos of giant NFL-licensed Crocs parachuting onto Dick’s Sporting Goods parking lots
|
||||
- Vertical 9:16 deliverables at 2K for Instagram Reels, TikTok, and YouTube Shorts
|
||||
- Same-day iteration on client notes instead of week-long asset updates
|
||||
- Winner, Aaron Awards 2024: Best AI Workflow for Production
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="The Problem Groove Jones Was Trying to Solve">
|
||||
|
||||
A traditional pipeline for this creative meant a live-action shoot at multiple store locations plus a full CG build: high-res modeling of every team’s clog, look development, lighting, rendering, compositing, and a new render every time the client wanted a variation. It also meant a large crew (modelers, texture artists, lighting artists, compositors) and a schedule measured in months. Neither the budget nor the holiday window supported that path.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="How Groove Jones Used Comfy to Solve the Problem">
|
||||
|
||||
Groove Jones’s Senior Creative Technologist, Doug Hogan, rebuilt the production process around Comfy’s node-based workflow system, using their proprietary GrooveTech GenVFX pipeline. Custom LoRAs handled brand accuracy, a single Comfy graph orchestrated multiple generative models, and Nuke handled final polish. For a team with feature-film and commercial roots, the environment was immediately familiar.
|
||||
|
||||
<Quote name="Doug Hogan | Senior Creative Technologist @ Groove Jones">Comfy felt very similar to working inside a traditional CG and compositing pipeline. Node-based logic, clear data flow, modular builds. It felt natural to our artists already.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="Brand-Trained LoRAs for Hero Assets">
|
||||
|
||||
Groove Jones trained custom LoRAs on the Crocs NFL Team Clogs and on Dick’s Sporting Goods storefronts, so every generation came out anchored in brand-accurate references. Real team colorways, real product silhouettes, and real store exteriors stayed consistent across shots without per-frame correction, replacing what would normally take weeks of manual look development.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp" alt="Grid of brand-accurate NFL team Crocs generated via custom LoRAs" caption="Brand-accurate NFL team colorways generated through custom LoRAs." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="Multi-Model Orchestration in a Single Graph">
|
||||
|
||||
The creative required different generative models at different stages: Flux for key-frame still development, Gemini Flash 2.5 (Nano Banana) for fast ideation and variants, and Veo 3.1 plus Moonvalley’s Marey for final video generation. Comfy routed between all four inside one graph, so outputs from one model fed directly into the next without ever leaving the environment.
|
||||
|
||||
<Quote name="Dale Carman | Co-founder @ Groove Jones">The Comfy community develops at an almost exponential curve, and we were able to leverage their existing nodes and tools to solve very specific production challenges instead of reinventing the wheel ourselves.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="Storyboards to Previz to Final Shot in One Pipeline">
|
||||
|
||||
The workflow opened with traditional storyboards for narrative approval, then moved into CGI blocking to lock composition, camera framing, and story beats. Comfy drove generation from there: the shoe drop, the parking lot reactions, the crowd coverage, and the environmental conversions that turned static summer storefronts into snow-covered holiday scenes, all inside the same graph.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp" alt="Storyboard grid for the Crocs x NFL holiday campaign" caption="Grayscale storyboards used to lock narrative beats before generation." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp" alt="Composition progression from blocking to mid-render to final shot" caption="Composition progression: wireframe blocking, mid-render, and final shot." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-8" title="Workflow Files as Version Control">
|
||||
|
||||
Every variant of every shot lived as a Comfy workflow file, which doubled as version control. When notes came in requesting a different team colorway, store exterior, or time of day, the team duplicated a branch instead of rebuilding, which made same-day iteration possible. GPU usage and API credit burn were trackable inside the same environment as the work itself, giving Production real-time visibility into compute cost per iteration.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-9" title="Finishing in Nuke">
|
||||
|
||||
Generated shots moved into Nuke for final compositing: falling snow, camera shake, crowd ambience, holiday audio, and 2K mastering in 9:16 for Instagram Reels, TikTok, and YouTube Shorts. Because Comfy handled generation cleanly, Nuke focused on polish and motion enhancement rather than patching generative artifacts.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-10" title="Conclusion">
|
||||
|
||||
By building the FOOH pipeline inside Comfy, Groove Jones turned a brief that would have required an expensive live-action shoot plus months of CG into a fast, iterative, single-environment workflow the client could direct in real time. The project recently won the Aaron Award for Best AI Workflow for Production.
|
||||
|
||||
<Quote name="Dale Carman | Co-founder @ Groove Jones">At Groove Jones, we care deeply about delivering work that makes people say WOW! But we also care about delivering on time and on budget. VFX projects used to operate at razor thin margins. Comfy solved that for us.</Quote>
|
||||
|
||||
</Section>
|
||||
156
apps/website/src/content/customers/en/moment-factory.mdx
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: "How Moment Factory Reimagined 3D Projection Mapping at Architectural Scale with ComfyUI"
|
||||
category: "CASE STUDY"
|
||||
description: "Moment Factory used ComfyUI to reimagine their 3D projection mapping pipeline, enabling architectural-scale visual experiences with AI-driven content generation and real-time iteration."
|
||||
cover: "https://media.comfy.org/website/customers/moment-factory/cover.webp"
|
||||
order: 2
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "BEFORE COMFY"
|
||||
- id: topic-3
|
||||
label: "WHAT CHANGED?"
|
||||
- id: topic-4
|
||||
label: "WHY COMFYUI WAS CRITICAL"
|
||||
- id: topic-5
|
||||
label: "THE TAKEAWAY"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
How do you make generative AI work at architectural scale? Moment Factory used ComfyUI to fundamentally transform how they handle early concept, look development, and design exploration for architectural projection mapping.
|
||||
|
||||
Before ComfyUI, this phase was slower, more abstract, and carried greater risk. After ComfyUI, it became faster, more concrete, and spatially grounded from the start.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/hero.webp" alt="Moment Factory architectural projection mapping" caption="Arched interior architectural projection by Moment Factory." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="Before ComfyUI: Slow Iteration, Abstract Decisions, Late Risk">
|
||||
|
||||
Early concept and look development traditionally relied on:
|
||||
|
||||
- Static sketches
|
||||
- Reference decks
|
||||
- Moodboards
|
||||
- Abstract discussions about intent
|
||||
|
||||
For architectural projection mapping, this creates a problem. You do not really know if something works until it is projected at scale. Seams, pixel density, spatial drift, and composition issues usually reveal themselves later in the process, when changes have a massive impact on production.
|
||||
|
||||
Traditionally, this means:
|
||||
|
||||
- Fewer directions explored
|
||||
- Longer back-and-forth cycles
|
||||
- Creative decisions made without spatial proof
|
||||
- Risk pushed downstream into production
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="What Changed with ComfyUI">
|
||||
|
||||
Moment Factory built a custom ComfyUI workflow and used it to enhance and accelerate large parts of early concept sketching, look-dev exploration, and part of the design phase.
|
||||
|
||||
They did not just generate images. They changed how decisions were made.
|
||||
|
||||
### 1. Iteration stopped being the bottleneck
|
||||
|
||||
ComfyUI transformed the iteration process, making it faster, sharper, and more intentional. Grounded in real production parameters, they explored:
|
||||
|
||||
- Over 20 main artistic directions
|
||||
- 20 to 40 iterations per direction
|
||||
- Styles ranging from hyper-realism to illustrative engraving
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/variations.webp" alt="Grid of generated artistic variations" caption="A grid of generated variations exploring different artistic directions." />
|
||||
|
||||
The studio used batching and parameter tweaks to move quickly, while intentionally stress-testing the system to understand its limits.
|
||||
|
||||
<Quote name="Guillaume Borgomano | Senior Multimedia Director & Innovation Creative Lead @ Moment Factory">With any GenAI tool, it's easy to over-iterate, to believe the best result is always one click away. Imposing real production constraints, whether financial or time-based, was essential to ensure these explorations remained meaningful and truly impacted our pipelines.</Quote>
|
||||
|
||||
That volume of exploration would not have been realistic in their previous workflow.
|
||||
|
||||
### 2. Concept work moved from days to hours
|
||||
|
||||
The biggest acceleration happened early. What would normally involve days of back-and-forth between static concepts and reference decks could happen within a few hours.
|
||||
|
||||
They generated intentionally low-resolution outputs around 2K, reviewed them quickly, and even generated new variations live on site. Those outputs could be checked directly in the media server timeline minutes later.
|
||||
|
||||
This low-resolution stage was not about polish. It was about validation and decision-making. That shift alone changed the pace of the entire project.
|
||||
|
||||
### 3. Spatial credibility came first, not last
|
||||
|
||||
A major reason this worked is that every generation was already spatially constrained. Moment Factory built the entire workflow around architectural surface templates, so outputs were pre-mapped from the start. The pipeline supported multiple template types in parallel, including flat UVs, 360 layouts, and camera-projection setups.
|
||||
|
||||
ControlNet injected structural information from those templates directly into the diffusion process, enforcing scale, layout, and spatial logic early.
|
||||
|
||||
Because of this, visuals were already spatially credible during the concept phase. Abstract intent turned into shared reference points. The team could react to something grounded instead of imagining how it might look later.
|
||||
|
||||
### 4. Approval no longer meant starting over
|
||||
|
||||
Once a direction was approved, the workflow did not reset. They could:
|
||||
|
||||
- Inpaint specific regions
|
||||
- Preserve composition
|
||||
- Upscale selected outputs to 18K in ~20 minutes
|
||||
|
||||
This completely changed how fast ideas moved from concept to projection-ready content. Previously, approval often meant rebuilding work. With ComfyUI, approval meant pushing forward.
|
||||
|
||||
### 5. Fewer people, better collaboration
|
||||
|
||||
Once the system was stable, one main artist operated inside ComfyUI. Around that setup, two additional team members were continuously involved in art direction, prompt tuning, selection, and alignment discussions.
|
||||
|
||||
They had to define a new working methodology to keep creative intent at the center, but in practice, ComfyUI functioned as a shared exploration tool, not a solo technical setup.
|
||||
|
||||
### 6. The moment it became undeniable
|
||||
|
||||
Within Moment Factory's innovation team, it felt like a breakthrough early on — the level of malleability and control simply wasn't achievable with more rigid tools. But the real turning point came during an in-situ live demo, held at 25 Broadway. Late in the process, Moment Factory swapped the surface template and reran the entire pipeline without re-authoring a single asset. The composition held and the spatial logic remained intact. The content dropped straight into the media server timeline.
|
||||
|
||||
The room went quiet.
|
||||
|
||||
In that moment, it stopped being a promising experiment and became a shared realization. People weren't asking "what if" anymore — they were asking how to prompt, and in what other context it could apply.
|
||||
|
||||
That's when it became undeniable: this wasn't just a powerful tool for R&D. It was a shift in how teams across Moment Factory could think, iterate, and produce.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/demo.webp" alt="Moment Factory live projection mapping demo" caption="Interior crowd view with projection mapping at architectural scale." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Why ComfyUI Was Critical at Architectural Scale">
|
||||
|
||||
Moment Factory had been exploring diffusion-based workflows for projection mapping for years. The ambition was clear: use generative systems not just for images, but as structured spatial material within complex, large-scale environments.
|
||||
|
||||
What architectural scale demanded, however, was not just image generation. It required:
|
||||
|
||||
- Precise control over spatial conditioning
|
||||
- The ability to inject UV layouts and depth constraints directly into inference
|
||||
- Rapid template switching without breaking composition
|
||||
- Iterative refinement without rebuilding from scratch
|
||||
- A pipeline that could evolve as constraints changed
|
||||
|
||||
This level of structural malleability was essential.
|
||||
|
||||
ComfyUI's node-based architecture allowed the team to design and reshape the workflow itself, not just the outputs. Conditioning logic, batching strategies, template inputs, and upscaling stages could be reconfigured as the project evolved.
|
||||
|
||||
Rather than adapting the project to fit a tool, the tool could be adapted to fit the architecture.
|
||||
|
||||
At that point, it became clear: achieving reliable architectural-scale generative workflows required a system flexible enough to be re-authored alongside the creative process. ComfyUI provided that flexibility.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/workflow.webp" alt="ComfyUI node-based workflow" caption="Screenshot of the ComfyUI node-based workflow used by Moment Factory." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="The Takeaway">
|
||||
|
||||
ComfyUI did not make the creative decisions. The vision stayed human. The constraints were architectural, and the expectations were production-level from the start.
|
||||
|
||||
What ComfyUI brought to the table was structural flexibility. It allowed the workflow itself to be shaped and reshaped as the project evolved. Spatial inputs could be injected directly into inference. Templates could be swapped without collapsing the composition. Refinements could happen without rebuilding entire directions.
|
||||
|
||||
Generative systems stopped behaving like black boxes and started behaving like controllable material. Spatial logic was embedded early, and scaling to architectural resolution became a managed step rather than a gamble.
|
||||
|
||||
The impact was not just speed. Decisions could be validated earlier, directly against geometry and projection conditions. Spatial alignment became part of concept development instead of a late-stage correction. That shift reduced uncertainty before entering production.
|
||||
|
||||
In that sense, ComfyUI did more than accelerate exploration. It made architectural-scale generative workflows structurally viable within real production constraints.
|
||||
|
||||
<Contributors label="MOMENT FACTORY CONTRIBUTORS" people={[{"name":"Guillaume Borgomano","role":"Senior Multimedia Director & Innovation Creative Lead"},{"name":"Conner Tozier","role":"Lead Motion Designer & Generative AI Lead"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "How Doodles, SYSTMS, and Open-Source Tools Like ComfyUI Are Rewriting the Rules for Artists"
|
||||
category: "OPEN SOURCE × BRAND"
|
||||
description: "Doodles and SYSTMS built Doodles AI — a generative platform powered by PRISM 1.0 — on open-source infrastructure including ComfyUI, proving that open-source workflows can power brand-quality, commercially successful products."
|
||||
cover: "https://media.comfy.org/website/customers/open-story-movement/cover.webp"
|
||||
order: 1
|
||||
readMore: "https://blog.comfy.org/p/how-open-source-is-fueling-the-open"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "IP WITHOUT WALLS"
|
||||
- id: topic-3
|
||||
label: "THE LAST MILE"
|
||||
- id: topic-4
|
||||
label: "CODED DNA"
|
||||
- id: topic-5
|
||||
label: "TAKEAWAY"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Doodles, the entertainment brand built around the iconic pastel-palette artwork of Canadian illustrator Scott Martin (known as Burnt Toast), is about to launch **Doodles AI** — a generative platform powered by **PRISM 1.0**, a generative image model trained on Doodles' extensive body of work that can reimagine people and objects in the unmistakable Doodles visual language.
|
||||
|
||||
Behind the scenes, the engineering is being handled by **SYSTMS**, an AI studio whose tagline — "Engineering the Impossible" — reflects their approach to building bespoke creative pipelines using open-source infrastructure, including node-based workflow tools like ComfyUI.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/cover.webp" alt="Doodles AI generative platform powered by PRISM 1.0" caption="The Doodles AI platform reimagines people and objects in the Doodles visual language." />
|
||||
|
||||
The story of how these pieces came together offers a compelling blueprint for anyone watching the intersection of open-source, AI, artist-driven brands, and the emerging concept the Doodles team is calling "open story."
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="IP Without Walls">
|
||||
|
||||
Artists have traditionally been protective of their IP, and for good reason. But the Doodles team is exploring a new model where the community doesn't just consume the brand — they co-create it. Every generation a user produces on the Doodles AI platform makes the model stronger.
|
||||
|
||||
Through reinforcement learning, user-generated content becomes part of the training data for future iterations of the PRISM. Users aren't just customers; they're collaborators shaping the brand's visual DNA.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/walls.webp" alt="Doodles community co-creation" caption="Users become collaborators, co-creating the Doodles brand through AI-generated content." />
|
||||
|
||||
As Scott Martin put it when he returned as CEO in early 2025, the goal is to recalibrate — creativity first, community at the center, art driving everything. Martin, who built his career as an illustrator working with Google, Snapchat, Dropbox, and Adobe before co-founding Doodles in 2021 alongside Evan Keast and Jordan Castro, understands both the commercial and artistic sides of this equation.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="The Last Mile Is the Whole Game">
|
||||
|
||||
Doodles AI represents something powerful: proof that open-source tools can power commercially successful, brand-quality products.
|
||||
|
||||
The SYSTMS team uses open-source tools in their rawest form, prioritizing control and innovation at the bleeding edge of the space. The fact that these same tools are now producing output with the kind of brand fidelity that differentiates Doodles from generalized platforms like MidJourney or Sora is significant. It's the "last mile" problem in creative AI — getting from 85% to 100% fidelity — and it's where the real value lies.
|
||||
|
||||
Doodles AI is a showcase of what's possible when open-source workflows meet professional creative direction. ComfyUI's powerful node-based platform allows users to package complex systems of open-source models, APIs, and other tools into consumer-facing applications, making it a natural fit for projects like this.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/workflow.webp" alt="ComfyUI workflow powering Doodles AI" caption="Open-source workflows powering brand-quality generative output." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Coded DNA">
|
||||
|
||||
Doodles AI launches with PRISM 1.0 as an image-to-image model, but the roadmap is ambitious: 2D and 3D output generation, video with sound, real-time AR, and gaming applications. Original Doodles holders receive 100 free generations on launch day — a deliberate move to seed the community and let them flood every timeline with the platform's output.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/dna.webp" alt="Doodles AI output examples" caption="Doodles AI output demonstrating brand-fidelity generative results." />
|
||||
|
||||
The deeper play is alignment with the speed and scale of the entire AI industry. By building on open-source infrastructure and fostering a community of co-creators, Doodles has positioned itself to plug its "coded DNA" into future technologies that don't yet exist. It's a bet that openness — open source, open story, open creation — isn't just philosophically appealing but strategically sound.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="What It Means for Artists">
|
||||
|
||||
For artists watching from the sidelines, the message is clear: the building blocks are here, the community is building, and the line between creator and consumer is disappearing. The question isn't whether open source will reshape creative industries. It's whether you'll be building with it when it does.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/output.webp" alt="Doodles AI creative output" caption="Open-source tools powering brand-quality creative output at scale." />
|
||||
|
||||
<Contributors label="LINKS" people={[{"name":"Doodles: doodles.app | SYSTMS: systms.ai | ComfyUI: comfy.org","role":"Official websites"}]} />
|
||||
|
||||
</Section>
|
||||
106
apps/website/src/content/customers/en/series-entertainment.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "How Series Entertainment Rebuilt Game and Video Production with ComfyUI"
|
||||
category: "GAME & VIDEO PRODUCTION"
|
||||
description: "Scaling emotional storytelling across 100,000+ assets and multiple Netflix titles, using repeatable ComfyUI production systems."
|
||||
cover: "https://media.comfy.org/website/customers/series-entertainment/cover.webp"
|
||||
order: 0
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE OUTPUT"
|
||||
- id: topic-3
|
||||
label: "THE PROBLEM"
|
||||
- id: topic-4
|
||||
label: "THE SOLUTION"
|
||||
- id: topic-5
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-6
|
||||
label: "CONCLUSION"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Series Entertainment builds story-driven games and short-form video experiences where characters, emotion, and visual consistency matter. As the scope of their work expanded across internal projects, partner collaborations, and Netflix titles, the team faced a growing challenge: they needed to produce more content, across more projects, without slowing down or losing consistency.
|
||||
|
||||
To meet that challenge, Series leveraged ComfyUI to scale their workflows. By building custom, repeatable workflows on top of ComfyUI, Series changed how they create characters, emotions, and video. The result was a scalable production system that supported over 100,000 assets, shipped Netflix games, and continues to power multiple projects in active development.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/series.webp" alt="Series Entertainment game titles including Olympus Rising, Gilded Scales, Evergrove, and The Wandering Teahouse" caption="Series Entertainment produces story-driven games and video experiences across multiple titles and visual styles." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="The Output Series Achieved Using ComfyUI">
|
||||
|
||||
With ComfyUI integrated into its production workflows, Series achieved:
|
||||
|
||||
- 100,000+ assets generated across games and video
|
||||
- 180× faster production speed
|
||||
- Six distinct character emotions generated in seconds
|
||||
- 15 minutes of final video per creator per week
|
||||
- Multiple Netflix titles shipped, with many more experiences in active development
|
||||
|
||||
These outputs span character assets, emotional variations, background consistency, and short-form video — all created through repeatable ComfyUI-powered workflows.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="The Problem Series Was Trying to Solve">
|
||||
|
||||
Series' work depends on expressive characters and consistent visual identity. As projects grew in size and complexity, the team needed a way to scale content creation without breaking timelines.
|
||||
|
||||
Traditional animation workflows rely on manual keyframing, multiple disconnected tools, and long production cycles that can stretch into weeks per video. Producing variations often means redoing work from scratch, and experimentation can be slow and expensive.
|
||||
|
||||
Series needed workflows that could be reused across teams and projects, while still supporting emotional storytelling, character consistency, and fast iteration.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="How Series Used ComfyUI to Solve the Problem">
|
||||
|
||||
Series rebuilt their production process around ComfyUI's node-based workflow system. Instead of treating generation as a one-off step, they treated workflows as long-term production assets. ComfyUI became the place where creative structure lived — from character creation to emotion generation to video output.
|
||||
|
||||
### Emotion Generation at Scale
|
||||
|
||||
Series built a custom avatar system using ComfyUI that generates six distinct emotions in seconds: Happy, Sad, Serious, Snarky, Thinking, and Surprised. This made it possible to create expressive characters with multiple emotional states without manually recreating each variation.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/panel.webp" alt="ComfyUI Expression Editor node for facial expression manipulation" caption="The Expression Editor node in ComfyUI enables fine-grained control over character emotions." />
|
||||
|
||||
### Replicable Pipelines from Test to Production
|
||||
|
||||
Using ComfyUI's modular node system, Series built four streamlined pipelines that support the full production cycle — from early exploration to final output. These workflows deliver results up to **180× faster** than traditional manual processes that can take six hours or more per asset, while maintaining production quality.
|
||||
|
||||
The pipelines range from quick 512×512 single-emotion tests to high-resolution batch generation, allowing teams to experiment quickly and move directly into production using the same workflows.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/workflows.webp" alt="ComfyUI workflow for facial expression manipulation and upscaling pipeline" caption="A ComfyUI workflow showing parallel expression editing, upscaling, and face detailing pipelines." />
|
||||
|
||||
### Consistency Across Games and Branching Stories
|
||||
|
||||
For multiple Netflix titles, Series used ComfyUI to build workflows that keep characters and backgrounds consistent across complex, branching narratives. Styling and consistency pipelines help ensure that characters stay visually aligned across scenes, emotions, and story paths — even as asset counts grow.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/consistency.webp" alt="Consistent character across multiple scenes and emotional states" caption="A single character maintained across six different scenes and emotional states using ComfyUI consistency pipelines." />
|
||||
|
||||
### Production at Scale with ComfyUI
|
||||
|
||||
Series also uses ComfyUI as part of an AI-assisted animation pipeline that connects story development directly to image and video generation. This pipeline includes bot-assisted video generation, allowing creators to repeatedly run the same workflows to produce video efficiently. Using this approach, each creator can generate Lorespark videos at scale, delivering over **15 minutes of final video per week**.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/batch.webp" alt="ComfyUI batch processing workflow using Nano Banana and Google Gemini" caption="A batch processing workflow connecting multiple character images to Nano Banana for style-consistent generation." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="Why ComfyUI Worked for Series">
|
||||
|
||||
ComfyUI worked well because its node-based structure makes workflows explicit and reusable — once a workflow is built, it can be refined and shared across projects. This allowed Series to turn video generation into a repeatable system rather than a one-off process.
|
||||
|
||||
Batch execution and bot integration allow those workflows to run at scale. Because the same workflows support both low-resolution testing and high-resolution final output, teams can move from exploration to delivery without switching tools or rebuilding pipelines.
|
||||
|
||||
Most importantly, ComfyUI let Series focus on building structure instead of relying on trial-and-error prompting. Emotions, consistency, and production logic live inside the workflows themselves.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/scale.webp" alt="Six variations of the same character generated with consistent style" caption="Multiple pose and expression variations of a single character, generated at scale while maintaining visual consistency." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="Conclusion">
|
||||
|
||||
By making ComfyUI a core creative platform, Series Entertainment transformed how it produces games and video. What started as a need for scale and consistency became a workflow-driven production system that supports emotional storytelling, large asset volumes, and ongoing development across multiple teams.
|
||||
|
||||
<Quote name="Series Entertainment">For Series, ComfyUI is not an experiment. It is how entertainment gets made.</Quote>
|
||||
|
||||
</Section>
|
||||
101
apps/website/src/content/customers/en/ubisoft-chord.mdx
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "Ubisoft Open-Sources the CHORD Model with ComfyUI for AAA PBR Material Generation"
|
||||
category: "AAA GAME PRODUCTION"
|
||||
description: "Ubisoft La Forge open-sourced its CHORD PBR material estimation model with ComfyUI custom nodes, enabling end-to-end texture generation workflows for AAA game production."
|
||||
cover: "https://media.comfy.org/website/customers/ubisoft/cover.webp"
|
||||
order: 3
|
||||
readMore: "https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE PROBLEM"
|
||||
- id: topic-3
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-4
|
||||
label: "THE PIPELINE"
|
||||
- id: topic-5
|
||||
label: "TRY IT"
|
||||
- id: topic-6
|
||||
label: "RESULTS"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Ubisoft La Forge has open-sourced its PBR material estimation model, **CHORD (Chain of Rendering Decomposition)**, together with **ComfyUI-Chord** custom node implementation to build an end-to-end material generation workflow with AI.
|
||||
|
||||
The model weights and code are released with a Research-Only license. Beyond research, this is a significant step toward integrating ComfyUI into AAA-scale video game production workflows.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/cover.webp" alt="CHORD PBR material generation in ComfyUI" caption="PBR materials generated using the CHORD model in ComfyUI." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="PBR Material Production in AAA Games Today">
|
||||
|
||||
In AAA game development, PBR materials are the foundation of visual realism. Large-scale titles require hundreds of reusable materials, each with full Base Color, Normal, Height, Roughness, and Metalness maps that meet strict svBRDF standards.
|
||||
|
||||
Traditionally, these assets are crafted by texture artists using photogrammetry, procedural tools, and extensive manual tuning — making the process time-consuming and highly expertise-dependent.
|
||||
|
||||
Ubisoft's Generative Base Material prototype directly targets this production bottleneck. The ComfyUI workflow outputs PBR texture sets that integrate directly into DCC tools and game engines for prototyping and placeholder assets.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="Why Ubisoft Chose ComfyUI as The Workflow Platform">
|
||||
|
||||
Ubisoft's choice of ComfyUI is rooted in production realities. For large studios, the requirement is not another image generator — it is a controllable and integratable AI workflow platform that can meet the bespoke requirements of game development.
|
||||
|
||||
<Quote name="Ubisoft La Forge Blog">Considering the multi-stage nature of our prototype, ComfyUI provides us with an efficient framework to build integrated workflows doing texture image synthesis, material estimation and material upscaling. This also enables us to leverage state-of-the-art generative models and the powerful features of ComfyUI that provide fine-grain control to creators with ControlNets, image guidance, inpainting, and countless other options.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="3 Stages of The Generative Base Material Pipeline">
|
||||
|
||||
The CHORD model is integrated into a broader pipeline consisting of 3 core stages.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/pipeline.webp" alt="The 3-stage generative base material pipeline" caption="The 3-stage generative base material pipeline: texture generation, CHORD estimation, and upscaling." />
|
||||
|
||||
### Stage 1 — Texture Image Generation
|
||||
|
||||
The first stage generates seamless, tileable 2D textures from text prompts or reference inputs such as lineart and height maps using a custom diffusion model with full conditional control.
|
||||
|
||||
### Stage 2 — CHORD Image-to-Material Estimation
|
||||
|
||||
A single texture is converted into a full set of PBR maps — including Base Color, Normal, Height, Roughness, and Metalness — using chained decomposition, unified multi-modal prediction, and efficient single-step diffusion inference for controllable and scalable results.
|
||||
|
||||
### Stage 3 — Material Upscaling
|
||||
|
||||
Since CHORD operates optimally at 1024 resolution, the third stage applies industrial-grade PBR upscaling. All channels are upscaled by 2x or 4x to produce 2K and 4K texture assets for real-time game production.
|
||||
|
||||
This complete pipeline enables artists to rapidly iterate on ideas and mix and match AI-generated outputs within their existing workflows, lowering the barrier to industrial-grade PBR material creation.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="How to Try CHORD in ComfyUI">
|
||||
|
||||
Ubisoft has open-sourced the CHORD model weights, ComfyUI custom nodes, and example workflows covering the texture image generation stage and the image-to-material estimation stage of the pipeline.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/workflow.webp" alt="CHORD example workflow in ComfyUI" caption="The CHORD example workflow in ComfyUI for end-to-end PBR material generation." />
|
||||
|
||||
<Steps items={["Install or update ComfyUI to the latest version","Install the CHORD ComfyUI custom node from Ubisoft","Download the CHORD model and place it in ./ComfyUI/models/checkpoints","Load the CHORD example workflow in ComfyUI"]} />
|
||||
|
||||
You can switch the texture image generation model to any other image model, and use the workflow modules for each stage separately.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="Example Outputs">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example1.webp" alt="CHORD PBR material example output 1" caption="Generated PBR material set showing Base Color, Normal, Height, Roughness, and Metalness maps." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example2.webp" alt="CHORD PBR material example output 2" caption="Another generated PBR material set demonstrating the variety of textures achievable with CHORD." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example3.webp" alt="CHORD PBR material example output 3" caption="Material generation output with full PBR channel decomposition." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example4.webp" alt="CHORD PBR material example output 4" caption="High-quality PBR texture set generated from a single input texture." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example5.webp" alt="CHORD PBR material example output 5" caption="Final rendered PBR material demonstrating production-ready quality." />
|
||||
|
||||
The release of CHORD demonstrates how ComfyUI has grown from a community-driven tool into a platform for real production. Studio users can build end-to-end pipelines from prompt or reference input through texture generation, material estimation, PBR upscaling, and finally export to DCC tools or game engines. Each stage can also operate independently and be embedded into an existing production system.
|
||||
|
||||
<Contributors label="AUTHOR" people={[{"name":"Jo Zhang","role":"ComfyUI Blog"},{"name":"Daxiong (Lin)","role":"ComfyUI Blog"}]} />
|
||||
|
||||
</Section>
|
||||
106
apps/website/src/content/customers/zh-CN/groove-jones.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Groove Jones 如何借助 Comfy 为 Dick's Sporting Goods 打造节日 FOOH 营销"
|
||||
category: "案例研究"
|
||||
description: "达拉斯创意工作室 Groove Jones 借助 Comfy,在紧迫的节日档期内为 Crocs x NFL 联名系列交付了超写实的 FOOH 营销内容。"
|
||||
cover: "https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp"
|
||||
order: 4
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "交付成果"
|
||||
- id: topic-3
|
||||
label: "挑战"
|
||||
- id: topic-4
|
||||
label: "Comfy 如何解决问题"
|
||||
- id: topic-5
|
||||
label: "品牌定制 LORA"
|
||||
- id: topic-6
|
||||
label: "多模型编排"
|
||||
- id: topic-7
|
||||
label: "流水线"
|
||||
- id: topic-8
|
||||
label: "版本管理"
|
||||
- id: topic-9
|
||||
label: "Nuke 终修"
|
||||
- id: topic-10
|
||||
label: "总结"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
位于达拉斯的创意工作室 Groove Jones,为众多大牌客户打造由 AI 驱动的营销活动和沉浸式体验,需要同时兼顾照片级的精细度、创意野心,以及适配社交媒体的交付速度。随着他们为 Crocs、NFL 和 Dick’s Sporting Goods 等客户的工作扩展到 AI 视频、AR、VR 和 WebGL,他们反复遇到同一个挑战:用商业项目的工期和预算,交付电影级的 VFX 质量。
|
||||
|
||||
在 Crocs x NFL 联名系列的节日上市项目中,这个挑战被推到了极致。Brief 要求制作超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入多个真实的 Dick’s Sporting Goods 停车场,并要在紧迫的节日档期前交付。实地拍摄加传统 CG 流水线的方案,已经完全行不通。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="Groove Jones 借助 Comfy 实现的交付成果">
|
||||
|
||||
- 在紧迫的节日档期内交付完整的 FOOH(虚构户外广告)社媒营销活动
|
||||
- 超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入 Dick’s Sporting Goods 停车场
|
||||
- 面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 竖屏 2K 交付物
|
||||
- 客户反馈当天迭代,不再需要数周的资产更新周期
|
||||
- 荣获 2024 年 Aaron Awards:最佳 AI 制作工作流奖
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="Groove Jones 试图解决的问题">
|
||||
|
||||
按照传统流水线做这个创意,意味着要在多家门店实地拍摄,加上完整的 CG 制作:每支球队鞋款的高精建模、look development、灯光、渲染、合成,客户每次想要新变体都要重新渲染。这也意味着庞大的团队(建模师、纹理师、灯光师、合成师),以及以"月"为单位的工期。无论是预算还是节日档期,都无法支撑这条路径。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Groove Jones 如何用 Comfy 解决问题">
|
||||
|
||||
Groove Jones 的高级创意技术总监 Doug Hogan 围绕 Comfy 的节点式工作流系统重新搭建了制作流程,并基于他们自研的 GrooveTech GenVFX 流水线展开。自定义 LoRA 负责保证品牌一致性,一张 Comfy 图编排多个生成模型,Nuke 负责最终精修。对于有电影和广告制作背景的团队,这套环境上手没有任何门槛。
|
||||
|
||||
<Quote name="Doug Hogan | Groove Jones 高级创意技术总监">Comfy 用起来非常像传统 CG 和合成流水线:节点逻辑、清晰的数据流、模块化构建。我们的艺术家用起来毫无违和感。</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="为主视觉资产定制的品牌 LoRA">
|
||||
|
||||
Groove Jones 基于 Crocs NFL 球队联名鞋款和 Dick’s Sporting Goods 门店外景训练了定制 LoRA,让每一次生成都能锚定品牌精准的参考素材。真实的球队配色、产品轮廓和门店外观在不同镜头之间保持一致,不需要逐帧修正——而这通常意味着数周的 look development 工作量。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp" alt="通过定制 LoRA 生成的多支 NFL 球队联名 Crocs 网格" caption="通过定制 LoRA 生成的、与品牌精准一致的 NFL 球队配色。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="单张图内的多模型编排">
|
||||
|
||||
这个创意在不同阶段需要不同的生成模型:Flux 用于关键帧静帧开发,Gemini Flash 2.5(Nano Banana)用于快速构思和变体生成,Veo 3.1 加上 Moonvalley 的 Marey 用于最终的视频生成。Comfy 在一张图里就把这四个模型串起来,前一个模型的输出直接喂给下一个模型,全程无需切换环境。
|
||||
|
||||
<Quote name="Dale Carman | Groove Jones 联合创始人">Comfy 社区几乎是指数级增长的,我们可以直接利用社区已有的节点和工具去解决非常具体的制作问题,而不必自己重新造轮子。</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="从故事板到 Previz 再到成片,全部在一条流水线内">
|
||||
|
||||
工作流从传统故事板开始用于叙事确认,再进入 CGI blocking,锁定构图、镜头取景和叙事节奏。从这里开始 Comfy 接管生成:鞋款空投、停车场反应镜头、人群覆盖、把夏季静态门店外景转换成被雪覆盖的节日场景——全部在同一张图里完成。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp" alt="Crocs x NFL 节日营销的故事板网格" caption="在生成之前用于锁定叙事节奏的灰度故事板。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp" alt="从 blocking 到中间渲染再到最终镜头的构图演进" caption="构图演进:线框 blocking、中间渲染、最终成片。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-8" title="把工作流文件当作版本管理">
|
||||
|
||||
每个镜头的每个变体都以 Comfy 工作流文件的形式存在,文件本身就是版本管理。当客户反馈要求换一支球队配色、换一个门店外景或者换一个时间段时,团队只需复制一个分支,而不是重建——这才让"当天迭代"成为可能。GPU 使用量和 API 额度消耗也都能在同一个环境里追踪到,让制作部门实时看到每次迭代的算力成本。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-9" title="在 Nuke 中完成终修">
|
||||
|
||||
生成的镜头进入 Nuke 完成最终合成:飘雪、镜头抖动、人群环境音、节日氛围音效,以及面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 2K 母带。由于 Comfy 把生成环节处理得很干净,Nuke 可以专注于精修和动态增强,而不是去修补生成模型留下的瑕疵。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-10" title="结语">
|
||||
|
||||
通过在 Comfy 中搭建整套 FOOH 流水线,Groove Jones 把一个原本需要昂贵实地拍摄加数月 CG 制作的项目,变成了一套高速迭代、单一环境、客户可以实时指挥的工作流。该项目近期还荣获 Aaron Award 的"最佳 AI 制作工作流"奖。
|
||||
|
||||
<Quote name="Dale Carman | Groove Jones 联合创始人">在 Groove Jones,我们非常在意交付让人说"WOW!"的作品,但我们同样在意按时按预算交付。VFX 项目以前的利润率薄得像刀刃,Comfy 帮我们彻底解决了这个问题。</Quote>
|
||||
|
||||
</Section>
|
||||
156
apps/website/src/content/customers/zh-CN/moment-factory.mdx
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: "Moment Factory 如何使用 ComfyUI 在建筑尺度重新定义 3D 投影映射"
|
||||
category: "案例研究"
|
||||
description: "Moment Factory 使用 ComfyUI 重新定义了 3D 投影映射管线,通过 AI 驱动的内容生成和实时迭代,实现建筑尺度的视觉体验。"
|
||||
cover: "https://media.comfy.org/website/customers/moment-factory/cover.webp"
|
||||
order: 2
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "使用前"
|
||||
- id: topic-3
|
||||
label: "发生了什么变化?"
|
||||
- id: topic-4
|
||||
label: "为什么 ComfyUI 至关重要"
|
||||
- id: topic-5
|
||||
label: "总结"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
如何让生成式 AI 在建筑尺度下发挥作用?Moment Factory 使用 ComfyUI 从根本上改变了他们在建筑投影映射中处理早期概念、外观开发和设计探索的方式。
|
||||
|
||||
在使用 ComfyUI 之前,这一阶段更慢、更抽象,风险也更大。使用 ComfyUI 之后,它变得更快、更具体,从一开始就在空间上有了坚实的基础。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/hero.webp" alt="Moment Factory 建筑投影映射" caption="Moment Factory 的拱形室内建筑投影。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="使用 ComfyUI 之前:迭代缓慢、决策抽象、风险滞后">
|
||||
|
||||
早期概念和外观开发传统上依赖于:
|
||||
|
||||
- 静态草图
|
||||
- 参考资料集
|
||||
- 情绪板
|
||||
- 关于意图的抽象讨论
|
||||
|
||||
对于建筑投影映射来说,这带来了一个问题。在实际投影到建筑上之前,你无法真正知道某个方案是否可行。接缝、像素密度、空间偏移和构图问题通常在流程后期才暴露出来,而此时的修改对制作的影响是巨大的。
|
||||
|
||||
传统上,这意味着:
|
||||
|
||||
- 探索的方向更少
|
||||
- 反复沟通的周期更长
|
||||
- 创意决策缺乏空间验证
|
||||
- 风险被推迟到制作阶段
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="使用 ComfyUI 后发生了什么变化">
|
||||
|
||||
Moment Factory 构建了自定义的 ComfyUI 工作流,并将其用于增强和加速早期概念草图、外观开发探索以及部分设计阶段。
|
||||
|
||||
他们不仅仅是生成图像,而是改变了决策方式。
|
||||
|
||||
### 1. 迭代不再是瓶颈
|
||||
|
||||
ComfyUI 改变了迭代过程,使其更快、更精准、更有目的性。基于真实的制作参数,他们探索了:
|
||||
|
||||
- 20 多个主要艺术方向
|
||||
- 每个方向 20 到 40 次迭代
|
||||
- 风格从超写实到插画版画不等
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/variations.webp" alt="生成的艺术变体网格" caption="探索不同艺术方向的生成变体网格。" />
|
||||
|
||||
工作室通过批处理和参数调整快速推进,同时有意地对系统进行压力测试以了解其极限。
|
||||
|
||||
<Quote name="Guillaume Borgomano | Moment Factory 高级多媒体总监 & 创新创意负责人">使用任何生成式 AI 工具,都很容易过度迭代,认为最佳结果总是只差一次点击。施加真实的制作约束,无论是财务上还是时间上的,对于确保这些探索保持有意义并真正影响我们的管线至关重要。</Quote>
|
||||
|
||||
在他们之前的工作流中,如此大量的探索是不现实的。
|
||||
|
||||
### 2. 概念工作从数天缩短到数小时
|
||||
|
||||
最大的加速发生在早期阶段。通常需要在静态概念和参考资料集之间来回数天的工作,现在可以在几个小时内完成。
|
||||
|
||||
他们有意生成约 2K 的低分辨率输出,快速审查,甚至在现场实时生成新的变体。这些输出可以在几分钟后直接在媒体服务器时间线中查看。
|
||||
|
||||
这个低分辨率阶段不是关于打磨,而是关于验证和决策。仅这一转变就改变了整个项目的节奏。
|
||||
|
||||
### 3. 空间可信度优先,而非滞后
|
||||
|
||||
这之所以有效的一个主要原因是,每次生成已经在空间上受到约束。Moment Factory 围绕建筑表面模板构建了整个工作流,因此输出从一开始就是预映射的。管线同时支持多种模板类型,包括平面 UV、360 布局和相机投影设置。
|
||||
|
||||
ControlNet 将这些模板的结构信息直接注入扩散过程,提前强制执行比例、布局和空间逻辑。
|
||||
|
||||
因此,视觉效果在概念阶段就已经具有空间可信度。抽象的意图转变为共享的参考点。团队可以对有据可依的东西做出反应,而不是想象它以后可能的样子。
|
||||
|
||||
### 4. 审批不再意味着重新开始
|
||||
|
||||
一旦方向获批,工作流不会重置。他们可以:
|
||||
|
||||
- 局部修复特定区域
|
||||
- 保留构图
|
||||
- 在约 20 分钟内将选定的输出放大到 18K
|
||||
|
||||
这完全改变了创意从概念到投影就绪内容的速度。以前,审批通常意味着重新制作。有了 ComfyUI,审批意味着继续推进。
|
||||
|
||||
### 5. 更少的人,更好的协作
|
||||
|
||||
一旦系统稳定,一名主要艺术家在 ComfyUI 中操作。在此设置周围,另外两名团队成员持续参与艺术指导、提示词调优、选择和对齐讨论。
|
||||
|
||||
他们必须定义新的工作方法以保持创意意图在核心位置,但在实践中,ComfyUI 作为共享的探索工具运作,而非单独的技术设置。
|
||||
|
||||
### 6. 不可否认的时刻
|
||||
|
||||
在 Moment Factory 的创新团队中,这在早期就感觉像是一个突破——这种程度的可塑性和控制力在更僵化的工具中根本无法实现。但真正的转折点出现在百老汇 25 号的一次现场演示中。在流程后期,Moment Factory 更换了表面模板,并重新运行了整个管线,没有重新制作任何资产。构图保持不变,空间逻辑完好无损。内容直接进入媒体服务器时间线。
|
||||
|
||||
全场安静了。
|
||||
|
||||
在那一刻,它不再是一个有前景的实验,而成为一种共识。人们不再问"如果怎样"——他们在问如何编写提示词,以及它还能应用在哪些场景中。
|
||||
|
||||
那时它变得不可否认:这不仅仅是研发的强大工具,而是 Moment Factory 各团队思考、迭代和制作方式的一次转变。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/demo.webp" alt="Moment Factory 现场投影映射演示" caption="建筑尺度投影映射的室内观众视角。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="为什么 ComfyUI 在建筑尺度至关重要">
|
||||
|
||||
Moment Factory 多年来一直在探索基于扩散的投影映射工作流。目标很明确:将生成系统不仅用于图像,还作为复杂大规模环境中的结构化空间素材。
|
||||
|
||||
然而,建筑尺度所要求的不仅仅是图像生成,还需要:
|
||||
|
||||
- 对空间条件的精确控制
|
||||
- 将 UV 布局和深度约束直接注入推理的能力
|
||||
- 不破坏构图的快速模板切换
|
||||
- 无需从头重建的迭代优化
|
||||
- 可以随约束变化而发展的管线
|
||||
|
||||
这种程度的结构可塑性是必不可少的。
|
||||
|
||||
ComfyUI 基于节点的架构使团队能够设计和重塑工作流本身,而不仅仅是输出。条件逻辑、批处理策略、模板输入和放大阶段可以随着项目的发展而重新配置。
|
||||
|
||||
项目无需适应工具,工具可以适应建筑。
|
||||
|
||||
在那一刻变得清晰:实现可靠的建筑尺度生成式工作流需要一个足够灵活的系统,可以在创意过程中被重新构建。ComfyUI 提供了这种灵活性。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/workflow.webp" alt="ComfyUI 基于节点的工作流" caption="Moment Factory 使用的 ComfyUI 基于节点工作流截图。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="总结">
|
||||
|
||||
ComfyUI 没有做出创意决策。愿景始终是人类的。约束是建筑性的,期望从一开始就是制作级别的。
|
||||
|
||||
ComfyUI 带来的是结构灵活性。它允许工作流本身随着项目的发展而被塑造和重塑。空间输入可以直接注入推理。模板可以在不破坏构图的情况下切换。优化可以在不重建整个方向的情况下进行。
|
||||
|
||||
生成系统不再像黑箱一样运作,而开始像可控材料一样行为。空间逻辑被提前嵌入,扩展到建筑分辨率成为一个可管理的步骤,而非赌博。
|
||||
|
||||
影响不仅仅是速度。决策可以更早地得到验证,直接针对几何形状和投影条件。空间对齐成为概念开发的一部分,而不是后期修正。这种转变减少了进入制作前的不确定性。
|
||||
|
||||
从这个意义上说,ComfyUI 不仅加速了探索,还使建筑尺度的生成式工作流在真实制作约束下具有结构可行性。
|
||||
|
||||
<Contributors label="MOMENT FACTORY 贡献者" people={[{"name":"Guillaume Borgomano","role":"高级多媒体总监 & 创新创意负责人"},{"name":"Conner Tozier","role":"首席动效设计师 & 生成式 AI 负责人"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "Doodles、SYSTMS 和 ComfyUI 等开源工具如何重写艺术家的规则"
|
||||
category: "开源 × 品牌"
|
||||
description: "Doodles 和 SYSTMS 在包括 ComfyUI 在内的开源基础设施上构建了 Doodles AI——一个由 PRISM 1.0 驱动的生成平台,证明了开源工作流可以支撑品牌级、商业成功的产品。"
|
||||
cover: "https://media.comfy.org/website/customers/open-story-movement/cover.webp"
|
||||
order: 1
|
||||
readMore: "https://blog.comfy.org/p/how-open-source-is-fueling-the-open"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "无墙 IP"
|
||||
- id: topic-3
|
||||
label: "最后一英里"
|
||||
- id: topic-4
|
||||
label: "编码 DNA"
|
||||
- id: topic-5
|
||||
label: "要点"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Doodles 是一个围绕加拿大插画师 Scott Martin(又名 Burnt Toast)标志性柔和色彩作品构建的娱乐品牌,即将推出 **Doodles AI**——一个由 **PRISM 1.0** 驱动的生成平台,这是一个基于 Doodles 大量作品训练的生成图像模型,能够以标志性的 Doodles 视觉语言重新想象人物和物体。
|
||||
|
||||
幕后的工程由 **SYSTMS** 负责,这是一家 AI 工作室,其口号"Engineering the Impossible"反映了他们使用开源基础设施构建定制创意管线的方法,包括像 ComfyUI 这样的基于节点的工作流工具。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/cover.webp" alt="由 PRISM 1.0 驱动的 Doodles AI 生成平台" caption="Doodles AI 平台以 Doodles 视觉语言重新想象人物和物体。" />
|
||||
|
||||
这些部分如何整合在一起的故事,为关注开源、AI、艺术家驱动品牌以及 Doodles 团队所称的"开放叙事"这一新兴概念交汇点的所有人提供了一个引人注目的蓝图。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="无墙 IP">
|
||||
|
||||
艺术家传统上一直保护自己的知识产权,这有充分的理由。但 Doodles 团队正在探索一种新模式,社区不仅仅是消费品牌——他们共同创造品牌。用户在 Doodles AI 平台上生成的每一次创作都会使模型更强大。
|
||||
|
||||
通过强化学习,用户生成的内容成为 PRISM 未来迭代的训练数据的一部分。用户不仅仅是客户;他们是塑造品牌视觉 DNA 的协作者。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/walls.webp" alt="Doodles 社区共创" caption="用户成为协作者,通过 AI 生成的内容共同创造 Doodles 品牌。" />
|
||||
|
||||
正如 Scott Martin 在 2025 年初重新担任 CEO 时所说,目标是重新校准——创意优先、社区为中心、艺术驱动一切。Martin 在 2021 年与 Evan Keast 和 Jordan Castro 共同创立 Doodles 之前,曾与 Google、Snapchat、Dropbox 和 Adobe 合作建立了自己的插画师职业生涯,他深谙这个等式的商业和艺术两面。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="最后一英里就是整个游戏">
|
||||
|
||||
Doodles AI 代表着一种强大的证明:开源工具可以驱动商业成功、品牌级品质的产品。
|
||||
|
||||
SYSTMS 团队以最原始的形式使用开源工具,在该领域的最前沿优先考虑控制和创新。这些工具现在能够生成具有品牌保真度的输出,使 Doodles 区别于 MidJourney 或 Sora 等通用平台,这一点意义重大。这就是创意 AI 中的"最后一英里"问题——从 85% 到 100% 的保真度——也是真正价值所在。
|
||||
|
||||
Doodles AI 展示了当开源工作流遇上专业创意方向时的可能性。ComfyUI 强大的基于节点的平台允许用户将开源模型、API 和其他工具的复杂系统打包成面向消费者的应用程序,使其成为此类项目的天然选择。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/workflow.webp" alt="驱动 Doodles AI 的 ComfyUI 工作流" caption="开源工作流驱动品牌级生成输出。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="编码 DNA">
|
||||
|
||||
Doodles AI 以 PRISM 1.0 作为图像到图像模型推出,但路线图雄心勃勃:2D 和 3D 输出生成、带声音的视频、实时 AR 和游戏应用。原始 Doodles 持有者在发布当天获得 100 次免费生成——这是一个有意识的举措,旨在为社区注入活力,让他们用平台的输出刷遍每一条时间线。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/dna.webp" alt="Doodles AI 输出示例" caption="Doodles AI 输出展示品牌保真的生成结果。" />
|
||||
|
||||
更深层的布局是与整个 AI 行业的速度和规模保持一致。通过在开源基础设施上构建并培育共创者社区,Doodles 已将自己定位为可以将其"编码 DNA"接入尚未存在的未来技术。这是一个赌注:开放性——开源、开放叙事、开放创造——不仅在哲学上有吸引力,而且在战略上是明智的。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="对艺术家意味着什么">
|
||||
|
||||
对于在场外观望的艺术家来说,信息很明确:构建模块已经就位,社区正在建设,创作者和消费者之间的界限正在消失。问题不在于开源是否会重塑创意产业。而在于当它发生时,你是否在用它构建。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/output.webp" alt="Doodles AI 创意输出" caption="开源工具大规模驱动品牌级创意输出。" />
|
||||
|
||||
<Contributors label="链接" people={[{"name":"Doodles: doodles.app | SYSTMS: systms.ai | ComfyUI: comfy.org","role":"官方网站"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Series Entertainment 如何使用 ComfyUI 重塑游戏和视频制作"
|
||||
category: "游戏与视频制作"
|
||||
description: "使用可复用的 ComfyUI 生产系统,在 100,000+ 资产和多部 Netflix 作品中实现情感叙事的规模化。"
|
||||
cover: "https://media.comfy.org/website/customers/series-entertainment/cover.webp"
|
||||
order: 0
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "产出成果"
|
||||
- id: topic-3
|
||||
label: "面临的问题"
|
||||
- id: topic-4
|
||||
label: "解决方案"
|
||||
- id: topic-5
|
||||
label: "为何选择 ComfyUI"
|
||||
- id: topic-6
|
||||
label: "总结"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Series Entertainment 构建以故事为驱动的游戏和短视频体验,其中角色、情感和视觉一致性至关重要。随着工作范围扩展到内部项目、合作伙伴协作和 Netflix 作品,团队面临日益增长的挑战:他们需要在更多项目中生产更多内容,同时不能放慢速度或失去一致性。
|
||||
|
||||
为了应对这一挑战,Series 利用 ComfyUI 扩展了工作流。通过在 ComfyUI 之上构建自定义的可复用工作流,Series 改变了创建角色、情感和视频的方式。最终打造出一个支持超过 100,000 个资产、交付 Netflix 游戏并持续为多个在研项目提供动力的可扩展生产系统。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/series.webp" alt="Series Entertainment 游戏作品,包括 Olympus Rising、Gilded Scales、Evergrove 和 The Wandering Teahouse" caption="Series Entertainment 制作跨多个作品和视觉风格的故事驱动游戏和视频体验。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="Series 使用 ComfyUI 达成的产出成果">
|
||||
|
||||
将 ComfyUI 集成到生产工作流后,Series 实现了:
|
||||
|
||||
- 在游戏和视频中生成超过 100,000 个资产
|
||||
- 180 倍的生产速度提升
|
||||
- 数秒内生成六种不同的角色情感
|
||||
- 每位创作者每周生产 15 分钟的最终视频
|
||||
- 多部 Netflix 作品交付,更多体验正在积极开发中
|
||||
|
||||
这些产出涵盖角色资产、情感变体、背景一致性和短视频——全部通过可复用的 ComfyUI 工作流创建。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="Series 试图解决的问题">
|
||||
|
||||
Series 的工作依赖于富有表现力的角色和一致的视觉标识。随着项目规模和复杂度的增长,团队需要一种在不打破时间线的前提下扩展内容创作的方法。
|
||||
|
||||
传统动画工作流依赖手动关键帧、多个断开的工具和漫长的制作周期——每个视频可能需要数周。制作变体通常意味着从头返工,实验过程缓慢且昂贵。
|
||||
|
||||
Series 需要能够在团队和项目间复用的工作流,同时仍然支持情感叙事、角色一致性和快速迭代。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Series 如何使用 ComfyUI 解决问题">
|
||||
|
||||
Series 围绕 ComfyUI 的节点式工作流系统重建了制作流程。他们不再将生成视为一次性步骤,而是将工作流作为长期生产资产。ComfyUI 成为了创意结构的所在——从角色创建到情感生成再到视频输出。
|
||||
|
||||
### 规模化情感生成
|
||||
|
||||
Series 使用 ComfyUI 构建了一个自定义头像系统,可在数秒内生成六种不同的情感:开心、悲伤、严肃、讽刺、思考和惊讶。这使得创建具有多种情感状态的表现力角色成为可能,而无需手动重新创建每个变体。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/panel.webp" alt="ComfyUI 表情编辑器节点,用于面部表情操控" caption="ComfyUI 中的表情编辑器节点实现了对角色情感的精细控制。" />
|
||||
|
||||
### 从测试到生产的可复用管线
|
||||
|
||||
利用 ComfyUI 的模块化节点系统,Series 构建了四条精简管线,支持从早期探索到最终输出的完整生产周期。这些工作流的效率比传统手工流程(每个资产可能需要六小时以上)**提高了 180 倍**,同时保持生产品质。
|
||||
|
||||
管线范围从快速的 512×512 单情感测试到高分辨率批量生成,使团队能够快速实验并使用相同的工作流直接进入生产。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/workflows.webp" alt="ComfyUI 面部表情操控和放大管线工作流" caption="ComfyUI 工作流展示了并行的表情编辑、放大和面部细化管线。" />
|
||||
|
||||
### 跨游戏和分支叙事的一致性
|
||||
|
||||
在多部 Netflix 作品中,Series 使用 ComfyUI 构建了工作流,确保角色和背景在复杂的分支叙事中保持一致。风格化和一致性管线帮助确保角色在场景、情感和故事路径之间保持视觉统一——即使资产数量不断增长。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/consistency.webp" alt="角色在多个场景和情感状态中保持一致" caption="使用 ComfyUI 一致性管线在六个不同场景和情感状态中保持同一角色。" />
|
||||
|
||||
### 使用 ComfyUI 实现规模化生产
|
||||
|
||||
Series 还将 ComfyUI 作为 AI 辅助动画管线的一部分,将故事开发直接连接到图像和视频生成。该管线包含机器人辅助视频生成,允许创作者反复运行相同的工作流以高效生产视频。使用这种方法,每位创作者可以规模化生成 Lorespark 视频,每周交付超过 **15 分钟的最终视频**。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/batch.webp" alt="ComfyUI 使用 Nano Banana 和 Google Gemini 的批处理工作流" caption="批处理工作流将多个角色图像连接到 Nano Banana,实现风格一致的生成。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="为什么 ComfyUI 适合 Series">
|
||||
|
||||
ComfyUI 之所以有效,是因为其节点式结构使工作流显式且可复用——一旦构建了工作流,就可以在项目间优化和共享。这使 Series 能够将视频生成从一次性过程转变为可重复的系统。
|
||||
|
||||
批量执行和机器人集成使这些工作流能够大规模运行。由于相同的工作流同时支持低分辨率测试和高分辨率最终输出,团队可以从探索无缝过渡到交付,无需切换工具或重建管线。
|
||||
|
||||
最重要的是,ComfyUI 让 Series 专注于构建结构,而非依赖试错式提示。情感、一致性和生产逻辑都存在于工作流本身之中。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/scale.webp" alt="以一致风格生成的同一角色的六个变体" caption="同一角色的多个姿态和表情变体,在保持视觉一致性的同时实现规模化生成。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="总结">
|
||||
|
||||
通过将 ComfyUI 作为核心创意平台,Series Entertainment 彻底改变了游戏和视频的制作方式。最初只是对规模和一致性的需求,最终演变成一个以工作流驱动的生产系统,支持情感叙事、大规模资产和多团队的持续开发。
|
||||
|
||||
<Quote name="Series Entertainment">对 Series 来说,ComfyUI 不是实验。它就是娱乐内容的制作方式。</Quote>
|
||||
|
||||
</Section>
|
||||
101
apps/website/src/content/customers/zh-CN/ubisoft-chord.mdx
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "育碧开源 CHORD 模型,通过 ComfyUI 实现 AAA 级 PBR 材质生成"
|
||||
category: "AAA 游戏制作"
|
||||
description: "育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。"
|
||||
cover: "https://media.comfy.org/website/customers/ubisoft/cover.webp"
|
||||
order: 3
|
||||
readMore: "https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "挑战"
|
||||
- id: topic-3
|
||||
label: "为什么选择 ComfyUI"
|
||||
- id: topic-4
|
||||
label: "流水线"
|
||||
- id: topic-5
|
||||
label: "试用"
|
||||
- id: topic-6
|
||||
label: "成果"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
育碧 La Forge 开源了其 PBR 材质估算模型 **CHORD(Chain of Rendering Decomposition)**,以及 **ComfyUI-Chord** 自定义节点实现,用于构建端到端的 AI 材质生成工作流。
|
||||
|
||||
模型权重和代码以仅限研究的许可证发布。除了研究之外,这是将 ComfyUI 集成到 AAA 级视频游戏制作工作流中的重要一步。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/cover.webp" alt="ComfyUI 中的 CHORD PBR 材质生成" caption="使用 ComfyUI 中的 CHORD 模型生成的 PBR 材质。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="当今 AAA 游戏中的 PBR 材质制作">
|
||||
|
||||
在 AAA 游戏开发中,PBR 材质是视觉真实感的基础。大型游戏需要数百种可复用的材质,每种都包含完整的基础颜色、法线、高度、粗糙度和金属度贴图,并须满足严格的 svBRDF 标准。
|
||||
|
||||
传统上,这些资产由纹理艺术家使用摄影测量、程序化工具和大量手动调整来制作——这使得流程耗时且高度依赖专业知识。
|
||||
|
||||
育碧的生成式基础材质原型直接针对这一制作瓶颈。ComfyUI 工作流输出的 PBR 纹理集可直接集成到 DCC 工具和游戏引擎中,用于原型制作和占位资产。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="育碧为何选择 ComfyUI 作为工作流平台">
|
||||
|
||||
育碧选择 ComfyUI 源于生产实际需求。对于大型工作室来说,需要的不是另一个图像生成器——而是一个可控且可集成的 AI 工作流平台,能够满足游戏开发的定制需求。
|
||||
|
||||
<Quote name="育碧 La Forge 博客">考虑到我们原型的多阶段特性,ComfyUI 为我们提供了一个高效的框架来构建集成工作流,涵盖纹理图像合成、材质估算和材质放大。这也使我们能够利用最先进的生成模型和 ComfyUI 的强大功能,通过 ControlNet、图像引导、修复等众多选项为创作者提供精细控制。</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="生成式基础材质流水线的三个阶段">
|
||||
|
||||
CHORD 模型集成在一个更广泛的流水线中,由三个核心阶段组成。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/pipeline.webp" alt="三阶段生成式基础材质流水线" caption="三阶段生成式基础材质流水线:纹理生成、CHORD 估算和放大。" />
|
||||
|
||||
### 阶段一 — 纹理图像生成
|
||||
|
||||
第一阶段使用具有完全条件控制的自定义扩散模型,从文本提示或参考输入(如线稿和高度图)生成无缝、可平铺的 2D 纹理。
|
||||
|
||||
### 阶段二 — CHORD 图像到材质估算
|
||||
|
||||
将单一纹理转换为完整的 PBR 贴图集——包括基础颜色、法线、高度、粗糙度和金属度——使用链式分解、统一多模态预测和高效的单步扩散推理,实现可控且可扩展的结果。
|
||||
|
||||
### 阶段三 — 材质放大
|
||||
|
||||
由于 CHORD 在 1024 分辨率下运行最佳,第三阶段应用工业级 PBR 放大。所有通道放大 2 倍或 4 倍,以生成用于实时游戏制作的 2K 和 4K 纹理资产。
|
||||
|
||||
这条完整的流水线使艺术家能够快速迭代创意,在现有工作流中混合搭配 AI 生成的输出,降低了工业级 PBR 材质创建的门槛。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="如何在 ComfyUI 中试用 CHORD">
|
||||
|
||||
育碧开源了 CHORD 模型权重、ComfyUI 自定义节点和示例工作流,涵盖流水线中的纹理图像生成阶段和图像到材质估算阶段。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/workflow.webp" alt="ComfyUI 中的 CHORD 示例工作流" caption="ComfyUI 中端到端 PBR 材质生成的 CHORD 示例工作流。" />
|
||||
|
||||
<Steps items={["安装或更新 ComfyUI 至最新版本","从育碧安装 CHORD ComfyUI 自定义节点","下载 CHORD 模型并放置在 ./ComfyUI/models/checkpoints 目录","在 ComfyUI 中加载 CHORD 示例工作流"]} />
|
||||
|
||||
您可以将纹理图像生成模型替换为任何其他图像模型,也可以单独使用每个阶段的工作流模块。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="输出示例">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example1.webp" alt="CHORD PBR 材质输出示例 1" caption="生成的 PBR 材质集,展示基础颜色、法线、高度、粗糙度和金属度贴图。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example2.webp" alt="CHORD PBR 材质输出示例 2" caption="另一组生成的 PBR 材质集,展示 CHORD 可实现的多样纹理效果。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example3.webp" alt="CHORD PBR 材质输出示例 3" caption="具有完整 PBR 通道分解的材质生成输出。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example4.webp" alt="CHORD PBR 材质输出示例 4" caption="从单一输入纹理生成的高质量 PBR 纹理集。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example5.webp" alt="CHORD PBR 材质输出示例 5" caption="最终渲染的 PBR 材质,展示可用于生产的质量。" />
|
||||
|
||||
CHORD 的发布表明,ComfyUI 已从一个社区驱动的工具成长为一个真正的生产平台。工作室用户可以构建端到端流水线,从提示或参考输入到纹理生成、材质估算、PBR 放大,最终导出到 DCC 工具或游戏引擎。每个阶段也可以独立运行并嵌入现有的生产系统中。
|
||||
|
||||
<Contributors label="作者" people={[{"name":"Jo Zhang","role":"ComfyUI 博客"},{"name":"Daxiong (Lin)","role":"ComfyUI 博客"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -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,
|
||||
|
||||
2
apps/website/src/env.d.ts
vendored
@@ -1 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ContactSection from '../components/customers/ContactSection.vue'
|
||||
import FeedbackSection from '../components/customers/FeedbackSection.vue'
|
||||
import HeroSection from '../components/customers/HeroSection.vue'
|
||||
import StorySection from '../components/customers/StorySection.vue'
|
||||
import FeedbackSection from '../components/customers/FeedbackSection.vue'
|
||||
import VideoSection from '../components/customers/VideoSection.vue'
|
||||
import ContactSection from '../components/customers/ContactSection.vue'
|
||||
import { toCardProps } from '../utils/customers'
|
||||
import { loadStories } from '../utils/loadStories'
|
||||
|
||||
const stories = (await loadStories('en')).map(toCardProps)
|
||||
---
|
||||
|
||||
<BaseLayout title="Customer Stories — Comfy">
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<StorySection stories={stories} />
|
||||
<FeedbackSection client:load />
|
||||
<VideoSection client:load />
|
||||
<ContactSection />
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CustomerArticle from '../../components/customers/CustomerArticle.astro'
|
||||
import DetailHeroSection from '../../components/customers/DetailHeroSection.vue'
|
||||
import ContentSection from '../../components/common/ContentSection.vue'
|
||||
import WhatsNextSection from '../../components/customers/WhatsNextSection.vue'
|
||||
import { customerStories, getNextStory, getStoryBySlug } from '../../config/customerStories'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { nextStory, storySlug } from '../../utils/customers'
|
||||
import { loadStories } from '../../utils/loadStories'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return customerStories.map((story) => ({
|
||||
params: { slug: story.slug }
|
||||
export async function getStaticPaths() {
|
||||
const stories = await loadStories('en')
|
||||
return stories.map((entry) => ({
|
||||
params: { slug: storySlug(entry.id) },
|
||||
props: { entry, next: nextStory(stories, storySlug(entry.id)) }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const story = getStoryBySlug(slug as string)!
|
||||
const title = t(story.title)
|
||||
const nextStory = getNextStory(slug as string)
|
||||
const { entry, next } = Astro.props
|
||||
---
|
||||
|
||||
<BaseLayout title={`${title} — Comfy`}>
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`}>
|
||||
<DetailHeroSection
|
||||
label={t(story.category)}
|
||||
title={title}
|
||||
description={t(story.body)}
|
||||
image={story.image}
|
||||
/>
|
||||
<ContentSection
|
||||
prefix={story.detailPrefix}
|
||||
readMoreHref={story.readMoreHref}
|
||||
client:load
|
||||
label={entry.data.category}
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
image={entry.data.cover}
|
||||
/>
|
||||
<CustomerArticle entry={entry} />
|
||||
<WhatsNextSection
|
||||
title={t(nextStory.title)}
|
||||
image={nextStory.image}
|
||||
href={`/customers/${nextStory.slug}`}
|
||||
title={next.data.title}
|
||||
image={next.data.cover}
|
||||
href={`/customers/${storySlug(next.id)}`}
|
||||
/>
|
||||
</BaseLayout>
|
||||
|
||||
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>
|
||||
@@ -1,15 +1,19 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ContactSection from '../../components/customers/ContactSection.vue'
|
||||
import FeedbackSection from '../../components/customers/FeedbackSection.vue'
|
||||
import HeroSection from '../../components/customers/HeroSection.vue'
|
||||
import StorySection from '../../components/customers/StorySection.vue'
|
||||
import FeedbackSection from '../../components/customers/FeedbackSection.vue'
|
||||
import VideoSection from '../../components/customers/VideoSection.vue'
|
||||
import ContactSection from '../../components/customers/ContactSection.vue'
|
||||
import { toCardProps } from '../../utils/customers'
|
||||
import { loadStories } from '../../utils/loadStories'
|
||||
|
||||
const stories = (await loadStories('zh-CN')).map(toCardProps)
|
||||
---
|
||||
|
||||
<BaseLayout title="客户故事 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<StorySection stories={stories} locale="zh-CN" />
|
||||
<FeedbackSection locale="zh-CN" client:load />
|
||||
<VideoSection locale="zh-CN" client:load />
|
||||
<ContactSection locale="zh-CN" />
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import CustomerArticle from '../../../components/customers/CustomerArticle.astro'
|
||||
import DetailHeroSection from '../../../components/customers/DetailHeroSection.vue'
|
||||
import ContentSection from '../../../components/common/ContentSection.vue'
|
||||
import WhatsNextSection from '../../../components/customers/WhatsNextSection.vue'
|
||||
import { customerStories, getNextStory, getStoryBySlug } from '../../../config/customerStories'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { nextStory, storySlug } from '../../../utils/customers'
|
||||
import { loadStories } from '../../../utils/loadStories'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return customerStories.map((story) => ({
|
||||
params: { slug: story.slug }
|
||||
export async function getStaticPaths() {
|
||||
const stories = await loadStories('zh-CN')
|
||||
return stories.map((entry) => ({
|
||||
params: { slug: storySlug(entry.id) },
|
||||
props: { entry, next: nextStory(stories, storySlug(entry.id)) }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const story = getStoryBySlug(slug as string)!
|
||||
const title = t(story.title, 'zh-CN')
|
||||
const nextStory = getNextStory(slug as string)
|
||||
const { entry, next } = Astro.props
|
||||
---
|
||||
|
||||
<BaseLayout title={`${title} — Comfy`}>
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`}>
|
||||
<DetailHeroSection
|
||||
label={t(story.category, 'zh-CN')}
|
||||
title={title}
|
||||
description={t(story.body, 'zh-CN')}
|
||||
image={story.image}
|
||||
/>
|
||||
<ContentSection
|
||||
prefix={story.detailPrefix}
|
||||
locale="zh-CN"
|
||||
readMoreHref={story.readMoreHref}
|
||||
client:load
|
||||
label={entry.data.category}
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
image={entry.data.cover}
|
||||
/>
|
||||
<CustomerArticle entry={entry} locale="zh-CN" />
|
||||
<WhatsNextSection
|
||||
title={t(nextStory.title, 'zh-CN')}
|
||||
image={nextStory.image}
|
||||
href={`/zh-CN/customers/${nextStory.slug}`}
|
||||
title={next.data.title}
|
||||
image={next.data.cover}
|
||||
href={`/zh-CN/customers/${storySlug(next.id)}`}
|
||||
locale="zh-CN"
|
||||
/>
|
||||
</BaseLayout>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
128
apps/website/src/utils/customers.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { customerStorySchema } from '../content/customers.schema'
|
||||
import { nextStory, sortStories, storySlug, toCardProps } from './customers'
|
||||
|
||||
const validFrontmatter = {
|
||||
title:
|
||||
'How Series Entertainment Rebuilt Game and Video Production with ComfyUI',
|
||||
category: 'GAME & VIDEO PRODUCTION',
|
||||
description: 'Scaling emotional storytelling across 100,000+ assets.',
|
||||
cover:
|
||||
'https://media.comfy.org/website/customers/series-entertainment/cover.webp',
|
||||
order: 0,
|
||||
sections: [
|
||||
{ id: 'intro', label: 'INTRO' },
|
||||
{ id: 'the-problem', label: 'THE PROBLEM' }
|
||||
]
|
||||
}
|
||||
|
||||
describe('customerStorySchema', () => {
|
||||
it('accepts a complete, valid story frontmatter', () => {
|
||||
expect(customerStorySchema.safeParse(validFrontmatter).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an optional external readMore url', () => {
|
||||
const result = customerStorySchema.safeParse({
|
||||
...validFrontmatter,
|
||||
readMore: 'https://blog.comfy.org/p/example'
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects frontmatter missing a required field', () => {
|
||||
const { title: _title, ...withoutTitle } = validFrontmatter
|
||||
expect(customerStorySchema.safeParse(withoutTitle).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a cover that is not a url', () => {
|
||||
const result = customerStorySchema.safeParse({
|
||||
...validFrontmatter,
|
||||
cover: 'cover.webp'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires each section to declare an id and a label', () => {
|
||||
const result = customerStorySchema.safeParse({
|
||||
...validFrontmatter,
|
||||
sections: [{ id: 'intro' }]
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown frontmatter keys so typos fail the build', () => {
|
||||
const result = customerStorySchema.safeParse({
|
||||
...validFrontmatter,
|
||||
readMoreHref: 'https://blog.comfy.org/p/example'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('storySlug', () => {
|
||||
it('drops the locale prefix from a collection id', () => {
|
||||
expect(storySlug('en/series-entertainment')).toBe('series-entertainment')
|
||||
expect(storySlug('zh-CN/groove-jones')).toBe('groove-jones')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortStories', () => {
|
||||
it('orders stories by their order field ascending', () => {
|
||||
const stories = [
|
||||
{ id: 'en/c', data: { order: 2 } },
|
||||
{ id: 'en/a', data: { order: 0 } },
|
||||
{ id: 'en/b', data: { order: 1 } }
|
||||
]
|
||||
expect(sortStories(stories).map((s) => s.id)).toEqual([
|
||||
'en/a',
|
||||
'en/b',
|
||||
'en/c'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const stories = [
|
||||
{ id: 'en/b', data: { order: 1 } },
|
||||
{ id: 'en/a', data: { order: 0 } }
|
||||
]
|
||||
sortStories(stories)
|
||||
expect(stories.map((s) => s.id)).toEqual(['en/b', 'en/a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextStory', () => {
|
||||
const ordered = [
|
||||
{ id: 'en/a', data: { order: 0 } },
|
||||
{ id: 'en/b', data: { order: 1 } },
|
||||
{ id: 'en/c', data: { order: 2 } }
|
||||
]
|
||||
|
||||
it('returns the following story', () => {
|
||||
expect(nextStory(ordered, 'a').id).toBe('en/b')
|
||||
})
|
||||
|
||||
it('wraps around from the last story to the first', () => {
|
||||
expect(nextStory(ordered, 'c').id).toBe('en/a')
|
||||
})
|
||||
|
||||
it('throws when no story matches the slug', () => {
|
||||
expect(() => nextStory(ordered, 'missing')).toThrow()
|
||||
})
|
||||
|
||||
it('throws when the list is empty', () => {
|
||||
expect(() => nextStory([], 'a')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCardProps', () => {
|
||||
it('maps a story entry to listing-card props', () => {
|
||||
const entry = { id: 'en/series-entertainment', data: validFrontmatter }
|
||||
expect(toCardProps(entry)).toEqual({
|
||||
slug: 'series-entertainment',
|
||||
title: validFrontmatter.title,
|
||||
category: validFrontmatter.category,
|
||||
cover: validFrontmatter.cover
|
||||
})
|
||||
})
|
||||
})
|
||||
48
apps/website/src/utils/customers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
import type { CustomerStoryFrontmatter } from '../content/customers.schema'
|
||||
|
||||
export type CustomerStoryEntry = CollectionEntry<'customers'>
|
||||
|
||||
export function storySlug(id: string): string {
|
||||
const separator = id.indexOf('/')
|
||||
return separator === -1 ? id : id.slice(separator + 1)
|
||||
}
|
||||
|
||||
export function sortStories<T extends { data: { order: number } }>(
|
||||
stories: T[]
|
||||
): T[] {
|
||||
return [...stories].sort((a, b) => a.data.order - b.data.order)
|
||||
}
|
||||
|
||||
export function nextStory<T extends { id: string }>(
|
||||
ordered: T[],
|
||||
slug: string
|
||||
): T {
|
||||
const index = ordered.findIndex((story) => storySlug(story.id) === slug)
|
||||
// Fail loud on a bad slug or empty list rather than silently returning the
|
||||
// first story, which would link to the wrong "what's next" article.
|
||||
if (index === -1) {
|
||||
throw new Error(`nextStory: no story found for slug "${slug}"`)
|
||||
}
|
||||
return ordered[(index + 1) % ordered.length]
|
||||
}
|
||||
|
||||
export interface StoryCard {
|
||||
slug: string
|
||||
title: string
|
||||
category: string
|
||||
cover: string
|
||||
}
|
||||
|
||||
export function toCardProps(entry: {
|
||||
id: string
|
||||
data: CustomerStoryFrontmatter
|
||||
}): StoryCard {
|
||||
return {
|
||||
slug: storySlug(entry.id),
|
||||
title: entry.data.title,
|
||||
category: entry.data.category,
|
||||
cover: entry.data.cover
|
||||
}
|
||||
}
|
||||
17
apps/website/src/utils/loadStories.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getCollection } from 'astro:content'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import type { CustomerStoryEntry } from './customers'
|
||||
import { sortStories } from './customers'
|
||||
|
||||
// Loads a locale's customer stories from the content collection, sorted by the
|
||||
// frontmatter `order`. Centralises the `<locale>/` id-prefix convention so the
|
||||
// listing and detail pages do not each hardcode it.
|
||||
export async function loadStories(
|
||||
locale: Locale
|
||||
): Promise<CustomerStoryEntry[]> {
|
||||
const stories = await getCollection('customers', ({ id }) =>
|
||||
id.startsWith(`${locale}/`)
|
||||
)
|
||||
return sortStories(stories)
|
||||
}
|
||||
BIN
browser_tests/assets/video/video-preview-portrait.webm
Normal file
BIN
browser_tests/assets/video/video-preview-square.webm
Normal file
BIN
browser_tests/assets/video/video-preview-wide.webm
Normal file
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -1,3 +1,4 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -6,72 +7,370 @@ import { VideoPreview } from '@e2e/fixtures/components/VideoPreview'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
const file1 = 'workflow.mp4' as const
|
||||
const file2 = 'workflow.webm' as const
|
||||
const file2 = 'video-preview-wide.webm' as const
|
||||
const file3 = 'video-preview-square.webm' as const
|
||||
const file4 = 'video-preview-portrait.webm' as const
|
||||
const MIN_PREVIEW_FRAME_HEIGHT = 100
|
||||
const CENTER_TOLERANCE_PX = 1
|
||||
const videoShapeFixtures = [
|
||||
[file2, 'landscape'],
|
||||
[file3, 'square'],
|
||||
[file4, 'portrait']
|
||||
] as const
|
||||
|
||||
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
|
||||
const loadVideo = new VideoPreview(loadVideoNode)
|
||||
type ThumbnailShape = (typeof videoShapeFixtures)[number][1]
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
interface VideoPreviewLayout {
|
||||
objectFit: string
|
||||
objectPosition: string
|
||||
wrapperHeight: number
|
||||
wrapperWidth: number
|
||||
wrapperX: number
|
||||
wrapperY: number
|
||||
videoBoxHeight: number
|
||||
videoBoxWidth: number
|
||||
videoIntrinsicHeight: number
|
||||
videoIntrinsicWidth: number
|
||||
videoX: number
|
||||
videoY: number
|
||||
}
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Video')
|
||||
await expect(loadVideoNode).toHaveCount(1)
|
||||
await expect(loadVideoNode).toBeVisible()
|
||||
async function readVideoPreviewLayout(
|
||||
preview: Locator
|
||||
): Promise<VideoPreviewLayout | null> {
|
||||
return await preview.evaluate((previewElement) => {
|
||||
const video = previewElement.querySelector('video')
|
||||
const wrapper = video?.parentElement
|
||||
if (!(video instanceof HTMLVideoElement) || !wrapper) return null
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const videoRect = video.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
objectFit: getComputedStyle(video).objectFit,
|
||||
objectPosition: getComputedStyle(video).objectPosition,
|
||||
wrapperHeight: wrapperRect.height,
|
||||
wrapperWidth: wrapperRect.width,
|
||||
wrapperX: wrapperRect.x,
|
||||
wrapperY: wrapperRect.y,
|
||||
videoBoxHeight: videoRect.height,
|
||||
videoBoxWidth: videoRect.width,
|
||||
videoIntrinsicHeight: video.videoHeight,
|
||||
videoIntrinsicWidth: video.videoWidth,
|
||||
videoX: videoRect.x,
|
||||
videoY: videoRect.y
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await test.step('Upload a video file', async () => {
|
||||
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file1}`))
|
||||
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
|
||||
await expect(loadVideoNode).toContainText(file1)
|
||||
await expect(loadVideo.video).toBeVisible()
|
||||
})
|
||||
async function requireBoundingBox(locator: Locator, subject: string) {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error(`${subject} should have a bounding box`)
|
||||
|
||||
await test.step('Update displayed video', async () => {
|
||||
const initialSrc = await loadVideo.videoSrc()
|
||||
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file2}`))
|
||||
comfyFiles.deleteAfterTest({ filename: file2, type: 'input' })
|
||||
await expect(loadVideoNode).toContainText(file2)
|
||||
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
await test.step('Display multiple videmus', async () => {
|
||||
await expect(loadVideo.navigationDots).toBeHidden()
|
||||
async function expectNodeBoxUnchanged(
|
||||
locator: Locator,
|
||||
before: { height: number; width: number },
|
||||
subject: string
|
||||
) {
|
||||
const after = await requireBoundingBox(locator, subject)
|
||||
expect(
|
||||
Math.abs(after.width - before.width),
|
||||
`${subject} should not change node width`
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
expect(
|
||||
Math.abs(after.height - before.height),
|
||||
`${subject} should not change node height`
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
}
|
||||
|
||||
//forcibly display multiple video files at once
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.evaluate(
|
||||
(names) => {
|
||||
graph!.nodes[0].images.splice(
|
||||
0,
|
||||
1,
|
||||
...names.map((filename) => ({
|
||||
type: 'input',
|
||||
filename,
|
||||
subfolder: ''
|
||||
}))
|
||||
function objectPositionFraction(value: string) {
|
||||
if (value.endsWith('%')) return Number.parseFloat(value) / 100
|
||||
|
||||
switch (value) {
|
||||
case 'left':
|
||||
case 'top':
|
||||
return 0
|
||||
case 'center':
|
||||
return 0.5
|
||||
case 'right':
|
||||
case 'bottom':
|
||||
return 1
|
||||
default:
|
||||
throw new Error(`Unsupported object-position value: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function objectPositionFractions(objectPosition: string) {
|
||||
const [x = '50%', y = '50%'] = objectPosition.split(/\s+/)
|
||||
|
||||
return {
|
||||
x: objectPositionFraction(x),
|
||||
y: objectPositionFraction(y)
|
||||
}
|
||||
}
|
||||
|
||||
function getPaintedVideoRect({
|
||||
objectPosition,
|
||||
videoBoxHeight,
|
||||
videoBoxWidth,
|
||||
videoIntrinsicHeight,
|
||||
videoIntrinsicWidth,
|
||||
videoX,
|
||||
videoY
|
||||
}: VideoPreviewLayout) {
|
||||
const videoAspectRatio = videoIntrinsicWidth / videoIntrinsicHeight
|
||||
const boxAspectRatio = videoBoxWidth / videoBoxHeight
|
||||
const paintedWidth =
|
||||
videoAspectRatio > boxAspectRatio
|
||||
? videoBoxWidth
|
||||
: videoBoxHeight * videoAspectRatio
|
||||
const paintedHeight =
|
||||
videoAspectRatio > boxAspectRatio
|
||||
? videoBoxWidth / videoAspectRatio
|
||||
: videoBoxHeight
|
||||
const position = objectPositionFractions(objectPosition)
|
||||
|
||||
return {
|
||||
height: paintedHeight,
|
||||
width: paintedWidth,
|
||||
x: videoX + (videoBoxWidth - paintedWidth) * position.x,
|
||||
y: videoY + (videoBoxHeight - paintedHeight) * position.y
|
||||
}
|
||||
}
|
||||
|
||||
function expectAspectRatioMatchesShape(
|
||||
aspectRatio: number,
|
||||
shape: ThumbnailShape
|
||||
) {
|
||||
if (shape === 'landscape') {
|
||||
expect(
|
||||
aspectRatio,
|
||||
'landscape fixture should be wider than tall'
|
||||
).toBeGreaterThan(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (shape === 'portrait') {
|
||||
expect(
|
||||
aspectRatio,
|
||||
'portrait fixture should be taller than wide'
|
||||
).toBeLessThan(1)
|
||||
return
|
||||
}
|
||||
|
||||
expect(
|
||||
Math.abs(aspectRatio - 1),
|
||||
'square fixture should have matching width and height'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX / 100)
|
||||
}
|
||||
|
||||
async function expectCenteredVideoPreview(preview: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const layout = await readVideoPreviewLayout(preview)
|
||||
return layout?.videoIntrinsicWidth ?? 0
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const layout = await readVideoPreviewLayout(preview)
|
||||
if (!layout) throw new Error('Video preview should render a video element')
|
||||
|
||||
expect(
|
||||
layout.wrapperHeight,
|
||||
'video preview should keep a usable minimum frame height'
|
||||
).toBeGreaterThanOrEqual(MIN_PREVIEW_FRAME_HEIGHT - CENTER_TOLERANCE_PX)
|
||||
expect(layout.videoBoxWidth).toBeGreaterThan(0)
|
||||
expect(layout.videoBoxHeight).toBeGreaterThan(0)
|
||||
expect(layout.objectFit).toBe('contain')
|
||||
|
||||
const objectPosition = objectPositionFractions(layout.objectPosition)
|
||||
expect(objectPosition.x).toBe(0.5)
|
||||
expect(objectPosition.y).toBe(0.5)
|
||||
|
||||
const wrapperCenterX = layout.wrapperX + layout.wrapperWidth / 2
|
||||
const wrapperCenterY = layout.wrapperY + layout.wrapperHeight / 2
|
||||
const paintedVideo = getPaintedVideoRect(layout)
|
||||
const paintedVideoCenterX = paintedVideo.x + paintedVideo.width / 2
|
||||
const paintedVideoCenterY = paintedVideo.y + paintedVideo.height / 2
|
||||
|
||||
expect(
|
||||
Math.abs(paintedVideoCenterX - wrapperCenterX),
|
||||
'painted video should be horizontally centered in the preview space'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
expect(
|
||||
Math.abs(paintedVideoCenterY - wrapperCenterY),
|
||||
'painted video should be vertically centered in the preview space'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
expect(layout.videoBoxWidth).toBeLessThanOrEqual(
|
||||
layout.wrapperWidth + CENTER_TOLERANCE_PX
|
||||
)
|
||||
expect(layout.videoBoxHeight).toBeLessThanOrEqual(
|
||||
layout.wrapperHeight + CENTER_TOLERANCE_PX
|
||||
)
|
||||
expect(paintedVideo.width).toBeLessThanOrEqual(
|
||||
layout.wrapperWidth + CENTER_TOLERANCE_PX
|
||||
)
|
||||
expect(paintedVideo.height).toBeLessThanOrEqual(
|
||||
layout.wrapperHeight + CENTER_TOLERANCE_PX
|
||||
)
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'VideoPreview',
|
||||
{ tag: ['@vue-nodes', '@node', '@widget'] },
|
||||
() => {
|
||||
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
|
||||
const loadVideo = new VideoPreview(loadVideoNode)
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Video')
|
||||
await expect(loadVideoNode).toHaveCount(1)
|
||||
await expect(loadVideoNode).toBeVisible()
|
||||
})
|
||||
|
||||
const loadVideoFixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Video')
|
||||
|
||||
await test.step('Upload a video file', async () => {
|
||||
await loadVideo.upload.setInputFiles(
|
||||
assetPath(`workflowInMedia/${file1}`)
|
||||
)
|
||||
},
|
||||
[file1, file2]
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
|
||||
await expect(loadVideoNode).toContainText(file1)
|
||||
await expect(loadVideo.video).toBeVisible()
|
||||
|
||||
await expect(loadVideo.navigationDots).toHaveCount(2)
|
||||
await loadVideo.navigationDots.nth(0).click()
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
|
||||
await loadVideo.navigationDots.nth(1).click()
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
|
||||
})
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
expect(layout.videoIntrinsicWidth).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
await test.step('Can redownload uploaded file', async () => {
|
||||
await loadVideo.video.hover()
|
||||
await expect(loadVideo.download).toBeVisible()
|
||||
await test.step('Update displayed video across thumbnail shapes', async () => {
|
||||
for (const [filename, shape] of videoShapeFixtures) {
|
||||
const initialSrc = await loadVideo.videoSrc()
|
||||
const nodeBoxBeforeLoad = await requireBoundingBox(
|
||||
loadVideoNode,
|
||||
`Load Video node before loading ${filename}`
|
||||
)
|
||||
await loadVideo.upload.setInputFiles(assetPath(`video/${filename}`))
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename,
|
||||
type: 'input'
|
||||
})
|
||||
await expect(loadVideoNode).toContainText(filename)
|
||||
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await loadVideo.download.click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe(file2)
|
||||
})
|
||||
})
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
await expectNodeBoxUnchanged(
|
||||
loadVideoNode,
|
||||
nodeBoxBeforeLoad,
|
||||
`Load Video node after loading ${filename}`
|
||||
)
|
||||
const updatedVideoAspectRatio =
|
||||
layout.videoIntrinsicWidth / layout.videoIntrinsicHeight
|
||||
|
||||
expectAspectRatioMatchesShape(updatedVideoAspectRatio, shape)
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('Keep video centered after horizontal resize', async () => {
|
||||
const nodeBox = await requireBoundingBox(
|
||||
loadVideoNode,
|
||||
'Load Video node before horizontal resize'
|
||||
)
|
||||
const initialLayout = await expectCenteredVideoPreview(
|
||||
loadVideo.preview
|
||||
)
|
||||
|
||||
await loadVideoFixture.resizeFromCorner('SE', 180, 0)
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(loadVideoFixture.pollWidth)
|
||||
.toBeGreaterThan(nodeBox.width + 100)
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
expect(
|
||||
layout.wrapperWidth - initialLayout.wrapperWidth,
|
||||
'video preview space should grow with a wider node'
|
||||
).toBeGreaterThan(100)
|
||||
expect(
|
||||
Math.abs(layout.wrapperHeight - initialLayout.wrapperHeight),
|
||||
'horizontal resize should not change the preview space height'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
})
|
||||
|
||||
await test.step('Keep video centered after vertical resize', async () => {
|
||||
const nodeBox = await requireBoundingBox(
|
||||
loadVideoNode,
|
||||
'Load Video node before vertical resize'
|
||||
)
|
||||
const initialLayout = await expectCenteredVideoPreview(
|
||||
loadVideo.preview
|
||||
)
|
||||
|
||||
await loadVideoFixture.resizeFromCorner('SE', 0, 180)
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(loadVideoFixture.pollHeight)
|
||||
.toBeGreaterThan(nodeBox.height + 100)
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
expect(
|
||||
layout.wrapperHeight - initialLayout.wrapperHeight,
|
||||
'video preview space should grow with a taller node'
|
||||
).toBeGreaterThan(100)
|
||||
expect(
|
||||
Math.abs(layout.wrapperWidth - initialLayout.wrapperWidth),
|
||||
'vertical resize should not change the preview space width'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
})
|
||||
|
||||
await test.step('Display multiple videos', async () => {
|
||||
await expect(loadVideo.navigationDots).toBeHidden()
|
||||
|
||||
try {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.evaluate(
|
||||
(names) => {
|
||||
graph!.nodes[0].images.splice(
|
||||
0,
|
||||
1,
|
||||
...names.map((filename) => ({
|
||||
type: 'input',
|
||||
filename,
|
||||
subfolder: ''
|
||||
}))
|
||||
)
|
||||
},
|
||||
[file1, file2]
|
||||
)
|
||||
} finally {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await expect(loadVideo.navigationDots).toHaveCount(2)
|
||||
await loadVideo.navigationDots.nth(0).press('Enter')
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
|
||||
await loadVideo.navigationDots.nth(1).press('Enter')
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
|
||||
})
|
||||
|
||||
await test.step('Can redownload uploaded file', async () => {
|
||||
await loadVideo.video.hover()
|
||||
await expect(loadVideo.download).toBeVisible()
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await loadVideo.download.click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe(file2)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -420,6 +420,14 @@ export default defineConfig([
|
||||
'@intlify/vue-i18n/no-raw-text': 'off'
|
||||
}
|
||||
},
|
||||
// Astro exposes virtual modules (astro:content, astro:assets, ...) that the
|
||||
// TypeScript resolver cannot see but are valid at build time.
|
||||
{
|
||||
files: ['apps/website/**/*.{ts,mts,vue}'],
|
||||
rules: {
|
||||
'import-x/no-unresolved': ['error', { ignore: ['^astro:'] }]
|
||||
}
|
||||
},
|
||||
// i18n import enforcement
|
||||
// Vue components must use the useI18n() composable, not the global t/d/st/te
|
||||
{
|
||||
|
||||
501
pnpm-lock.yaml
generated
@@ -12,6 +12,9 @@ catalogs:
|
||||
'@astrojs/check':
|
||||
specifier: ^0.9.9
|
||||
version: 0.9.9
|
||||
'@astrojs/mdx':
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
'@astrojs/sitemap':
|
||||
specifier: ^3.7.3
|
||||
version: 3.7.3
|
||||
@@ -996,6 +999,9 @@ importers:
|
||||
'@astrojs/check':
|
||||
specifier: 'catalog:'
|
||||
version: 0.9.9(prettier@3.7.4)(typescript@5.9.3)
|
||||
'@astrojs/mdx':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3(astro@6.4.2(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
'@astrojs/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.1(@types/node@25.0.3)(astro@6.4.2(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.34(typescript@5.9.3))(yaml@2.9.0)
|
||||
@@ -1023,6 +1029,9 @@ importers:
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/coverage-v8@4.0.16(vitest@4.1.8))(@vitest/ui@4.0.16(vitest@4.1.8))(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vue-component-type-helpers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.2
|
||||
|
||||
packages/comfyui-desktop-bridge-types: {}
|
||||
|
||||
@@ -1218,6 +1227,16 @@ packages:
|
||||
'@astrojs/markdown-remark@7.2.0':
|
||||
resolution: {integrity: sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==}
|
||||
|
||||
'@astrojs/mdx@6.0.3':
|
||||
resolution: {integrity: sha512-+4P3ZvwsRAqAbBgY+uZMewFo3ficlIBPZfu/Luk+v4ia/ZOuFhpsw7r+7672uT2Fc1UPdp7yW0eU5egvSq0wbw==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
peerDependencies:
|
||||
'@astrojs/markdown-satteri': 0.3.0
|
||||
astro: ^6.4.0
|
||||
peerDependenciesMeta:
|
||||
'@astrojs/markdown-satteri':
|
||||
optional: true
|
||||
|
||||
'@astrojs/prism@4.0.2':
|
||||
resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
@@ -2433,6 +2452,9 @@ packages:
|
||||
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@mdx-js/mdx@3.1.1':
|
||||
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
@@ -3864,6 +3886,9 @@ packages:
|
||||
'@types/esrecurse@4.3.1':
|
||||
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -3945,6 +3970,9 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
@@ -4675,6 +4703,10 @@ packages:
|
||||
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
astring@1.9.0:
|
||||
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
|
||||
hasBin: true
|
||||
|
||||
astro@6.4.2:
|
||||
resolution: {integrity: sha512-8H89CH2dKL5SCU99OCqdU9BGjmPkSJqaPurywj5XMo7eMFGUFD3vsNhdEKnEh4mK4LgGje3/QDTTSIIGst0G0Q==}
|
||||
engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'}
|
||||
@@ -4850,6 +4882,9 @@ packages:
|
||||
character-parser@2.2.0:
|
||||
resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==}
|
||||
|
||||
character-reference-invalid@2.0.1:
|
||||
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
||||
|
||||
chart.js@4.5.0:
|
||||
resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
|
||||
engines: {pnpm: '>=8'}
|
||||
@@ -4919,6 +4954,9 @@ packages:
|
||||
resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
collapse-white-space@2.1.0:
|
||||
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -5374,6 +5412,12 @@ packages:
|
||||
es-toolkit@1.39.10:
|
||||
resolution: {integrity: sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==}
|
||||
|
||||
esast-util-from-estree@2.0.0:
|
||||
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
|
||||
|
||||
esast-util-from-js@2.0.1:
|
||||
resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==}
|
||||
|
||||
esbuild@0.25.5:
|
||||
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5571,6 +5615,24 @@ packages:
|
||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
estree-util-attach-comments@3.0.0:
|
||||
resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==}
|
||||
|
||||
estree-util-build-jsx@3.0.1:
|
||||
resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==}
|
||||
|
||||
estree-util-is-identifier-name@3.0.0:
|
||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||
|
||||
estree-util-scope@1.0.0:
|
||||
resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==}
|
||||
|
||||
estree-util-to-js@2.0.0:
|
||||
resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==}
|
||||
|
||||
estree-util-visit@2.0.0:
|
||||
resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==}
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
@@ -5952,9 +6014,15 @@ packages:
|
||||
hast-util-raw@9.1.0:
|
||||
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
|
||||
|
||||
hast-util-to-estree@3.1.3:
|
||||
resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
hast-util-to-parse5@8.0.1:
|
||||
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
|
||||
|
||||
@@ -6085,6 +6153,9 @@ packages:
|
||||
react-devtools-core:
|
||||
optional: true
|
||||
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -6092,6 +6163,12 @@ packages:
|
||||
iron-webcrypto@1.2.1:
|
||||
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
|
||||
|
||||
is-alphabetical@2.0.1:
|
||||
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
|
||||
|
||||
is-alphanumerical@2.0.1:
|
||||
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
|
||||
|
||||
is-arguments@1.2.0:
|
||||
resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -6130,6 +6207,9 @@ packages:
|
||||
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-decimal@2.0.1:
|
||||
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
|
||||
|
||||
is-docker@2.2.1:
|
||||
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6168,6 +6248,9 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-hexadecimal@2.0.1:
|
||||
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
|
||||
|
||||
is-in-ci@1.0.0:
|
||||
resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6667,6 +6750,10 @@ packages:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
markdown-extensions@2.0.0:
|
||||
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
markdown-it-task-lists@2.1.1:
|
||||
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
|
||||
|
||||
@@ -6719,6 +6806,18 @@ packages:
|
||||
mdast-util-gfm@3.1.0:
|
||||
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
|
||||
|
||||
mdast-util-mdx-expression@2.0.1:
|
||||
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
|
||||
|
||||
mdast-util-mdx-jsx@3.2.0:
|
||||
resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
|
||||
|
||||
mdast-util-mdx@3.0.0:
|
||||
resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==}
|
||||
|
||||
mdast-util-mdxjs-esm@2.0.1:
|
||||
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
|
||||
|
||||
mdast-util-phrasing@4.1.0:
|
||||
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
||||
|
||||
@@ -6790,12 +6889,30 @@ packages:
|
||||
micromark-extension-gfm@3.0.0:
|
||||
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
|
||||
|
||||
micromark-extension-mdx-expression@3.0.1:
|
||||
resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==}
|
||||
|
||||
micromark-extension-mdx-jsx@3.0.2:
|
||||
resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==}
|
||||
|
||||
micromark-extension-mdx-md@2.0.0:
|
||||
resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==}
|
||||
|
||||
micromark-extension-mdxjs-esm@3.0.0:
|
||||
resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==}
|
||||
|
||||
micromark-extension-mdxjs@3.0.0:
|
||||
resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==}
|
||||
|
||||
micromark-factory-destination@2.0.1:
|
||||
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
|
||||
|
||||
micromark-factory-label@2.0.1:
|
||||
resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
|
||||
|
||||
micromark-factory-mdx-expression@2.0.3:
|
||||
resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==}
|
||||
|
||||
micromark-factory-space@2.0.1:
|
||||
resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
|
||||
|
||||
@@ -6826,6 +6943,9 @@ packages:
|
||||
micromark-util-encode@2.0.1:
|
||||
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
|
||||
|
||||
micromark-util-events-to-acorn@2.0.3:
|
||||
resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==}
|
||||
|
||||
micromark-util-html-tag-name@2.0.1:
|
||||
resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
|
||||
|
||||
@@ -7174,6 +7294,9 @@ packages:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
parse-json@5.2.0:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7549,6 +7672,20 @@ packages:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
recma-build-jsx@1.0.0:
|
||||
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
|
||||
|
||||
recma-jsx@1.0.1:
|
||||
resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==}
|
||||
peerDependencies:
|
||||
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
recma-parse@1.0.0:
|
||||
resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==}
|
||||
|
||||
recma-stringify@1.0.0:
|
||||
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
|
||||
|
||||
recorder-audio-worklet-processor@5.0.35:
|
||||
resolution: {integrity: sha512-5Nzbk/6QzC3QFQ1EG2SE34c1ygLE22lIOvLyjy7N6XxE/jpAZrL4e7xR+yihiTaG3ajiWy6UjqL4XEBMM9ahFQ==}
|
||||
|
||||
@@ -7586,6 +7723,9 @@ packages:
|
||||
rehype-raw@7.0.0:
|
||||
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
|
||||
|
||||
rehype-recma@1.0.0:
|
||||
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
|
||||
|
||||
rehype-stringify@10.0.1:
|
||||
resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
|
||||
|
||||
@@ -7607,6 +7747,9 @@ packages:
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
remark-mdx@3.1.1:
|
||||
resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==}
|
||||
|
||||
remark-parse@11.0.0:
|
||||
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
|
||||
|
||||
@@ -7971,6 +8114,12 @@ packages:
|
||||
stubborn-utils@1.0.2:
|
||||
resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
||||
|
||||
style-to-object@1.0.14:
|
||||
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
||||
|
||||
stylelint@16.26.1:
|
||||
resolution: {integrity: sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
@@ -8278,6 +8427,9 @@ packages:
|
||||
unist-util-modify-children@4.0.0:
|
||||
resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==}
|
||||
|
||||
unist-util-position-from-estree@2.0.0:
|
||||
resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==}
|
||||
|
||||
unist-util-position@5.0.0:
|
||||
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
|
||||
|
||||
@@ -9264,6 +9416,26 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@astrojs/mdx@6.0.3(astro@6.4.2(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@astrojs/internal-helpers': 0.10.0
|
||||
'@astrojs/markdown-remark': 7.2.0
|
||||
'@mdx-js/mdx': 3.1.1
|
||||
acorn: 8.16.0
|
||||
astro: 6.4.2(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
es-module-lexer: 2.1.0
|
||||
estree-util-visit: 2.0.0
|
||||
hast-util-to-html: 9.0.5
|
||||
piccolore: 0.1.3
|
||||
rehype-raw: 7.0.0
|
||||
remark-gfm: 4.0.1
|
||||
remark-smartypants: 3.0.2
|
||||
source-map: 0.7.6
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@astrojs/prism@4.0.2':
|
||||
dependencies:
|
||||
prismjs: 1.30.0
|
||||
@@ -10576,6 +10748,36 @@ snapshots:
|
||||
dependencies:
|
||||
'@lukeed/csprng': 1.1.0
|
||||
|
||||
'@mdx-js/mdx@3.1.1':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdx': 2.0.13
|
||||
acorn: 8.16.0
|
||||
collapse-white-space: 2.1.0
|
||||
devlop: 1.1.0
|
||||
estree-util-is-identifier-name: 3.0.0
|
||||
estree-util-scope: 1.0.0
|
||||
estree-walker: 3.0.3
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
markdown-extensions: 2.0.0
|
||||
recma-build-jsx: 1.0.0
|
||||
recma-jsx: 1.0.1(acorn@8.16.0)
|
||||
recma-stringify: 1.0.0
|
||||
rehype-recma: 1.0.0
|
||||
remark-mdx: 3.1.1
|
||||
remark-parse: 11.0.0
|
||||
remark-rehype: 11.1.2
|
||||
source-map: 0.7.6
|
||||
unified: 11.0.5
|
||||
unist-util-position-from-estree: 2.0.0
|
||||
unist-util-stringify-position: 4.0.0
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.1.9)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@types/mdx': 2.0.13
|
||||
@@ -11796,6 +11998,10 @@ snapshots:
|
||||
|
||||
'@types/esrecurse@4.3.1': {}
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
@@ -11892,6 +12098,8 @@ snapshots:
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
@@ -12728,6 +12936,8 @@ snapshots:
|
||||
|
||||
astral-regex@2.0.0: {}
|
||||
|
||||
astring@1.9.0: {}
|
||||
|
||||
astro@6.4.2(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 4.0.0
|
||||
@@ -13012,6 +13222,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-regex: 1.2.1
|
||||
|
||||
character-reference-invalid@2.0.1: {}
|
||||
|
||||
chart.js@4.5.0:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.4
|
||||
@@ -13083,6 +13295,8 @@ snapshots:
|
||||
dependencies:
|
||||
convert-to-spaces: 2.0.1
|
||||
|
||||
collapse-white-space@2.1.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -13513,6 +13727,20 @@ snapshots:
|
||||
|
||||
es-toolkit@1.39.10: {}
|
||||
|
||||
esast-util-from-estree@2.0.0:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
devlop: 1.1.0
|
||||
estree-util-visit: 2.0.0
|
||||
unist-util-position-from-estree: 2.0.0
|
||||
|
||||
esast-util-from-js@2.0.1:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
acorn: 8.16.0
|
||||
esast-util-from-estree: 2.0.0
|
||||
vfile-message: 4.0.3
|
||||
|
||||
esbuild@0.25.5:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.5
|
||||
@@ -13777,6 +14005,35 @@ snapshots:
|
||||
|
||||
estraverse@5.3.0: {}
|
||||
|
||||
estree-util-attach-comments@3.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
estree-util-build-jsx@3.0.1:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
devlop: 1.1.0
|
||||
estree-util-is-identifier-name: 3.0.0
|
||||
estree-walker: 3.0.3
|
||||
|
||||
estree-util-is-identifier-name@3.0.0: {}
|
||||
|
||||
estree-util-scope@1.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
devlop: 1.1.0
|
||||
|
||||
estree-util-to-js@2.0.0:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
astring: 1.9.0
|
||||
source-map: 0.7.6
|
||||
|
||||
estree-util-visit@2.0.0:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
@@ -14242,6 +14499,27 @@ snapshots:
|
||||
web-namespaces: 2.0.1
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-estree@3.1.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
comma-separated-tokens: 2.0.3
|
||||
devlop: 1.1.0
|
||||
estree-util-attach-comments: 3.0.0
|
||||
estree-util-is-identifier-name: 3.0.0
|
||||
hast-util-whitespace: 3.0.0
|
||||
mdast-util-mdx-expression: 2.0.1
|
||||
mdast-util-mdx-jsx: 3.2.0
|
||||
mdast-util-mdxjs-esm: 2.0.1
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
style-to-js: 1.1.21
|
||||
unist-util-position: 5.0.0
|
||||
zwitch: 2.0.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -14256,6 +14534,26 @@ snapshots:
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
comma-separated-tokens: 2.0.3
|
||||
devlop: 1.1.0
|
||||
estree-util-is-identifier-name: 3.0.0
|
||||
hast-util-whitespace: 3.0.0
|
||||
mdast-util-mdx-expression: 2.0.1
|
||||
mdast-util-mdx-jsx: 3.2.0
|
||||
mdast-util-mdxjs-esm: 2.0.1
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
style-to-js: 1.1.21
|
||||
unist-util-position: 5.0.0
|
||||
vfile-message: 4.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
hast-util-to-parse5@8.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -14414,6 +14712,8 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -14422,6 +14722,13 @@ snapshots:
|
||||
|
||||
iron-webcrypto@1.2.1: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
|
||||
is-alphanumerical@2.0.1:
|
||||
dependencies:
|
||||
is-alphabetical: 2.0.1
|
||||
is-decimal: 2.0.1
|
||||
|
||||
is-arguments@1.2.0:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -14463,6 +14770,8 @@ snapshots:
|
||||
call-bound: 1.0.4
|
||||
has-tostringtag: 1.0.2
|
||||
|
||||
is-decimal@2.0.1: {}
|
||||
|
||||
is-docker@2.2.1: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
@@ -14488,6 +14797,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-hexadecimal@2.0.1: {}
|
||||
|
||||
is-in-ci@1.0.0: {}
|
||||
|
||||
is-in-ci@2.0.0: {}
|
||||
@@ -14955,6 +15266,8 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
markdown-extensions@2.0.0: {}
|
||||
|
||||
markdown-it-task-lists@2.1.1: {}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
@@ -15072,6 +15385,55 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-mdx-expression@2.0.1:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-mdx-jsx@3.2.0:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
parse-entities: 4.0.2
|
||||
stringify-entities: 4.0.4
|
||||
unist-util-stringify-position: 4.0.0
|
||||
vfile-message: 4.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-mdx@3.0.0:
|
||||
dependencies:
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
mdast-util-mdx-expression: 2.0.1
|
||||
mdast-util-mdx-jsx: 3.2.0
|
||||
mdast-util-mdxjs-esm: 2.0.1
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-mdxjs-esm@2.0.1:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-phrasing@4.1.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -15225,6 +15587,57 @@ snapshots:
|
||||
micromark-util-combine-extensions: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-mdx-expression@3.0.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
devlop: 1.1.0
|
||||
micromark-factory-mdx-expression: 2.0.3
|
||||
micromark-factory-space: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-events-to-acorn: 2.0.3
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-mdx-jsx@3.0.2:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
devlop: 1.1.0
|
||||
estree-util-is-identifier-name: 3.0.0
|
||||
micromark-factory-mdx-expression: 2.0.3
|
||||
micromark-factory-space: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-events-to-acorn: 2.0.3
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
vfile-message: 4.0.3
|
||||
|
||||
micromark-extension-mdx-md@2.0.0:
|
||||
dependencies:
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-mdxjs-esm@3.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-events-to-acorn: 2.0.3
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
unist-util-position-from-estree: 2.0.0
|
||||
vfile-message: 4.0.3
|
||||
|
||||
micromark-extension-mdxjs@3.0.0:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn-jsx: 5.3.2(acorn@8.16.0)
|
||||
micromark-extension-mdx-expression: 3.0.1
|
||||
micromark-extension-mdx-jsx: 3.0.2
|
||||
micromark-extension-mdx-md: 2.0.0
|
||||
micromark-extension-mdxjs-esm: 3.0.0
|
||||
micromark-util-combine-extensions: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-factory-destination@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
@@ -15238,6 +15651,18 @@ snapshots:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-factory-mdx-expression@2.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
devlop: 1.1.0
|
||||
micromark-factory-space: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-events-to-acorn: 2.0.3
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
unist-util-position-from-estree: 2.0.0
|
||||
vfile-message: 4.0.3
|
||||
|
||||
micromark-factory-space@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
@@ -15290,6 +15715,16 @@ snapshots:
|
||||
|
||||
micromark-util-encode@2.0.1: {}
|
||||
|
||||
micromark-util-events-to-acorn@2.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/unist': 3.0.3
|
||||
devlop: 1.1.0
|
||||
estree-util-visit: 2.0.0
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
vfile-message: 4.0.3
|
||||
|
||||
micromark-util-html-tag-name@2.0.1: {}
|
||||
|
||||
micromark-util-normalize-identifier@2.0.1:
|
||||
@@ -15725,6 +16160,16 @@ snapshots:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
character-entities-legacy: 3.0.0
|
||||
character-reference-invalid: 2.0.1
|
||||
decode-named-character-reference: 1.3.0
|
||||
is-alphanumerical: 2.0.1
|
||||
is-decimal: 2.0.1
|
||||
is-hexadecimal: 2.0.1
|
||||
|
||||
parse-json@5.2.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
@@ -16177,6 +16622,35 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tslib: 2.8.1
|
||||
|
||||
recma-build-jsx@1.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
estree-util-build-jsx: 3.0.1
|
||||
vfile: 6.0.3
|
||||
|
||||
recma-jsx@1.0.1(acorn@8.16.0):
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn-jsx: 5.3.2(acorn@8.16.0)
|
||||
estree-util-to-js: 2.0.0
|
||||
recma-parse: 1.0.0
|
||||
recma-stringify: 1.0.0
|
||||
unified: 11.0.5
|
||||
|
||||
recma-parse@1.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
esast-util-from-js: 2.0.1
|
||||
unified: 11.0.5
|
||||
vfile: 6.0.3
|
||||
|
||||
recma-stringify@1.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
estree-util-to-js: 2.0.0
|
||||
unified: 11.0.5
|
||||
vfile: 6.0.3
|
||||
|
||||
recorder-audio-worklet-processor@5.0.35:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@@ -16237,6 +16711,14 @@ snapshots:
|
||||
hast-util-raw: 9.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
rehype-recma@1.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-estree: 3.1.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
rehype-stringify@10.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -16289,6 +16771,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
remark-mdx@3.1.1:
|
||||
dependencies:
|
||||
mdast-util-mdx: 3.0.0
|
||||
micromark-extension-mdxjs: 3.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
remark-parse@11.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -16720,6 +17209,14 @@ snapshots:
|
||||
|
||||
stubborn-utils@1.0.2: {}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
dependencies:
|
||||
style-to-object: 1.0.14
|
||||
|
||||
style-to-object@1.0.14:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
stylelint@16.26.1(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
@@ -17050,6 +17547,10 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
array-iterate: 2.0.1
|
||||
|
||||
unist-util-position-from-estree@2.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-position@5.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -12,6 +12,7 @@ publicHoistPattern:
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@astrojs/check': ^0.9.9
|
||||
'@astrojs/mdx': ^6.0.3
|
||||
'@astrojs/sitemap': ^3.7.3
|
||||
'@astrojs/vue': ^6.0.1
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,12 +3,13 @@ import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: { linkIds?: number[] }
|
||||
_subgraphSlot?: SubgraphInput
|
||||
}
|
||||
|
||||
const componentIconSvg = new Image()
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
101
src/config/comfyApi.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from './comfyApi'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (route: string) => `/api${route}`,
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
|
||||
describe('getComfyApiBaseUrl', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors the server-provided override', () => {
|
||||
remoteConfig.value = { comfy_api_base_url: 'https://my-ephem.example.com' }
|
||||
expect(getComfyApiBaseUrl()).toBe('https://my-ephem.example.com')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the key is absent', () => {
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the value is empty', () => {
|
||||
remoteConfig.value = { comfy_api_base_url: '' }
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getComfyPlatformBaseUrl', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors the server-provided override', () => {
|
||||
remoteConfig.value = {
|
||||
comfy_platform_base_url: 'https://my-ephem-platform.example.com'
|
||||
}
|
||||
expect(getComfyPlatformBaseUrl()).toBe(
|
||||
'https://my-ephem-platform.example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the key is absent', () => {
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the value is empty', () => {
|
||||
remoteConfig.value = { comfy_platform_base_url: '' }
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compatibility with comfyui servers that predate the override keys', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('falls back to build-time defaults when /features omits the URL keys', async () => {
|
||||
// An older comfyui server has /features but doesn't know about
|
||||
// comfy_api_base_url / comfy_platform_base_url yet.
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
supports_preview_metadata: true,
|
||||
max_upload_size: 104857600
|
||||
})
|
||||
} as Response)
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
@@ -20,10 +19,6 @@ const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
STAGING_PLATFORM_BASE_URL)
|
||||
|
||||
export function getComfyApiBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_API_BASE_URL
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_api_base_url',
|
||||
@@ -32,10 +27,6 @@ export function getComfyApiBaseUrl(): string {
|
||||
}
|
||||
|
||||
export function getComfyPlatformBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_PLATFORM_BASE_URL
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_platform_base_url',
|
||||
|
||||
45
src/config/firebase.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
async function loadFirebase(useProdConfig: boolean) {
|
||||
vi.resetModules()
|
||||
vi.stubGlobal('__USE_PROD_CONFIG__', useProdConfig)
|
||||
const { remoteConfig } = await import('@/platform/remoteConfig/remoteConfig')
|
||||
const { getFirebaseConfig } = await import('./firebase')
|
||||
return { getFirebaseConfig, remoteConfig }
|
||||
}
|
||||
|
||||
describe('getFirebaseConfig', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('honors a full server-provided firebase_config (cloud builds)', async () => {
|
||||
const cloud = {
|
||||
apiKey: 'cloud-key',
|
||||
authDomain: 'cloud.example.com',
|
||||
projectId: 'some-cloud-project',
|
||||
storageBucket: 'cloud.appspot.com',
|
||||
messagingSenderId: '1',
|
||||
appId: '1:1:web:abc'
|
||||
}
|
||||
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
|
||||
remoteConfig.value = { firebase_config: cloud }
|
||||
expect(getFirebaseConfig()).toEqual(cloud)
|
||||
})
|
||||
|
||||
it('uses the dev project when the server reports firebase_env "dev", even if the build-time fallback is prod', async () => {
|
||||
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
|
||||
remoteConfig.value = { firebase_env: 'dev' }
|
||||
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
})
|
||||
|
||||
it('falls back to the build-time config when the server reports no firebase_env', async () => {
|
||||
const prod = await loadFirebase(true)
|
||||
prod.remoteConfig.value = {}
|
||||
expect(prod.getFirebaseConfig().projectId).toBe('dreamboothy')
|
||||
|
||||
const dev = await loadFirebase(false)
|
||||
dev.remoteConfig.value = {}
|
||||
expect(dev.getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FirebaseOptions } from 'firebase/app'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
const DEV_CONFIG: FirebaseOptions = {
|
||||
@@ -28,15 +27,12 @@ const PROD_CONFIG: FirebaseOptions = {
|
||||
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
|
||||
|
||||
/**
|
||||
* Returns the Firebase configuration for the current environment.
|
||||
* - Cloud builds use runtime configuration delivered via feature flags
|
||||
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
|
||||
* Firebase config for the current backend: the server's firebase_config (cloud builds),
|
||||
* else the bundled DEV_CONFIG when the server reports a dev-tier backend, else the build-time default.
|
||||
*/
|
||||
export function getFirebaseConfig(): FirebaseOptions {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_CONFIG
|
||||
}
|
||||
|
||||
const runtimeConfig = remoteConfig.value.firebase_config
|
||||
return runtimeConfig ?? BUILD_TIME_CONFIG
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
if (remoteConfig.value.firebase_env === 'dev') return DEV_CONFIG
|
||||
return BUILD_TIME_CONFIG
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -369,7 +370,7 @@ describe('flushProxyWidgetMigration', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
|
||||
|
||||
const danglingLinkId = 999_999
|
||||
const danglingLinkId = toLinkId(999_999)
|
||||
expect(host.subgraph.links.has(danglingLinkId)).toBe(false)
|
||||
primitive.outputs[0].links = [
|
||||
...(primitive.outputs[0].links ?? []),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
function promotedInputNames(host: {
|
||||
@@ -800,7 +801,7 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
hostInput.link = 9999
|
||||
hostInput.link = toLinkId(9999)
|
||||
const promotedInputId = hostInput.widgetId
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { t } from '@/i18n'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { LinkId } from '@/types/linkId'
|
||||
import { reorderSubgraphInputs } from '@/lib/litegraph/src/subgraph/subgraphUtils'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -63,7 +64,7 @@ export function findHostInputForPromotion(
|
||||
|
||||
function resolvePromotionSource(
|
||||
subgraphNode: SubgraphNode,
|
||||
subgraphInput: { linkIds: readonly number[] }
|
||||
subgraphInput: { linkIds: readonly LinkId[] }
|
||||
): PromotedWidgetSource | undefined {
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -458,7 +458,7 @@ export function setWidgetConfig(slot: INodeInputSlot, config?: InputSpec) {
|
||||
if (!(slot instanceof NodeSlot)) return
|
||||
const graph = slot.node.graph
|
||||
if (!graph) return
|
||||
const link = graph.links[slot.link ?? -1]
|
||||
const link = graph.getLink(slot.link)
|
||||
if (!link) return
|
||||
const originNode = graph.getNodeById(link.origin_id)
|
||||
if (!originNode || !isPrimitiveNode(originNode)) return
|
||||
|
||||
@@ -17,6 +17,8 @@ import type { UUID } from '@/utils/uuid'
|
||||
import { zeroUuid } from '@/utils/uuid'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
@@ -132,7 +134,7 @@ describe('LGraph', () => {
|
||||
|
||||
emptySubgraph.inputNode.pos = [0, 0]
|
||||
// Reroute needs offset of ~20y to align with first slot
|
||||
const reroute = new Reroute(1, emptySubgraph, [0, 20])
|
||||
const reroute = new Reroute(toRerouteId(1), emptySubgraph, [0, 20])
|
||||
|
||||
node.snapToGrid(10)
|
||||
reroute.snapToGrid(10)
|
||||
@@ -744,7 +746,14 @@ describe('ensureGlobalIdUniqueness', () => {
|
||||
subgraph._nodes.push(subNodeB)
|
||||
subgraph._nodes_by_id[subNodeB.id] = subNodeB
|
||||
|
||||
const link = new LLink(1, 'number', subNodeA.id, 0, subNodeB.id, 0)
|
||||
const link = new LLink(
|
||||
toLinkId(1),
|
||||
'number',
|
||||
subNodeA.id,
|
||||
0,
|
||||
subNodeB.id,
|
||||
0
|
||||
)
|
||||
subgraph._links.set(link.id, link)
|
||||
|
||||
rootGraph.ensureGlobalIdUniqueness()
|
||||
@@ -818,14 +827,9 @@ describe('_removeDuplicateLinks', () => {
|
||||
source: LGraphNode,
|
||||
target: LGraphNode
|
||||
) {
|
||||
const dup = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
const linkId = toLinkId(Number(graph.state.lastLinkId) + 1)
|
||||
graph.state.lastLinkId = linkId
|
||||
const dup = new LLink(linkId, 'number', source.id, 0, target.id, 0)
|
||||
graph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
return dup
|
||||
@@ -1001,8 +1005,10 @@ describe('Subgraph Unpacking', () => {
|
||||
|
||||
function duplicateExistingLink(graph: LGraph, source: LGraphNode) {
|
||||
const existingLink = graph._links.values().next().value!
|
||||
const linkId = toLinkId(Number(graph.state.lastLinkId) + 1)
|
||||
graph.state.lastLinkId = linkId
|
||||
const dup = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
linkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { UUID } from '@/utils/uuid'
|
||||
import { createUuidv4, zeroUuid } from '@/utils/uuid'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { UNASSIGNED_NODE_ID, parseNodeId, toNodeId } from '@/types/nodeId'
|
||||
@@ -245,8 +247,8 @@ export class LGraph
|
||||
private _state: LGraphState = {
|
||||
lastGroupId: 0,
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastRerouteId: 0
|
||||
lastLinkId: toLinkId(0),
|
||||
lastRerouteId: toRerouteId(0)
|
||||
}
|
||||
|
||||
get state(): LGraphState {
|
||||
@@ -342,7 +344,7 @@ export class LGraph
|
||||
}
|
||||
|
||||
set last_link_id(value) {
|
||||
this.state.lastLinkId = value
|
||||
this.state.lastLinkId = toLinkId(value)
|
||||
}
|
||||
|
||||
onNodeAdded?(node: LGraphNode): void
|
||||
@@ -370,7 +372,9 @@ export class LGraph
|
||||
/** @see MapProxyHandler */
|
||||
const links = this._links
|
||||
MapProxyHandler.bindAllMethods(links)
|
||||
const handler = new MapProxyHandler<LLink>()
|
||||
const handler = new MapProxyHandler<LinkId, LLink>((value) =>
|
||||
toLinkId(Number(value))
|
||||
)
|
||||
this.links = new Proxy(links, handler) as Map<LinkId, LLink> &
|
||||
Record<LinkId, LLink>
|
||||
|
||||
@@ -399,8 +403,8 @@ export class LGraph
|
||||
this.state = {
|
||||
lastGroupId: 0,
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastRerouteId: 0
|
||||
lastLinkId: toLinkId(0),
|
||||
lastRerouteId: toRerouteId(0)
|
||||
}
|
||||
|
||||
// used to detect changes
|
||||
@@ -1415,7 +1419,7 @@ export class LGraph
|
||||
|
||||
addFloatingLink(link: LLink): LLink {
|
||||
if (link.id === -1) {
|
||||
link.id = ++this._lastFloatingLinkId
|
||||
link.id = toLinkId(++this._lastFloatingLinkId)
|
||||
}
|
||||
this.floatingLinksInternal.set(link.id, link)
|
||||
|
||||
@@ -1495,12 +1499,20 @@ export class LGraph
|
||||
linkIds,
|
||||
floating
|
||||
}: OptionalProps<SerialisableReroute, 'id'>): Reroute {
|
||||
id ??= ++this.state.lastRerouteId
|
||||
if (id > this.state.lastRerouteId) this.state.lastRerouteId = id
|
||||
const rerouteId =
|
||||
id === undefined
|
||||
? toRerouteId(Number(this.state.lastRerouteId) + 1)
|
||||
: toRerouteId(id)
|
||||
if (rerouteId > this.state.lastRerouteId) {
|
||||
this.state.lastRerouteId = rerouteId
|
||||
}
|
||||
|
||||
const reroute = this.reroutes.get(id) ?? new Reroute(id, this)
|
||||
reroute.update(parentId, pos, linkIds, floating)
|
||||
this.reroutes.set(id, reroute)
|
||||
const reroute = this.reroutes.get(rerouteId) ?? new Reroute(rerouteId, this)
|
||||
const typedParentId =
|
||||
parentId === undefined ? undefined : toRerouteId(parentId)
|
||||
const typedLinkIds = linkIds?.map(toLinkId)
|
||||
reroute.update(typedParentId, pos, typedLinkIds, floating)
|
||||
this.reroutes.set(rerouteId, reroute)
|
||||
return reroute
|
||||
}
|
||||
|
||||
@@ -1509,11 +1521,15 @@ export class LGraph
|
||||
* @param pos Position in graph space
|
||||
* @param before The existing link segment (reroute, link) that will be after this reroute,
|
||||
* going from the node output to input.
|
||||
* @returns The newly created reroute - typically ignored.
|
||||
* @returns The newly created reroute, or undefined when the segment cannot be resolved.
|
||||
*/
|
||||
createReroute(pos: Point, before: LinkSegment): Reroute {
|
||||
createReroute(pos: Point, before: LinkSegment): Reroute | undefined {
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const rerouteId = ++this.state.lastRerouteId
|
||||
if (!(before instanceof LLink) && !(before instanceof Reroute)) {
|
||||
return
|
||||
}
|
||||
const rerouteId = toRerouteId(Number(this.state.lastRerouteId) + 1)
|
||||
this.state.lastRerouteId = rerouteId
|
||||
const linkIds = before instanceof Reroute ? before.linkIds : [before.id]
|
||||
const floatingLinkIds =
|
||||
before instanceof Reroute ? before.floatingLinkIds : [before.id]
|
||||
@@ -2192,7 +2208,11 @@ export class LGraph
|
||||
) {
|
||||
console.error('Missing Parent ID')
|
||||
}
|
||||
const migratedReroute = new Reroute(++this.state.lastRerouteId, this, [
|
||||
const migratedRerouteId = toRerouteId(
|
||||
Number(this.state.lastRerouteId) + 1
|
||||
)
|
||||
this.state.lastRerouteId = migratedRerouteId
|
||||
const migratedReroute = new Reroute(migratedRerouteId, this, [
|
||||
reroute.pos[0] + offsetX,
|
||||
reroute.pos[1] + offsetY
|
||||
])
|
||||
@@ -2503,11 +2523,13 @@ export class LGraph
|
||||
if (lastGroupId != null)
|
||||
state.lastGroupId = Math.max(state.lastGroupId, lastGroupId)
|
||||
if (lastLinkId != null)
|
||||
state.lastLinkId = Math.max(state.lastLinkId, lastLinkId)
|
||||
state.lastLinkId = toLinkId(Math.max(state.lastLinkId, lastLinkId))
|
||||
if (lastNodeId != null)
|
||||
state.lastNodeId = Math.max(state.lastNodeId, lastNodeId)
|
||||
if (lastRerouteId != null)
|
||||
state.lastRerouteId = Math.max(state.lastRerouteId, lastRerouteId)
|
||||
state.lastRerouteId = toRerouteId(
|
||||
Math.max(state.lastRerouteId, lastRerouteId)
|
||||
)
|
||||
}
|
||||
|
||||
// Links
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
@@ -68,7 +69,8 @@ function createTestLink(
|
||||
targetNode: LGraphNode,
|
||||
inputSlot: number
|
||||
): LLink {
|
||||
const linkId = ++graph.state.lastLinkId
|
||||
const linkId = toLinkId(Number(graph.state.lastLinkId) + 1)
|
||||
graph.state.lastLinkId = linkId
|
||||
const link = new LLink(
|
||||
linkId,
|
||||
sourceNode.outputs[outputSlot].type,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
@@ -2589,6 +2591,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return
|
||||
} else if (e.altKey && !e.shiftKey) {
|
||||
const newReroute = graph.createReroute([x, y], linkSegment)
|
||||
if (!newReroute) return
|
||||
|
||||
pointer.onDragStart = (pointer) =>
|
||||
this._startDraggingItems(newReroute, pointer)
|
||||
pointer.onDragEnd = (e) => this._processDraggedItems(e)
|
||||
@@ -4245,7 +4249,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
const reroute = graph.setReroute(rerouteInfo)
|
||||
created.push(reroute)
|
||||
reroutes.set(id, reroute)
|
||||
reroutes.set(toRerouteId(id), reroute)
|
||||
}
|
||||
|
||||
// Remap reroute parentIds for pasted reroutes
|
||||
@@ -4262,9 +4266,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
let outNode: LGraphNode | null | undefined = nodes.get(
|
||||
serializeNodeId(info.origin_id)
|
||||
)
|
||||
let afterRerouteId: number | undefined
|
||||
let afterRerouteId: RerouteId | undefined
|
||||
if (info.parentId != null)
|
||||
afterRerouteId = reroutes.get(info.parentId)?.id
|
||||
afterRerouteId = reroutes.get(toRerouteId(info.parentId))?.id
|
||||
|
||||
// If it wasn't copied, use the original graph value
|
||||
if (
|
||||
@@ -4273,7 +4277,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
) {
|
||||
const originNodeId = parseNodeId(info.origin_id)
|
||||
outNode ??= originNodeId ? graph.getNodeById(originNodeId) : null
|
||||
afterRerouteId ??= info.parentId
|
||||
if (info.parentId !== undefined) {
|
||||
afterRerouteId ??= toRerouteId(info.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
const inNode = nodes.get(serializeNodeId(info.target_id))
|
||||
@@ -4284,7 +4290,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
info.target_slot,
|
||||
afterRerouteId
|
||||
)
|
||||
if (link) links.set(info.id, link)
|
||||
if (link) links.set(toLinkId(info.id), link)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6700,7 +6706,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const linkId =
|
||||
segment instanceof Reroute
|
||||
? segment.linkIds.values().next().value
|
||||
: segment.id
|
||||
: segment instanceof LLink
|
||||
? segment.id
|
||||
: undefined
|
||||
if (linkId !== undefined) {
|
||||
graph.removeLink(linkId)
|
||||
// Clean up layout store
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('LGraphNode', () => {
|
||||
expect(disconnected).toBe(true)
|
||||
expect(node2.inputs[0].link).toBeNull()
|
||||
expect(node1.outputs[0].links?.length).toBe(0)
|
||||
expect(graph._links.has(link?.id ?? -1)).toBe(false)
|
||||
expect(graph._links.has(link!.id)).toBe(false)
|
||||
|
||||
// Test disconnecting by slot name
|
||||
node1.connect(0, node2, 0)
|
||||
@@ -248,8 +248,8 @@ describe('LGraphNode', () => {
|
||||
expect(disconnectedSpecific).toBe(true)
|
||||
expect(targetNode1.inputs[0].link).toBeNull()
|
||||
expect(sourceNode.outputs[0].links?.length).toBe(1)
|
||||
expect(graph._links.has(link1?.id ?? -1)).toBe(false)
|
||||
expect(graph._links.has(link2?.id ?? -1)).toBe(true)
|
||||
expect(graph._links.has(link1!.id)).toBe(false)
|
||||
expect(graph._links.has(link2!.id)).toBe(true)
|
||||
|
||||
// Test disconnecting by slot name
|
||||
const link3 = sourceNode.connect(1, targetNode1, 0)
|
||||
@@ -271,8 +271,8 @@ describe('LGraphNode', () => {
|
||||
expect(sourceNode.outputs[0].links).toBeNull()
|
||||
expect(targetNode1.inputs[0].link).toBeNull()
|
||||
expect(targetNode2.inputs[0].link).toBeNull()
|
||||
expect(graph._links.has(link2?.id ?? -1)).toBe(false)
|
||||
expect(graph._links.has(link4?.id ?? -1)).toBe(false)
|
||||
expect(graph._links.has(link2!.id)).toBe(false)
|
||||
expect(graph._links.has(link4!.id)).toBe(false)
|
||||
|
||||
// Test disconnecting non-existent slot
|
||||
const invalidDisconnect = sourceNode.disconnectOutput(999)
|
||||
|
||||