Compare commits
16 Commits
load-video
...
matt/be-22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4638b2c6bc | ||
|
|
49a90d4e2e | ||
|
|
d6c582c399 | ||
|
|
a6db1ab3d6 | ||
|
|
2ec2a0e091 | ||
|
|
9cf5c9a93f | ||
|
|
9e5fb67b76 | ||
|
|
690e0e3590 | ||
|
|
01738b7b19 | ||
|
|
be9de941c9 | ||
|
|
f4e0430072 | ||
|
|
c78592c1ec | ||
|
|
00b0c6b434 | ||
|
|
da34fa3944 | ||
|
|
c8ed15da31 | ||
|
|
b132abc64a |
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,3 +55,6 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
5
.gitignore
vendored
@@ -96,7 +96,4 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.vercel
|
||||
.env*
|
||||
!.env_example
|
||||
.amp
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
108
apps/website/e2e/customers-detail.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders a Creative Campus story with its education blocks', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/xindi-zhang')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /The tool that expands my art/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
|
||||
await expect(nav.getByRole('button', { name: 'AT A GLANCE' })).toBeVisible()
|
||||
|
||||
// At a glance block (AtAGlance component) with its spec rows.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'At a glance' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Program', { exact: true })).toBeVisible()
|
||||
|
||||
// Workflow download button (Download component).
|
||||
await expect(
|
||||
page.getByRole('link', {
|
||||
name: /Download Xindi's style transfer workflow/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
// Shared education call to action (EducationCta component).
|
||||
await expect(
|
||||
page.getByRole('link', { name: /Explore the Education Program/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
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 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 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 380 B After Width: | Height: | Size: 279 B |
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_PX = -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_PX,
|
||||
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>
|
||||
84
apps/website/src/components/customers/CustomerArticle.astro
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
import { render } from 'astro:content'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { CustomerStoryEntry } from '../../utils/customers'
|
||||
import ArticleNav from './ArticleNav.vue'
|
||||
import AtAGlance from './content/AtAGlance.astro'
|
||||
import AuthorBio from './content/AuthorBio.astro'
|
||||
import BulletList from './content/BulletList.astro'
|
||||
import Contributors from './content/Contributors.astro'
|
||||
import Download from './content/Download.astro'
|
||||
import EducationCta from './content/EducationCta.astro'
|
||||
import Embed from './content/Embed.astro'
|
||||
import Figure from './content/Figure.astro'
|
||||
import Heading from './content/Heading.astro'
|
||||
import Heading4 from './content/Heading4.astro'
|
||||
import Link from './content/Link.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'
|
||||
import Video from './content/Video.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,
|
||||
a: Link,
|
||||
h3: Heading,
|
||||
h4: Heading4,
|
||||
ul: BulletList,
|
||||
li: ListItem,
|
||||
Section,
|
||||
Figure,
|
||||
Quote,
|
||||
Contributors,
|
||||
Steps,
|
||||
AtAGlance,
|
||||
AuthorBio,
|
||||
Download,
|
||||
EducationCta,
|
||||
Embed,
|
||||
Video
|
||||
}
|
||||
---
|
||||
|
||||
<section class="max-w-9xl mx-auto 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,29 @@
|
||||
---
|
||||
interface Row {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: Row[]
|
||||
}
|
||||
|
||||
const { rows } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class="my-8 overflow-hidden rounded-2xl border border-white/10 bg-site-bg-soft"
|
||||
>
|
||||
<dl class="divide-y divide-white/10">
|
||||
{
|
||||
rows.map((row) => (
|
||||
<div class="flex flex-col gap-1 p-5 sm:flex-row sm:gap-6">
|
||||
<dt class="text-primary-comfy-yellow shrink-0 text-xs font-bold tracking-widest uppercase sm:w-44">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd class="text-sm/relaxed text-primary-comfy-canvas">{row.value}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
interface Author {
|
||||
name?: string
|
||||
role?: string
|
||||
photo?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
people: Author[]
|
||||
}
|
||||
|
||||
const { label, people } = Astro.props
|
||||
const hasBioSlot = Astro.slots.has('default')
|
||||
---
|
||||
|
||||
<div class="mt-12 border-t border-white/10 pt-8">
|
||||
{
|
||||
label && (
|
||||
<span class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<div class="mt-4 space-y-8">
|
||||
{
|
||||
people.map((person) => (
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
|
||||
{person.photo && (
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name ?? ''}
|
||||
class="size-20 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{person.name && (
|
||||
<p class="text-sm font-semibold text-primary-comfy-canvas">
|
||||
{person.name}
|
||||
{person.role && (
|
||||
<span class="text-primary-warm-gray"> · {person.role}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{person.bio ? (
|
||||
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
|
||||
{person.bio}
|
||||
</p>
|
||||
) : hasBioSlot ? (
|
||||
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
|
||||
<slot />
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
19
apps/website/src/components/customers/content/Download.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string
|
||||
label: string
|
||||
newTab?: boolean
|
||||
}
|
||||
|
||||
const { href, label, newTab = false } = Astro.props
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
download={newTab ? undefined : true}
|
||||
target={newTab ? '_blank' : undefined}
|
||||
rel={newTab ? 'noopener noreferrer' : undefined}
|
||||
class="text-primary-comfy-yellow my-4 inline-block text-sm font-semibold underline underline-offset-2 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import Link from './Link.astro'
|
||||
---
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow mt-12 rounded-2xl border-l-4 bg-site-bg-soft p-8"
|
||||
>
|
||||
<p class="text-base/relaxed text-primary-comfy-canvas">
|
||||
<strong class="font-semibold">Teaching with ComfyUI?</strong> The Comfy Education
|
||||
Program is live: educational pricing, classroom cloud accounts on one invoice,
|
||||
<Link href="https://comfy.org/education">Explore the Education Program</Link> or
|
||||
<Link href="https://tally.so/r/Xx97lL">apply to be a part of the Creative
|
||||
Campus program</Link> if you're interested in exploring a deeper partnership with Comfy.
|
||||
</p>
|
||||
</div>
|
||||
22
apps/website/src/components/customers/content/Embed.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const { src, title } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class="my-8 aspect-video overflow-hidden rounded-2xl border border-white/10 bg-black"
|
||||
>
|
||||
<iframe
|
||||
src={src}
|
||||
title={title}
|
||||
class="size-full"
|
||||
loading="lazy"
|
||||
allow="autoplay; fullscreen; picture-in-picture; clipboard-write"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"
|
||||
></iframe>
|
||||
</div>
|
||||
21
apps/website/src/components/customers/content/Figure.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props
|
||||
const hasCaptionSlot = Astro.slots.has('default')
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<img src={src} alt={alt} class="w-full rounded-2xl object-cover" />
|
||||
{
|
||||
(hasCaptionSlot || caption) && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{hasCaptionSlot ? <slot /> : 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,6 @@
|
||||
---
|
||||
---
|
||||
|
||||
<h4 class="mt-6 mb-2 text-base font-semibold text-primary-comfy-canvas">
|
||||
<slot />
|
||||
</h4>
|
||||
15
apps/website/src/components/customers/content/Link.astro
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string
|
||||
}
|
||||
|
||||
const { href } = Astro.props
|
||||
const isExternal = /^https?:\/\//.test(href)
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
class="text-primary-comfy-yellow underline underline-offset-2 transition-opacity hover:opacity-80"
|
||||
><slot /></a>
|
||||
@@ -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"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
@@ -0,0 +1 @@
|
||||
<p class="mt-4 text-sm/relaxed text-primary-comfy-canvas"><slot /></p>
|
||||
20
apps/website/src/components/customers/content/Quote.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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>
|
||||
{
|
||||
name && (
|
||||
<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>
|
||||
22
apps/website/src/components/customers/content/Video.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import VideoPlayer from '../../common/VideoPlayer.vue'
|
||||
|
||||
interface Props {
|
||||
src: string
|
||||
poster?: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
const { src, poster, caption } = Astro.props
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<VideoPlayer src={src} poster={poster} client:visible />
|
||||
{
|
||||
caption && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
@@ -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]
|
||||
}
|
||||
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.
|
||||
101
apps/website/src/content/customers.content.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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 customer stories in every locale', () => {
|
||||
for (const locale of locales) {
|
||||
const prefix = `${locale}/`
|
||||
const inLocale = stories.filter((story) => story.file.startsWith(prefix))
|
||||
expect(inLocale.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
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>
|
||||
148
apps/website/src/content/customers/en/golan-levin.mdx
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: "Seeing the world in new ways: how Prof. Golan Levin teaches with ComfyUI at Carnegie Mellon University"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "\"For me, ComfyUI is not just about generative AI. It's an image-processing workstation for completely new kinds of work.\""
|
||||
cover: "https://media.comfy.org/website/customers/golan-levin/cover.png"
|
||||
order: 7
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "WHERE COMFYUI FITS"
|
||||
- id: topic-3
|
||||
label: "IMAGE SYNTHESIS"
|
||||
- id: topic-4
|
||||
label: "IMAGE ANALYSIS"
|
||||
- id: topic-5
|
||||
label: "THE CV LAB"
|
||||
- id: topic-6
|
||||
label: "AT A GLANCE"
|
||||
- id: topic-7
|
||||
label: "STUDENT WORK"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/augmented-hand.jpg" alt="Golan Levin, Augmented Hand Series" caption="Golan Levin, Augmented Hand Series (2014), with Chris Sugrue and Kyle McDonald. Photo: Gerlinde de Geus, courtesy Cinekid." />
|
||||
|
||||
For many people, AI in the arts means image generation. But Levin has spent much of the past two decades teaching artists how computers can interpret, analyze, and measure the visual world. His own artworks have long explored machine perception through real-time computer vision systems, and since 2024 he has increasingly used ComfyUI to teach these principles.
|
||||
|
||||
For Levin, ComfyUI is less an image generator than an image-processing workbench. Students use it to assemble custom workflows for segmentation, tracking, depth estimation, and other forms of computational perception. The result is an environment where artists can experiment directly with research-grade machine learning tools and combine them into systems of their own design.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### Where does ComfyUI fit in what you're trying to do?
|
||||
|
||||
I'm training creative technologists and technologically literate artists. The typical student in my Creative Coding class is a true hybrid: an art or design undergraduate who is also studying computer science, human-computer interaction, or information science. They have strong visual abilities, strong cultural literacy, and strong algorithmic thinking skills, but my course may be the first time they've had the opportunity to bring those together.
|
||||
|
||||
To me, that means giving students tools they can understand, modify, and remix to make systems of their own design, rather than treating creative software as a fixed given. That's why I'm such a proponent of community-driven, open-source software development toolkits for the arts.
|
||||
|
||||
<Quote>ComfyUI is the first AI tool I've found with both a low floor and a high ceiling. It's incredibly powerful and flexible, in terms of allowing artists to design their own AI workflows with the latest cutting-edge algorithms. But it also leapfrogs the headaches of coping with quirky GitHub repos and obsolete Colab notebooks.</Quote>
|
||||
|
||||
### What were students stuck on before?
|
||||
|
||||
Students often found themselves caught between two worlds. On one side were commercial AI tools that produced impressive results but offered limited opportunities for customization. On the other side were research projects published by universities and laboratories, where the software was often difficult to install, poorly documented, or already out of date.
|
||||
|
||||
ComfyUI bridges that gap. It gives students access to state-of-the-art algorithms through an environment they can understand, modify, and extend. Instead of adapting their ideas to fit a tool's built-in workflow, they can build workflows that reflect their own interests and questions.
|
||||
|
||||
<Quote>My students are explorers. They're artists who can write code and want to build systems that haven't existed before.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### The first exercise: a p5.js sketch driving image synthesis, inside ComfyUI
|
||||
|
||||
In one of Levin's introductory exercises — students' first exposure to the ComfyUI environment — they write a simple p5.js sketch directly inside ComfyUI, then use the shapes they draw, plus a text prompt, to guide a Stable Diffusion image synthesis. They document the pairs of images it produces: their JavaScript canvas drawing on the left, and the AI synthesis on the right. Having already spent a few weeks fighting to get nuance out of p5.js, they're tickled to get these results from simple shapes, and they learn a lot about how Stable Diffusion works.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-landscape.png" alt="p5.js ellipses guiding a Stable Diffusion synthesis" caption={`Some wide ellipses drawn in p5.js (left) guiding a Stable Diffusion synthesis with the prompt "rolling hills, foggy day" (right).`} />
|
||||
|
||||
It runs on a node-based canvas that art students pick up quickly, because it works like tools they already know.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-workflow.png" alt="Template ComfyUI workflow using the ComfyUI-p5js-node" caption="The template ComfyUI workflow students receive. It uses the custom ComfyUI-p5js-node by Ben Fox. From Levin's 60-212 course repo." />
|
||||
|
||||
*Try it yourself: [json file](https://media.comfy.org/website/customers/golan-levin/p5-in-comfy.json) (Comfy Local only)*
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4">
|
||||
|
||||
### Many artists start off by using ComfyUI for generative AI. You use it differently.
|
||||
|
||||
Maybe so. I'm interested in AI as a framework for expanded perception, so a lot of how I've used machine learning and computer vision over the past 25 years has been for image analysis, rather than image synthesis. Essentially, I use computer vision to understand video and images, and then use the information I extract to create new kinds of interactive experiences. In the classroom, I use ComfyUI to help teach students how to "see like a machine." So I have students use ComfyUI as a framework for analyzing images, not just generating them. For example, I ask them to take an input image and then use AI to compute new ones from it, such as a semantic segmentation ("which pixels belong to the elephant?") and a monocular depth estimate ("how far away is each pixel?"). Then the students build an interactive piece that interprets the original image, but using five channels of information instead of three: the usual red, green, and blue, plus depth, plus segmentation. In my demo project, the segmentation colors the elephant pink, and the background pixels change size based on how far away the AI thinks they are.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/depth-segmentation.png" alt="Semantic segmentation and monocular depth analysis in ComfyUI" caption={`An input image analyzed inside ComfyUI: semantic segmentation and monocular depth, feeding a five-channel "Custom Pixel" exercise. From Levin's 60-212 course repo.`} />
|
||||
|
||||
*Try it yourself: [demo project](https://editor.p5js.org/golan/sketches/-_cFmLtoP) · [lesson plan & workflow](https://github.com/golanlevin/60-212/tree/main/lectures/comfy/image_analysis#3-segment-the-image-with-ai)*
|
||||
|
||||
*Workflow files: download the [.json](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.json), or the [.png with the workflow embedded in its metadata](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.png) (drag it into ComfyUI to load the graph).*
|
||||
|
||||
<Quote>I want students to understand that AI is not only a tool for generating images. It's also a tool for perception, measurement, and analysis.</Quote>
|
||||
|
||||
The computer vision tools built for this are usually aimed at developers and enterprises. They assume an engineering workflow. I wanted my art students to get to segmentation, depth, and tracking inside an environment they already think in, without standing up a production pipeline first.
|
||||
|
||||
### What changed once ComfyUI was in the workflow?
|
||||
|
||||
Two things. First, it runs on a node-based canvas that many art students already understand from environments like TouchDesigner, Max/MSP, and Grasshopper — except it runs in a browser and it's for AI. As a result, students can focus on the ideas behind machine learning workflows instead of first learning an entirely new interaction paradigm. Second, it collapses the distance between a research lab and a classroom.
|
||||
|
||||
<Quote>There's a fast pipeline from the lab to your classroom. It's become commonplace for enthusiasts to convert AI research code into Comfy nodes, often within days of their release.</Quote>
|
||||
|
||||
One of the most remarkable things about the ComfyUI ecosystem is how quickly new research becomes accessible. A computer-vision paper might appear at CVPR or ICCV, and within days someone in the community has wrapped it as a reusable ComfyUI node. For educators, that dramatically shortens the distance between a research laboratory and a classroom. Instead of spending weeks reconstructing an experimental software environment, students can begin exploring the underlying ideas almost immediately.
|
||||
|
||||
The cloud matters for accessibility and equity, too. Most of my students don't have big GPU workstations, and I don't want their access to advanced tools to depend on the caliber of their personal hardware. Cloud platforms make it possible for everyone in a class to work in the same environment, with the same models, regardless of what laptop they happen to own.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5">
|
||||
|
||||
### In your advanced Experimental Capture studio, you've turned ComfyUI into a computer-vision lab.
|
||||
|
||||
The goal of this course is to use technologies to help us see the world in new ways: the very fast, the very slow, the very small, the very large, and in spectra beyond human perception, like IR and UV. It's about cultivating the students' curiosity. But the limitation in this studio is hardware. We have one camera that can shoot 100,000 frames per second, one high-resolution thermal camera, and access to one electron microscope — but we've got 20 students. We can't always queue them all up for one exotic camera; it's a bottleneck.
|
||||
|
||||
<Quote>I need to give them tools they can use to see the world in new ways, that they can all run on their own hardware.</Quote>
|
||||
|
||||
ComfyUI allows students to use their own phones to ask questions they couldn't before. So they duct-tape their phone camera to a window, record the world going by, and then track things with the LocateAnything and SAM3 ComfyUI nodes, producing data files that distill what the camera saw. ComfyUI becomes a laboratory for computational observation, allowing students to ask questions of images and videos that would otherwise be difficult to formulate.
|
||||
|
||||
### You also wrap niche research libraries into ComfyUI nodes yourself.
|
||||
|
||||
One of the remarkable things about the ComfyUI ecosystem is the community that forms around it. There's a hero of mine on GitHub, Kijai, who keeps taking libraries from computer vision labs and turning them into ComfyUI nodes. He's made hundreds, probably doing more than anyone to turn lab-grade models into tools anyone can use. My students and I are starting to do this too. Niche is the right word. Right now I have my eye on a zoology lab that released a good library for tracking insect legs. The people who made it probably don't even know what ComfyUI is. But I want that algorithm for my students, and there's gotta be someone else out there who would love it too.
|
||||
|
||||
### What's the bigger pattern you see in your students?
|
||||
|
||||
My students are explorers. They see a new tool and immediately start wondering what else it could be connected to. They explore: I should be able to combine this thing with that other thing. That's the whole reason to give them a system they can build on, instead of a tool that tells them what they're allowed to do.
|
||||
|
||||
<Quote>We're educating students who want to invent new forms and experiences, not just reproduce existing ones.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Courses", value: "Intermediate Studio: Creative Coding (60-212); Experimental Capture (co-taught with Nica Ross)" },
|
||||
{ label: "Level", value: "Undergraduate (sophomore studio + advanced studio, ~20 students)" },
|
||||
{ label: "Setup", value: "Cloud-hosted ComfyUI; runs on students' own laptops" },
|
||||
{ label: "Core techniques", value: "p5.js-driven synthesis; semantic segmentation; monocular depth; LocateAnything + SAM3 tracking" },
|
||||
{ label: "Distinctive angle", value: "ComfyUI as computer-vision lab, not just a generator" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="Student work">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-tippi.png" alt="Student work by Tippi Li" caption={`"nuclear explosion" by Tippi Li`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-xiao.png" alt="Student work by Xiao Yuan" caption={`"Chinese painting, plants, ink, transparent" by Xiao Yuan`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-aarnav.png" alt="Student work by Aarnav Patel" caption={`"NASA space image of a new cosmos detected" by Aarnav Patel`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-jeffrey.png" alt="Student work by Jeffrey Wang" caption={`"Dream Scene Painting" by Jeffrey Wang`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-kai.gif" alt="Student work by Kai Okorodudu" caption={`"Electric hand" by Kai Okorodudu`} />
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio people={[{ name: "Golan Levin", photo: "https://media.comfy.org/website/customers/golan-levin/author-golan.png" }]}>Golan Levin is a Professor of Computational Art at Carnegie Mellon University and co-author, with Tega Brain, of "Code as Creative Medium." This fall he is teaching two CMU courses with ComfyUI: "Intermediate Studio: Creative Coding" (60-212), built around p5.js, and "Experimental Capture," a studio in computational and expanded photography he co-teaches with Nica Ross. Levin is also widely known for interactive art installations driven by real-time machine vision, such as his [Augmented Hand Series](https://flong.com/archive/projects/augmented-hand-series/index.html) (2014), created with Kyle McDonald and Christine Sugrue.</AuthorBio>
|
||||
|
||||
<EducationCta />
|
||||
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>
|
||||
149
apps/website/src/content/customers/en/ina-conradi.mdx
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: "From Node Graph to Building Façade: how Ina Conradi's NTU students compose architectural-scale public art with ComfyUI"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "At NTU in Singapore, Ina Conradi's students compose 90-second films for building-sized LED walls that prompt boxes cannot render but ComfyUI can, work that travels from campus to Hangzhou's West Lake Media Façade and a million viewers a day."
|
||||
cover: "https://media.comfy.org/website/customers/ina-conradi/cover.png"
|
||||
order: 6
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE CANVAS"
|
||||
- id: topic-3
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-4
|
||||
label: "THE 2026 BRIEF"
|
||||
- id: topic-5
|
||||
label: "STUDENT WORK"
|
||||
- id: topic-6
|
||||
label: "PUBLIC SCREENS"
|
||||
- id: topic-7
|
||||
label: "AT A GLANCE"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig1-quantum-logos.jpg" alt="Quantum Logos (Vision Serpent) on the Media Art Nexus LED screen" caption="Quantum Logos (Vision Serpent), Mark Chavez and Ina Conradi. Experimental animation, Media Art Nexus LED screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
### Building an AI art pipeline from studio to screen
|
||||
|
||||
Ina Conradi has written and taught NTU's two AI courses since 2022: DM2012, Explorations in AI-Generated Art (undergraduate), and AP7055, Art in the Age of the Creative Machine (postgraduate). Each runs about 30 students a semester. Working alongside her on the production pipeline is Mark Chavez, an animation veteran (DreamWorks, Rhythm & Hues) and early ComfyUI adopter. Together they co-curate the platform those courses build for: a 15-metre by 2-metre LED wall installed at NTU's North Spine in 2016 as Media Art Nexus, now run by NTU Museum as NTU Index and still taking new work each semester.
|
||||
|
||||
Work from the wall has travelled to giant public screens in Singapore (Ten Square), Hangzhou, and Chongqing, and into collaborations with Bauhaus University, the University of the Arts Berlin, and the Elbphilharmonie in Hamburg.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig2-nature-sanctuary.jpg" alt="Nature Sanctuary 3000 on the West Lake Media Façade" caption="Nature Sanctuary 3000, Sowmya Sreeshna. Experimental animation, West Lake Media Façade (170 m × 18 m), Hangzhou, China. Photo: Limpid Art." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### Ina, your students don't make films for laptops. Why screens the size of buildings?
|
||||
|
||||
Because the format teaches. A 90-second film at 6K across, in an 8:1 panorama, cannot be a lucky prompt. It has to be composed. And the screens are real: the strongest student work plays on NTU Index, our 15-metre by 2-metre wall on campus, and travels to urban façades in China and Europe through the City Digital Skin Art Festival (CDSA). When a student knows a million people a day might walk past their film in Hangzhou, the conversation about craft changes.
|
||||
|
||||
### Mark, describe the canvas.
|
||||
|
||||
Basically, we do compositions for really large media LED screens in Singapore and China. We have a screen that's eight by one in Singapore. It's 5,888 by 768 pixels.Students create images in the class, usually about 6K resolution across, a long landscape panorama. The output is 90-second short films. Two minutes, 90 seconds. I'm not going to change. I love that format because it's manageable within the class.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### That format breaks most AI tools. What happened?
|
||||
|
||||
Runway is one of the tools we use, on an educational plan that has worked well for the school. The constraint we hit is format: Runway works in 16:9, and our 6K panoramas fall outside that. Last semester Midjourney gave us trouble at our resolution, and the upscale was difficult. So we're expanding the palette and bringing in ComfyUI alongside what we already run.
|
||||
|
||||
<Quote>ComfyUI gave the cleanest results. Upscaling to 8K at a 1-by-8 panorama after composition is genuinely hard, and ComfyUI is the only pipeline that lets students compose image, motion, and upscale models together.</Quote>
|
||||
|
||||
### What about the budget side?
|
||||
|
||||
Budget will keep being an issue. The school supports us well, but new tools arrive every semester and students want to try them and build their own pipelines. Monthly per-seat licenses don't fit how a semester runs. Running ComfyUI locally is hard for students: most laptops don't have a GPU with enough VRAM, and getting it working takes real trial and error. Many would rather work from home, but the hardware blocks them, so they come into the lab. Others used Comfy Cloud. It charges a subscription, but it still cost significantly less than the prepaid tools, and the results were better. Either way they're chasing the same thing: a pipeline they can keep working on, wherever they are.
|
||||
|
||||
### Ina, you insist these courses are not about tools. What are they about?
|
||||
|
||||
My class isn’t about teaching a single tool. It is the responsive system students interact with across platforms, directing, critiquing, and shaping outputs through ongoing dialogue. ComfyUI fits this: a node graph is an argument you can read, question, and rebuild. A prompt box is not. Singaporean students become technically fluent very fast. What they need from arts education is the language to question what they're making, not just the skill to make it.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4">
|
||||
|
||||
### Ina, the 2026 brief sends students to the ocean. What's the assignment?
|
||||
|
||||
The project is The Liquid Commons: Bringing Ocean Science into Global Media Architecture, developed in dialogue with OceanX, the organization behind the OceanXplorer research vessel, and the CDSA 2026 festival theme. The brief is strict: do not illustrate the science, translate it. The 2026 cohort is the first to build these films in ComfyUI with Topaz upscaling, working towards two real deadlines at once. Their pieces are in consideration for the OceanX Summit in Singapore this October, and jury-selected works will screen during the City Digital Skin Art Festival on Hangzhou's West Lake Media Façade: 170 metres by 18 metres, around a million viewers a day.
|
||||
|
||||
<Quote>The delivery spec tells you why the tooling matters: final exports at 5,888 × 768 px, 8K where required. That's the brief no prompt box can fill.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5">
|
||||
|
||||
### Mark, what does the student work look like?
|
||||
|
||||
About eight students have built their films through Comfy so far, and they're all pretty cool. They're surprising and insightful, because they're not limited by game-engine graphics. One student was the standout: he tried every model in Comfy and pushed the furthest.
|
||||
|
||||
Three projects from the 2026 cohort show the range.
|
||||
|
||||
**The Tao of Water** (Wang Zilin, AP7055) reads the ocean through the Tao Te Ching, a three-part arc from water to marine plant to void and back to origin. The pipeline moves from Pinterest research boards through Midjourney into ComfyUI, where Nano Banana extends single frames into seamless panoramas and Kling 3.0 animates first-frame-to-last-frame motion at full 5,888-pixel width, before a Premiere edit.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig3-tao-of-water.jpg" alt="The Tao of Water on the NTU Index screen" caption="The Tao of Water, Wang Zilin. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
**microscophony** (Jiin Ko, AP7055) fuses *microscopic* and *micropolyphony*, Ligeti's term for dense webs of voices that blur into a single cloud of sound. The source is based on OceanX microscope footage of deep-sea microbes, translated into the visual logic of graphic notation (Ligeti, Xenakis, Cardew) so the panorama becomes a listening score. Images ran through Midjourney and Nano Banana, video through ComfyUI with Vidu Q2, sound design in Ableton Live, with distinct sonic textures mapped to distinct visual forms.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig4-microscophony.jpg" alt="microscophony on the NTU Index screen" caption="microscophony, Jiin Ko. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
**GO! PLASTIC** (Jianwei Hoe, DM2012) is an ocean-plastics piece whose production log reads like studio paperwork, not prompt history. It opens with a one-line art direction (every project states its idea in a single line, with embedded irony, before a frame is generated), then walks through model selection, a platform-versus-local cost comparison (cost per clip and per scene on an RTX 5090 against a cloud B200, render times included), and a shot-by-shot sheet pairing every source image with its full prompt and settings.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig5-go-plastic.jpg" alt="GO! PLASTIC on the NTU Index screen" caption="GO! PLASTIC, Hoe Jianwei. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6">
|
||||
|
||||
### Ina, where does the work go after the classroom?
|
||||
|
||||
Onto public screens, and into juried international competition. The City Digital Skin Art Festival was established in 2023, initiated by the China Academy of Art's School of Sculpture and Public Art and co-curated with Public Art Lab Berlin, MEET Digital Culture Center Milan, and NTU ADM, with a network of more than 29 art academies across China and Europe.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig6-cdsa-awards.jpg" alt="CDSA Festival award winners on the West Lake Media Façade" caption="CDSA Festival award winners, curators, and organizers. West Lake Media Façade (170 m × 18 m), Hangzhou, China. Photo: Limpid Art. Asia's largest high-definition outdoor screen" />
|
||||
|
||||
The 2024 edition ran across 11 LED screens in 9 cities in 5 countries and reached over 100 million views. The 2025–2026 edition, themed Memory Coexistence, drew over 200 international submissions, with the top 40 selected by a 16-member jury. I curate the Singapore programme across NTU Index and the Ten Square landmark façade. A student composing at 6K in our classroom is composing for that circuit.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig7-crispr.jpg" alt="Crispr on the Ten Square Landmark Façade" caption="Crispr, Lee Chaewon. Experimental animation, Ten Square Landmark Façade (21.2 m × 14.4 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
NTU ADM students have already won at this level. At CDSA 2025, the majority of the top awards went to students from these two courses: Gold (Sun Yutong, *Echoes of Her*), Silver (Tan Yu Yan Cheerie, *Eternal Flux*), Bronze (Shah Pranjal Kirti, *Mumbai Miniatures*), Business (Ong Sze Ching, *Nuwa*), and Creative (Leah Chakola, *Caravan of Memory*). The courses have also taken NTU to Ars Electronica in Linz as the only Singapore campus partner since 2023, first with *Butterfly's Dreams* (2023, "Who Owns the Truth?") and then in 2025 with *Beyond the Screen*, a joint exhibition with the China Academy of Art and Bauhaus-Universität Weimar.
|
||||
|
||||
### Mark, you spent a decade at DreamWorks. Why does this tool fit art students?
|
||||
|
||||
I come from visual effects. I was at DreamWorks about ten years, then Rhythm & Hues, then the game industry and big interactive installations. I'm not a programmer, so I love ComfyUI.
|
||||
|
||||
<Quote>Everybody I know who does graphics now is using this, because it's so adaptable. Sometimes we use Comfy as just a back end. That's what everybody's doing.</Quote>
|
||||
|
||||
We got this large 15-metre by 2-metre screen in an art installation at the university, and it let us explore media and different techniques. We found students weren't technical enough to handle TouchDesigner, so they just started making movies. Then I started playing with AI, and now everything's AI. What I'd love next is templates custom-made for these screens.
|
||||
|
||||
Take *Echoes, Whispers and Memories*, the piece Ina and I made. We don't use Comfy to spit out finished illustrations. We build workflows that keep recomposing the image, breaking it apart and putting it back together so it evolves on screen, which is the whole point: entropy, memory, things falling apart and reforming. Then we push those outputs into real-time and projection systems for big rooms, places like Ars Electronica's Deep Space 8K and MEET in Milan.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig8-echoes.jpg" alt="Echoes, Whispers and Memories at Ars Electronica Deep Space 8K" caption="Echoes, Whispers and Memories, Mark Chavez and Ina Conradi. AI-generated immersive installation using ComfyUI, Deep Space 8K, Ars Electronica, Linz, Austria. Photo: Wolfgang Simlinger." />
|
||||
|
||||
### The signal from the industry
|
||||
|
||||
<Quote>I hear from my students looking for internships or jobs that the first question over there is, "Do you know Comfy?" Because they want to hire kids who know the pipeline.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Institution", value: "Nanyang Technological University, School of Art, Design and Media (Singapore)" },
|
||||
{ label: "Courses", value: "DM2012: Explorations in AI-Generated Art (UG) and AP7055: Art in the Age of the Creative Machine (PG), written and taught by Ina Conradi since 2022; ~30 students/semester" },
|
||||
{ label: "The canvas", value: "6K-wide, 8:1 LED walls in Singapore and China; NTU Index wall on campus (15 m × 2 m, 5,888 × 768 px)" },
|
||||
{ label: "Core technique", value: "ComfyUI compositions with Topaz upscaling for ultra-wide panoramic output; production logs with per-clip cost and prompt sheets" },
|
||||
{ label: "Why Comfy won", value: "Hosted tools locked to 16:9; upscaling to 8K at a 1-by-8 panorama after composition needed a multi-model pipeline; per-seat monthly renewals didn't fit the semester" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio label="About the authors" people={[
|
||||
{ name: "Ina Conradi", photo: "https://media.comfy.org/website/customers/ina-conradi/author-ina.jpg", bio: `Ina Conradi is an artist and curator based between Singapore and Los Angeles. She is founding faculty at NTU's School of Art, Design and Media (est. 2005), where she has written and taught the school's AI courses since 2022. Her film Moirai: Thread of Life won Best in Show at the SIGGRAPH Asia Computer Animation Festival 2023, a first for Singapore.` },
|
||||
{ name: "Mark Chavez", photo: "https://media.comfy.org/website/customers/ina-conradi/author-mark.jpg", bio: `Mark Chavez is an animator, director, and founding faculty at NTU's School of Art, Design and Media in Singapore. After a decade at DreamWorks Animation and visual effects work at the original Rhythm & Hues Studios, he established NTU's Digital Animation area (2005) and an animation research think-tank funded by Singapore's National Research Foundation and the Media Development Authority.` }
|
||||
]} />
|
||||
|
||||
<EducationCta />
|
||||
138
apps/website/src/content/customers/en/kathy-smith.mdx
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: "Built for AI: Prof. Kathy Smith on USC's Expanded Animation program and ComfyUI"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "Inside the experimental USC MFA that put AI into animation pedagogy from day one, and the student pipelines it produced."
|
||||
cover: "https://media.comfy.org/website/customers/kathy-smith/cover.png"
|
||||
order: 8
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "THE PROGRAM"
|
||||
- id: topic-2
|
||||
label: "TEACHING WITH AI"
|
||||
- id: topic-3
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-4
|
||||
label: "STUDENT WORK"
|
||||
- id: topic-5
|
||||
label: "AT A GLANCE"
|
||||
- id: topic-6
|
||||
label: "WHAT'S NEXT"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
### You built the Expanded Animation program in 2022 specifically to put AI into the curriculum from day one. What did you see that other programs missed?
|
||||
|
||||
We created Expanded Animation: Research and Practice specifically to focus on creative process and AI as part of how animators learn to make work. The thesis at the start was that AI was going to reshape animation as a medium, and the question was not whether to teach it but how to embed it in the curriculum so students learn it as part of their creative process rather than as a separate technical specialty.
|
||||
|
||||
USC's School of Cinematic Arts already had decades of cinematic storytelling tradition. What we did with XA was put AI inside that tradition. The conceptual thinking, the storytelling, the cinematic history come first. AI is one of the many tools available to them, sitting alongside hand-drawing, paint, 3D, and live-action footage. Students do not learn AI in one course and animation in another. They learn both side by side.
|
||||
|
||||
The students who arrive at the program are usually self-selected for it. They show up technically fluent, with their own GPU-equipped laptops. What we offer them is the storytelling, the cinematic history, and the conceptual frame. They bring the technical nimbleness.
|
||||
|
||||
<Quote>They are way ahead of the curve. They are ahead of the faculty in the way they work, technically, but not so much artistically. That is what we are there to deliver.</Quote>
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/usc-campus.png" alt="USC School of Cinematic Arts" caption="USC School of Cinematic Arts. Source: USC Today" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### How do you actually structure an AI assignment? Walk us through one.
|
||||
|
||||
In my Animation, Dreams, and Consciousness class, I have the students document their dreams and then use the dream as the source. Some of them draw, some of them write. The dream becomes the prompt, and they generate the image and emotion of the dream. I love when you get six fingers and weird stuff happening in the algorithms. Our human perception in dreams is often doing the same thing. Therefore, AI is evolving and dreaming with us.
|
||||
|
||||
That structure is deliberate. The students are not asking the model to produce work for them. They are using it as a layer of their process, alongside hand-drawing and painting and 3D rendering and live-action footage. The work that comes out is theirs because the creative decisions are theirs. The tool just gives them new ways to reach what they were trying to make.
|
||||
|
||||
There is a fear factor around AI, and I understand it. There has been a lot of scraping of artists' work, and that conversation is real and is going to take time to resolve. But I have been working with AI conceptually since 1998, and the way I describe the data sets to my students is that they are a repository of all of our creation. It is like the collective unconscious of the human mind. Artists have always drawn from everything around them.
|
||||
|
||||
<Quote>What really matters is what the artist does with it, *intentionality*.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### Why does ComfyUI specifically fit the way your students work?
|
||||
|
||||
It is the node-based system. Those who have done Houdini feel very at home in Comfy. You can work with the prompts, but it is very visual. That is what they are used to. They are not asking a black box for an output. They are building a workflow.
|
||||
|
||||
And it stays in its lane. The students are not using Comfy to make AI art. They are using Comfy as one node graph alongside Blender, hand-drawn frames, paint, and live-action footage. The reason it fits is that it does not try to be the whole pipeline. It is one stage of a creative practice that still has cinema at its core.
|
||||
|
||||
What also matters is that Comfy is open and inspectable. The students can see what the model is doing at each step, fork a workflow, swap a sampler, drop in a custom node, and share what they built with the next cohort. That is closer to how an animation studio tradition has always behaved, with techniques passed along and improved rather than hidden behind a paywall.
|
||||
|
||||
They also work across whatever hardware they have: Comfy Cloud at home and when they are mobile, the portable version on their personal laptops, and the research computer in my office for the high-end runs. Animation students do not sit in one cubicle for a thesis project. They work everywhere.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4">
|
||||
|
||||
### Tell us about the work coming out of the program.
|
||||
|
||||
The pattern shows up across the cohort: the AI is in service of the cinematic story, not in place of it. Three students walked us through how Comfy actually sits inside their pipelines.
|
||||
|
||||
#### Sijia Zheng — Ori & Kiddo
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/ori-kiddo.png" alt="Sijia Zheng, Ori & Kiddo" />
|
||||
|
||||
**What Comfy enabled:** an oil-paint, brush-stroke dream look that "other AI tools cannot possibly make," held consistent across shots with IP-Adapter style transfer and a custom LoRA.
|
||||
|
||||
*Ori & Kiddo* follows two ghosts who, after the universe dies, search for old human memories, rediscover love, and reverse the universe back into being. Most of the film is hand-drawn 2D. Comfy enters in the dream sequences, where the ghost Kiddo dreams of past lives and the look had to be unlike anything else in the film. Sijia drew stylized reference images first, then used them as the style reference over video clips through an IP-Adapter workflow to produce long, oil-painted, brush-stroke sequences. The same control shows up in shots where Sijia appears on screen: real footage, masked in Comfy to change the haircut and swap the background. For a look that has to stay locked, Sijia trains a LoRA and runs it through Comfy.
|
||||
|
||||
Sijia found Comfy in early 2025 while hunting for a style-transfer tool that Midjourney and DALL-E could not deliver, testing it on a stylized animated-film-look conversion.
|
||||
|
||||
<Quote>It totally broke my mind. Most of the time, I think I'll just stand on other people's shoulders. The workflows are already pretty amazing, and I'll base on the workflows and add something that I want.</Quote>
|
||||
|
||||
Since *Ori & Kiddo*, Sijia has taken the same Comfy-anchored workflow into professional commercial video work, on deadlines as tight as four days.
|
||||
|
||||
#### Ion Yunyang Li — L1LY
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/l1ly.gif" alt="Ion Yunyang Li, L1LY" />
|
||||
|
||||
**What Comfy enabled:** a repeatable multi-step pipeline that drops the filmmaker into a photorealistic world, because "a sequence of a prompt is not the only thing you need."
|
||||
|
||||
Ion taught himself ComfyUI in early 2025, from tutorials in the generative-AI community, and built his most distinctive Comfy work in a body-and-environment project: start from 3D-model stills, convert them to a pencil-sketch style so the model would not over-study the original 3D aesthetic, generate photorealistic frames from the sketches, build character T-poses, composite himself into the scene, and animate the stills with a video model.
|
||||
|
||||
<Quote>A sequence of a prompt is not the only thing you need. You need many different settings, and it is very hard to redo those settings every time.</Quote>
|
||||
|
||||
What he values as much as the pipeline is where it can run: the same Comfy setup moves across a workstation in his school cubicle, a remote session from his apartment laptop, and fully cloud-based instances, depending on where he is.
|
||||
|
||||
#### Sihan Wu — Scary Coaster
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/scary-coaster.gif" alt="Sihan Wu, Scary Coaster" />
|
||||
|
||||
**What Comfy enabled:** roughly 100 hand-drawn keyframes carried through a single workflow so a two-to-three-minute film stays visually consistent, on his first-ever AI project.
|
||||
|
||||
*Scary Coaster* (December 2024) was Sihan's first project ever made with AI. Coming from a digital-media and game-development undergrad, Sihan joined Professor Smith's Expanded Animation class and wanted something more controllable than the prompt-only tools on offer. The workflow he built: draw roughly 100 rough keyframes by hand, run them through Comfy to find a stylized Chinese-horror look, pick the favorite, then generate the in-betweens to produce the full sequence.
|
||||
|
||||
<Quote>I want to have a more controllable flow. I don't want to just use prompts and generate random images. I just use one workflow to create the whole two or three minutes, and I can make everything look very consistent.</Quote>
|
||||
|
||||
Sihan is honest that the on-ramp was steep: learning from the official ComfyUI GitHub workflows, combining them, and debugging Python environments along the way. His ask was specific: an official, beginner-to-advanced tutorial series. And his view on where AI should head next was equally specific: aim it at "the very time-consuming but not that creative process, like creating in-betweens," and leave the creative decisions to the artist.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Program", value: "Expanded Animation: Research + Practice (XA), USC School of Cinematic Arts" },
|
||||
{ label: "Founded", value: "2022, AI embedded in the MFA curriculum from day one" },
|
||||
{ label: "Setup", value: "Students' own GPU laptops + Comfy Cloud + lab research machine" },
|
||||
{ label: "Core techniques", value: "IP-Adapter style transfer, custom LoRAs, masked compositing, keyframe-to-in-between pipelines" },
|
||||
{ label: "Outcomes", value: "Amazing student works from Sihan, and Ion, Sijia" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6">
|
||||
|
||||
### What excites you about where this is going?
|
||||
|
||||
I have a philosophy that everyone is an artist. They just forget that they are an artist. Creativity drives everything, and the tools we are getting now make it possible for more people to find that capacity in themselves. ComfyUI, because it is node-based and visual and open, gives non-programmers a way forward that is honest about how the model works. It does not pretend the AI is doing something magical. It shows the artist what is happening at each step.
|
||||
|
||||
The two basic rights of human life are health and education. The work Comfy is doing on the education side is touching something integral. The students who came through XA are already extending the work in directions the program did not anticipate, and the next generation of educators and students will keep doing the same.
|
||||
|
||||
<Quote>Everyone is an artist. They just forget that they are an artist.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio people={[{ name: "Kathy Smith", photo: "https://media.comfy.org/website/customers/kathy-smith/kathy-smith.jpg", bio: `Kathy Smith is Professor of Cinematic Arts at USC's School of Cinematic Arts and inaugural director (2022-2023) of Expanded Animation: Research + Practice (XA), the experimental MFA program she helped found in 2022 to integrate AI into animation pedagogy from the first day of the degree. To date she is the longest-serving chair of combined USC animation programs and has been exploring concepts of AI in her creative practice since 1998.` }]} />
|
||||
|
||||
<EducationCta />
|
||||
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>
|
||||
56
apps/website/src/content/customers/en/ual-cci.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "Comfy and UAL's Creative Computing Institute Announce Creative Campus Partnership"
|
||||
category: "CREATIVE CAMPUS PARTNERSHIP"
|
||||
description: "Comfy announces Creative Campus Partnership to support teaching and research across UAL CCI's masters, PhD, and industry programmes"
|
||||
cover: "https://media.comfy.org/website/customers/ual-cci/cover.png"
|
||||
order: 9
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "WHAT CCI DOES"
|
||||
- id: topic-3
|
||||
label: "THE PARTNERSHIP"
|
||||
- id: topic-4
|
||||
label: "ABOUT CCI"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Comfy Org, the team behind ComfyUI, the open-source node-based interface for generative AI, and the Creative Computing Institute (CCI) at University of the Arts London today announced a Creative Campus partnership, making CCI a founding partner of the [Comfy Education Initiative](https://comfy.org/education).
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
CCI already runs ComfyUI at every level of the institute. On the Applied Machine Learning for Creatives masters course, students build image, video, audio, and text workflows, train their own models, and construct interactive pipelines. PhD researchers use Comfy for fine-tuning, custom datasets, and custom node development. The institute also uses ComfyUI in industry training, where its node-based interface gives non-technical collaborators a way into generative AI that code alone does not.
|
||||
|
||||
<Quote name="Prof Mick Grierson, Research Leader, UAL Creative Computing Institute">ComfyUI has become part of how we teach, research, and work with industry. It is one of the few generative AI environments where the workflows our students build are portable, inspectable, and forkable, and that open-source foundation is exactly what a university should be teaching on.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
Through the partnership, CCI educators and students gain access to classroom licenses with central billing & administration, educational discounts, early access to upcoming team features, a dedicated educator community with direct support from the Comfy team, and a voice in shaping the future of the education program.
|
||||
|
||||
Creative Campus partnerships are the deepest tier of the program: a direct, ongoing collaboration in which an institution works hand in hand with the Comfy team to roll out ComfyUI across teaching, research, and industry training.
|
||||
|
||||
<Quote name="The Comfy Team">CCI is the model we hope every creative campus follows: ComfyUI in the masters classroom, in PhD research, and in industry collaboration, all at once. As our first Creative Campus Partner, they are helping us design an education program that works the way universities actually work.</Quote>
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ual-cci/cci-camberwell.jpg" alt="Creative Computing Institute campus at UAL" caption="Creative Computing Institute Campus. Photo: Ana Escobar, courtesy UAL." />
|
||||
|
||||
The institute is leading a major £1.5 million publicly funded research programme developing copyright-compliant audiovisual foundation models for the UK's creative industries. Bringing together expertise in sound, image, and artificial intelligence, the project is building open tools and responsible AI national infrastructure designed to support UK creative production, research, and experimentation across the sector.
|
||||
|
||||
The outputs of the research will explore wider dissemination and adoption through open, node-based tools such as ComfyUI to support experimentation, workflows, and collaboration around emerging multimodal AI systems.
|
||||
|
||||
UAL CCI joins a founding cohort of educators and institutions featured at the launch of the Comfy Education Initiative, alongside researchers such as CCI co-founder Dr. Phoenix Perry, whose Antigravity Machine project Comfy supports as an industry partner.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="About the Creative Computing Institute at UAL">
|
||||
|
||||
The Creative Computing Institute at University of the Arts London applies computing to creativity and social impact, operating at the intersection of computational technologies and creative practice, teaching undergraduate, postgraduate, and PhD students alongside research and industry collaboration.
|
||||
|
||||
</Section>
|
||||
|
||||
<EducationCta />
|
||||
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>
|
||||
103
apps/website/src/content/customers/en/xindi-zhang.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: "The tool that expands my art: Xindi Zhang's Oscar-shortlisted thesis, built in ComfyUI"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "How a USC Expanded Animation thesis became a Student Academy Award winner, an Oscar shortlist entry, and helped land a job at Amazon — with the artist's own illustrations as the style guide."
|
||||
cover: "https://media.comfy.org/website/customers/xindi-zhang/cover.webp"
|
||||
order: 5
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-3
|
||||
label: "THE PIPELINE"
|
||||
- id: topic-4
|
||||
label: "AT A GLANCE"
|
||||
- id: topic-5
|
||||
label: "WHAT'S NEXT"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
<Embed src="https://player.vimeo.com/video/1131160045" title="The Song of Drifters by Xindi Zhang" />
|
||||
|
||||
*From The Song of Drifters. Film images: Xindi Zhang.*
|
||||
|
||||
### Tell us about The Song of Drifters. What is it about, and where did it start?
|
||||
|
||||
The Song of Drifters is a documentary animation about people caught between leaving and returning, wanderers who drift through unfamiliar cities, holding onto memories of a homeland out of reach and searching for a sense of belonging. The title is a direct translation from an ancient Chinese poem about a mother's love for a child who leaves her hometown. My version takes the opposite point of view, from the child's perspective.
|
||||
|
||||
I built the film in ComfyUI. When I started, I was not trying to show what AI could do. I was trying to prove something almost opposite.
|
||||
|
||||
<Quote>It started as a challenge to the stereotype that AI-generated work is generic and cheap. I wanted to prove that AI could be an amplifier for personal vision, not a replacement for it.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### You came to this from illustration, not engineering. How did you end up in ComfyUI?
|
||||
|
||||
I started as an illustrator. I earned my BFA in illustration at the Rhode Island School of Design, then worked as a game concept artist, where I picked up shaders, Unity, and Unreal. That technical side made me a fast learner with new tools. Later I went to USC's School of Cinematic Arts for an MFA in Expanded Animation, where I studied with Professor Kathy Smith.
|
||||
|
||||
By my thesis year I had moved from Stable Diffusion's standard interfaces to ComfyUI, because I think in node-based structures and I wanted to control every step. Most AI tools are one click: you prompt, you click, you get a result. That is not what I wanted.
|
||||
|
||||
<Quote>I want to control the process, and the process is even more important than the result itself. For artists like me, I don't want to automate anything. I want to participate in every single stage of designing the workflow. That's the fun part of it.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### Walk us through the pipeline. What were you actually feeding the model?
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/balloon-workflow.png" alt="Xindi's ComfyUI workflow for the balloon sequence">Xindi's ComfyUI workflow for the balloon sequence. Source: [xindizhangart.com](https://xindizhangart.com).</Figure>
|
||||
|
||||
My core technique was style transfer in Stable Diffusion 1.5, driven by IP-Adapter and ControlNet. What mattered most was what I fed it: my own work. The base materials were live-action footage I shot on an iPhone 15 Pro and 3D animation I built in Blender. The AI restyled imagery I had already made. It did not invent it.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/film-still.jpg" alt="Style-guide still from The Song of Drifters">Style-guide still from The Song of Drifters. Source: [xindizhangart.com](https://xindizhangart.com).</Figure>
|
||||
|
||||
<Quote>Unlike most AI-generated videos, which use other artists' works from the model, I use my own illustrations as the style guide.</Quote>
|
||||
|
||||
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/style-transfer-workflow.json" label="Download Xindi's style transfer workflow (json) on ComfyUI" />
|
||||
|
||||
I also trained custom LoRAs on my own video, footage of the cities I had lived in. Capturing that footage became a vital part of the documentary process. Wandering through the streets where I once lived let me reconnect with those cities. Most of it never appears in the final cut, but it lives in the visuals as training data. The hybrid pipeline made rendering the final look more efficient and saved more time for ideation.
|
||||
|
||||
For the dream sequences I combined animated 3D with AI morphing, moving from abstract to concrete to mimic the feeling of being half awake.
|
||||
|
||||
<Video src="https://media.comfy.org/website/customers/xindi-zhang/bts-clip.mp4" poster="https://media.comfy.org/website/customers/xindi-zhang/bts-poster.jpg" caption="BTS clip, AI morphing. Source: Xindi Zhang." />
|
||||
|
||||
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/morphing-workflow.json" label="Download Xindi's AI morphing workflow (json) on ComfyUI" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Program", value: "USC School of Cinematic Arts — MFA Expanded Animation (thesis)" },
|
||||
{ label: "Base materials", value: "iPhone 15 Pro live-action; her own Blender 3D animation" },
|
||||
{ label: "Core technique", value: "Style transfer in SD 1.5 via IP-Adapter + ControlNet, in ComfyUI" },
|
||||
{ label: "Style source", value: "Her own illustrations + custom LoRAs trained on her own city footage" },
|
||||
{ label: "Finishing", value: "Depth, mask, and fade passes in After Effects; heavy compositing" },
|
||||
{ label: "Outcome", value: "Student Academy Awards Golden Award (2025); 98th Academy Awards shortlist; AI Creative role at Amazon AI Studio" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5">
|
||||
|
||||
### The film won gold at the Student Academy Awards and was shortlisted for the Oscars. What's next?
|
||||
|
||||
I made the film for creative reasons, not career ones. I honestly did not expect it to connect to a job at all. Then it won the Golden Award at the 2025 Student Academy Awards and was shortlisted for the Oscars, and the calls started.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/awards.png" alt="Xindi Zhang at the 2025 Student Academy Awards" caption="Xindi Zhang at the 2025 Student Academy Awards. Source: Oscars Press Office." />
|
||||
|
||||
What people wanted was the combination: someone who understands both traditional craft and AI tools. I now work as an AI Creative at Amazon AI Studio building custom production pipelines. I see that same demand across the industry, with ComfyUI experience starting to show up as a requirement in job postings at major studios and design agencies.
|
||||
|
||||
<Quote>It's not the tool that steals my art. It's the tool that expands my art.</Quote>
|
||||
|
||||
My advice to other students is not really about software. AI is just another tool to convey ideas, but nothing is more important than the story itself. If you use AI, use it on purpose. The more you understand it, the more freedom you have to make work that is genuinely yours.
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio people={[{ name: "Xindi Zhang", photo: "https://media.comfy.org/website/customers/xindi-zhang/profile.jpg", bio: `Xindi Zhang is a Chinese animation director and visual artist (RISD BFA in illustration, 2020; USC MFA in Expanded Animation, 2025). The Song of Drifters won the Golden Award at the 2025 Student Academy Awards and was shortlisted for the 98th Academy Awards. She works as an AI Creative at Amazon AI Studio, has collaborated with Sony Music's immersive studio, and is now on the faculty at the University of South Florida.` }]} />
|
||||
|
||||
<EducationCta />
|
||||
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>
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -61,6 +61,11 @@
|
||||
|
||||
@theme {
|
||||
--color-site-dropdown: #332b38;
|
||||
--color-site-bg-soft: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary-comfy-ink) 88%,
|
||||
black 12%
|
||||
);
|
||||
--color-primary-comfy-yellow: #f2ff59;
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-ink-light: #2a2330;
|
||||
@@ -261,6 +266,6 @@ video::-webkit-media-controls-panel {
|
||||
|
||||
:root {
|
||||
--site-bg: var(--color-primary-comfy-ink);
|
||||
--site-bg-soft: color-mix(in srgb, var(--site-bg) 88%, black 12%);
|
||||
--site-bg-soft: var(--color-site-bg-soft);
|
||||
--site-border-subtle: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
45
browser_tests/assets/linear-validation-warning.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
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
@@ -34,6 +34,10 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
@@ -92,13 +96,19 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
|
||||
@@ -172,6 +172,9 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
|
||||
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -15,9 +16,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
|
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
|
||||
{
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
@@ -266,9 +266,6 @@
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-400);
|
||||
|
||||
--video-trim-selection-background: var(--color-datatype-CLIP, #ffd500);
|
||||
--video-trim-playhead-background: #f0513b;
|
||||
|
||||
/* Default UI element color palette variables */
|
||||
--palette-contrast-mix-color: #fff;
|
||||
--palette-interface-panel-surface: var(--comfy-menu-bg);
|
||||
@@ -552,10 +549,6 @@
|
||||
);
|
||||
--color-component-node-widget-promoted: var(--component-node-widget-promoted);
|
||||
--color-component-node-widget-advanced: var(--component-node-widget-advanced);
|
||||
--color-video-trim-selection-background: var(
|
||||
--video-trim-selection-background
|
||||
);
|
||||
--color-video-trim-playhead-background: var(--video-trim-playhead-background);
|
||||
|
||||
/* Semantic tokens */
|
||||
--color-base-foreground: var(--base-foreground);
|
||||
|
||||
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
|
||||
|
||||
@@ -158,8 +158,8 @@ import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
@@ -178,7 +178,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
// Constants
|
||||
@@ -260,9 +260,9 @@ async function handleBuy() {
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
|
||||
// In workspace mode (personal workspace), show workspace settings panel
|
||||
// Otherwise, show legacy subscription/credits panel
|
||||
const settingsPanel = flags.teamWorkspacesEnabled
|
||||
// On the consolidated (workspace) billing flow, show the workspace settings
|
||||
// panel; otherwise show the legacy subscription/credits panel.
|
||||
const settingsPanel = shouldUseWorkspaceBilling.value
|
||||
? 'workspace'
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
|
||||
@@ -2,12 +2,11 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { defineComponent, nextTick, onMounted, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -35,19 +34,29 @@ vi.mock('@/services/customerEventsService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
checkForCompletedTopup: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
useTelemetry: () => mockTelemetry
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
const mockBillingRouting = vi.hoisted(() => ({
|
||||
shouldUseWorkspaceBilling: false
|
||||
}))
|
||||
vi.mock('@/composables/billing/useBillingRouting', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const shouldUseWorkspaceBilling = ref(false)
|
||||
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
|
||||
get: () => shouldUseWorkspaceBilling.value,
|
||||
set: (value: boolean) => {
|
||||
shouldUseWorkspaceBilling.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
|
||||
}
|
||||
})
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
@@ -68,7 +77,10 @@ const i18n = createI18n({
|
||||
additionalInfo: 'Additional Info',
|
||||
added: 'Added',
|
||||
accountInitialized: 'Account initialized',
|
||||
model: 'Model'
|
||||
model: 'Model',
|
||||
loadEventsError: 'Failed to load activity. Please try again.',
|
||||
loadEventsUnknownError:
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +107,11 @@ const AutoRefreshWrapper = defineComponent({
|
||||
template: '<UsageLogsTable ref="tableRef" />'
|
||||
})
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function makeEventsResponse(
|
||||
events: Partial<AuditLog>[],
|
||||
overrides: Record<string, unknown> = {}
|
||||
@@ -137,7 +154,7 @@ describe('UsageLogsTable', () => {
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -228,7 +245,7 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message when service throws', async () => {
|
||||
it('shows a localized fallback instead of a raw Error message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockRejectedValue(
|
||||
new Error('Network error')
|
||||
)
|
||||
@@ -236,7 +253,25 @@ describe('UsageLogsTable', () => {
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a localized fallback when the service reports no message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
|
||||
mockCustomerEventsService.error.value = null
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Failed to load activity. Please try again.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -341,8 +376,8 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
@@ -352,6 +387,90 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards a stale legacy response when routing flips mid-fetch', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveLegacy(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs top-up completion telemetry for a superseded response', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const legacyResponse = makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
resolveLegacy(legacyResponse)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
|
||||
legacyResponse.events
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
|
||||
@@ -96,11 +96,11 @@ import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
@@ -109,14 +109,15 @@ import {
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const events = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -139,7 +140,12 @@ const tooltipContentMap = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
// A billing-route flip can overlap two loads against different backends; only
|
||||
// the latest may mutate state, so a superseded response is discarded.
|
||||
let latestLoadToken = 0
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadToken = ++latestLoadToken
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -148,10 +154,17 @@ const loadEvents = async () => {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
}
|
||||
const response = useBillingApi.value
|
||||
const response = shouldUseWorkspaceBilling.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
|
||||
// Completion telemetry must run even when a mid-checkout route flip
|
||||
// supersedes this load, since legacy and workspace backends emit different
|
||||
// top-up events and the winning fetch may not carry the completion yet.
|
||||
useTelemetry()?.checkForCompletedTopup(response?.events)
|
||||
|
||||
if (loadToken !== latestLoadToken) return
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
events.value = response.events
|
||||
@@ -165,24 +178,25 @@ const loadEvents = async () => {
|
||||
pagination.value.limit = response.limit
|
||||
}
|
||||
|
||||
if (response.total) {
|
||||
if (response.total != null) {
|
||||
pagination.value.total = response.total
|
||||
}
|
||||
|
||||
if (response.totalPages) {
|
||||
if (response.totalPages != null) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
|
||||
// Check if a pending top-up has completed
|
||||
useTelemetry()?.checkForCompletedTopup(response.events)
|
||||
} else {
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
const legacyError = shouldUseWorkspaceBilling.value
|
||||
? null
|
||||
: customerEventService.error.value
|
||||
error.value = legacyError || t('credits.loadEventsError')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
if (loadToken !== latestLoadToken) return
|
||||
error.value = t('credits.loadEventsUnknownError')
|
||||
console.error('Error loading events:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (loadToken === latestLoadToken) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +212,12 @@ const refresh = async () => {
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
watch(shouldUseWorkspaceBilling, () => {
|
||||
refresh().catch((error) => {
|
||||
console.error('Error loading events:', error)
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
@@ -67,31 +67,18 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,15 +39,10 @@
|
||||
>
|
||||
<i class="icon-[lucide--monitor-x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
<LocateNodeButton
|
||||
:label="t('rightSidePanel.locateNode')"
|
||||
@locate="handleLocateNode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,6 +175,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import LocateNodeButton from './LocateNodeButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import LocateNodeButton from '@/components/rightSidePanel/errors/LocateNodeButton.vue'
|
||||
|
||||
describe('LocateNodeButton', () => {
|
||||
it('exposes the aria-label as the button accessible name', () => {
|
||||
render(LocateNodeButton, { props: { label: 'Locate node on canvas' } })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Locate node on canvas' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits locate when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = render(LocateNodeButton, {
|
||||
props: { label: 'Locate node on canvas' }
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(emitted().locate).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stops click propagation so an ancestor handler does not also fire', async () => {
|
||||
const user = userEvent.setup()
|
||||
let ancestorClicks = 0
|
||||
render(
|
||||
{
|
||||
components: { LocateNodeButton },
|
||||
template:
|
||||
'<div @click="onAncestorClick"><LocateNodeButton label="Locate node on canvas" /></div>',
|
||||
methods: {
|
||||
onAncestorClick() {
|
||||
ancestorClicks++
|
||||
}
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(ancestorClicks).toBe(0)
|
||||
})
|
||||
})
|
||||
23
src/components/rightSidePanel/errors/LocateNodeButton.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="label"
|
||||
@click.stop="emit('locate')"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { label } = defineProps<{
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locate: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -146,16 +146,11 @@
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
<LocateNodeButton
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
:label="t('rightSidePanel.locateNode')"
|
||||
@locate="handleLocateNode(primaryLocatableNodeType)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
@@ -195,16 +190,11 @@
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
<LocateNodeButton
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
:label="t('rightSidePanel.locateNode')"
|
||||
@locate="handleLocateNode(nodeType)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -218,6 +208,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import LocateNodeButton from '@/components/rightSidePanel/errors/LocateNodeButton.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
|
||||
@@ -215,17 +215,12 @@
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
<LocateNodeButton
|
||||
:label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
@locate="handleLocateNode(item.nodeId)"
|
||||
/>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
@@ -323,6 +318,7 @@ import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import LocateNodeButton from './LocateNodeButton.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
|
||||
@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -239,8 +239,7 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
@@ -254,7 +253,7 @@ const handleOpenPartnerNodesInfo = () => {
|
||||
}
|
||||
|
||||
const handleUpgradeToAddCredits = () => {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
function handleClick() {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
|
||||
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<TooltipRootProps>()
|
||||
const emits = defineEmits<TooltipRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TooltipRoot>
|
||||
</template>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
|
||||
import {
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
useForwardPropsEmits
|
||||
} from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
sideOffset = 4,
|
||||
class: className,
|
||||
arrowClass,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
TooltipContentProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
arrowClass?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
const emits = defineEmits<TooltipContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
sideOffset,
|
||||
...restProps
|
||||
}))
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 w-fit rounded-md border bg-base-background px-3 py-1.5 text-sm text-base-foreground shadow-md',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<TooltipArrow
|
||||
:class="cn('fill-base-background', arrowClass)"
|
||||
:width="10"
|
||||
:height="5"
|
||||
/>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import TooltipHint from './TooltipHint.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const meta: Meta<typeof TooltipHint> = {
|
||||
title: 'Components/Tooltip/TooltipHint',
|
||||
component: TooltipHint,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
content: 'Tooltip hint',
|
||||
side: 'top',
|
||||
delayDuration: 300,
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { TooltipHint, Button },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center p-16">
|
||||
<TooltipHint v-bind="args">
|
||||
<Button variant="secondary">Hover me</Button>
|
||||
</TooltipHint>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
content: 'Hidden tooltip'
|
||||
},
|
||||
render: Default.render
|
||||
}
|
||||
|
||||
export const IconButton: Story = {
|
||||
args: {
|
||||
content: 'Set start frame'
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { TooltipHint },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center p-16">
|
||||
<TooltipHint v-bind="args">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 cursor-pointer items-center justify-center rounded-lg bg-component-node-widget-background text-component-node-foreground"
|
||||
aria-label="Set start frame"
|
||||
>
|
||||
<i class="icon-[lucide--skip-back] size-4" />
|
||||
</button>
|
||||
</TooltipHint>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentProps } from 'reka-ui'
|
||||
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipProvider from '@/components/ui/tooltip/TooltipProvider.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
content,
|
||||
side = 'top',
|
||||
sideOffset = 4,
|
||||
delayDuration = 300,
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
content: string
|
||||
side?: TooltipContentProps['side']
|
||||
sideOffset?: number
|
||||
delayDuration?: number
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="delayDuration">
|
||||
<Tooltip :disabled="disabled">
|
||||
<TooltipTrigger as-child>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
:side
|
||||
:side-offset="sideOffset"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-md border border-node-component-tooltip-border bg-node-component-tooltip-surface px-2.5 py-1 text-xs leading-none text-node-component-tooltip shadow-none'
|
||||
)
|
||||
"
|
||||
arrow-class="fill-node-component-tooltip-surface"
|
||||
>
|
||||
{{ content }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipProviderProps } from 'reka-ui'
|
||||
import { TooltipProvider } from 'reka-ui'
|
||||
|
||||
const { ...restProps } = defineProps<TooltipProviderProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider v-bind="restProps">
|
||||
<slot />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipTriggerProps } from 'reka-ui'
|
||||
import { TooltipTrigger } from 'reka-ui'
|
||||
|
||||
const { ...restProps } = defineProps<TooltipTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipTrigger v-bind="restProps">
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
</template>
|
||||
@@ -1,169 +0,0 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref, toRefs } from 'vue'
|
||||
|
||||
import LoadVideoTrimPanel from './LoadVideoTrimPanel.vue'
|
||||
|
||||
const SAMPLE_VIDEO =
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof LoadVideoTrimPanel> & {
|
||||
trimEnabled?: boolean
|
||||
startFrame?: number
|
||||
endFrame?: number
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Video/LoadVideoTrimPanel',
|
||||
component: LoadVideoTrimPanel,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="w-[350px] rounded-2xl bg-node-component-surface p-2"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
trimEnabled: false,
|
||||
startFrame: 0,
|
||||
endFrame: 400
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function renderPanel(initialTrimEnabled: boolean) {
|
||||
return (args: StoryArgs) => ({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
const { videoUrl } = toRefs(args)
|
||||
const trimEnabled = ref(initialTrimEnabled)
|
||||
const startFrame = ref(args.startFrame ?? 0)
|
||||
const endFrame = ref(args.endFrame ?? 400)
|
||||
const playheadFrame = ref(0)
|
||||
return {
|
||||
videoUrl,
|
||||
trimEnabled,
|
||||
startFrame,
|
||||
endFrame,
|
||||
playheadFrame
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const TrimDisabled: Story = {
|
||||
render: renderPanel(false)
|
||||
}
|
||||
|
||||
export const TrimEnabled: Story = {
|
||||
render: renderPanel(true)
|
||||
}
|
||||
|
||||
export const EmptyNoVideo: Story = {
|
||||
args: {
|
||||
videoUrl: undefined
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
const trimEnabled = ref(false)
|
||||
const startFrame = ref(0)
|
||||
const endFrame = ref(0)
|
||||
const playheadFrame = ref(0)
|
||||
const uploading = ref(false)
|
||||
function handleBrowse() {
|
||||
uploading.value = true
|
||||
setTimeout(() => {
|
||||
uploading.value = false
|
||||
}, 1200)
|
||||
}
|
||||
return {
|
||||
args,
|
||||
trimEnabled,
|
||||
startFrame,
|
||||
endFrame,
|
||||
playheadFrame,
|
||||
uploading,
|
||||
handleBrowse
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
:video-url="args.videoUrl"
|
||||
:uploading="uploading"
|
||||
@browse="handleBrowse"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const EmptyNodeLayout: Story = {
|
||||
args: {
|
||||
videoUrl: undefined
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
const trimEnabled = ref(false)
|
||||
const startFrame = ref(0)
|
||||
const endFrame = ref(0)
|
||||
const playheadFrame = ref(0)
|
||||
const uploading = ref(false)
|
||||
return {
|
||||
args,
|
||||
trimEnabled,
|
||||
startFrame,
|
||||
endFrame,
|
||||
playheadFrame,
|
||||
uploading
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="px-2">
|
||||
<label class="mb-1 block text-sm text-muted-foreground">video</label>
|
||||
<div class="flex h-8 items-center justify-between rounded-lg bg-component-node-widget-background px-2 text-sm text-text-secondary">
|
||||
<span>Browse asset library</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-component-node-foreground-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<LoadVideoTrimPanel
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
:video-url="args.videoUrl"
|
||||
:uploading="uploading"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LongVideoManyFrames: Story = {
|
||||
args: {
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
startFrame: 120,
|
||||
endFrame: 3600
|
||||
},
|
||||
render: renderPanel(true)
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import LoadVideoTrimPanel from './LoadVideoTrimPanel.vue'
|
||||
|
||||
vi.mock('@/composables/video/useVideoFilmstrip', () => ({
|
||||
DEFAULT_VIDEO_FPS: 30,
|
||||
useVideoFilmstrip: () => ({
|
||||
thumbnails: ref<string[]>(['data:image/jpeg;base64,one']),
|
||||
duration: ref(10),
|
||||
totalFrames: ref(101),
|
||||
width: ref(1920),
|
||||
height: ref(1080),
|
||||
fps: ref(30),
|
||||
fileSize: ref(5 * 1024 * 1024),
|
||||
loading: ref(false)
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
increment: 'Increment',
|
||||
decrement: 'Decrement',
|
||||
remove: 'Remove'
|
||||
},
|
||||
loadVideoTrim: {
|
||||
trimVideo: 'Trim Video',
|
||||
startFrame: 'Start Frame',
|
||||
endFrame: 'End Frame',
|
||||
setStartFrame: 'Set start frame',
|
||||
setEndFrame: 'Set end frame',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
adjustStartFrame: 'Adjust start frame',
|
||||
adjustEndFrame: 'Adjust end frame',
|
||||
duration: 'Duration',
|
||||
frames: 'Number of Frames',
|
||||
fileSize: 'File Size',
|
||||
durationZero: '0s',
|
||||
durationSeconds: '{count}s',
|
||||
fileSizeUnknown: '—',
|
||||
fileSizeBytes: '{count} B',
|
||||
fileSizeKilobytes: '{count} KB',
|
||||
fileSizeMegabytes: '{count} MB',
|
||||
resolution: '{width} × {height}',
|
||||
dragAndDropVideos: 'Drag and drop videos here to upload',
|
||||
uploadFromDevice: 'Upload from device',
|
||||
uploading: 'Uploading…',
|
||||
loadingVideo: 'Loading video preview'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type PanelProps = ComponentProps<typeof LoadVideoTrimPanel>
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function renderPanel(props: PanelProps) {
|
||||
return render(LoadVideoTrimPanel, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('LoadVideoTrimPanel', () => {
|
||||
it('shows upload empty state and hides trim controls when no video', () => {
|
||||
renderPanel({
|
||||
videoUrl: undefined
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('media-upload-empty')).toBeTruthy()
|
||||
expect(screen.queryByText('Trim Video')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows trim controls when video is loaded', () => {
|
||||
renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('media-upload-empty')).toBeNull()
|
||||
expect(screen.getByText('Trim Video')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the filmstrip visible when trim is toggled off', () => {
|
||||
renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
trimEnabled: false
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('trim-track')).toBeTruthy()
|
||||
expect(screen.queryByText('Start Frame')).toBeNull()
|
||||
expect(screen.queryByText('End Frame')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows drag and drop empty state while not uploading', () => {
|
||||
renderPanel({
|
||||
videoUrl: undefined,
|
||||
uploading: false
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('media-upload-browse-button')).toBeTruthy()
|
||||
expect(screen.queryByText('Uploading…')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows uploading state only while an upload is in progress', () => {
|
||||
renderPanel({
|
||||
videoUrl: undefined,
|
||||
uploading: true
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
|
||||
expect(screen.getByText('Uploading…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows remove button and emits remove when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
})
|
||||
|
||||
const removeButton = screen.getByTestId('video-remove-button')
|
||||
expect(removeButton).toBeTruthy()
|
||||
expect(removeButton).toHaveAttribute('aria-label', 'Remove')
|
||||
|
||||
await user.click(removeButton)
|
||||
expect(emitted().remove).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('activates remove from keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
})
|
||||
|
||||
const removeButton = screen.getByTestId('video-remove-button')
|
||||
removeButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(emitted().remove).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('forwards browse event from empty state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderPanel({
|
||||
videoUrl: undefined
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('media-upload-browse-button'))
|
||||
|
||||
expect(emitted().browse).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps playhead when trim edges move without collision', async () => {
|
||||
const playheadFrame = ref(50)
|
||||
const startFrame = ref(10)
|
||||
const endFrame = ref(80)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
startFrame.value = 20
|
||||
await Promise.resolve()
|
||||
|
||||
expect(playheadFrame.value).toBe(50)
|
||||
})
|
||||
|
||||
it('moves playhead when trim edge collides with it', async () => {
|
||||
const playheadFrame = ref(50)
|
||||
const startFrame = ref(10)
|
||||
const endFrame = ref(80)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
startFrame.value = 60
|
||||
await Promise.resolve()
|
||||
|
||||
expect(playheadFrame.value).toBe(60)
|
||||
})
|
||||
|
||||
it('moves playhead when start frame increment passes playhead', async () => {
|
||||
const user = userEvent.setup()
|
||||
const playheadFrame = ref(50)
|
||||
const startFrame = ref(50)
|
||||
const endFrame = ref(80)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
await user.click(screen.getAllByTestId('increment')[0])
|
||||
|
||||
expect(startFrame.value).toBe(51)
|
||||
expect(playheadFrame.value).toBe(51)
|
||||
})
|
||||
|
||||
it('disables set start and end frame when trim handles are at defaults', () => {
|
||||
renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
trimEnabled: true,
|
||||
startFrame: 0,
|
||||
endFrame: 100,
|
||||
playheadFrame: 0
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Set start frame')).toBeDisabled()
|
||||
expect(screen.getByLabelText('Set end frame')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables set end frame when trim end is already at the last frame', () => {
|
||||
renderPanel({
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
trimEnabled: true,
|
||||
startFrame: 10,
|
||||
endFrame: 100,
|
||||
playheadFrame: 50
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Set start frame')).not.toBeDisabled()
|
||||
expect(screen.getByLabelText('Set end frame')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('resets the start trim handle to the first frame', async () => {
|
||||
const user = userEvent.setup()
|
||||
const startFrame = ref(10)
|
||||
const endFrame = ref(100)
|
||||
const playheadFrame = ref(50)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
await user.click(screen.getByLabelText('Set start frame'))
|
||||
|
||||
expect(startFrame.value).toBe(0)
|
||||
expect(playheadFrame.value).toBe(0)
|
||||
})
|
||||
|
||||
it('resets the end trim handle to the last frame', async () => {
|
||||
const user = userEvent.setup()
|
||||
const startFrame = ref(10)
|
||||
const endFrame = ref(80)
|
||||
const playheadFrame = ref(50)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
await user.click(screen.getByLabelText('Set end frame'))
|
||||
|
||||
expect(endFrame.value).toBe(100)
|
||||
expect(playheadFrame.value).toBe(100)
|
||||
})
|
||||
|
||||
it('seeks the video preview when scrubbing the filmstrip', async () => {
|
||||
const playheadFrame = ref(0)
|
||||
const startFrame = ref(0)
|
||||
const endFrame = ref(100)
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { LoadVideoTrimPanel },
|
||||
setup() {
|
||||
return {
|
||||
playheadFrame,
|
||||
startFrame,
|
||||
endFrame,
|
||||
trimEnabled: ref(true),
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<LoadVideoTrimPanel
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:trim-enabled="trimEnabled"
|
||||
:video-url="videoUrl"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
const video = screen.getByTestId('video-preview') as HTMLVideoElement
|
||||
let currentTime = 0
|
||||
Object.defineProperty(video, 'currentTime', {
|
||||
get: () => currentTime,
|
||||
set: (value: number) => {
|
||||
currentTime = value
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(video, 'duration', {
|
||||
value: 10,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
await fireEvent.loadedMetadata(video)
|
||||
await flushPromises()
|
||||
await fireEvent.seeked(video)
|
||||
await flushPromises()
|
||||
|
||||
const track = screen.getByTestId('trim-track')
|
||||
vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 64,
|
||||
right: 200,
|
||||
bottom: 64,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
track.setPointerCapture = vi.fn()
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event -- pointer capture scrubbing needs low-level pointer events
|
||||
await fireEvent.pointerDown(track, {
|
||||
clientX: 100,
|
||||
button: 0,
|
||||
pointerId: 1
|
||||
})
|
||||
await flushPromises()
|
||||
await fireEvent.seeked(video)
|
||||
await flushPromises()
|
||||
|
||||
expect(playheadFrame.value).toBe(50)
|
||||
expect(currentTime).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -1,501 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
:class="!videoUrl && 'min-h-0 flex-1 pb-3'"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<MediaUploadEmpty
|
||||
v-if="!videoUrl"
|
||||
fill
|
||||
accept="video/*"
|
||||
:disabled="uploadDisabled"
|
||||
:uploading
|
||||
:on-drag-over
|
||||
:on-drag-drop
|
||||
@browse="emit('browse')"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
data-testid="video-preview-container"
|
||||
class="group relative w-full"
|
||||
:style="videoAspectRatioStyle"
|
||||
>
|
||||
<div
|
||||
class="relative size-full overflow-hidden rounded-lg bg-node-component-surface"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
data-testid="video-preview"
|
||||
:src="videoUrl"
|
||||
class="size-full object-contain"
|
||||
preload="auto"
|
||||
muted
|
||||
playsinline
|
||||
@loadedmetadata="handleVideoMetadata"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
/>
|
||||
<div
|
||||
v-if="filmstripLoading"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-0 bg-node-component-surface"
|
||||
data-testid="video-preview-loading"
|
||||
:aria-busy="true"
|
||||
:aria-label="t('loadVideoTrim.loadingVideo')"
|
||||
>
|
||||
<Loader size="md" variant="loader-circle" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('loadVideoTrim.loadingVideo') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipHint v-if="!filmstripLoading" :content="t('g.remove')">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="video-remove-button"
|
||||
:class="
|
||||
cn(
|
||||
removeButtonClass,
|
||||
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
|
||||
)
|
||||
"
|
||||
:aria-label="t('g.remove')"
|
||||
@pointerdown.stop
|
||||
@click.stop="emit('remove')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</TooltipHint>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="videoUrl"
|
||||
class="grid grid-cols-[minmax(80px,min-content)_minmax(125px,1fr)] gap-1"
|
||||
>
|
||||
<WidgetToggleSwitch
|
||||
v-model="trimEnabled"
|
||||
class="col-span-full grid grid-cols-subgrid"
|
||||
:widget="trimToggleWidget"
|
||||
/>
|
||||
|
||||
<VideoFilmstripTrim
|
||||
v-model:start-frame="startFrame"
|
||||
v-model:end-frame="endFrame"
|
||||
v-model:playhead-frame="playheadFrame"
|
||||
v-model:is-playing="isPlaying"
|
||||
class="col-span-full mt-2"
|
||||
:trim-enabled="trimEnabled"
|
||||
:total-frames="effectiveTotalFrames"
|
||||
:thumbnails="thumbnails"
|
||||
@scrub="handleScrub"
|
||||
/>
|
||||
|
||||
<WidgetInputNumberInput
|
||||
v-if="trimEnabled"
|
||||
v-model="startFrame"
|
||||
root-class="col-span-full grid grid-cols-subgrid items-center"
|
||||
:widget="startFrameWidget"
|
||||
/>
|
||||
|
||||
<WidgetInputNumberInput
|
||||
v-if="trimEnabled"
|
||||
v-model="endFrame"
|
||||
root-class="col-span-full grid grid-cols-subgrid items-center"
|
||||
:widget="endFrameWidget"
|
||||
/>
|
||||
|
||||
<div v-if="trimEnabled" class="col-span-full grid grid-cols-2 gap-1">
|
||||
<TooltipHint
|
||||
:content="t('loadVideoTrim.setStartFrame')"
|
||||
:disabled="setStartFrameDisabled"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="WidgetInputActionButtonClass"
|
||||
:disabled="setStartFrameDisabled"
|
||||
:aria-label="t('loadVideoTrim.setStartFrame')"
|
||||
@click="setStartFrame"
|
||||
>
|
||||
<i class="icon-[lucide--skip-back] size-4" />
|
||||
</button>
|
||||
</TooltipHint>
|
||||
<TooltipHint
|
||||
:content="t('loadVideoTrim.setEndFrame')"
|
||||
:disabled="setEndFrameDisabled"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="WidgetInputActionButtonClass"
|
||||
:disabled="setEndFrameDisabled"
|
||||
:aria-label="t('loadVideoTrim.setEndFrame')"
|
||||
@click="setEndFrame"
|
||||
>
|
||||
<i class="icon-[lucide--skip-forward] size-4" />
|
||||
</button>
|
||||
</TooltipHint>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-span-full mt-2 grid grid-cols-subgrid gap-y-0.5 border-t border-node-stroke py-2"
|
||||
>
|
||||
<div
|
||||
v-for="row in metadataRows"
|
||||
:key="row.label"
|
||||
class="col-span-full grid grid-cols-subgrid py-0.5 text-sm"
|
||||
>
|
||||
<span class="truncate text-muted-foreground">{{ row.label }}</span>
|
||||
<span class="text-right text-base-foreground">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="resolutionLabel"
|
||||
class="col-span-full m-0 border-t border-node-stroke py-3 text-center text-sm text-base-foreground"
|
||||
>
|
||||
{{ resolutionLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import MediaUploadEmpty from '@/components/video/MediaUploadEmpty.vue'
|
||||
import VideoFilmstripTrim from '@/components/video/VideoFilmstripTrim.vue'
|
||||
import TooltipHint from '@/components/ui/tooltip/TooltipHint.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DEFAULT_VIDEO_FPS,
|
||||
useVideoFilmstrip
|
||||
} from '@/composables/video/useVideoFilmstrip'
|
||||
import { WidgetInputActionButtonClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
|
||||
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue'
|
||||
import WidgetToggleSwitch from '@/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const {
|
||||
videoUrl,
|
||||
uploading = false,
|
||||
uploadDisabled = false,
|
||||
onDragOver,
|
||||
onDragDrop
|
||||
} = defineProps<{
|
||||
videoUrl?: string
|
||||
uploading?: boolean
|
||||
uploadDisabled?: boolean
|
||||
onDragOver?: (event: DragEvent) => boolean
|
||||
onDragDrop?: (event: DragEvent) => boolean | Promise<boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: []
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const removeButtonClass =
|
||||
'absolute top-2 right-2 z-10 flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-transparent'
|
||||
|
||||
const trimEnabled = defineModel<boolean>('trimEnabled', { default: false })
|
||||
const startFrame = defineModel<number>('startFrame', { default: 0 })
|
||||
const endFrame = defineModel<number>('endFrame', { default: 0 })
|
||||
const playheadFrame = defineModel<number>('playheadFrame', { default: 0 })
|
||||
|
||||
const { t } = useI18n()
|
||||
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
|
||||
const isPlaying = ref(false)
|
||||
const isSeeking = ref(false)
|
||||
const videoIntrinsicSize = ref<{ width: number; height: number } | null>(null)
|
||||
let activeSeekId = 0
|
||||
|
||||
const videoUrlRef = computed(() => videoUrl)
|
||||
const {
|
||||
thumbnails,
|
||||
duration,
|
||||
totalFrames,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
fileSize,
|
||||
loading: filmstripLoading
|
||||
} = useVideoFilmstrip(videoUrlRef)
|
||||
|
||||
const effectiveTotalFrames = computed(() => Math.max(totalFrames.value, 1))
|
||||
|
||||
const frameMax = computed(() => Math.max(totalFrames.value - 1, 0))
|
||||
|
||||
const controlsDisabled = computed(() => !trimEnabled.value || !videoUrl)
|
||||
|
||||
const setStartFrameDisabled = computed(
|
||||
() => controlsDisabled.value || startFrame.value <= 0
|
||||
)
|
||||
|
||||
const setEndFrameDisabled = computed(
|
||||
() => controlsDisabled.value || endFrame.value >= frameMax.value
|
||||
)
|
||||
|
||||
const trimToggleWidget = computed(
|
||||
(): SimplifiedWidget<boolean> => ({
|
||||
name: 'trim_enabled',
|
||||
label: t('loadVideoTrim.trimVideo'),
|
||||
type: 'toggle',
|
||||
value: trimEnabled.value
|
||||
})
|
||||
)
|
||||
|
||||
const startFrameWidget = computed(
|
||||
(): SimplifiedWidget<number> => ({
|
||||
name: 'start_frame',
|
||||
label: t('loadVideoTrim.startFrame'),
|
||||
type: 'number',
|
||||
value: startFrame.value,
|
||||
options: {
|
||||
min: 0,
|
||||
max: Math.max(endFrame.value - 1, 0),
|
||||
step: 1,
|
||||
step2: 1,
|
||||
precision: 0,
|
||||
disabled: !videoUrl
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const endFrameWidget = computed(
|
||||
(): SimplifiedWidget<number> => ({
|
||||
name: 'end_frame',
|
||||
label: t('loadVideoTrim.endFrame'),
|
||||
type: 'number',
|
||||
value: endFrame.value,
|
||||
options: {
|
||||
min: Math.min(startFrame.value + 1, effectiveTotalFrames.value - 1),
|
||||
max: Math.max(effectiveTotalFrames.value - 1, 0),
|
||||
step: 1,
|
||||
step2: 1,
|
||||
precision: 0,
|
||||
disabled: !videoUrl
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const videoAspectRatioStyle = computed(() => {
|
||||
const intrinsic = videoIntrinsicSize.value
|
||||
const aspectWidth = width.value || intrinsic?.width
|
||||
const aspectHeight = height.value || intrinsic?.height
|
||||
if (aspectWidth && aspectHeight) {
|
||||
return { aspectRatio: `${aspectWidth} / ${aspectHeight}` }
|
||||
}
|
||||
return { aspectRatio: '16 / 9' }
|
||||
})
|
||||
|
||||
const metadataRows = computed(() => [
|
||||
{
|
||||
label: t('loadVideoTrim.duration'),
|
||||
value: formatDuration(duration.value)
|
||||
},
|
||||
{
|
||||
label: t('loadVideoTrim.frames'),
|
||||
value: String(effectiveTotalFrames.value)
|
||||
},
|
||||
{
|
||||
label: t('loadVideoTrim.fileSize'),
|
||||
value: formatFileSize(fileSize.value)
|
||||
}
|
||||
])
|
||||
|
||||
const resolutionLabel = computed(() => {
|
||||
const intrinsic = videoIntrinsicSize.value
|
||||
const displayWidth = width.value || intrinsic?.width
|
||||
const displayHeight = height.value || intrinsic?.height
|
||||
if (!displayWidth || !displayHeight) return ''
|
||||
return t('loadVideoTrim.resolution', {
|
||||
width: displayWidth,
|
||||
height: displayHeight
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => videoUrl,
|
||||
() => {
|
||||
startFrame.value = 0
|
||||
playheadFrame.value = 0
|
||||
endFrame.value = 0
|
||||
isPlaying.value = false
|
||||
videoIntrinsicSize.value = null
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
totalFrames,
|
||||
(frames) => {
|
||||
if (!videoUrl || frames <= 0) return
|
||||
const lastFrame = Math.max(frames - 1, 0)
|
||||
if (endFrame.value === 0 || endFrame.value > lastFrame) {
|
||||
endFrame.value = lastFrame
|
||||
}
|
||||
playheadFrame.value = clamp(playheadFrame.value, 0, frameMax.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch([startFrame, endFrame], ([start, end]) => {
|
||||
if (start >= end && end > 0) {
|
||||
startFrame.value = Math.max(end - 1, 0)
|
||||
}
|
||||
resolvePlayheadTrimCollision()
|
||||
})
|
||||
|
||||
watch(isPlaying, (playing) => {
|
||||
void handlePlaybackChange(playing)
|
||||
})
|
||||
|
||||
async function handlePlaybackChange(playing: boolean) {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
if (playing) {
|
||||
const startAt = trimEnabled.value
|
||||
? clamp(playheadFrame.value, startFrame.value, endFrame.value)
|
||||
: clamp(playheadFrame.value, 0, frameMax.value)
|
||||
await seekPreviewToFrame(startAt)
|
||||
if (!isPlaying.value) return
|
||||
try {
|
||||
await video.play()
|
||||
} catch {
|
||||
isPlaying.value = false
|
||||
}
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
|
||||
function frameToTime(frame: number) {
|
||||
if (duration.value > 0 && frameMax.value > 0) {
|
||||
return (frame / frameMax.value) * duration.value
|
||||
}
|
||||
return frame / (fps.value || DEFAULT_VIDEO_FPS)
|
||||
}
|
||||
|
||||
function clampSeekTime(video: HTMLVideoElement, time: number) {
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0) {
|
||||
return Math.max(time, 0)
|
||||
}
|
||||
return clamp(time, 0, Math.max(video.duration - 0.001, 0))
|
||||
}
|
||||
|
||||
function waitForVideoSeek(video: HTMLVideoElement): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
video.removeEventListener('seeked', finish)
|
||||
video.removeEventListener('error', finish)
|
||||
resolve()
|
||||
}
|
||||
video.addEventListener('seeked', finish, { once: true })
|
||||
video.addEventListener('error', finish, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function seekPreviewToFrame(frame: number) {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
|
||||
const clamped = clamp(frame, 0, frameMax.value)
|
||||
playheadFrame.value = clamped
|
||||
|
||||
const targetTime = clampSeekTime(video, frameToTime(clamped))
|
||||
if (Math.abs(video.currentTime - targetTime) <= 0.0001) return
|
||||
|
||||
const seekId = ++activeSeekId
|
||||
isSeeking.value = true
|
||||
video.currentTime = targetTime
|
||||
await waitForVideoSeek(video)
|
||||
|
||||
if (seekId === activeSeekId) {
|
||||
isSeeking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePlayheadTrimCollision() {
|
||||
if (!trimEnabled.value) return
|
||||
|
||||
const start = startFrame.value
|
||||
const end = endFrame.value
|
||||
const previous = playheadFrame.value
|
||||
if (previous < start) {
|
||||
playheadFrame.value = start
|
||||
} else if (previous > end) {
|
||||
playheadFrame.value = end
|
||||
}
|
||||
if (!isPlaying.value && playheadFrame.value !== previous) {
|
||||
void seekPreviewToFrame(playheadFrame.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleScrub(frame: number) {
|
||||
isPlaying.value = false
|
||||
void seekPreviewToFrame(frame)
|
||||
}
|
||||
|
||||
function handleVideoMetadata() {
|
||||
const video = videoRef.value
|
||||
if (video?.videoWidth && video.videoHeight) {
|
||||
videoIntrinsicSize.value = {
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight
|
||||
}
|
||||
}
|
||||
void seekPreviewToFrame(playheadFrame.value)
|
||||
}
|
||||
|
||||
function timeToFrame(time: number) {
|
||||
if (duration.value > 0 && frameMax.value > 0) {
|
||||
return Math.round((time / duration.value) * frameMax.value)
|
||||
}
|
||||
return Math.round(time * (fps.value || DEFAULT_VIDEO_FPS))
|
||||
}
|
||||
|
||||
function handleTimeUpdate() {
|
||||
const video = videoRef.value
|
||||
if (!video || !isPlaying.value || isSeeking.value) return
|
||||
|
||||
const frame = timeToFrame(video.currentTime)
|
||||
const minFrame = trimEnabled.value ? startFrame.value : 0
|
||||
const maxFrame = trimEnabled.value ? endFrame.value : frameMax.value
|
||||
playheadFrame.value = clamp(frame, minFrame, maxFrame)
|
||||
|
||||
if (frame >= maxFrame) {
|
||||
isPlaying.value = false
|
||||
void seekPreviewToFrame(maxFrame)
|
||||
}
|
||||
}
|
||||
|
||||
function setStartFrame() {
|
||||
isPlaying.value = false
|
||||
startFrame.value = 0
|
||||
void seekPreviewToFrame(0)
|
||||
}
|
||||
|
||||
function setEndFrame() {
|
||||
isPlaying.value = false
|
||||
endFrame.value = frameMax.value
|
||||
void seekPreviewToFrame(frameMax.value)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!seconds) return t('loadVideoTrim.durationZero')
|
||||
return t('loadVideoTrim.durationSeconds', { count: Math.round(seconds) })
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number) {
|
||||
if (bytes == null) return t('loadVideoTrim.fileSizeUnknown')
|
||||
if (bytes < 1024) {
|
||||
return t('loadVideoTrim.fileSizeBytes', { count: bytes })
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return t('loadVideoTrim.fileSizeKilobytes', {
|
||||
count: Math.round(bytes / 1024)
|
||||
})
|
||||
}
|
||||
return t('loadVideoTrim.fileSizeMegabytes', {
|
||||
count: Number((bytes / (1024 * 1024)).toFixed(1))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,80 +0,0 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MediaUploadEmpty from './MediaUploadEmpty.vue'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof MediaUploadEmpty>
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Video/MediaUploadEmpty',
|
||||
component: MediaUploadEmpty,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="w-[350px] rounded-2xl bg-node-component-surface p-2"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
accept: 'video/*',
|
||||
disabled: false,
|
||||
uploading: false
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MediaUploadEmpty },
|
||||
setup() {
|
||||
const uploading = ref(false)
|
||||
function handleBrowse() {
|
||||
uploading.value = true
|
||||
setTimeout(() => {
|
||||
uploading.value = false
|
||||
}, 1200)
|
||||
}
|
||||
return { args, uploading, handleBrowse }
|
||||
},
|
||||
template: `
|
||||
<MediaUploadEmpty
|
||||
v-bind="args"
|
||||
:uploading="uploading"
|
||||
@browse="handleBrowse"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Uploading: Story = {
|
||||
args: {
|
||||
uploading: true
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
export const Hovered: Story = {
|
||||
render: (args) => ({
|
||||
components: { MediaUploadEmpty },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<MediaUploadEmpty
|
||||
v-bind="args"
|
||||
class="border-component-node-foreground-secondary bg-component-node-widget-background-hovered"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MediaUploadEmpty from './MediaUploadEmpty.vue'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
|
||||
function useDropZone(
|
||||
target: { value: HTMLElement | null | undefined },
|
||||
options?:
|
||||
| {
|
||||
onDrop?: (files: File[] | null, event: DragEvent) => void
|
||||
onOver?: (files: File[] | null, event: DragEvent) => void
|
||||
onLeave?: (files: File[] | null, event: DragEvent) => void
|
||||
}
|
||||
| ((files: File[] | null, event: DragEvent) => void)
|
||||
) {
|
||||
const isOverDropZone = ref(false)
|
||||
const resolved =
|
||||
typeof options === 'function' ? { onDrop: options } : options
|
||||
|
||||
watch(
|
||||
() => target.value,
|
||||
(element, _, onCleanup) => {
|
||||
if (!element || !resolved) return
|
||||
const callbacks = resolved
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isOverDropZone.value = true
|
||||
callbacks.onOver?.(Array.from(event.dataTransfer?.files ?? []), event)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isOverDropZone.value = false
|
||||
callbacks.onDrop?.(Array.from(event.dataTransfer?.files ?? []), event)
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent) {
|
||||
isOverDropZone.value = false
|
||||
callbacks.onLeave?.(null, event)
|
||||
}
|
||||
|
||||
element.addEventListener('dragover', onDragOver)
|
||||
element.addEventListener('drop', onDrop)
|
||||
element.addEventListener('dragleave', onDragLeave)
|
||||
onCleanup(() => {
|
||||
element.removeEventListener('dragover', onDragOver)
|
||||
element.removeEventListener('drop', onDrop)
|
||||
element.removeEventListener('dragleave', onDragLeave)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { isOverDropZone }
|
||||
}
|
||||
|
||||
return { ...actual, useDropZone }
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
loadVideoTrim: {
|
||||
dragAndDropVideos: 'Drag and drop videos here to upload',
|
||||
uploadFromDevice: 'Upload from device',
|
||||
uploading: 'Uploading…'
|
||||
},
|
||||
g: {
|
||||
loading: 'Loading'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function dragPayload(files: File[] = []) {
|
||||
return {
|
||||
dataTransfer: {
|
||||
files,
|
||||
types: ['Files'],
|
||||
items: files.map((file) => ({
|
||||
kind: 'file',
|
||||
type: file.type,
|
||||
getAsFile: () => file
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderEmpty(
|
||||
props: Partial<ComponentProps<typeof MediaUploadEmpty>> = {}
|
||||
) {
|
||||
const result = render(MediaUploadEmpty, {
|
||||
props: {
|
||||
accept: 'video/*',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
return result
|
||||
}
|
||||
|
||||
async function simulateDrop(
|
||||
target: HTMLElement,
|
||||
payload: ReturnType<typeof dragPayload>
|
||||
) {
|
||||
await fireEvent.dragOver(target, payload)
|
||||
await fireEvent.drop(target, payload)
|
||||
}
|
||||
|
||||
describe('MediaUploadEmpty', () => {
|
||||
it('renders drag-drop prompt and upload button', async () => {
|
||||
await renderEmpty()
|
||||
|
||||
expect(screen.getByText('Drag and drop videos here to upload')).toBeTruthy()
|
||||
expect(screen.getByTestId('media-upload-browse-button')).toBeTruthy()
|
||||
expect(screen.getByText('Upload from device')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits browse when upload button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = await renderEmpty()
|
||||
|
||||
await user.click(screen.getByTestId('media-upload-browse-button'))
|
||||
|
||||
expect(emitted().browse).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits upload with video files on drop', async () => {
|
||||
const { emitted } = await renderEmpty()
|
||||
const zone = screen.getByTestId('media-upload-empty')
|
||||
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' })
|
||||
|
||||
await simulateDrop(zone, dragPayload([file]))
|
||||
|
||||
expect(emitted().upload).toHaveLength(1)
|
||||
expect((emitted().upload[0] as [File[]])[0][0].name).toBe('clip.mp4')
|
||||
})
|
||||
|
||||
it('delegates drag events to provided handlers', async () => {
|
||||
const onDragOver = vi.fn(() => true)
|
||||
const onDragDrop = vi.fn(() => true)
|
||||
await renderEmpty({ onDragOver, onDragDrop })
|
||||
const zone = screen.getByTestId('media-upload-empty')
|
||||
|
||||
await simulateDrop(zone, dragPayload([]))
|
||||
|
||||
expect(onDragOver).toHaveBeenCalled()
|
||||
expect(onDragDrop).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not emit browse when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = await renderEmpty({ disabled: true })
|
||||
|
||||
await user.click(screen.getByTestId('media-upload-browse-button'))
|
||||
|
||||
expect(emitted().browse).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows uploading spinner and hides upload controls while processing', async () => {
|
||||
await renderEmpty({
|
||||
uploading: true
|
||||
})
|
||||
|
||||
expect(screen.getByText('Uploading…')).toBeTruthy()
|
||||
expect(screen.queryByText('Drag and drop videos here to upload')).toBeNull()
|
||||
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not emit browse while uploading', async () => {
|
||||
await renderEmpty({ uploading: true })
|
||||
|
||||
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useDropZone } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
accept = 'video/*',
|
||||
disabled = false,
|
||||
uploading = false,
|
||||
fill = false,
|
||||
onDragOver,
|
||||
onDragDrop
|
||||
} = defineProps<{
|
||||
accept?: string
|
||||
disabled?: boolean
|
||||
uploading?: boolean
|
||||
fill?: boolean
|
||||
onDragOver?: (event: DragEvent) => boolean
|
||||
onDragDrop?: (event: DragEvent) => boolean | Promise<boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: []
|
||||
upload: [files: File[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
const canAcceptDrop = ref(false)
|
||||
|
||||
const isInteractionDisabled = computed(() => disabled || uploading)
|
||||
|
||||
function matchesAccept(file: File) {
|
||||
if (!accept || accept === '*/*') return true
|
||||
return accept.split(',').some((pattern) => {
|
||||
const trimmed = pattern.trim()
|
||||
if (trimmed.endsWith('/*')) {
|
||||
return file.type.startsWith(trimmed.slice(0, -1))
|
||||
}
|
||||
return file.type === trimmed
|
||||
})
|
||||
}
|
||||
|
||||
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
onDrop: (files, event) => {
|
||||
event?.stopPropagation()
|
||||
if (isInteractionDisabled.value) return
|
||||
|
||||
if (onDragDrop && event) {
|
||||
void Promise.resolve(onDragDrop(event)).catch(() => {})
|
||||
} else {
|
||||
const droppedFiles =
|
||||
files && files.length > 0
|
||||
? files
|
||||
: Array.from(event?.dataTransfer?.files ?? [])
|
||||
const accepted = droppedFiles.filter(matchesAccept)
|
||||
if (accepted.length) emit('upload', accepted)
|
||||
}
|
||||
canAcceptDrop.value = false
|
||||
},
|
||||
onOver: (_, event) => {
|
||||
if (isInteractionDisabled.value) {
|
||||
canAcceptDrop.value = false
|
||||
return
|
||||
}
|
||||
if (onDragOver && event) {
|
||||
canAcceptDrop.value = onDragOver(event)
|
||||
return
|
||||
}
|
||||
const items = event?.dataTransfer?.items
|
||||
canAcceptDrop.value = items
|
||||
? Array.from(items).some(
|
||||
(item) => item.kind === 'file' && matchesAcceptType(item.type)
|
||||
)
|
||||
: false
|
||||
},
|
||||
onLeave: () => {
|
||||
canAcceptDrop.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function matchesAcceptType(type: string) {
|
||||
if (!accept || accept === '*/*') return true
|
||||
return accept.split(',').some((pattern) => {
|
||||
const trimmed = pattern.trim()
|
||||
if (trimmed.endsWith('/*')) {
|
||||
return type.startsWith(trimmed.slice(0, -1))
|
||||
}
|
||||
return type === trimmed
|
||||
})
|
||||
}
|
||||
|
||||
const isHovered = computed(
|
||||
() =>
|
||||
!isInteractionDisabled.value && canAcceptDrop.value && isOverDropZone.value
|
||||
)
|
||||
|
||||
function handleBrowseClick() {
|
||||
if (isInteractionDisabled.value) return
|
||||
emit('browse')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dropZoneRef"
|
||||
data-testid="media-upload-empty"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-75 w-full min-w-75 flex-col items-center justify-center gap-0 rounded-lg border border-dashed border-node-component-border bg-node-component-surface px-6 py-8 transition-colors',
|
||||
fill && 'size-full flex-1',
|
||||
isHovered &&
|
||||
'border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="uploading">
|
||||
<Loader size="md" variant="loader-circle" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('loadVideoTrim.uploading') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i
|
||||
class="icon-[lucide--upload] size-8 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p class="max-w-48 text-center text-sm/snug text-muted-foreground">
|
||||
{{ t('loadVideoTrim.dragAndDropVideos') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
class="min-w-40"
|
||||
:disabled="disabled"
|
||||
data-testid="media-upload-browse-button"
|
||||
@click="handleBrowseClick"
|
||||
>
|
||||
{{ t('loadVideoTrim.uploadFromDevice') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||