Compare commits
43 Commits
proto/load
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61d1cbfdb0 | ||
|
|
14666b09c4 | ||
|
|
efb0365bc3 | ||
|
|
065bc0c336 | ||
|
|
1248c4628a | ||
|
|
ee83d67834 | ||
|
|
f63b7d866e | ||
|
|
068191ea47 | ||
|
|
07c4b230b2 | ||
|
|
9ed51f1e4b | ||
|
|
4a91fa4849 | ||
|
|
0991905a89 | ||
|
|
df6764762b | ||
|
|
2d2b318450 | ||
|
|
0f94da8746 | ||
|
|
d80427d014 | ||
|
|
d02e665290 | ||
|
|
dc83cc4df6 | ||
|
|
8b81a4f359 | ||
|
|
8f567e8ef0 | ||
|
|
4fb282f853 | ||
|
|
d17a387ddb | ||
|
|
68ba0aa613 | ||
|
|
675140c164 | ||
|
|
64706c53c3 | ||
|
|
bfa94d4118 | ||
|
|
b7708d5ad0 | ||
|
|
564de12d46 | ||
|
|
5a1f788230 | ||
|
|
a6db1ab3d6 | ||
|
|
2ec2a0e091 | ||
|
|
9cf5c9a93f | ||
|
|
9e5fb67b76 | ||
|
|
690e0e3590 | ||
|
|
01738b7b19 | ||
|
|
be9de941c9 | ||
|
|
f4e0430072 | ||
|
|
c78592c1ec | ||
|
|
00b0c6b434 | ||
|
|
da34fa3944 | ||
|
|
c8ed15da31 | ||
|
|
b132abc64a | ||
|
|
55c52a730a |
154
.github/workflows/ci-tests-custom-nodes.yaml
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
# Runs the custom-node regression suite against a backend that has the manifest
|
||||
# packs actually installed, so the load/run tiers execute for real. This is a
|
||||
# GATING check: if a pack fails to install or any tier is skipped, the job goes
|
||||
# red - a regression gate that let a broken pack through as a "skip" would be
|
||||
# pointless. Mark `custom-nodes-e2e` as a required status check in branch
|
||||
# protection to block merges on failure.
|
||||
name: 'CI: Tests Custom Nodes'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
push:
|
||||
branches: [main, master]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Path gating lives here, not in a trigger-level `paths:` filter: a required
|
||||
# check gated by trigger paths never creates a check run on an unrelated PR
|
||||
# and leaves branch protection stuck Pending. A job-level `if:` still creates
|
||||
# the check and marks it Skipped (= passing). Mirrors ci-tests-unit.yaml.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
custom-nodes-e2e:
|
||||
needs: changes
|
||||
# Run only when non-docs code changed AND the PR is same-repo. Fork PRs can
|
||||
# edit the manifest's repo/pin URLs, and this job clones and pip-installs
|
||||
# whatever they point at (setup.py runs at install time), so an untrusted
|
||||
# fork must not be able to aim the clone at an attacker-controlled repo.
|
||||
# Fork PRs still get the environment-agnostic coverage via the main e2e
|
||||
# shards. A skipped job counts as passing, so this stays required-safe.
|
||||
if: >-
|
||||
needs.changes.outputs.should-run == 'true' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
# Checks out ComfyUI, installs Python/torch/requirements and ComfyUI_devtools.
|
||||
# launch_server:false so we can add the manifest packs before booting.
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: 'false'
|
||||
|
||||
# Install every pack the manifest declares (DRY: a new pack row installs
|
||||
# itself here, no workflow change). A clone or dependency failure fails the
|
||||
# job - if a pack can't be installed, its coverage can't run, and that is a
|
||||
# gate failure, not something to paper over. The `jq | while` pipe hides
|
||||
# failures in a subshell, so read into an array and loop with `set -e`.
|
||||
- name: Install manifest custom nodes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Pin the CPU torch stack that setup-comfyui-server installed so no
|
||||
# pack's requirements.txt can pull a GPU/incompatible torch onto this
|
||||
# --cpu runner. A pack that genuinely needs a different torch fails
|
||||
# the constrained install loudly rather than silently swapping it.
|
||||
pip freeze | grep -iE '^(torch|torchvision|torchaudio)==' \
|
||||
> /tmp/torch-constraints.txt || true
|
||||
manifest=browser_tests/fixtures/data/customNodeManifest.json
|
||||
mapfile -t entries < <(jq -c '.[]' "$manifest")
|
||||
for entry in "${entries[@]}"; do
|
||||
repo=$(jq -r '.repo' <<<"$entry")
|
||||
pin=$(jq -r '.pin' <<<"$entry")
|
||||
name=$(basename "$repo")
|
||||
dir="ComfyUI/custom_nodes/$name"
|
||||
echo "::group::install $name"
|
||||
git clone --depth 1 "$repo" "$dir"
|
||||
if [ -n "$pin" ]; then
|
||||
git -C "$dir" fetch --depth 1 origin "$pin"
|
||||
git -C "$dir" checkout "$pin"
|
||||
fi
|
||||
if [ -f "$dir/requirements.txt" ]; then
|
||||
pip install -r "$dir/requirements.txt" -c /tmp/torch-constraints.txt
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
# The VHS run-tier workflow reads input/plain_video.mp4.
|
||||
- name: Stage run-tier assets
|
||||
shell: bash
|
||||
run: cp browser_tests/assets/plain_video.mp4 ComfyUI/input/plain_video.mp4
|
||||
|
||||
# --cache-none so retried run-tier tests re-execute every node (a cached
|
||||
# node emits no `executing` event and would false-fail PARTIAL).
|
||||
- name: Start ComfyUI server
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python main.py --cpu --multi-user --cache-none --front-end-root ../dist &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
|
||||
- name: Run custom-node suite
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: custom-nodes-results.json
|
||||
run: |
|
||||
pnpm exec playwright test browser_tests/tests/customNodes/ \
|
||||
--project=chromium --reporter=list,json
|
||||
|
||||
# A skip here means a pack or devtools did not load: on this backend every
|
||||
# tier is meant to run, so a skip is a gate failure, not an honest pass.
|
||||
- name: Forbid skipped tests
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
skipped=$(jq '.stats.skipped' custom-nodes-results.json)
|
||||
echo "skipped tests: $skipped"
|
||||
if [ "$skipped" != "0" ]; then
|
||||
echo "::error::$skipped test(s) skipped - a manifest pack or devtools failed to load; skips are not acceptable in the gating job"
|
||||
# Recurse so specs nested under describe() blocks are found, and
|
||||
# print only the specs that actually skipped.
|
||||
jq -r '.. | objects
|
||||
| select(has("title") and has("tests"))
|
||||
| select(any(.tests[]?; .status == "skipped"))
|
||||
| .title' custom-nodes-results.json | sort -u | head -40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: playwright-report-custom-nodes
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"public/materialdesignicons.min.css",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/__fixtures__/**/*.json"
|
||||
"**/__fixtures__/**/*.json",
|
||||
"apps/website/src/content/**/*.mdx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import mdx from '@astrojs/mdx'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
@@ -24,6 +25,9 @@ export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
prefetch: { prefetchAll: true },
|
||||
// Keep MDX punctuation verbatim; SmartyPants would turn the source's straight
|
||||
// quotes into curly ones and drift from the rest of the site's copy.
|
||||
markdown: { smartypants: false },
|
||||
redirects: {
|
||||
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
|
||||
'/customers/moment-factory/',
|
||||
@@ -37,6 +41,7 @@ export default defineConfig({
|
||||
devToolbar: { enabled: !process.env.NO_TOOLBAR },
|
||||
integrations: [
|
||||
vue(),
|
||||
mdx(),
|
||||
sitemap({
|
||||
filter: (page) => !isExcludedFromSitemap(page)
|
||||
})
|
||||
|
||||
73
apps/website/e2e/customers-detail.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customer story detail @smoke', () => {
|
||||
test('renders the migrated article: hero, section nav, and body', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /Series Entertainment Rebuilt Game and Video Production/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
|
||||
await expect(nav.getByRole('button', { name: 'CONCLUSION' })).toBeVisible()
|
||||
|
||||
// Section title rendered from the MDX <Section title> wrapper.
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: 'The Output Series Achieved Using ComfyUI'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('section nav highlights the section the reader selects', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
const intro = nav.getByRole('button', { name: 'INTRO' })
|
||||
const problem = nav.getByRole('button', { name: 'THE PROBLEM' })
|
||||
|
||||
await expect(intro).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await problem.click()
|
||||
await expect(problem).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('shows the read-more link only when an external source exists', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/open-story-movement')
|
||||
await expect(
|
||||
page.getByRole('link', { name: /read more on this topic/i })
|
||||
).toBeVisible()
|
||||
|
||||
// series-entertainment only redirected back to itself, so the link is gone.
|
||||
await page.goto('/customers/series-entertainment')
|
||||
await expect(
|
||||
page.getByRole('link', { name: /read more on this topic/i })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('links to the next story in the what-is-next section', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
const nextLink = page.getByRole('link', { name: /view article/i })
|
||||
await expect(nextLink).toBeVisible()
|
||||
// Links to another customer story, without coupling the test to the
|
||||
// specific slug or sort order.
|
||||
await expect(nextLink).toHaveAttribute('href', /^\/customers\/[a-z0-9-]+$/)
|
||||
await expect(nextLink).not.toHaveAttribute(
|
||||
'href',
|
||||
'/customers/series-entertainment'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
"@astrojs/mdx": "catalog:",
|
||||
"@astrojs/vue": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
@@ -48,6 +49,7 @@
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
"vitest": "catalog:",
|
||||
"vue-component-type-helpers": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg 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>
|
||||
68
apps/website/src/components/customers/CustomerArticle.astro
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import { render } from 'astro:content'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { CustomerStoryEntry } from '../../utils/customers'
|
||||
import ArticleNav from './ArticleNav.vue'
|
||||
import BulletList from './content/BulletList.astro'
|
||||
import Contributors from './content/Contributors.astro'
|
||||
import Figure from './content/Figure.astro'
|
||||
import Heading from './content/Heading.astro'
|
||||
import ListItem from './content/ListItem.astro'
|
||||
import Paragraph from './content/Paragraph.astro'
|
||||
import Quote from './content/Quote.astro'
|
||||
import ReadMore from './content/ReadMore.vue'
|
||||
import Section from './content/Section.astro'
|
||||
import Steps from './content/Steps.astro'
|
||||
|
||||
interface Props {
|
||||
entry: CustomerStoryEntry
|
||||
locale?: Locale
|
||||
}
|
||||
|
||||
const { entry, locale = 'en' } = Astro.props
|
||||
const { Content } = await render(entry)
|
||||
|
||||
// The sidebar nav mirrors the section outline declared in frontmatter so it is
|
||||
// server-rendered, exactly like the previous ContentSection sidebar.
|
||||
const categories = entry.data.sections.map((section) => ({
|
||||
label: section.label,
|
||||
value: section.id
|
||||
}))
|
||||
|
||||
// Markdown elements are mapped to the ported block styles; the named
|
||||
// components (Section, Figure, ...) are used directly inside the MDX body.
|
||||
const contentComponents = {
|
||||
p: Paragraph,
|
||||
h3: Heading,
|
||||
ul: BulletList,
|
||||
li: ListItem,
|
||||
Section,
|
||||
Figure,
|
||||
Quote,
|
||||
Contributors,
|
||||
Steps
|
||||
}
|
||||
---
|
||||
|
||||
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
<ArticleNav
|
||||
categories={categories}
|
||||
client:media="(min-width: 1024px)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1">
|
||||
<Content components={contentComponents} />
|
||||
{
|
||||
entry.data.readMore && (
|
||||
<ReadMore href={entry.data.readMore} locale={locale} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { customerStories } from '../../config/customerStories'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { StoryCard } from '../../utils/customers'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const { stories, locale = 'en' } = defineProps<{
|
||||
stories: StoryCard[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
</script>
|
||||
@@ -13,7 +16,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
|
||||
>
|
||||
<a
|
||||
v-for="story in customerStories"
|
||||
v-for="story in stories"
|
||||
:key="story.slug"
|
||||
:href="`${prefix}/customers/${story.slug}`"
|
||||
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
|
||||
@@ -22,7 +25,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
<div class="m-2 aspect-video overflow-hidden rounded-2xl">
|
||||
<div
|
||||
class="size-full rounded-2xl bg-white/5 bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${story.image})` }"
|
||||
:style="{ backgroundImage: `url(${story.cover})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,12 +35,12 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(story.category, locale) }}
|
||||
{{ story.category }}
|
||||
</span>
|
||||
<h3
|
||||
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
|
||||
>
|
||||
{{ t(story.title, locale) }}
|
||||
{{ story.title }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<ul class="mt-4 space-y-1 pl-5 text-sm"><slot /></ul>
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
interface Person {
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
people: Person[]
|
||||
}
|
||||
|
||||
const { label, people } = Astro.props
|
||||
---
|
||||
|
||||
<div class="mt-8 rounded-2xl bg-(--site-bg-soft) p-6">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{
|
||||
people.map((person, i) => (
|
||||
<>
|
||||
<p
|
||||
class={cn(
|
||||
'text-sm font-semibold text-primary-comfy-canvas',
|
||||
i === 0 ? 'mt-2' : 'mt-4'
|
||||
)}
|
||||
>
|
||||
{person.name}
|
||||
</p>
|
||||
<p class="text-xs text-primary-comfy-canvas">{person.role}</p>
|
||||
</>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
20
apps/website/src/components/customers/content/Figure.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<img src={src} alt={alt} class="w-full rounded-2xl object-cover" />
|
||||
{
|
||||
caption && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h3 class="text-primary-comfy-yellow mt-6 mb-2 text-lg font-semibold italic">
|
||||
<slot />
|
||||
</h3>
|
||||
@@ -0,0 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
@@ -0,0 +1 @@
|
||||
<p class="mt-4 text-sm/relaxed text-primary-comfy-canvas"><slot /></p>
|
||||
16
apps/website/src/components/customers/content/Quote.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
interface Props {
|
||||
name: string
|
||||
}
|
||||
|
||||
const { name } = Astro.props
|
||||
---
|
||||
|
||||
<blockquote
|
||||
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-(--site-bg-soft) p-8"
|
||||
>
|
||||
<p class="text-lg/relaxed font-light text-primary-comfy-canvas italic">
|
||||
"<slot />"
|
||||
</p>
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
|
||||
</blockquote>
|
||||
21
apps/website/src/components/customers/content/ReadMore.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import Button from '../../ui/button/Button.vue'
|
||||
|
||||
const { href, locale = 'en' } = defineProps<{
|
||||
href: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-8 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ t('customers.story.readMore', locale) }}
|
||||
<template #append>
|
||||
<span class="text-base" aria-hidden="true">↗</span>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
17
apps/website/src/components/customers/content/Section.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
interface Props {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const { id, title } = Astro.props
|
||||
---
|
||||
|
||||
<div id={id} class="mb-16 scroll-mt-24 lg:scroll-mt-36">
|
||||
{
|
||||
title && (
|
||||
<h2 class="mb-6 text-2xl font-light text-primary-comfy-canvas">{title}</h2>
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
17
apps/website/src/components/customers/content/Steps.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
interface Props {
|
||||
items: string[]
|
||||
}
|
||||
|
||||
const { items } = Astro.props
|
||||
---
|
||||
|
||||
<ol class="mt-4 space-y-1 pl-1 text-sm [counter-reset:step]">
|
||||
{
|
||||
items.map((item) => (
|
||||
<li class="flex items-start gap-3 text-primary-comfy-canvas [counter-increment:step] before:shrink-0 before:font-semibold before:tabular-nums before:text-primary-comfy-yellow before:content-[counter(step,_decimal-leading-zero)]">
|
||||
{item}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
@@ -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.
|
||||
97
apps/website/src/content/customers.content.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const customersDir = join(dirname(fileURLToPath(import.meta.url)), 'customers')
|
||||
const locales = ['en', 'zh-CN'] as const
|
||||
|
||||
interface Story {
|
||||
file: string
|
||||
frontmatter: string
|
||||
body: string
|
||||
}
|
||||
|
||||
function loadStories(): Story[] {
|
||||
const stories: Story[] = []
|
||||
for (const locale of locales) {
|
||||
const dir = join(customersDir, locale)
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (!name.endsWith('.mdx')) continue
|
||||
const raw = readFileSync(join(dir, name), 'utf8')
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||||
if (!match) throw new Error(`No frontmatter block in ${locale}/${name}`)
|
||||
stories.push({
|
||||
file: `${locale}/${name}`,
|
||||
frontmatter: match[1],
|
||||
body: match[2]
|
||||
})
|
||||
}
|
||||
}
|
||||
return stories
|
||||
}
|
||||
|
||||
// The TOC sidebar is built from frontmatter `sections`, but the scroll-spy
|
||||
// anchors come from `<Section id="...">` in the body. Nothing binds the two but
|
||||
// matching strings, so this guards against silent drift (a renamed body id or a
|
||||
// missing frontmatter entry would leave the nav pointing at a dead anchor).
|
||||
function frontmatterSections(
|
||||
frontmatter: string
|
||||
): { id: string; label: string }[] {
|
||||
const sections: { id: string; label: string }[] = []
|
||||
const pattern = /-\s*id:\s*(\S+)\s*\n\s*label:\s*(.+)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(frontmatter)) !== null) {
|
||||
sections.push({
|
||||
id: match[1].trim(),
|
||||
label: match[2].trim().replace(/^["']|["']$/g, '')
|
||||
})
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
function bodySectionIds(body: string): string[] {
|
||||
const ids: string[] = []
|
||||
const pattern = /<Section\b[^>]*\bid="([^"]*)"/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(body)) !== null) {
|
||||
ids.push(match[1])
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const stories = loadStories()
|
||||
|
||||
it('finds all ten customer stories', () => {
|
||||
expect(stories).toHaveLength(10)
|
||||
})
|
||||
|
||||
describe.for(stories)('$file', ({ frontmatter, body }) => {
|
||||
const sections = frontmatterSections(frontmatter)
|
||||
const bodyIds = bodySectionIds(body)
|
||||
|
||||
it('declares at least one section', () => {
|
||||
expect(sections.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('has a non-empty id and label for every section', () => {
|
||||
for (const section of sections) {
|
||||
expect(section.id).not.toBe('')
|
||||
expect(section.label).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every body <Section> an id', () => {
|
||||
expect(bodyIds).not.toContain('')
|
||||
expect(bodyIds.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('matches frontmatter section ids to body <Section> ids', () => {
|
||||
const fromFrontmatter = [
|
||||
...new Set(sections.map((section) => section.id))
|
||||
].sort()
|
||||
const fromBody = [...new Set(bodyIds)].sort()
|
||||
expect(fromBody).toEqual(fromFrontmatter)
|
||||
})
|
||||
})
|
||||
15
apps/website/src/content/customers.schema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
// strictObject so a misspelled frontmatter key (e.g. readMoreHref) fails the
|
||||
// content build instead of being silently dropped.
|
||||
export const customerStorySchema = z.strictObject({
|
||||
title: z.string(),
|
||||
category: z.string(),
|
||||
description: z.string(),
|
||||
cover: z.url(),
|
||||
readMore: z.url().optional(),
|
||||
order: z.number().int().nonnegative(),
|
||||
sections: z.array(z.object({ id: z.string(), label: z.string() }))
|
||||
})
|
||||
|
||||
export type CustomerStoryFrontmatter = z.infer<typeof customerStorySchema>
|
||||
106
apps/website/src/content/customers/en/groove-jones.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "How Groove Jones Delivered a Holiday FOOH Campaign for Dick's Sporting Goods with Comfy"
|
||||
category: "CASE STUDY"
|
||||
description: "Groove Jones, a Dallas-based creative studio, used Comfy to deliver a hyper-realistic FOOH holiday campaign for the Crocs x NFL collection on a fast-approaching deadline."
|
||||
cover: "https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp"
|
||||
order: 4
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE OUTPUT"
|
||||
- id: topic-3
|
||||
label: "THE PROBLEM"
|
||||
- id: topic-4
|
||||
label: "HOW COMFY SOLVED THE PROBLEM"
|
||||
- id: topic-5
|
||||
label: "BRAND-TRAINED LORAS"
|
||||
- id: topic-6
|
||||
label: "MULTI-MODEL ORCHESTRATION"
|
||||
- id: topic-7
|
||||
label: "THE PIPELINE"
|
||||
- id: topic-8
|
||||
label: "VERSION CONTROL"
|
||||
- id: topic-9
|
||||
label: "FINISHING IN NUKE"
|
||||
- id: topic-10
|
||||
label: "THE TAKEAWAY"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Groove Jones, a Dallas-based creative studio, builds AI-driven campaigns and immersive experiences for major brands where photoreal polish, creative ambition, and social-ready speed all have to land together. As their work expanded across AI Video, AR, VR, and WebGL for clients like Crocs, the NFL, and Dick’s Sporting Goods, they faced a recurring challenge: delivering feature-film-quality VFX on commercial timelines and budgets.
|
||||
|
||||
For the Crocs x NFL collection holiday launch, that challenge came to a head. The brief called for hyper-realistic video of giant NFL-licensed Crocs parachuting into real Dick’s Sporting Goods parking lots, across multiple locations, delivered on a fast-approaching holiday deadline. A live-action shoot plus a traditional CG pipeline was off the table.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="The Output Groove Jones Achieved Using Comfy">
|
||||
|
||||
- A full FOOH (faux out-of-home) social campaign delivered on a tight holiday deadline
|
||||
- Hyper-realistic videos of giant NFL-licensed Crocs parachuting onto Dick’s Sporting Goods parking lots
|
||||
- Vertical 9:16 deliverables at 2K for Instagram Reels, TikTok, and YouTube Shorts
|
||||
- Same-day iteration on client notes instead of week-long asset updates
|
||||
- Winner, Aaron Awards 2024: Best AI Workflow for Production
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="The Problem Groove Jones Was Trying to Solve">
|
||||
|
||||
A traditional pipeline for this creative meant a live-action shoot at multiple store locations plus a full CG build: high-res modeling of every team’s clog, look development, lighting, rendering, compositing, and a new render every time the client wanted a variation. It also meant a large crew (modelers, texture artists, lighting artists, compositors) and a schedule measured in months. Neither the budget nor the holiday window supported that path.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="How Groove Jones Used Comfy to Solve the Problem">
|
||||
|
||||
Groove Jones’s Senior Creative Technologist, Doug Hogan, rebuilt the production process around Comfy’s node-based workflow system, using their proprietary GrooveTech GenVFX pipeline. Custom LoRAs handled brand accuracy, a single Comfy graph orchestrated multiple generative models, and Nuke handled final polish. For a team with feature-film and commercial roots, the environment was immediately familiar.
|
||||
|
||||
<Quote name="Doug Hogan | Senior Creative Technologist @ Groove Jones">Comfy felt very similar to working inside a traditional CG and compositing pipeline. Node-based logic, clear data flow, modular builds. It felt natural to our artists already.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="Brand-Trained LoRAs for Hero Assets">
|
||||
|
||||
Groove Jones trained custom LoRAs on the Crocs NFL Team Clogs and on Dick’s Sporting Goods storefronts, so every generation came out anchored in brand-accurate references. Real team colorways, real product silhouettes, and real store exteriors stayed consistent across shots without per-frame correction, replacing what would normally take weeks of manual look development.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp" alt="Grid of brand-accurate NFL team Crocs generated via custom LoRAs" caption="Brand-accurate NFL team colorways generated through custom LoRAs." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="Multi-Model Orchestration in a Single Graph">
|
||||
|
||||
The creative required different generative models at different stages: Flux for key-frame still development, Gemini Flash 2.5 (Nano Banana) for fast ideation and variants, and Veo 3.1 plus Moonvalley’s Marey for final video generation. Comfy routed between all four inside one graph, so outputs from one model fed directly into the next without ever leaving the environment.
|
||||
|
||||
<Quote name="Dale Carman | Co-founder @ Groove Jones">The Comfy community develops at an almost exponential curve, and we were able to leverage their existing nodes and tools to solve very specific production challenges instead of reinventing the wheel ourselves.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="Storyboards to Previz to Final Shot in One Pipeline">
|
||||
|
||||
The workflow opened with traditional storyboards for narrative approval, then moved into CGI blocking to lock composition, camera framing, and story beats. Comfy drove generation from there: the shoe drop, the parking lot reactions, the crowd coverage, and the environmental conversions that turned static summer storefronts into snow-covered holiday scenes, all inside the same graph.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp" alt="Storyboard grid for the Crocs x NFL holiday campaign" caption="Grayscale storyboards used to lock narrative beats before generation." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp" alt="Composition progression from blocking to mid-render to final shot" caption="Composition progression: wireframe blocking, mid-render, and final shot." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-8" title="Workflow Files as Version Control">
|
||||
|
||||
Every variant of every shot lived as a Comfy workflow file, which doubled as version control. When notes came in requesting a different team colorway, store exterior, or time of day, the team duplicated a branch instead of rebuilding, which made same-day iteration possible. GPU usage and API credit burn were trackable inside the same environment as the work itself, giving Production real-time visibility into compute cost per iteration.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-9" title="Finishing in Nuke">
|
||||
|
||||
Generated shots moved into Nuke for final compositing: falling snow, camera shake, crowd ambience, holiday audio, and 2K mastering in 9:16 for Instagram Reels, TikTok, and YouTube Shorts. Because Comfy handled generation cleanly, Nuke focused on polish and motion enhancement rather than patching generative artifacts.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-10" title="Conclusion">
|
||||
|
||||
By building the FOOH pipeline inside Comfy, Groove Jones turned a brief that would have required an expensive live-action shoot plus months of CG into a fast, iterative, single-environment workflow the client could direct in real time. The project recently won the Aaron Award for Best AI Workflow for Production.
|
||||
|
||||
<Quote name="Dale Carman | Co-founder @ Groove Jones">At Groove Jones, we care deeply about delivering work that makes people say WOW! But we also care about delivering on time and on budget. VFX projects used to operate at razor thin margins. Comfy solved that for us.</Quote>
|
||||
|
||||
</Section>
|
||||
156
apps/website/src/content/customers/en/moment-factory.mdx
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: "How Moment Factory Reimagined 3D Projection Mapping at Architectural Scale with ComfyUI"
|
||||
category: "CASE STUDY"
|
||||
description: "Moment Factory used ComfyUI to reimagine their 3D projection mapping pipeline, enabling architectural-scale visual experiences with AI-driven content generation and real-time iteration."
|
||||
cover: "https://media.comfy.org/website/customers/moment-factory/cover.webp"
|
||||
order: 2
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "BEFORE COMFY"
|
||||
- id: topic-3
|
||||
label: "WHAT CHANGED?"
|
||||
- id: topic-4
|
||||
label: "WHY COMFYUI WAS CRITICAL"
|
||||
- id: topic-5
|
||||
label: "THE TAKEAWAY"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
How do you make generative AI work at architectural scale? Moment Factory used ComfyUI to fundamentally transform how they handle early concept, look development, and design exploration for architectural projection mapping.
|
||||
|
||||
Before ComfyUI, this phase was slower, more abstract, and carried greater risk. After ComfyUI, it became faster, more concrete, and spatially grounded from the start.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/hero.webp" alt="Moment Factory architectural projection mapping" caption="Arched interior architectural projection by Moment Factory." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="Before ComfyUI: Slow Iteration, Abstract Decisions, Late Risk">
|
||||
|
||||
Early concept and look development traditionally relied on:
|
||||
|
||||
- Static sketches
|
||||
- Reference decks
|
||||
- Moodboards
|
||||
- Abstract discussions about intent
|
||||
|
||||
For architectural projection mapping, this creates a problem. You do not really know if something works until it is projected at scale. Seams, pixel density, spatial drift, and composition issues usually reveal themselves later in the process, when changes have a massive impact on production.
|
||||
|
||||
Traditionally, this means:
|
||||
|
||||
- Fewer directions explored
|
||||
- Longer back-and-forth cycles
|
||||
- Creative decisions made without spatial proof
|
||||
- Risk pushed downstream into production
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="What Changed with ComfyUI">
|
||||
|
||||
Moment Factory built a custom ComfyUI workflow and used it to enhance and accelerate large parts of early concept sketching, look-dev exploration, and part of the design phase.
|
||||
|
||||
They did not just generate images. They changed how decisions were made.
|
||||
|
||||
### 1. Iteration stopped being the bottleneck
|
||||
|
||||
ComfyUI transformed the iteration process, making it faster, sharper, and more intentional. Grounded in real production parameters, they explored:
|
||||
|
||||
- Over 20 main artistic directions
|
||||
- 20 to 40 iterations per direction
|
||||
- Styles ranging from hyper-realism to illustrative engraving
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/variations.webp" alt="Grid of generated artistic variations" caption="A grid of generated variations exploring different artistic directions." />
|
||||
|
||||
The studio used batching and parameter tweaks to move quickly, while intentionally stress-testing the system to understand its limits.
|
||||
|
||||
<Quote name="Guillaume Borgomano | Senior Multimedia Director & Innovation Creative Lead @ Moment Factory">With any GenAI tool, it's easy to over-iterate, to believe the best result is always one click away. Imposing real production constraints, whether financial or time-based, was essential to ensure these explorations remained meaningful and truly impacted our pipelines.</Quote>
|
||||
|
||||
That volume of exploration would not have been realistic in their previous workflow.
|
||||
|
||||
### 2. Concept work moved from days to hours
|
||||
|
||||
The biggest acceleration happened early. What would normally involve days of back-and-forth between static concepts and reference decks could happen within a few hours.
|
||||
|
||||
They generated intentionally low-resolution outputs around 2K, reviewed them quickly, and even generated new variations live on site. Those outputs could be checked directly in the media server timeline minutes later.
|
||||
|
||||
This low-resolution stage was not about polish. It was about validation and decision-making. That shift alone changed the pace of the entire project.
|
||||
|
||||
### 3. Spatial credibility came first, not last
|
||||
|
||||
A major reason this worked is that every generation was already spatially constrained. Moment Factory built the entire workflow around architectural surface templates, so outputs were pre-mapped from the start. The pipeline supported multiple template types in parallel, including flat UVs, 360 layouts, and camera-projection setups.
|
||||
|
||||
ControlNet injected structural information from those templates directly into the diffusion process, enforcing scale, layout, and spatial logic early.
|
||||
|
||||
Because of this, visuals were already spatially credible during the concept phase. Abstract intent turned into shared reference points. The team could react to something grounded instead of imagining how it might look later.
|
||||
|
||||
### 4. Approval no longer meant starting over
|
||||
|
||||
Once a direction was approved, the workflow did not reset. They could:
|
||||
|
||||
- Inpaint specific regions
|
||||
- Preserve composition
|
||||
- Upscale selected outputs to 18K in ~20 minutes
|
||||
|
||||
This completely changed how fast ideas moved from concept to projection-ready content. Previously, approval often meant rebuilding work. With ComfyUI, approval meant pushing forward.
|
||||
|
||||
### 5. Fewer people, better collaboration
|
||||
|
||||
Once the system was stable, one main artist operated inside ComfyUI. Around that setup, two additional team members were continuously involved in art direction, prompt tuning, selection, and alignment discussions.
|
||||
|
||||
They had to define a new working methodology to keep creative intent at the center, but in practice, ComfyUI functioned as a shared exploration tool, not a solo technical setup.
|
||||
|
||||
### 6. The moment it became undeniable
|
||||
|
||||
Within Moment Factory's innovation team, it felt like a breakthrough early on — the level of malleability and control simply wasn't achievable with more rigid tools. But the real turning point came during an in-situ live demo, held at 25 Broadway. Late in the process, Moment Factory swapped the surface template and reran the entire pipeline without re-authoring a single asset. The composition held and the spatial logic remained intact. The content dropped straight into the media server timeline.
|
||||
|
||||
The room went quiet.
|
||||
|
||||
In that moment, it stopped being a promising experiment and became a shared realization. People weren't asking "what if" anymore — they were asking how to prompt, and in what other context it could apply.
|
||||
|
||||
That's when it became undeniable: this wasn't just a powerful tool for R&D. It was a shift in how teams across Moment Factory could think, iterate, and produce.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/demo.webp" alt="Moment Factory live projection mapping demo" caption="Interior crowd view with projection mapping at architectural scale." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Why ComfyUI Was Critical at Architectural Scale">
|
||||
|
||||
Moment Factory had been exploring diffusion-based workflows for projection mapping for years. The ambition was clear: use generative systems not just for images, but as structured spatial material within complex, large-scale environments.
|
||||
|
||||
What architectural scale demanded, however, was not just image generation. It required:
|
||||
|
||||
- Precise control over spatial conditioning
|
||||
- The ability to inject UV layouts and depth constraints directly into inference
|
||||
- Rapid template switching without breaking composition
|
||||
- Iterative refinement without rebuilding from scratch
|
||||
- A pipeline that could evolve as constraints changed
|
||||
|
||||
This level of structural malleability was essential.
|
||||
|
||||
ComfyUI's node-based architecture allowed the team to design and reshape the workflow itself, not just the outputs. Conditioning logic, batching strategies, template inputs, and upscaling stages could be reconfigured as the project evolved.
|
||||
|
||||
Rather than adapting the project to fit a tool, the tool could be adapted to fit the architecture.
|
||||
|
||||
At that point, it became clear: achieving reliable architectural-scale generative workflows required a system flexible enough to be re-authored alongside the creative process. ComfyUI provided that flexibility.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/workflow.webp" alt="ComfyUI node-based workflow" caption="Screenshot of the ComfyUI node-based workflow used by Moment Factory." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="The Takeaway">
|
||||
|
||||
ComfyUI did not make the creative decisions. The vision stayed human. The constraints were architectural, and the expectations were production-level from the start.
|
||||
|
||||
What ComfyUI brought to the table was structural flexibility. It allowed the workflow itself to be shaped and reshaped as the project evolved. Spatial inputs could be injected directly into inference. Templates could be swapped without collapsing the composition. Refinements could happen without rebuilding entire directions.
|
||||
|
||||
Generative systems stopped behaving like black boxes and started behaving like controllable material. Spatial logic was embedded early, and scaling to architectural resolution became a managed step rather than a gamble.
|
||||
|
||||
The impact was not just speed. Decisions could be validated earlier, directly against geometry and projection conditions. Spatial alignment became part of concept development instead of a late-stage correction. That shift reduced uncertainty before entering production.
|
||||
|
||||
In that sense, ComfyUI did more than accelerate exploration. It made architectural-scale generative workflows structurally viable within real production constraints.
|
||||
|
||||
<Contributors label="MOMENT FACTORY CONTRIBUTORS" people={[{"name":"Guillaume Borgomano","role":"Senior Multimedia Director & Innovation Creative Lead"},{"name":"Conner Tozier","role":"Lead Motion Designer & Generative AI Lead"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "How Doodles, SYSTMS, and Open-Source Tools Like ComfyUI Are Rewriting the Rules for Artists"
|
||||
category: "OPEN SOURCE × BRAND"
|
||||
description: "Doodles and SYSTMS built Doodles AI — a generative platform powered by PRISM 1.0 — on open-source infrastructure including ComfyUI, proving that open-source workflows can power brand-quality, commercially successful products."
|
||||
cover: "https://media.comfy.org/website/customers/open-story-movement/cover.webp"
|
||||
order: 1
|
||||
readMore: "https://blog.comfy.org/p/how-open-source-is-fueling-the-open"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "IP WITHOUT WALLS"
|
||||
- id: topic-3
|
||||
label: "THE LAST MILE"
|
||||
- id: topic-4
|
||||
label: "CODED DNA"
|
||||
- id: topic-5
|
||||
label: "TAKEAWAY"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Doodles, the entertainment brand built around the iconic pastel-palette artwork of Canadian illustrator Scott Martin (known as Burnt Toast), is about to launch **Doodles AI** — a generative platform powered by **PRISM 1.0**, a generative image model trained on Doodles' extensive body of work that can reimagine people and objects in the unmistakable Doodles visual language.
|
||||
|
||||
Behind the scenes, the engineering is being handled by **SYSTMS**, an AI studio whose tagline — "Engineering the Impossible" — reflects their approach to building bespoke creative pipelines using open-source infrastructure, including node-based workflow tools like ComfyUI.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/cover.webp" alt="Doodles AI generative platform powered by PRISM 1.0" caption="The Doodles AI platform reimagines people and objects in the Doodles visual language." />
|
||||
|
||||
The story of how these pieces came together offers a compelling blueprint for anyone watching the intersection of open-source, AI, artist-driven brands, and the emerging concept the Doodles team is calling "open story."
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="IP Without Walls">
|
||||
|
||||
Artists have traditionally been protective of their IP, and for good reason. But the Doodles team is exploring a new model where the community doesn't just consume the brand — they co-create it. Every generation a user produces on the Doodles AI platform makes the model stronger.
|
||||
|
||||
Through reinforcement learning, user-generated content becomes part of the training data for future iterations of the PRISM. Users aren't just customers; they're collaborators shaping the brand's visual DNA.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/walls.webp" alt="Doodles community co-creation" caption="Users become collaborators, co-creating the Doodles brand through AI-generated content." />
|
||||
|
||||
As Scott Martin put it when he returned as CEO in early 2025, the goal is to recalibrate — creativity first, community at the center, art driving everything. Martin, who built his career as an illustrator working with Google, Snapchat, Dropbox, and Adobe before co-founding Doodles in 2021 alongside Evan Keast and Jordan Castro, understands both the commercial and artistic sides of this equation.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="The Last Mile Is the Whole Game">
|
||||
|
||||
Doodles AI represents something powerful: proof that open-source tools can power commercially successful, brand-quality products.
|
||||
|
||||
The SYSTMS team uses open-source tools in their rawest form, prioritizing control and innovation at the bleeding edge of the space. The fact that these same tools are now producing output with the kind of brand fidelity that differentiates Doodles from generalized platforms like MidJourney or Sora is significant. It's the "last mile" problem in creative AI — getting from 85% to 100% fidelity — and it's where the real value lies.
|
||||
|
||||
Doodles AI is a showcase of what's possible when open-source workflows meet professional creative direction. ComfyUI's powerful node-based platform allows users to package complex systems of open-source models, APIs, and other tools into consumer-facing applications, making it a natural fit for projects like this.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/workflow.webp" alt="ComfyUI workflow powering Doodles AI" caption="Open-source workflows powering brand-quality generative output." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Coded DNA">
|
||||
|
||||
Doodles AI launches with PRISM 1.0 as an image-to-image model, but the roadmap is ambitious: 2D and 3D output generation, video with sound, real-time AR, and gaming applications. Original Doodles holders receive 100 free generations on launch day — a deliberate move to seed the community and let them flood every timeline with the platform's output.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/dna.webp" alt="Doodles AI output examples" caption="Doodles AI output demonstrating brand-fidelity generative results." />
|
||||
|
||||
The deeper play is alignment with the speed and scale of the entire AI industry. By building on open-source infrastructure and fostering a community of co-creators, Doodles has positioned itself to plug its "coded DNA" into future technologies that don't yet exist. It's a bet that openness — open source, open story, open creation — isn't just philosophically appealing but strategically sound.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="What It Means for Artists">
|
||||
|
||||
For artists watching from the sidelines, the message is clear: the building blocks are here, the community is building, and the line between creator and consumer is disappearing. The question isn't whether open source will reshape creative industries. It's whether you'll be building with it when it does.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/output.webp" alt="Doodles AI creative output" caption="Open-source tools powering brand-quality creative output at scale." />
|
||||
|
||||
<Contributors label="LINKS" people={[{"name":"Doodles: doodles.app | SYSTMS: systms.ai | ComfyUI: comfy.org","role":"Official websites"}]} />
|
||||
|
||||
</Section>
|
||||
106
apps/website/src/content/customers/en/series-entertainment.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "How Series Entertainment Rebuilt Game and Video Production with ComfyUI"
|
||||
category: "GAME & VIDEO PRODUCTION"
|
||||
description: "Scaling emotional storytelling across 100,000+ assets and multiple Netflix titles, using repeatable ComfyUI production systems."
|
||||
cover: "https://media.comfy.org/website/customers/series-entertainment/cover.webp"
|
||||
order: 0
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE OUTPUT"
|
||||
- id: topic-3
|
||||
label: "THE PROBLEM"
|
||||
- id: topic-4
|
||||
label: "THE SOLUTION"
|
||||
- id: topic-5
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-6
|
||||
label: "CONCLUSION"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Series Entertainment builds story-driven games and short-form video experiences where characters, emotion, and visual consistency matter. As the scope of their work expanded across internal projects, partner collaborations, and Netflix titles, the team faced a growing challenge: they needed to produce more content, across more projects, without slowing down or losing consistency.
|
||||
|
||||
To meet that challenge, Series leveraged ComfyUI to scale their workflows. By building custom, repeatable workflows on top of ComfyUI, Series changed how they create characters, emotions, and video. The result was a scalable production system that supported over 100,000 assets, shipped Netflix games, and continues to power multiple projects in active development.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/series.webp" alt="Series Entertainment game titles including Olympus Rising, Gilded Scales, Evergrove, and The Wandering Teahouse" caption="Series Entertainment produces story-driven games and video experiences across multiple titles and visual styles." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="The Output Series Achieved Using ComfyUI">
|
||||
|
||||
With ComfyUI integrated into its production workflows, Series achieved:
|
||||
|
||||
- 100,000+ assets generated across games and video
|
||||
- 180× faster production speed
|
||||
- Six distinct character emotions generated in seconds
|
||||
- 15 minutes of final video per creator per week
|
||||
- Multiple Netflix titles shipped, with many more experiences in active development
|
||||
|
||||
These outputs span character assets, emotional variations, background consistency, and short-form video — all created through repeatable ComfyUI-powered workflows.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="The Problem Series Was Trying to Solve">
|
||||
|
||||
Series' work depends on expressive characters and consistent visual identity. As projects grew in size and complexity, the team needed a way to scale content creation without breaking timelines.
|
||||
|
||||
Traditional animation workflows rely on manual keyframing, multiple disconnected tools, and long production cycles that can stretch into weeks per video. Producing variations often means redoing work from scratch, and experimentation can be slow and expensive.
|
||||
|
||||
Series needed workflows that could be reused across teams and projects, while still supporting emotional storytelling, character consistency, and fast iteration.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="How Series Used ComfyUI to Solve the Problem">
|
||||
|
||||
Series rebuilt their production process around ComfyUI's node-based workflow system. Instead of treating generation as a one-off step, they treated workflows as long-term production assets. ComfyUI became the place where creative structure lived — from character creation to emotion generation to video output.
|
||||
|
||||
### Emotion Generation at Scale
|
||||
|
||||
Series built a custom avatar system using ComfyUI that generates six distinct emotions in seconds: Happy, Sad, Serious, Snarky, Thinking, and Surprised. This made it possible to create expressive characters with multiple emotional states without manually recreating each variation.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/panel.webp" alt="ComfyUI Expression Editor node for facial expression manipulation" caption="The Expression Editor node in ComfyUI enables fine-grained control over character emotions." />
|
||||
|
||||
### Replicable Pipelines from Test to Production
|
||||
|
||||
Using ComfyUI's modular node system, Series built four streamlined pipelines that support the full production cycle — from early exploration to final output. These workflows deliver results up to **180× faster** than traditional manual processes that can take six hours or more per asset, while maintaining production quality.
|
||||
|
||||
The pipelines range from quick 512×512 single-emotion tests to high-resolution batch generation, allowing teams to experiment quickly and move directly into production using the same workflows.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/workflows.webp" alt="ComfyUI workflow for facial expression manipulation and upscaling pipeline" caption="A ComfyUI workflow showing parallel expression editing, upscaling, and face detailing pipelines." />
|
||||
|
||||
### Consistency Across Games and Branching Stories
|
||||
|
||||
For multiple Netflix titles, Series used ComfyUI to build workflows that keep characters and backgrounds consistent across complex, branching narratives. Styling and consistency pipelines help ensure that characters stay visually aligned across scenes, emotions, and story paths — even as asset counts grow.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/consistency.webp" alt="Consistent character across multiple scenes and emotional states" caption="A single character maintained across six different scenes and emotional states using ComfyUI consistency pipelines." />
|
||||
|
||||
### Production at Scale with ComfyUI
|
||||
|
||||
Series also uses ComfyUI as part of an AI-assisted animation pipeline that connects story development directly to image and video generation. This pipeline includes bot-assisted video generation, allowing creators to repeatedly run the same workflows to produce video efficiently. Using this approach, each creator can generate Lorespark videos at scale, delivering over **15 minutes of final video per week**.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/batch.webp" alt="ComfyUI batch processing workflow using Nano Banana and Google Gemini" caption="A batch processing workflow connecting multiple character images to Nano Banana for style-consistent generation." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="Why ComfyUI Worked for Series">
|
||||
|
||||
ComfyUI worked well because its node-based structure makes workflows explicit and reusable — once a workflow is built, it can be refined and shared across projects. This allowed Series to turn video generation into a repeatable system rather than a one-off process.
|
||||
|
||||
Batch execution and bot integration allow those workflows to run at scale. Because the same workflows support both low-resolution testing and high-resolution final output, teams can move from exploration to delivery without switching tools or rebuilding pipelines.
|
||||
|
||||
Most importantly, ComfyUI let Series focus on building structure instead of relying on trial-and-error prompting. Emotions, consistency, and production logic live inside the workflows themselves.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/scale.webp" alt="Six variations of the same character generated with consistent style" caption="Multiple pose and expression variations of a single character, generated at scale while maintaining visual consistency." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="Conclusion">
|
||||
|
||||
By making ComfyUI a core creative platform, Series Entertainment transformed how it produces games and video. What started as a need for scale and consistency became a workflow-driven production system that supports emotional storytelling, large asset volumes, and ongoing development across multiple teams.
|
||||
|
||||
<Quote name="Series Entertainment">For Series, ComfyUI is not an experiment. It is how entertainment gets made.</Quote>
|
||||
|
||||
</Section>
|
||||
101
apps/website/src/content/customers/en/ubisoft-chord.mdx
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "Ubisoft Open-Sources the CHORD Model with ComfyUI for AAA PBR Material Generation"
|
||||
category: "AAA GAME PRODUCTION"
|
||||
description: "Ubisoft La Forge open-sourced its CHORD PBR material estimation model with ComfyUI custom nodes, enabling end-to-end texture generation workflows for AAA game production."
|
||||
cover: "https://media.comfy.org/website/customers/ubisoft/cover.webp"
|
||||
order: 3
|
||||
readMore: "https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE PROBLEM"
|
||||
- id: topic-3
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-4
|
||||
label: "THE PIPELINE"
|
||||
- id: topic-5
|
||||
label: "TRY IT"
|
||||
- id: topic-6
|
||||
label: "RESULTS"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Ubisoft La Forge has open-sourced its PBR material estimation model, **CHORD (Chain of Rendering Decomposition)**, together with **ComfyUI-Chord** custom node implementation to build an end-to-end material generation workflow with AI.
|
||||
|
||||
The model weights and code are released with a Research-Only license. Beyond research, this is a significant step toward integrating ComfyUI into AAA-scale video game production workflows.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/cover.webp" alt="CHORD PBR material generation in ComfyUI" caption="PBR materials generated using the CHORD model in ComfyUI." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="PBR Material Production in AAA Games Today">
|
||||
|
||||
In AAA game development, PBR materials are the foundation of visual realism. Large-scale titles require hundreds of reusable materials, each with full Base Color, Normal, Height, Roughness, and Metalness maps that meet strict svBRDF standards.
|
||||
|
||||
Traditionally, these assets are crafted by texture artists using photogrammetry, procedural tools, and extensive manual tuning — making the process time-consuming and highly expertise-dependent.
|
||||
|
||||
Ubisoft's Generative Base Material prototype directly targets this production bottleneck. The ComfyUI workflow outputs PBR texture sets that integrate directly into DCC tools and game engines for prototyping and placeholder assets.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="Why Ubisoft Chose ComfyUI as The Workflow Platform">
|
||||
|
||||
Ubisoft's choice of ComfyUI is rooted in production realities. For large studios, the requirement is not another image generator — it is a controllable and integratable AI workflow platform that can meet the bespoke requirements of game development.
|
||||
|
||||
<Quote name="Ubisoft La Forge Blog">Considering the multi-stage nature of our prototype, ComfyUI provides us with an efficient framework to build integrated workflows doing texture image synthesis, material estimation and material upscaling. This also enables us to leverage state-of-the-art generative models and the powerful features of ComfyUI that provide fine-grain control to creators with ControlNets, image guidance, inpainting, and countless other options.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="3 Stages of The Generative Base Material Pipeline">
|
||||
|
||||
The CHORD model is integrated into a broader pipeline consisting of 3 core stages.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/pipeline.webp" alt="The 3-stage generative base material pipeline" caption="The 3-stage generative base material pipeline: texture generation, CHORD estimation, and upscaling." />
|
||||
|
||||
### Stage 1 — Texture Image Generation
|
||||
|
||||
The first stage generates seamless, tileable 2D textures from text prompts or reference inputs such as lineart and height maps using a custom diffusion model with full conditional control.
|
||||
|
||||
### Stage 2 — CHORD Image-to-Material Estimation
|
||||
|
||||
A single texture is converted into a full set of PBR maps — including Base Color, Normal, Height, Roughness, and Metalness — using chained decomposition, unified multi-modal prediction, and efficient single-step diffusion inference for controllable and scalable results.
|
||||
|
||||
### Stage 3 — Material Upscaling
|
||||
|
||||
Since CHORD operates optimally at 1024 resolution, the third stage applies industrial-grade PBR upscaling. All channels are upscaled by 2x or 4x to produce 2K and 4K texture assets for real-time game production.
|
||||
|
||||
This complete pipeline enables artists to rapidly iterate on ideas and mix and match AI-generated outputs within their existing workflows, lowering the barrier to industrial-grade PBR material creation.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="How to Try CHORD in ComfyUI">
|
||||
|
||||
Ubisoft has open-sourced the CHORD model weights, ComfyUI custom nodes, and example workflows covering the texture image generation stage and the image-to-material estimation stage of the pipeline.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/workflow.webp" alt="CHORD example workflow in ComfyUI" caption="The CHORD example workflow in ComfyUI for end-to-end PBR material generation." />
|
||||
|
||||
<Steps items={["Install or update ComfyUI to the latest version","Install the CHORD ComfyUI custom node from Ubisoft","Download the CHORD model and place it in ./ComfyUI/models/checkpoints","Load the CHORD example workflow in ComfyUI"]} />
|
||||
|
||||
You can switch the texture image generation model to any other image model, and use the workflow modules for each stage separately.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="Example Outputs">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example1.webp" alt="CHORD PBR material example output 1" caption="Generated PBR material set showing Base Color, Normal, Height, Roughness, and Metalness maps." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example2.webp" alt="CHORD PBR material example output 2" caption="Another generated PBR material set demonstrating the variety of textures achievable with CHORD." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example3.webp" alt="CHORD PBR material example output 3" caption="Material generation output with full PBR channel decomposition." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example4.webp" alt="CHORD PBR material example output 4" caption="High-quality PBR texture set generated from a single input texture." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example5.webp" alt="CHORD PBR material example output 5" caption="Final rendered PBR material demonstrating production-ready quality." />
|
||||
|
||||
The release of CHORD demonstrates how ComfyUI has grown from a community-driven tool into a platform for real production. Studio users can build end-to-end pipelines from prompt or reference input through texture generation, material estimation, PBR upscaling, and finally export to DCC tools or game engines. Each stage can also operate independently and be embedded into an existing production system.
|
||||
|
||||
<Contributors label="AUTHOR" people={[{"name":"Jo Zhang","role":"ComfyUI Blog"},{"name":"Daxiong (Lin)","role":"ComfyUI Blog"}]} />
|
||||
|
||||
</Section>
|
||||
106
apps/website/src/content/customers/zh-CN/groove-jones.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Groove Jones 如何借助 Comfy 为 Dick's Sporting Goods 打造节日 FOOH 营销"
|
||||
category: "案例研究"
|
||||
description: "达拉斯创意工作室 Groove Jones 借助 Comfy,在紧迫的节日档期内为 Crocs x NFL 联名系列交付了超写实的 FOOH 营销内容。"
|
||||
cover: "https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp"
|
||||
order: 4
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "交付成果"
|
||||
- id: topic-3
|
||||
label: "挑战"
|
||||
- id: topic-4
|
||||
label: "Comfy 如何解决问题"
|
||||
- id: topic-5
|
||||
label: "品牌定制 LORA"
|
||||
- id: topic-6
|
||||
label: "多模型编排"
|
||||
- id: topic-7
|
||||
label: "流水线"
|
||||
- id: topic-8
|
||||
label: "版本管理"
|
||||
- id: topic-9
|
||||
label: "Nuke 终修"
|
||||
- id: topic-10
|
||||
label: "总结"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
位于达拉斯的创意工作室 Groove Jones,为众多大牌客户打造由 AI 驱动的营销活动和沉浸式体验,需要同时兼顾照片级的精细度、创意野心,以及适配社交媒体的交付速度。随着他们为 Crocs、NFL 和 Dick’s Sporting Goods 等客户的工作扩展到 AI 视频、AR、VR 和 WebGL,他们反复遇到同一个挑战:用商业项目的工期和预算,交付电影级的 VFX 质量。
|
||||
|
||||
在 Crocs x NFL 联名系列的节日上市项目中,这个挑战被推到了极致。Brief 要求制作超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入多个真实的 Dick’s Sporting Goods 停车场,并要在紧迫的节日档期前交付。实地拍摄加传统 CG 流水线的方案,已经完全行不通。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="Groove Jones 借助 Comfy 实现的交付成果">
|
||||
|
||||
- 在紧迫的节日档期内交付完整的 FOOH(虚构户外广告)社媒营销活动
|
||||
- 超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入 Dick’s Sporting Goods 停车场
|
||||
- 面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 竖屏 2K 交付物
|
||||
- 客户反馈当天迭代,不再需要数周的资产更新周期
|
||||
- 荣获 2024 年 Aaron Awards:最佳 AI 制作工作流奖
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="Groove Jones 试图解决的问题">
|
||||
|
||||
按照传统流水线做这个创意,意味着要在多家门店实地拍摄,加上完整的 CG 制作:每支球队鞋款的高精建模、look development、灯光、渲染、合成,客户每次想要新变体都要重新渲染。这也意味着庞大的团队(建模师、纹理师、灯光师、合成师),以及以"月"为单位的工期。无论是预算还是节日档期,都无法支撑这条路径。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Groove Jones 如何用 Comfy 解决问题">
|
||||
|
||||
Groove Jones 的高级创意技术总监 Doug Hogan 围绕 Comfy 的节点式工作流系统重新搭建了制作流程,并基于他们自研的 GrooveTech GenVFX 流水线展开。自定义 LoRA 负责保证品牌一致性,一张 Comfy 图编排多个生成模型,Nuke 负责最终精修。对于有电影和广告制作背景的团队,这套环境上手没有任何门槛。
|
||||
|
||||
<Quote name="Doug Hogan | Groove Jones 高级创意技术总监">Comfy 用起来非常像传统 CG 和合成流水线:节点逻辑、清晰的数据流、模块化构建。我们的艺术家用起来毫无违和感。</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="为主视觉资产定制的品牌 LoRA">
|
||||
|
||||
Groove Jones 基于 Crocs NFL 球队联名鞋款和 Dick’s Sporting Goods 门店外景训练了定制 LoRA,让每一次生成都能锚定品牌精准的参考素材。真实的球队配色、产品轮廓和门店外观在不同镜头之间保持一致,不需要逐帧修正——而这通常意味着数周的 look development 工作量。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp" alt="通过定制 LoRA 生成的多支 NFL 球队联名 Crocs 网格" caption="通过定制 LoRA 生成的、与品牌精准一致的 NFL 球队配色。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="单张图内的多模型编排">
|
||||
|
||||
这个创意在不同阶段需要不同的生成模型:Flux 用于关键帧静帧开发,Gemini Flash 2.5(Nano Banana)用于快速构思和变体生成,Veo 3.1 加上 Moonvalley 的 Marey 用于最终的视频生成。Comfy 在一张图里就把这四个模型串起来,前一个模型的输出直接喂给下一个模型,全程无需切换环境。
|
||||
|
||||
<Quote name="Dale Carman | Groove Jones 联合创始人">Comfy 社区几乎是指数级增长的,我们可以直接利用社区已有的节点和工具去解决非常具体的制作问题,而不必自己重新造轮子。</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="从故事板到 Previz 再到成片,全部在一条流水线内">
|
||||
|
||||
工作流从传统故事板开始用于叙事确认,再进入 CGI blocking,锁定构图、镜头取景和叙事节奏。从这里开始 Comfy 接管生成:鞋款空投、停车场反应镜头、人群覆盖、把夏季静态门店外景转换成被雪覆盖的节日场景——全部在同一张图里完成。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp" alt="Crocs x NFL 节日营销的故事板网格" caption="在生成之前用于锁定叙事节奏的灰度故事板。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp" alt="从 blocking 到中间渲染再到最终镜头的构图演进" caption="构图演进:线框 blocking、中间渲染、最终成片。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-8" title="把工作流文件当作版本管理">
|
||||
|
||||
每个镜头的每个变体都以 Comfy 工作流文件的形式存在,文件本身就是版本管理。当客户反馈要求换一支球队配色、换一个门店外景或者换一个时间段时,团队只需复制一个分支,而不是重建——这才让"当天迭代"成为可能。GPU 使用量和 API 额度消耗也都能在同一个环境里追踪到,让制作部门实时看到每次迭代的算力成本。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-9" title="在 Nuke 中完成终修">
|
||||
|
||||
生成的镜头进入 Nuke 完成最终合成:飘雪、镜头抖动、人群环境音、节日氛围音效,以及面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 2K 母带。由于 Comfy 把生成环节处理得很干净,Nuke 可以专注于精修和动态增强,而不是去修补生成模型留下的瑕疵。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-10" title="结语">
|
||||
|
||||
通过在 Comfy 中搭建整套 FOOH 流水线,Groove Jones 把一个原本需要昂贵实地拍摄加数月 CG 制作的项目,变成了一套高速迭代、单一环境、客户可以实时指挥的工作流。该项目近期还荣获 Aaron Award 的"最佳 AI 制作工作流"奖。
|
||||
|
||||
<Quote name="Dale Carman | Groove Jones 联合创始人">在 Groove Jones,我们非常在意交付让人说"WOW!"的作品,但我们同样在意按时按预算交付。VFX 项目以前的利润率薄得像刀刃,Comfy 帮我们彻底解决了这个问题。</Quote>
|
||||
|
||||
</Section>
|
||||
156
apps/website/src/content/customers/zh-CN/moment-factory.mdx
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: "Moment Factory 如何使用 ComfyUI 在建筑尺度重新定义 3D 投影映射"
|
||||
category: "案例研究"
|
||||
description: "Moment Factory 使用 ComfyUI 重新定义了 3D 投影映射管线,通过 AI 驱动的内容生成和实时迭代,实现建筑尺度的视觉体验。"
|
||||
cover: "https://media.comfy.org/website/customers/moment-factory/cover.webp"
|
||||
order: 2
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "使用前"
|
||||
- id: topic-3
|
||||
label: "发生了什么变化?"
|
||||
- id: topic-4
|
||||
label: "为什么 ComfyUI 至关重要"
|
||||
- id: topic-5
|
||||
label: "总结"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
如何让生成式 AI 在建筑尺度下发挥作用?Moment Factory 使用 ComfyUI 从根本上改变了他们在建筑投影映射中处理早期概念、外观开发和设计探索的方式。
|
||||
|
||||
在使用 ComfyUI 之前,这一阶段更慢、更抽象,风险也更大。使用 ComfyUI 之后,它变得更快、更具体,从一开始就在空间上有了坚实的基础。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/hero.webp" alt="Moment Factory 建筑投影映射" caption="Moment Factory 的拱形室内建筑投影。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="使用 ComfyUI 之前:迭代缓慢、决策抽象、风险滞后">
|
||||
|
||||
早期概念和外观开发传统上依赖于:
|
||||
|
||||
- 静态草图
|
||||
- 参考资料集
|
||||
- 情绪板
|
||||
- 关于意图的抽象讨论
|
||||
|
||||
对于建筑投影映射来说,这带来了一个问题。在实际投影到建筑上之前,你无法真正知道某个方案是否可行。接缝、像素密度、空间偏移和构图问题通常在流程后期才暴露出来,而此时的修改对制作的影响是巨大的。
|
||||
|
||||
传统上,这意味着:
|
||||
|
||||
- 探索的方向更少
|
||||
- 反复沟通的周期更长
|
||||
- 创意决策缺乏空间验证
|
||||
- 风险被推迟到制作阶段
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="使用 ComfyUI 后发生了什么变化">
|
||||
|
||||
Moment Factory 构建了自定义的 ComfyUI 工作流,并将其用于增强和加速早期概念草图、外观开发探索以及部分设计阶段。
|
||||
|
||||
他们不仅仅是生成图像,而是改变了决策方式。
|
||||
|
||||
### 1. 迭代不再是瓶颈
|
||||
|
||||
ComfyUI 改变了迭代过程,使其更快、更精准、更有目的性。基于真实的制作参数,他们探索了:
|
||||
|
||||
- 20 多个主要艺术方向
|
||||
- 每个方向 20 到 40 次迭代
|
||||
- 风格从超写实到插画版画不等
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/variations.webp" alt="生成的艺术变体网格" caption="探索不同艺术方向的生成变体网格。" />
|
||||
|
||||
工作室通过批处理和参数调整快速推进,同时有意地对系统进行压力测试以了解其极限。
|
||||
|
||||
<Quote name="Guillaume Borgomano | Moment Factory 高级多媒体总监 & 创新创意负责人">使用任何生成式 AI 工具,都很容易过度迭代,认为最佳结果总是只差一次点击。施加真实的制作约束,无论是财务上还是时间上的,对于确保这些探索保持有意义并真正影响我们的管线至关重要。</Quote>
|
||||
|
||||
在他们之前的工作流中,如此大量的探索是不现实的。
|
||||
|
||||
### 2. 概念工作从数天缩短到数小时
|
||||
|
||||
最大的加速发生在早期阶段。通常需要在静态概念和参考资料集之间来回数天的工作,现在可以在几个小时内完成。
|
||||
|
||||
他们有意生成约 2K 的低分辨率输出,快速审查,甚至在现场实时生成新的变体。这些输出可以在几分钟后直接在媒体服务器时间线中查看。
|
||||
|
||||
这个低分辨率阶段不是关于打磨,而是关于验证和决策。仅这一转变就改变了整个项目的节奏。
|
||||
|
||||
### 3. 空间可信度优先,而非滞后
|
||||
|
||||
这之所以有效的一个主要原因是,每次生成已经在空间上受到约束。Moment Factory 围绕建筑表面模板构建了整个工作流,因此输出从一开始就是预映射的。管线同时支持多种模板类型,包括平面 UV、360 布局和相机投影设置。
|
||||
|
||||
ControlNet 将这些模板的结构信息直接注入扩散过程,提前强制执行比例、布局和空间逻辑。
|
||||
|
||||
因此,视觉效果在概念阶段就已经具有空间可信度。抽象的意图转变为共享的参考点。团队可以对有据可依的东西做出反应,而不是想象它以后可能的样子。
|
||||
|
||||
### 4. 审批不再意味着重新开始
|
||||
|
||||
一旦方向获批,工作流不会重置。他们可以:
|
||||
|
||||
- 局部修复特定区域
|
||||
- 保留构图
|
||||
- 在约 20 分钟内将选定的输出放大到 18K
|
||||
|
||||
这完全改变了创意从概念到投影就绪内容的速度。以前,审批通常意味着重新制作。有了 ComfyUI,审批意味着继续推进。
|
||||
|
||||
### 5. 更少的人,更好的协作
|
||||
|
||||
一旦系统稳定,一名主要艺术家在 ComfyUI 中操作。在此设置周围,另外两名团队成员持续参与艺术指导、提示词调优、选择和对齐讨论。
|
||||
|
||||
他们必须定义新的工作方法以保持创意意图在核心位置,但在实践中,ComfyUI 作为共享的探索工具运作,而非单独的技术设置。
|
||||
|
||||
### 6. 不可否认的时刻
|
||||
|
||||
在 Moment Factory 的创新团队中,这在早期就感觉像是一个突破——这种程度的可塑性和控制力在更僵化的工具中根本无法实现。但真正的转折点出现在百老汇 25 号的一次现场演示中。在流程后期,Moment Factory 更换了表面模板,并重新运行了整个管线,没有重新制作任何资产。构图保持不变,空间逻辑完好无损。内容直接进入媒体服务器时间线。
|
||||
|
||||
全场安静了。
|
||||
|
||||
在那一刻,它不再是一个有前景的实验,而成为一种共识。人们不再问"如果怎样"——他们在问如何编写提示词,以及它还能应用在哪些场景中。
|
||||
|
||||
那时它变得不可否认:这不仅仅是研发的强大工具,而是 Moment Factory 各团队思考、迭代和制作方式的一次转变。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/demo.webp" alt="Moment Factory 现场投影映射演示" caption="建筑尺度投影映射的室内观众视角。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="为什么 ComfyUI 在建筑尺度至关重要">
|
||||
|
||||
Moment Factory 多年来一直在探索基于扩散的投影映射工作流。目标很明确:将生成系统不仅用于图像,还作为复杂大规模环境中的结构化空间素材。
|
||||
|
||||
然而,建筑尺度所要求的不仅仅是图像生成,还需要:
|
||||
|
||||
- 对空间条件的精确控制
|
||||
- 将 UV 布局和深度约束直接注入推理的能力
|
||||
- 不破坏构图的快速模板切换
|
||||
- 无需从头重建的迭代优化
|
||||
- 可以随约束变化而发展的管线
|
||||
|
||||
这种程度的结构可塑性是必不可少的。
|
||||
|
||||
ComfyUI 基于节点的架构使团队能够设计和重塑工作流本身,而不仅仅是输出。条件逻辑、批处理策略、模板输入和放大阶段可以随着项目的发展而重新配置。
|
||||
|
||||
项目无需适应工具,工具可以适应建筑。
|
||||
|
||||
在那一刻变得清晰:实现可靠的建筑尺度生成式工作流需要一个足够灵活的系统,可以在创意过程中被重新构建。ComfyUI 提供了这种灵活性。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/workflow.webp" alt="ComfyUI 基于节点的工作流" caption="Moment Factory 使用的 ComfyUI 基于节点工作流截图。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="总结">
|
||||
|
||||
ComfyUI 没有做出创意决策。愿景始终是人类的。约束是建筑性的,期望从一开始就是制作级别的。
|
||||
|
||||
ComfyUI 带来的是结构灵活性。它允许工作流本身随着项目的发展而被塑造和重塑。空间输入可以直接注入推理。模板可以在不破坏构图的情况下切换。优化可以在不重建整个方向的情况下进行。
|
||||
|
||||
生成系统不再像黑箱一样运作,而开始像可控材料一样行为。空间逻辑被提前嵌入,扩展到建筑分辨率成为一个可管理的步骤,而非赌博。
|
||||
|
||||
影响不仅仅是速度。决策可以更早地得到验证,直接针对几何形状和投影条件。空间对齐成为概念开发的一部分,而不是后期修正。这种转变减少了进入制作前的不确定性。
|
||||
|
||||
从这个意义上说,ComfyUI 不仅加速了探索,还使建筑尺度的生成式工作流在真实制作约束下具有结构可行性。
|
||||
|
||||
<Contributors label="MOMENT FACTORY 贡献者" people={[{"name":"Guillaume Borgomano","role":"高级多媒体总监 & 创新创意负责人"},{"name":"Conner Tozier","role":"首席动效设计师 & 生成式 AI 负责人"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "Doodles、SYSTMS 和 ComfyUI 等开源工具如何重写艺术家的规则"
|
||||
category: "开源 × 品牌"
|
||||
description: "Doodles 和 SYSTMS 在包括 ComfyUI 在内的开源基础设施上构建了 Doodles AI——一个由 PRISM 1.0 驱动的生成平台,证明了开源工作流可以支撑品牌级、商业成功的产品。"
|
||||
cover: "https://media.comfy.org/website/customers/open-story-movement/cover.webp"
|
||||
order: 1
|
||||
readMore: "https://blog.comfy.org/p/how-open-source-is-fueling-the-open"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "无墙 IP"
|
||||
- id: topic-3
|
||||
label: "最后一英里"
|
||||
- id: topic-4
|
||||
label: "编码 DNA"
|
||||
- id: topic-5
|
||||
label: "要点"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Doodles 是一个围绕加拿大插画师 Scott Martin(又名 Burnt Toast)标志性柔和色彩作品构建的娱乐品牌,即将推出 **Doodles AI**——一个由 **PRISM 1.0** 驱动的生成平台,这是一个基于 Doodles 大量作品训练的生成图像模型,能够以标志性的 Doodles 视觉语言重新想象人物和物体。
|
||||
|
||||
幕后的工程由 **SYSTMS** 负责,这是一家 AI 工作室,其口号"Engineering the Impossible"反映了他们使用开源基础设施构建定制创意管线的方法,包括像 ComfyUI 这样的基于节点的工作流工具。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/cover.webp" alt="由 PRISM 1.0 驱动的 Doodles AI 生成平台" caption="Doodles AI 平台以 Doodles 视觉语言重新想象人物和物体。" />
|
||||
|
||||
这些部分如何整合在一起的故事,为关注开源、AI、艺术家驱动品牌以及 Doodles 团队所称的"开放叙事"这一新兴概念交汇点的所有人提供了一个引人注目的蓝图。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="无墙 IP">
|
||||
|
||||
艺术家传统上一直保护自己的知识产权,这有充分的理由。但 Doodles 团队正在探索一种新模式,社区不仅仅是消费品牌——他们共同创造品牌。用户在 Doodles AI 平台上生成的每一次创作都会使模型更强大。
|
||||
|
||||
通过强化学习,用户生成的内容成为 PRISM 未来迭代的训练数据的一部分。用户不仅仅是客户;他们是塑造品牌视觉 DNA 的协作者。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/walls.webp" alt="Doodles 社区共创" caption="用户成为协作者,通过 AI 生成的内容共同创造 Doodles 品牌。" />
|
||||
|
||||
正如 Scott Martin 在 2025 年初重新担任 CEO 时所说,目标是重新校准——创意优先、社区为中心、艺术驱动一切。Martin 在 2021 年与 Evan Keast 和 Jordan Castro 共同创立 Doodles 之前,曾与 Google、Snapchat、Dropbox 和 Adobe 合作建立了自己的插画师职业生涯,他深谙这个等式的商业和艺术两面。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="最后一英里就是整个游戏">
|
||||
|
||||
Doodles AI 代表着一种强大的证明:开源工具可以驱动商业成功、品牌级品质的产品。
|
||||
|
||||
SYSTMS 团队以最原始的形式使用开源工具,在该领域的最前沿优先考虑控制和创新。这些工具现在能够生成具有品牌保真度的输出,使 Doodles 区别于 MidJourney 或 Sora 等通用平台,这一点意义重大。这就是创意 AI 中的"最后一英里"问题——从 85% 到 100% 的保真度——也是真正价值所在。
|
||||
|
||||
Doodles AI 展示了当开源工作流遇上专业创意方向时的可能性。ComfyUI 强大的基于节点的平台允许用户将开源模型、API 和其他工具的复杂系统打包成面向消费者的应用程序,使其成为此类项目的天然选择。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/workflow.webp" alt="驱动 Doodles AI 的 ComfyUI 工作流" caption="开源工作流驱动品牌级生成输出。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="编码 DNA">
|
||||
|
||||
Doodles AI 以 PRISM 1.0 作为图像到图像模型推出,但路线图雄心勃勃:2D 和 3D 输出生成、带声音的视频、实时 AR 和游戏应用。原始 Doodles 持有者在发布当天获得 100 次免费生成——这是一个有意识的举措,旨在为社区注入活力,让他们用平台的输出刷遍每一条时间线。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/dna.webp" alt="Doodles AI 输出示例" caption="Doodles AI 输出展示品牌保真的生成结果。" />
|
||||
|
||||
更深层的布局是与整个 AI 行业的速度和规模保持一致。通过在开源基础设施上构建并培育共创者社区,Doodles 已将自己定位为可以将其"编码 DNA"接入尚未存在的未来技术。这是一个赌注:开放性——开源、开放叙事、开放创造——不仅在哲学上有吸引力,而且在战略上是明智的。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="对艺术家意味着什么">
|
||||
|
||||
对于在场外观望的艺术家来说,信息很明确:构建模块已经就位,社区正在建设,创作者和消费者之间的界限正在消失。问题不在于开源是否会重塑创意产业。而在于当它发生时,你是否在用它构建。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/output.webp" alt="Doodles AI 创意输出" caption="开源工具大规模驱动品牌级创意输出。" />
|
||||
|
||||
<Contributors label="链接" people={[{"name":"Doodles: doodles.app | SYSTMS: systms.ai | ComfyUI: comfy.org","role":"官方网站"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Series Entertainment 如何使用 ComfyUI 重塑游戏和视频制作"
|
||||
category: "游戏与视频制作"
|
||||
description: "使用可复用的 ComfyUI 生产系统,在 100,000+ 资产和多部 Netflix 作品中实现情感叙事的规模化。"
|
||||
cover: "https://media.comfy.org/website/customers/series-entertainment/cover.webp"
|
||||
order: 0
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "产出成果"
|
||||
- id: topic-3
|
||||
label: "面临的问题"
|
||||
- id: topic-4
|
||||
label: "解决方案"
|
||||
- id: topic-5
|
||||
label: "为何选择 ComfyUI"
|
||||
- id: topic-6
|
||||
label: "总结"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Series Entertainment 构建以故事为驱动的游戏和短视频体验,其中角色、情感和视觉一致性至关重要。随着工作范围扩展到内部项目、合作伙伴协作和 Netflix 作品,团队面临日益增长的挑战:他们需要在更多项目中生产更多内容,同时不能放慢速度或失去一致性。
|
||||
|
||||
为了应对这一挑战,Series 利用 ComfyUI 扩展了工作流。通过在 ComfyUI 之上构建自定义的可复用工作流,Series 改变了创建角色、情感和视频的方式。最终打造出一个支持超过 100,000 个资产、交付 Netflix 游戏并持续为多个在研项目提供动力的可扩展生产系统。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/series.webp" alt="Series Entertainment 游戏作品,包括 Olympus Rising、Gilded Scales、Evergrove 和 The Wandering Teahouse" caption="Series Entertainment 制作跨多个作品和视觉风格的故事驱动游戏和视频体验。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="Series 使用 ComfyUI 达成的产出成果">
|
||||
|
||||
将 ComfyUI 集成到生产工作流后,Series 实现了:
|
||||
|
||||
- 在游戏和视频中生成超过 100,000 个资产
|
||||
- 180 倍的生产速度提升
|
||||
- 数秒内生成六种不同的角色情感
|
||||
- 每位创作者每周生产 15 分钟的最终视频
|
||||
- 多部 Netflix 作品交付,更多体验正在积极开发中
|
||||
|
||||
这些产出涵盖角色资产、情感变体、背景一致性和短视频——全部通过可复用的 ComfyUI 工作流创建。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="Series 试图解决的问题">
|
||||
|
||||
Series 的工作依赖于富有表现力的角色和一致的视觉标识。随着项目规模和复杂度的增长,团队需要一种在不打破时间线的前提下扩展内容创作的方法。
|
||||
|
||||
传统动画工作流依赖手动关键帧、多个断开的工具和漫长的制作周期——每个视频可能需要数周。制作变体通常意味着从头返工,实验过程缓慢且昂贵。
|
||||
|
||||
Series 需要能够在团队和项目间复用的工作流,同时仍然支持情感叙事、角色一致性和快速迭代。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Series 如何使用 ComfyUI 解决问题">
|
||||
|
||||
Series 围绕 ComfyUI 的节点式工作流系统重建了制作流程。他们不再将生成视为一次性步骤,而是将工作流作为长期生产资产。ComfyUI 成为了创意结构的所在——从角色创建到情感生成再到视频输出。
|
||||
|
||||
### 规模化情感生成
|
||||
|
||||
Series 使用 ComfyUI 构建了一个自定义头像系统,可在数秒内生成六种不同的情感:开心、悲伤、严肃、讽刺、思考和惊讶。这使得创建具有多种情感状态的表现力角色成为可能,而无需手动重新创建每个变体。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/panel.webp" alt="ComfyUI 表情编辑器节点,用于面部表情操控" caption="ComfyUI 中的表情编辑器节点实现了对角色情感的精细控制。" />
|
||||
|
||||
### 从测试到生产的可复用管线
|
||||
|
||||
利用 ComfyUI 的模块化节点系统,Series 构建了四条精简管线,支持从早期探索到最终输出的完整生产周期。这些工作流的效率比传统手工流程(每个资产可能需要六小时以上)**提高了 180 倍**,同时保持生产品质。
|
||||
|
||||
管线范围从快速的 512×512 单情感测试到高分辨率批量生成,使团队能够快速实验并使用相同的工作流直接进入生产。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/workflows.webp" alt="ComfyUI 面部表情操控和放大管线工作流" caption="ComfyUI 工作流展示了并行的表情编辑、放大和面部细化管线。" />
|
||||
|
||||
### 跨游戏和分支叙事的一致性
|
||||
|
||||
在多部 Netflix 作品中,Series 使用 ComfyUI 构建了工作流,确保角色和背景在复杂的分支叙事中保持一致。风格化和一致性管线帮助确保角色在场景、情感和故事路径之间保持视觉统一——即使资产数量不断增长。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/consistency.webp" alt="角色在多个场景和情感状态中保持一致" caption="使用 ComfyUI 一致性管线在六个不同场景和情感状态中保持同一角色。" />
|
||||
|
||||
### 使用 ComfyUI 实现规模化生产
|
||||
|
||||
Series 还将 ComfyUI 作为 AI 辅助动画管线的一部分,将故事开发直接连接到图像和视频生成。该管线包含机器人辅助视频生成,允许创作者反复运行相同的工作流以高效生产视频。使用这种方法,每位创作者可以规模化生成 Lorespark 视频,每周交付超过 **15 分钟的最终视频**。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/batch.webp" alt="ComfyUI 使用 Nano Banana 和 Google Gemini 的批处理工作流" caption="批处理工作流将多个角色图像连接到 Nano Banana,实现风格一致的生成。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="为什么 ComfyUI 适合 Series">
|
||||
|
||||
ComfyUI 之所以有效,是因为其节点式结构使工作流显式且可复用——一旦构建了工作流,就可以在项目间优化和共享。这使 Series 能够将视频生成从一次性过程转变为可重复的系统。
|
||||
|
||||
批量执行和机器人集成使这些工作流能够大规模运行。由于相同的工作流同时支持低分辨率测试和高分辨率最终输出,团队可以从探索无缝过渡到交付,无需切换工具或重建管线。
|
||||
|
||||
最重要的是,ComfyUI 让 Series 专注于构建结构,而非依赖试错式提示。情感、一致性和生产逻辑都存在于工作流本身之中。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/scale.webp" alt="以一致风格生成的同一角色的六个变体" caption="同一角色的多个姿态和表情变体,在保持视觉一致性的同时实现规模化生成。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="总结">
|
||||
|
||||
通过将 ComfyUI 作为核心创意平台,Series Entertainment 彻底改变了游戏和视频的制作方式。最初只是对规模和一致性的需求,最终演变成一个以工作流驱动的生产系统,支持情感叙事、大规模资产和多团队的持续开发。
|
||||
|
||||
<Quote name="Series Entertainment">对 Series 来说,ComfyUI 不是实验。它就是娱乐内容的制作方式。</Quote>
|
||||
|
||||
</Section>
|
||||
101
apps/website/src/content/customers/zh-CN/ubisoft-chord.mdx
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "育碧开源 CHORD 模型,通过 ComfyUI 实现 AAA 级 PBR 材质生成"
|
||||
category: "AAA 游戏制作"
|
||||
description: "育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。"
|
||||
cover: "https://media.comfy.org/website/customers/ubisoft/cover.webp"
|
||||
order: 3
|
||||
readMore: "https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "挑战"
|
||||
- id: topic-3
|
||||
label: "为什么选择 ComfyUI"
|
||||
- id: topic-4
|
||||
label: "流水线"
|
||||
- id: topic-5
|
||||
label: "试用"
|
||||
- id: topic-6
|
||||
label: "成果"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
育碧 La Forge 开源了其 PBR 材质估算模型 **CHORD(Chain of Rendering Decomposition)**,以及 **ComfyUI-Chord** 自定义节点实现,用于构建端到端的 AI 材质生成工作流。
|
||||
|
||||
模型权重和代码以仅限研究的许可证发布。除了研究之外,这是将 ComfyUI 集成到 AAA 级视频游戏制作工作流中的重要一步。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/cover.webp" alt="ComfyUI 中的 CHORD PBR 材质生成" caption="使用 ComfyUI 中的 CHORD 模型生成的 PBR 材质。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="当今 AAA 游戏中的 PBR 材质制作">
|
||||
|
||||
在 AAA 游戏开发中,PBR 材质是视觉真实感的基础。大型游戏需要数百种可复用的材质,每种都包含完整的基础颜色、法线、高度、粗糙度和金属度贴图,并须满足严格的 svBRDF 标准。
|
||||
|
||||
传统上,这些资产由纹理艺术家使用摄影测量、程序化工具和大量手动调整来制作——这使得流程耗时且高度依赖专业知识。
|
||||
|
||||
育碧的生成式基础材质原型直接针对这一制作瓶颈。ComfyUI 工作流输出的 PBR 纹理集可直接集成到 DCC 工具和游戏引擎中,用于原型制作和占位资产。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="育碧为何选择 ComfyUI 作为工作流平台">
|
||||
|
||||
育碧选择 ComfyUI 源于生产实际需求。对于大型工作室来说,需要的不是另一个图像生成器——而是一个可控且可集成的 AI 工作流平台,能够满足游戏开发的定制需求。
|
||||
|
||||
<Quote name="育碧 La Forge 博客">考虑到我们原型的多阶段特性,ComfyUI 为我们提供了一个高效的框架来构建集成工作流,涵盖纹理图像合成、材质估算和材质放大。这也使我们能够利用最先进的生成模型和 ComfyUI 的强大功能,通过 ControlNet、图像引导、修复等众多选项为创作者提供精细控制。</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="生成式基础材质流水线的三个阶段">
|
||||
|
||||
CHORD 模型集成在一个更广泛的流水线中,由三个核心阶段组成。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/pipeline.webp" alt="三阶段生成式基础材质流水线" caption="三阶段生成式基础材质流水线:纹理生成、CHORD 估算和放大。" />
|
||||
|
||||
### 阶段一 — 纹理图像生成
|
||||
|
||||
第一阶段使用具有完全条件控制的自定义扩散模型,从文本提示或参考输入(如线稿和高度图)生成无缝、可平铺的 2D 纹理。
|
||||
|
||||
### 阶段二 — CHORD 图像到材质估算
|
||||
|
||||
将单一纹理转换为完整的 PBR 贴图集——包括基础颜色、法线、高度、粗糙度和金属度——使用链式分解、统一多模态预测和高效的单步扩散推理,实现可控且可扩展的结果。
|
||||
|
||||
### 阶段三 — 材质放大
|
||||
|
||||
由于 CHORD 在 1024 分辨率下运行最佳,第三阶段应用工业级 PBR 放大。所有通道放大 2 倍或 4 倍,以生成用于实时游戏制作的 2K 和 4K 纹理资产。
|
||||
|
||||
这条完整的流水线使艺术家能够快速迭代创意,在现有工作流中混合搭配 AI 生成的输出,降低了工业级 PBR 材质创建的门槛。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="如何在 ComfyUI 中试用 CHORD">
|
||||
|
||||
育碧开源了 CHORD 模型权重、ComfyUI 自定义节点和示例工作流,涵盖流水线中的纹理图像生成阶段和图像到材质估算阶段。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/workflow.webp" alt="ComfyUI 中的 CHORD 示例工作流" caption="ComfyUI 中端到端 PBR 材质生成的 CHORD 示例工作流。" />
|
||||
|
||||
<Steps items={["安装或更新 ComfyUI 至最新版本","从育碧安装 CHORD ComfyUI 自定义节点","下载 CHORD 模型并放置在 ./ComfyUI/models/checkpoints 目录","在 ComfyUI 中加载 CHORD 示例工作流"]} />
|
||||
|
||||
您可以将纹理图像生成模型替换为任何其他图像模型,也可以单独使用每个阶段的工作流模块。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="输出示例">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example1.webp" alt="CHORD PBR 材质输出示例 1" caption="生成的 PBR 材质集,展示基础颜色、法线、高度、粗糙度和金属度贴图。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example2.webp" alt="CHORD PBR 材质输出示例 2" caption="另一组生成的 PBR 材质集,展示 CHORD 可实现的多样纹理效果。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example3.webp" alt="CHORD PBR 材质输出示例 3" caption="具有完整 PBR 通道分解的材质生成输出。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example4.webp" alt="CHORD PBR 材质输出示例 4" caption="从单一输入纹理生成的高质量 PBR 纹理集。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example5.webp" alt="CHORD PBR 材质输出示例 5" caption="最终渲染的 PBR 材质,展示可用于生产的质量。" />
|
||||
|
||||
CHORD 的发布表明,ComfyUI 已从一个社区驱动的工具成长为一个真正的生产平台。工作室用户可以构建端到端流水线,从提示或参考输入到纹理生成、材质估算、PBR 放大,最终导出到 DCC 工具或游戏引擎。每个阶段也可以独立运行并嵌入现有的生产系统中。
|
||||
|
||||
<Contributors label="作者" people={[{"name":"Jo Zhang","role":"ComfyUI 博客"},{"name":"Daxiong (Lin)","role":"ComfyUI 博客"}]} />
|
||||
|
||||
</Section>
|
||||
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>
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -123,6 +123,15 @@ Browser tests in this project follow a specific organization pattern:
|
||||
- **Utilities**: Located in `utils/` - Common utility functions
|
||||
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes
|
||||
|
||||
### Custom-node regression suite
|
||||
|
||||
`tests/customNodes/` holds the manifest-driven suite that proves community
|
||||
custom-node packs load, render in both renderers (LiteGraph canvas and Vue
|
||||
Nodes 2.0), and execute real workflows. It has its own prerequisites, pnpm
|
||||
scripts (`pnpm test:custom-nodes` and per-pack variants), and a
|
||||
one-JSON-row process for adding packs - see
|
||||
[tests/customNodes/README.md](tests/customNodes/README.md).
|
||||
|
||||
## Writing Effective Tests
|
||||
|
||||
When writing new tests, follow these patterns:
|
||||
|
||||
53
browser_tests/assets/customNodes/core_smoke.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 80 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [42, "fixed"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "StringFunction|pysssss",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 300, "1": 240 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringFunction|pysssss"
|
||||
},
|
||||
"widgets_values": ["append", "yes", "hello", " world", ""]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ShowText|pysssss",
|
||||
"pos": { "0": 380, "1": 60 },
|
||||
"size": { "0": 220, "1": 80 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ShowText|pysssss"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "STRING"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "SimpleMathInt+",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SimpleMathInt+"
|
||||
},
|
||||
"widgets_values": [5]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "DisplayAny",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 80 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "DisplayAny"
|
||||
},
|
||||
"widgets_values": ["raw value"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
98
browser_tests/assets/customNodes/impact_primitives_run.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImpactInt",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImpactInt"
|
||||
},
|
||||
"widgets_values": [42]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ImpactFloat",
|
||||
"pos": { "0": 20, "1": 220 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImpactFloat"
|
||||
},
|
||||
"widgets_values": [3.14]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 220 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "INT"],
|
||||
[2, 3, 0, 4, 0, "FLOAT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
98
browser_tests/assets/customNodes/kjnodes_constants_run.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "INTConstant",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "INTConstant"
|
||||
},
|
||||
"widgets_values": [42]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "FloatConstant",
|
||||
"pos": { "0": 20, "1": 220 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "FloatConstant"
|
||||
},
|
||||
"widgets_values": [3.14]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 220 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "INT"],
|
||||
[2, 3, 0, 4, 0, "FLOAT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Seed (rgthree)",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 130 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "SEED",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Seed (rgthree)"
|
||||
},
|
||||
"widgets_values": [12345]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Display Any (rgthree)",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "Display Any (rgthree)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
107
browser_tests/assets/customNodes/vhs_video_pipeline_run.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "VHS_LoadVideoPath",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 320, "1": 260 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "frame_count",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "video_info",
|
||||
"type": "VHS_VIDEOINFO",
|
||||
"links": [1],
|
||||
"slot_index": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VHS_LoadVideoPath"
|
||||
},
|
||||
"widgets_values": ["input/plain_video.mp4", 0, 0, 0, 0, 0, 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VHS_VideoInfo",
|
||||
"pos": { "0": 400, "1": 60 },
|
||||
"size": { "0": 240, "1": 260 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "video_info",
|
||||
"type": "VHS_VIDEOINFO",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "source_fps🟨",
|
||||
"type": "FLOAT",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
},
|
||||
{ "name": "source_frame_count🟨", "type": "INT", "links": null },
|
||||
{ "name": "source_duration🟨", "type": "FLOAT", "links": null },
|
||||
{ "name": "source_width🟨", "type": "INT", "links": null },
|
||||
{ "name": "source_height🟨", "type": "INT", "links": null },
|
||||
{ "name": "loaded_fps🟦", "type": "FLOAT", "links": null },
|
||||
{ "name": "loaded_frame_count🟦", "type": "INT", "links": null },
|
||||
{ "name": "loaded_duration🟦", "type": "FLOAT", "links": null },
|
||||
{ "name": "loaded_width🟦", "type": "INT", "links": null },
|
||||
{ "name": "loaded_height🟦", "type": "INT", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VHS_VideoInfo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 700, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 3, 2, 0, "VHS_VIDEOINFO"],
|
||||
[2, 2, 0, 3, 0, "FLOAT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
103
browser_tests/assets/customNodes/was_number_text_run.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Constant Number",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 100 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "NUMBER",
|
||||
"type": "NUMBER",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": null,
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null,
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Constant Number"
|
||||
},
|
||||
"widgets_values": ["integer", 7]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Number to Text",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "number",
|
||||
"type": "NUMBER",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Number to Text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "Text to Console",
|
||||
"pos": { "0": 640, "1": 60 },
|
||||
"size": { "0": 250, "1": 80 },
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Text to Console"
|
||||
},
|
||||
"widgets_values": ["Text Output"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "NUMBER"],
|
||||
[2, 2, 0, 3, 0, "STRING"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
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
@@ -268,8 +268,16 @@ export class ComfyPage {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
if (resp.status() !== 200)
|
||||
throw new Error(`Failed to create user: ${await resp.text()}`)
|
||||
if (resp.status() !== 200) {
|
||||
const body = await resp.text()
|
||||
// Persistent backends (Comfy Desktop server user storage) keep the user
|
||||
// across runs and do not list it via GET /api/users, so a duplicate means
|
||||
// it already exists. Returns the username since the generated id is not
|
||||
// retrievable here; only reached on single-user / default-resolving backends.
|
||||
if (resp.status() === 400 && body.includes('Duplicate username.'))
|
||||
return username
|
||||
throw new Error(`Failed to create user: ${body}`)
|
||||
}
|
||||
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
127
browser_tests/fixtures/customNode/ComfyTarget.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ObjectInfo } from '@e2e/fixtures/customNode/objectInfoValidator'
|
||||
import type {
|
||||
ExecutionError,
|
||||
PromptEvent,
|
||||
RunResult
|
||||
} from '@e2e/fixtures/customNode/runResult'
|
||||
import { classifyRun } from '@e2e/fixtures/customNode/runResult'
|
||||
|
||||
interface RawEvent {
|
||||
type: string
|
||||
node?: string | null
|
||||
exception_type?: string
|
||||
node_id?: string
|
||||
node_type?: string
|
||||
traceback?: string[]
|
||||
}
|
||||
|
||||
const TERMINAL = [
|
||||
'execution_success',
|
||||
'execution_error',
|
||||
'execution_interrupted'
|
||||
]
|
||||
|
||||
function toPromptEvent(raw: RawEvent): PromptEvent {
|
||||
if (raw.type === 'executing')
|
||||
return { type: 'executing', node: raw.node ?? null }
|
||||
if (raw.type === 'execution_error' || raw.type === 'execution_interrupted') {
|
||||
const error: ExecutionError = {
|
||||
exceptionType: raw.exception_type,
|
||||
nodeId: raw.node_id,
|
||||
nodeType: raw.node_type,
|
||||
traceback: raw.traceback
|
||||
}
|
||||
return { type: raw.type, error }
|
||||
}
|
||||
return { type: raw.type as 'execution_start' | 'execution_success' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a real ComfyUI backend through the running frontend. The verdict logic
|
||||
* lives in the pure `classifyRun`; this class is only the in-page IO plumbing.
|
||||
*/
|
||||
export class LocalDesktopTarget {
|
||||
async getObjectInfo(page: Page): Promise<ObjectInfo> {
|
||||
return await page.evaluate(async () => {
|
||||
const defs = await window.app!.api.getNodeDefs()
|
||||
const out: Record<
|
||||
string,
|
||||
{ input?: { required?: Record<string, unknown> } }
|
||||
> = {}
|
||||
for (const [name, def] of Object.entries(defs)) {
|
||||
const required = (
|
||||
def as { input?: { required?: Record<string, unknown> } }
|
||||
).input?.required
|
||||
out[name] = { input: { required } }
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
async runWorkflow(
|
||||
page: Page,
|
||||
opts: { expectedNodeIds: string[]; timeoutMs: number }
|
||||
): Promise<RunResult> {
|
||||
await page.evaluate(
|
||||
(types) => {
|
||||
const sink = window as unknown as {
|
||||
__cnEvents: RawEvent[]
|
||||
__cnTapInstalled?: boolean
|
||||
}
|
||||
sink.__cnEvents = []
|
||||
if (sink.__cnTapInstalled) return
|
||||
sink.__cnTapInstalled = true
|
||||
for (const type of types)
|
||||
(window.app!.api as EventTarget).addEventListener(
|
||||
type,
|
||||
(event: Event) => {
|
||||
const detail: unknown = (event as CustomEvent).detail
|
||||
// `executing` dispatches a bare node-id string (api.ts
|
||||
// dispatchCustomEvent('executing', msg.data.node)); the other
|
||||
// events dispatch object payloads.
|
||||
sink.__cnEvents.push(
|
||||
detail !== null && typeof detail === 'object'
|
||||
? { type, ...(detail as Record<string, unknown>) }
|
||||
: { type, node: (detail as string | undefined) ?? null }
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
['execution_start', ...TERMINAL, 'executing']
|
||||
)
|
||||
|
||||
// Browser path: app.queuePrompt runs graphToPrompt internally. Do NOT call
|
||||
// app.api.queuePrompt, which submits an already-serialized (empty) prompt.
|
||||
await page.evaluate(() => window.app!.queuePrompt(0))
|
||||
|
||||
await page
|
||||
.waitForFunction(
|
||||
(terminal) => {
|
||||
const events =
|
||||
(window as unknown as { __cnEvents?: { type: string }[] })
|
||||
.__cnEvents ?? []
|
||||
return events.some((event) => terminal.includes(event.type))
|
||||
},
|
||||
TERMINAL,
|
||||
{ timeout: opts.timeoutMs }
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
// Only a Playwright wait timeout means "no terminal event"; surface any
|
||||
// other fault instead of masquerading it as a run TIMEOUT.
|
||||
if (error instanceof Error && error.name === 'TimeoutError') return
|
||||
throw error
|
||||
})
|
||||
|
||||
const raw = await page.evaluate(
|
||||
() => (window as unknown as { __cnEvents?: RawEvent[] }).__cnEvents ?? []
|
||||
)
|
||||
const timedOut = !raw.some((event) => TERMINAL.includes(event.type))
|
||||
return classifyRun({
|
||||
events: raw.map(toPromptEvent),
|
||||
expectedNodeIds: opts.expectedNodeIds,
|
||||
timedOut
|
||||
})
|
||||
}
|
||||
}
|
||||
91
browser_tests/fixtures/customNode/manifest.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const MANIFEST_PATH = fileURLToPath(
|
||||
new URL('../data/customNodeManifest.json', import.meta.url)
|
||||
)
|
||||
|
||||
const VALID_TIERS = ['load', 'run', 'connectivity', 'io'] as const
|
||||
|
||||
type CustomNodeTier = (typeof VALID_TIERS)[number]
|
||||
|
||||
export interface CustomNodeManifestEntry {
|
||||
pack: string
|
||||
repo: string
|
||||
pin: string
|
||||
tiers: CustomNodeTier[]
|
||||
// Frontend-format workflow (path relative to browser_tests/) loaded and queued
|
||||
// by the run/io tiers; empty or absent file = tier skips. Run the backend with
|
||||
// --cache-none, or repeat runs classify PARTIAL when cached nodes skip executing.
|
||||
workflow: string
|
||||
// Runtime class_type / object_info keys, NOT Python class names (e.g. rgthree
|
||||
// registers "Power Primitive (rgthree)", not RgthreePowerPrimitive).
|
||||
expectedNodes: string[]
|
||||
requiresGpu: boolean
|
||||
requiresModels: string[]
|
||||
timeoutMs: number
|
||||
// Optional; absent means true. Set false ONLY with evidence that the pack's
|
||||
// nodes fail to mount under Vue Nodes 2.0 (probe it - a README grumble is
|
||||
// not evidence). When false, renderer-specific Vue assertions are not
|
||||
// applied to this pack: its tests still run and pass their LiteGraph-canvas
|
||||
// assertions, so the zero-skip gate is preserved.
|
||||
vueNodesCompatible?: boolean
|
||||
}
|
||||
|
||||
function assertEntry(entry: CustomNodeManifestEntry, index: number): void {
|
||||
const missing: string[] = []
|
||||
if (typeof entry.pack !== 'string' || entry.pack.length === 0)
|
||||
missing.push('pack')
|
||||
// CI clones from repo, so an empty value must fail here, not mid-clone.
|
||||
// pin stays optional ("" = default branch head).
|
||||
if (typeof entry.repo !== 'string' || entry.repo.length === 0)
|
||||
missing.push('repo')
|
||||
// workflow may be an empty string until the pack gains a run-tier fixture.
|
||||
if (typeof entry.workflow !== 'string') missing.push('workflow')
|
||||
// A run-tier row with no workflow would otherwise skip locally, leaving
|
||||
// only CI's skip gate to notice the lost coverage. Fail at load instead.
|
||||
else if (
|
||||
entry.workflow === '' &&
|
||||
Array.isArray(entry.tiers) &&
|
||||
entry.tiers.includes('run')
|
||||
)
|
||||
missing.push('workflow (required when tiers includes "run")')
|
||||
if (!Array.isArray(entry.expectedNodes) || entry.expectedNodes.length === 0)
|
||||
missing.push('expectedNodes')
|
||||
if (!Array.isArray(entry.tiers) || entry.tiers.length === 0)
|
||||
missing.push('tiers')
|
||||
// A typo like "connectivty" would otherwise pass and silently drop that
|
||||
// tier's coverage - the exact drift this manifest exists to catch.
|
||||
else if (entry.tiers.some((tier) => !VALID_TIERS.includes(tier)))
|
||||
missing.push(`tiers (unknown value; allowed: ${VALID_TIERS.join(', ')})`)
|
||||
if (!Array.isArray(entry.requiresModels)) missing.push('requiresModels')
|
||||
if (typeof entry.requiresGpu !== 'boolean') missing.push('requiresGpu')
|
||||
if (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0)
|
||||
missing.push('timeoutMs')
|
||||
if (
|
||||
entry.vueNodesCompatible !== undefined &&
|
||||
typeof entry.vueNodesCompatible !== 'boolean'
|
||||
)
|
||||
missing.push('vueNodesCompatible')
|
||||
if (missing.length > 0)
|
||||
throw new Error(
|
||||
`custom-node manifest entry ${index} (${entry.pack ?? '?'}) missing: ${missing.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Renderer passes for the load tier: LiteGraph canvas always, Vue Nodes 2.0
|
||||
// unless the pack declares itself incompatible. Conditional coverage, never a
|
||||
// test.skip - the caller still runs and gates on the returned passes.
|
||||
export function rendererPassesFor(
|
||||
entry: Pick<CustomNodeManifestEntry, 'vueNodesCompatible'>
|
||||
): boolean[] {
|
||||
return entry.vueNodesCompatible === false ? [false] : [false, true]
|
||||
}
|
||||
|
||||
export function loadManifest(): CustomNodeManifestEntry[] {
|
||||
const entries = JSON.parse(
|
||||
readFileSync(MANIFEST_PATH, 'utf-8')
|
||||
) as CustomNodeManifestEntry[]
|
||||
entries.forEach(assertEntry)
|
||||
return entries
|
||||
}
|
||||
54
browser_tests/fixtures/customNode/objectInfoValidator.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { CustomNodeOutcome } from '@e2e/fixtures/customNode/runResult'
|
||||
|
||||
interface ObjectInfoNode {
|
||||
input?: { required?: Record<string, unknown> }
|
||||
}
|
||||
export type ObjectInfo = Record<string, ObjectInfoNode>
|
||||
|
||||
export interface ApiPromptNode {
|
||||
id: string
|
||||
classType: string
|
||||
inputs: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function expectedNodesPresent(
|
||||
objectInfo: ObjectInfo,
|
||||
expectedNodes: string[]
|
||||
): { present: string[]; missing: string[] } {
|
||||
const present: string[] = []
|
||||
const missing: string[] = []
|
||||
for (const name of expectedNodes) {
|
||||
if (name in objectInfo) present.push(name)
|
||||
else missing.push(name)
|
||||
}
|
||||
return { present, missing }
|
||||
}
|
||||
|
||||
export interface PreValidationFailure {
|
||||
outcome: Extract<CustomNodeOutcome, 'MISSING_NODE' | 'VALIDATION_FAIL'>
|
||||
message: string
|
||||
}
|
||||
|
||||
// Turns an opaque backend 400 into a precise infra error before submit (BE-401):
|
||||
// every required input declared in object_info must be present in the fixture node.
|
||||
export function preValidate(
|
||||
objectInfo: ObjectInfo,
|
||||
nodes: ApiPromptNode[]
|
||||
): PreValidationFailure | null {
|
||||
for (const node of nodes) {
|
||||
const def = objectInfo[node.classType]
|
||||
if (!def)
|
||||
return {
|
||||
outcome: 'MISSING_NODE',
|
||||
message: `node ${node.id} ${node.classType} missing from object_info`
|
||||
}
|
||||
for (const name of Object.keys(def.input?.required ?? {})) {
|
||||
if (!(name in node.inputs))
|
||||
return {
|
||||
outcome: 'VALIDATION_FAIL',
|
||||
message: `node ${node.id} ${node.classType} missing required input "${name}"`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
72
browser_tests/fixtures/customNode/runResult.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type CustomNodeOutcome =
|
||||
| 'NOT_INSTALLED'
|
||||
| 'IMPORT_ERROR'
|
||||
| 'MISSING_NODE'
|
||||
| 'VALIDATION_FAIL'
|
||||
| 'EXECUTION_ERROR'
|
||||
| 'PARTIAL'
|
||||
| 'TIMEOUT'
|
||||
| 'PASS'
|
||||
|
||||
export interface ExecutionError {
|
||||
exceptionType?: string
|
||||
nodeId?: string
|
||||
nodeType?: string
|
||||
traceback?: string[]
|
||||
}
|
||||
|
||||
export type PromptEvent =
|
||||
| { type: 'execution_start' }
|
||||
| { type: 'executing'; node: string | null }
|
||||
| { type: 'execution_success' }
|
||||
| { type: 'execution_error'; error: ExecutionError }
|
||||
| { type: 'execution_interrupted'; error?: ExecutionError }
|
||||
|
||||
export interface RunResult {
|
||||
outcome: CustomNodeOutcome
|
||||
executedNodes: string[]
|
||||
error?: ExecutionError
|
||||
}
|
||||
|
||||
// `executing` with a non-null node is the only cache-safe "this node actually ran"
|
||||
// signal: ComfyUI emits it solely for non-cached nodes (execution.py:493), while the
|
||||
// `executed` message and /history outputs are replayed for cached nodes too.
|
||||
function executedNodesFrom(events: PromptEvent[]): string[] {
|
||||
const executed = new Set<string>()
|
||||
for (const event of events) {
|
||||
if (event.type === 'executing' && event.node !== null)
|
||||
executed.add(event.node)
|
||||
}
|
||||
return [...executed]
|
||||
}
|
||||
|
||||
export function classifyRun(input: {
|
||||
events: PromptEvent[]
|
||||
expectedNodeIds: string[]
|
||||
timedOut?: boolean
|
||||
}): RunResult {
|
||||
const { events, expectedNodeIds, timedOut = false } = input
|
||||
const executedNodes = executedNodesFrom(events)
|
||||
|
||||
if (timedOut) return { outcome: 'TIMEOUT', executedNodes }
|
||||
|
||||
const failure = events.find(
|
||||
(
|
||||
event
|
||||
): event is Extract<
|
||||
PromptEvent,
|
||||
{ type: 'execution_error' | 'execution_interrupted' }
|
||||
> =>
|
||||
event.type === 'execution_error' || event.type === 'execution_interrupted'
|
||||
)
|
||||
if (failure)
|
||||
return { outcome: 'EXECUTION_ERROR', executedNodes, error: failure.error }
|
||||
|
||||
if (!events.some((event) => event.type === 'execution_success'))
|
||||
return { outcome: 'TIMEOUT', executedNodes }
|
||||
|
||||
const ranEveryExpected = expectedNodeIds.every((node) =>
|
||||
executedNodes.includes(node)
|
||||
)
|
||||
return { outcome: ranEveryExpected ? 'PASS' : 'PARTIAL', executedNodes }
|
||||
}
|
||||
216
browser_tests/fixtures/customNode/typePairing.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// Type-driven pairing generator for the connectivity (contract) tier.
|
||||
// Wildcard `*` slots are excluded from pairing: LiteGraph.isValidConnection
|
||||
// short-circuits on `*` before the real type compare, so a wildcard link
|
||||
// proves reachability, not type interop.
|
||||
|
||||
export interface RawNodeDef {
|
||||
input?: {
|
||||
required?: Record<string, unknown>
|
||||
optional?: Record<string, unknown>
|
||||
}
|
||||
output?: unknown[]
|
||||
output_name?: string[]
|
||||
python_module?: string
|
||||
}
|
||||
|
||||
interface NormalizedSlot {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface NormalizedNode {
|
||||
type: string
|
||||
pack: string
|
||||
inputs: NormalizedSlot[]
|
||||
outputs: NormalizedSlot[]
|
||||
}
|
||||
|
||||
interface SlotRef {
|
||||
nodeType: string
|
||||
pack: string
|
||||
slotName: string
|
||||
slotType: string
|
||||
}
|
||||
|
||||
export interface PlannedPair {
|
||||
producer: SlotRef
|
||||
consumer: SlotRef
|
||||
}
|
||||
|
||||
export interface PairingPlan {
|
||||
pairs: PlannedPair[]
|
||||
// No compatible partner in the loaded corpus: a health signal, not a failure.
|
||||
orphans: Array<SlotRef & { dir: 'in' | 'out' }>
|
||||
// `*` / empty-typed slots, excluded by design (false confidence).
|
||||
wildcards: Array<SlotRef & { dir: 'in' | 'out' }>
|
||||
// COMBO-literal slots, excluded by design: isValidConnection only compares
|
||||
// the string COMBO while each slot carries its own option set, so a
|
||||
// type-level pairing proves nothing (a checkpoint dropdown would "connect"
|
||||
// to a scheduler dropdown). Targeted fixtures cover combo behavior.
|
||||
combos: Array<SlotRef & { dir: 'in' | 'out' }>
|
||||
}
|
||||
|
||||
// Extends the shared outcome taxonomy (runResult.ts); ORPHAN_TYPE is a
|
||||
// plan-time skip so it never reaches the executor.
|
||||
// WIDGET_ONLY_ON_INSTANCE: the pack's own frontend JS rebuilt a declared
|
||||
// input as a widget-only control, so there is no socket to wire - excluded
|
||||
// like wildcards, never a failure and never a silent pass.
|
||||
export type ConnectivityOutcome =
|
||||
| 'PASS'
|
||||
| 'CONNECT_REJECTED'
|
||||
| 'ROUNDTRIP_LOST'
|
||||
| 'SLOT_CONTRACT_MISMATCH'
|
||||
| 'WIDGET_ONLY_ON_INSTANCE'
|
||||
|
||||
export function packOf(pythonModule: string | undefined): string {
|
||||
if (pythonModule?.startsWith('custom_nodes.'))
|
||||
return pythonModule.slice('custom_nodes.'.length)
|
||||
return 'core'
|
||||
}
|
||||
|
||||
export function isWildcard(type: string): boolean {
|
||||
return type === '' || type === '*'
|
||||
}
|
||||
|
||||
// COMBO list literals are arrays; their connectable socket type is COMBO.
|
||||
function slotTypeOf(rawType: unknown): string | null {
|
||||
if (Array.isArray(rawType)) return 'COMBO'
|
||||
return typeof rawType === 'string' ? rawType : null
|
||||
}
|
||||
|
||||
function inputSlots(
|
||||
entries: Record<string, unknown> | undefined
|
||||
): NormalizedSlot[] {
|
||||
if (!entries) return []
|
||||
const slots: NormalizedSlot[] = []
|
||||
for (const [name, spec] of Object.entries(entries)) {
|
||||
const specArray = Array.isArray(spec) ? spec : [spec]
|
||||
const type = slotTypeOf(specArray[0])
|
||||
if (type === null) continue
|
||||
const opts = specArray[1] as { socketless?: boolean } | undefined
|
||||
// socketless = widget only, no slot: not connectable, out of the matrix.
|
||||
if (opts?.socketless) continue
|
||||
slots.push({ name, type })
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
export function normalizeNodeDefs(
|
||||
defs: Record<string, RawNodeDef>
|
||||
): NormalizedNode[] {
|
||||
return Object.entries(defs).map(([type, def]) => ({
|
||||
type,
|
||||
pack: packOf(def.python_module),
|
||||
inputs: [
|
||||
...inputSlots(def.input?.required),
|
||||
...inputSlots(def.input?.optional)
|
||||
],
|
||||
outputs: (def.output ?? []).flatMap((rawType, index) => {
|
||||
const slotType = slotTypeOf(rawType)
|
||||
if (slotType === null) return []
|
||||
// output_name entries can be non-strings (COMBO literals repeat the
|
||||
// option array); the slot name must stay a string.
|
||||
const rawName = def.output_name?.[index]
|
||||
return [
|
||||
{
|
||||
name: typeof rawName === 'string' ? rawName : slotType,
|
||||
type: slotType
|
||||
}
|
||||
]
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// Faithful mirror of LiteGraph.isValidConnection (LiteGraphGlobal.ts):
|
||||
// wildcard/empty always match, comparison is case-insensitive, comma-unions
|
||||
// match if any member pair matches. The live sweep still connects through the
|
||||
// REAL validator, so any drift here surfaces as CONNECT_REJECTED, not a
|
||||
// silent false green.
|
||||
export function isTypeCompatible(a: string, b: string): boolean {
|
||||
if (isWildcard(a) || isWildcard(b)) return true
|
||||
const typeA = a.toLowerCase()
|
||||
const typeB = b.toLowerCase()
|
||||
if (typeA === typeB) return true
|
||||
if (!typeA.includes(',') && !typeB.includes(',')) return false
|
||||
return typeA
|
||||
.split(',')
|
||||
.some((memberA) =>
|
||||
typeB.split(',').some((memberB) => isTypeCompatible(memberA, memberB))
|
||||
)
|
||||
}
|
||||
|
||||
function slotRef(node: NormalizedNode, slot: NormalizedSlot): SlotRef {
|
||||
return {
|
||||
nodeType: node.type,
|
||||
pack: node.pack,
|
||||
slotName: slot.name,
|
||||
slotType: slot.type
|
||||
}
|
||||
}
|
||||
|
||||
// One representative compatible edge per slot, deterministically the first
|
||||
// partner in (nodeType, slotName) order. This bounds cost to O(slots) but
|
||||
// does NOT prove every pair; a full cross-product is an opt-in deep mode.
|
||||
export function planPairs(
|
||||
all: NormalizedNode[],
|
||||
corpusTypes: string[]
|
||||
): PairingPlan {
|
||||
const sorted = [...all].sort((a, b) => a.type.localeCompare(b.type))
|
||||
const pairable = (slot: NormalizedSlot) =>
|
||||
!isWildcard(slot.type) && slot.type !== 'COMBO'
|
||||
const producers: Array<SlotRef> = sorted.flatMap((node) =>
|
||||
node.outputs.filter(pairable).map((slot) => slotRef(node, slot))
|
||||
)
|
||||
const consumers: Array<SlotRef> = sorted.flatMap((node) =>
|
||||
node.inputs.filter(pairable).map((slot) => slotRef(node, slot))
|
||||
)
|
||||
|
||||
const plan: PairingPlan = {
|
||||
pairs: [],
|
||||
orphans: [],
|
||||
wildcards: [],
|
||||
combos: []
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const addPair = (producer: SlotRef, consumer: SlotRef) => {
|
||||
const key = `${producer.nodeType}.${producer.slotName}->${consumer.nodeType}.${consumer.slotName}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
plan.pairs.push({ producer, consumer })
|
||||
}
|
||||
|
||||
const corpus = all.filter((node) => corpusTypes.includes(node.type))
|
||||
for (const node of corpus) {
|
||||
for (const slot of node.inputs) {
|
||||
if (isWildcard(slot.type)) {
|
||||
plan.wildcards.push({ ...slotRef(node, slot), dir: 'in' })
|
||||
continue
|
||||
}
|
||||
if (slot.type === 'COMBO') {
|
||||
plan.combos.push({ ...slotRef(node, slot), dir: 'in' })
|
||||
continue
|
||||
}
|
||||
const producer = producers.find((candidate) =>
|
||||
isTypeCompatible(candidate.slotType, slot.type)
|
||||
)
|
||||
if (producer) addPair(producer, slotRef(node, slot))
|
||||
else plan.orphans.push({ ...slotRef(node, slot), dir: 'in' })
|
||||
}
|
||||
for (const slot of node.outputs) {
|
||||
if (isWildcard(slot.type)) {
|
||||
plan.wildcards.push({ ...slotRef(node, slot), dir: 'out' })
|
||||
continue
|
||||
}
|
||||
if (slot.type === 'COMBO') {
|
||||
plan.combos.push({ ...slotRef(node, slot), dir: 'out' })
|
||||
continue
|
||||
}
|
||||
const consumer = consumers.find((candidate) =>
|
||||
isTypeCompatible(slot.type, candidate.slotType)
|
||||
)
|
||||
if (consumer) addPair(slotRef(node, slot), consumer)
|
||||
else plan.orphans.push({ ...slotRef(node, slot), dir: 'out' })
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
79
browser_tests/fixtures/data/customNodeManifest.json
Normal file
@@ -0,0 +1,79 @@
|
||||
[
|
||||
{
|
||||
"pack": "ComfyUI-Impact-Pack",
|
||||
"repo": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/impact_primitives_run.json",
|
||||
"expectedNodes": ["ImpactInt", "ImpactFloat"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "ComfyUI-VideoHelperSuite",
|
||||
"repo": "https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/vhs_video_pipeline_run.json",
|
||||
"expectedNodes": ["VHS_LoadVideoPath", "VHS_VideoInfo"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 90000
|
||||
},
|
||||
{
|
||||
"pack": "rgthree-comfy",
|
||||
"repo": "https://github.com/rgthree/rgthree-comfy",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/rgthree_seed_display_run.json",
|
||||
"expectedNodes": ["Seed (rgthree)", "Display Any (rgthree)"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "ComfyUI_essentials",
|
||||
"repo": "https://github.com/cubiq/ComfyUI_essentials",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/essentials_math_display_run.json",
|
||||
"expectedNodes": ["SimpleMathInt+", "DisplayAny"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "ComfyUI-KJNodes",
|
||||
"repo": "https://github.com/kijai/ComfyUI-KJNodes",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/kjnodes_constants_run.json",
|
||||
"expectedNodes": ["INTConstant", "FloatConstant"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "ComfyUI-Custom-Scripts",
|
||||
"repo": "https://github.com/pythongosssss/ComfyUI-Custom-Scripts",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/customscripts_string_show_run.json",
|
||||
"expectedNodes": ["StringFunction|pysssss", "ShowText|pysssss"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "was-node-suite-comfyui",
|
||||
"repo": "https://github.com/WASasquatch/was-node-suite-comfyui",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/was_number_text_run.json",
|
||||
"expectedNodes": ["Constant Number", "Number to Text", "Text to Console"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
]
|
||||
@@ -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: {
|
||||
|
||||
13
browser_tests/fixtures/utils/consoleErrorCollector.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ConsoleMessage, Page } from '@playwright/test'
|
||||
|
||||
export function collectConsoleErrors(page: Page): {
|
||||
errors: string[]
|
||||
stop: () => void
|
||||
} {
|
||||
const errors: string[] = []
|
||||
const listener = (message: ConsoleMessage) => {
|
||||
if (message.type() === 'error') errors.push(message.text())
|
||||
}
|
||||
page.on('console', listener)
|
||||
return { errors, stop: () => page.off('console', listener) }
|
||||
}
|
||||
27
browser_tests/fixtures/utils/customNodeSuite.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
// Boot every session with a blank graph (loadBlankWorkflow) instead of the
|
||||
// bundled default template, whose model references error on a model-less
|
||||
// harness backend and would trip the zero-visible-errors invariant. The
|
||||
// backend must run --multi-user (the repo-wide prerequisite for browser
|
||||
// tests): the fixture then writes these settings to the same per-worker
|
||||
// user the session reads, on CI and locally alike.
|
||||
// The shared fixture disables the errors tab to hide missing-model
|
||||
// indicators in unrelated suites; this suite exists to SEE errors, so every
|
||||
// error surface stays live.
|
||||
export const customNodeSuiteSettings = {
|
||||
'Comfy.TutorialCompleted': false,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': true
|
||||
}
|
||||
|
||||
// The tutorial path auto-opens the templates browser over the blank graph.
|
||||
// Dismiss it deterministically so no window ever shows unexpected UI.
|
||||
export async function dismissTemplatesDialog(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const templates = comfyPage.page.getByTestId(TestIds.templates.content)
|
||||
await templates.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await templates.waitFor({ state: 'hidden' })
|
||||
}
|
||||
16
browser_tests/fixtures/utils/errorSurfaces.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
// The app's user-visible error surfaces. A regression run is green only if a
|
||||
// human looking at the screen would see zero errors - not merely a clean
|
||||
// console. The harness self-check asserts the overlay IS visible after a
|
||||
// forced execution error, so these selectors are permanently proven live.
|
||||
export function errorSurfaces(page: Page): Record<string, Locator> {
|
||||
return {
|
||||
errorOverlay: page.getByTestId(TestIds.dialogs.errorOverlay),
|
||||
errorDialog: page.getByTestId(TestIds.dialogs.errorDialog),
|
||||
nodeRenderErrors: page.locator('.node-error'),
|
||||
errorToasts: page.locator('.p-toast-message-error')
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,14 @@ class NodeSlotReference {
|
||||
const rawPos = node.getConnectionPos(type === 'input', index)
|
||||
const convertedPos =
|
||||
window.app!.canvas.ds!.convertOffsetToCanvas(rawPos)
|
||||
// convertOffsetToCanvas is canvas-relative; page.mouse needs page
|
||||
// coordinates. Identical when the canvas sits at (0,0), but custom-node
|
||||
// JS can inject page chrome above it (e.g. rgthree's progress bar
|
||||
// shifts the canvas 16px down), which silently turned every slot drag
|
||||
// into a title-bar node drag.
|
||||
const rect = window.app!.canvas.canvas.getBoundingClientRect()
|
||||
convertedPos[0] += rect.left
|
||||
convertedPos[1] += rect.top
|
||||
|
||||
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
||||
console.warn(
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
)
|
||||
273
browser_tests/tests/customNodes/ADDING_PACKS.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Adding a custom-node pack to the regression suite
|
||||
|
||||
The authoritative, step-by-step process for onboarding a new pack. Written to
|
||||
be followable by a human or an agent with no prior context. The suite itself
|
||||
(what it asserts, how to run it) is documented in [README.md](README.md);
|
||||
this file is only about adding coverage for a new pack.
|
||||
|
||||
The short version: install the pack on a local test backend, read the pack's
|
||||
real node keys out of `/object_info`, author one small model-free workflow,
|
||||
add one row to the manifest, prove it green locally, push. No new test code
|
||||
is ever needed - the specs iterate the manifest.
|
||||
|
||||
## Step 0 - prerequisites
|
||||
|
||||
- A local test backend and dev server set up exactly per the
|
||||
[README prerequisites](README.md#prerequisites). Do not skip `--multi-user`
|
||||
or `--cache-none`.
|
||||
- The pack's GitHub URL. The CI job clones and pip-installs it, so the repo
|
||||
must be public and its `requirements.txt` must install on a CPU-only
|
||||
runner. Packs that hard-require CUDA at import time cannot be onboarded
|
||||
until they guard that import.
|
||||
|
||||
## Step 1 - install the pack on the test backend
|
||||
|
||||
```bash
|
||||
cd <test-backend>/custom_nodes
|
||||
git clone https://github.com/<owner>/<pack>
|
||||
pip install -r <pack>/requirements.txt # if the pack has one
|
||||
```
|
||||
|
||||
If you run a CPU-only backend, constrain pip so the pack cannot swap in a
|
||||
different torch (CI does the same):
|
||||
|
||||
```bash
|
||||
pip freeze | grep -iE '^(torch|torchvision|torchaudio)==' > /tmp/torch-constraints.txt
|
||||
pip install -r <pack>/requirements.txt -c /tmp/torch-constraints.txt
|
||||
```
|
||||
|
||||
Restart the backend and check its log: the `Import times for custom nodes`
|
||||
block must list the pack with no `IMPORT FAILED` marker. An import failure is
|
||||
a pack bug or a missing dependency - fix that first; nothing downstream can
|
||||
work without a clean import.
|
||||
|
||||
While you are here, note whether the pack ships frontend JS:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8288/extensions | python3 -c '
|
||||
import json, sys
|
||||
print(sum(1 for p in json.load(sys.stdin) if p.startswith("/extensions/<pack-dir-name>/")))
|
||||
'
|
||||
```
|
||||
|
||||
Non-zero means the pack patches the frontend at runtime (restyled nodes,
|
||||
rebuilt widgets, injected page chrome). Write that down - it decides whether
|
||||
Step 6 needs the CI-parity run. Both "green locally, red on CI" failures in
|
||||
the first 5-pack onboarding came from exactly this.
|
||||
|
||||
## Step 2 - read the pack's real node keys
|
||||
|
||||
The manifest's `expectedNodes` are the pack's `object_info` keys (the same
|
||||
strings the API uses as `class_type`). They are NOT Python class names and
|
||||
NOT display names. Get them from the running backend:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8288/object_info | python3 -c '
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
for key, node in sorted(d.items()):
|
||||
if node.get("python_module") == "custom_nodes.<pack-dir-name>":
|
||||
print(key)
|
||||
'
|
||||
```
|
||||
|
||||
Real traps this step catches (each one shipped in a real pack):
|
||||
|
||||
| Pack | Correct key | Wrong guesses that look right |
|
||||
| ---------------------- | ------------------- | ------------------------------------------------------------------------------- |
|
||||
| ComfyUI_essentials | `SimpleMathInt+` | `SimpleMathInt` (keys carry a trailing `+`, except `DisplayAny` which has none) |
|
||||
| ComfyUI-KJNodes | `INTConstant` | `INT Constant` (that is the display name) |
|
||||
| ComfyUI-Custom-Scripts | `ShowText\|pysssss` | `ShowText` (keys carry a `\|pysssss` suffix) |
|
||||
| rgthree-comfy | `Seed (rgthree)` | `RgthreeSeed` (the Python class name) |
|
||||
|
||||
## Step 3 - pick the expected nodes
|
||||
|
||||
Choose 2-3 nodes that are:
|
||||
|
||||
- **Model-free**: no checkpoint / VAE / CLIP inputs, no file downloads. The
|
||||
gate runs on CPU with no models installed. Constants, math, text, and
|
||||
display nodes are ideal.
|
||||
- **Wireable into a chain**: at least one producer (has a typed output) and
|
||||
one terminal node. A terminal node either has `output_node: true` in
|
||||
`/object_info` (it terminates a workflow by itself) or you end the chain in
|
||||
the core `PreviewAny` node, which accepts any type.
|
||||
|
||||
Check a candidate's inputs, outputs, and `output_node` flag:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8288/object_info | python3 -c '
|
||||
import json, sys
|
||||
node = json.load(sys.stdin)["<exact key>"]
|
||||
print(json.dumps({k: node[k] for k in ("input", "output", "output_name", "output_node")}, indent=1))
|
||||
'
|
||||
```
|
||||
|
||||
Every node you list in `expectedNodes` must appear in the run workflow: the
|
||||
run tier asserts each one actually executes on the backend.
|
||||
|
||||
## Step 4 - author the run-tier workflow
|
||||
|
||||
Add one JSON file under `browser_tests/assets/customNodes/`, named
|
||||
`<pack>_<what it does>_run.json`. Copy an existing asset as the template
|
||||
(`rgthree_seed_display_run.json` is the simplest two-node example;
|
||||
`was_number_text_run.json` shows a 3-node chain). It is the frontend
|
||||
workflow format, hand-authorable:
|
||||
|
||||
- `nodes[].type` is the exact `object_info` key from Step 2.
|
||||
- `widgets_values` is an array in the node's widget order: the `input`
|
||||
entries from `/object_info` in declaration order (`required` first, then
|
||||
`optional`), keeping only widget-type inputs (INT, FLOAT, STRING, BOOLEAN,
|
||||
and combo lists) and skipping any input whose options say
|
||||
`"forceInput": true` (those are sockets, never widgets). A required input
|
||||
that is neither a widget type nor `forceInput` (a custom type like
|
||||
`NUMBER`) is also a socket: wire a link into it or the run fails on a
|
||||
missing required input.
|
||||
- A link is one row in `links`: `[link_id, from_node_id, from_slot,
|
||||
to_node_id, to_slot, "TYPE"]`, plus the matching `link`/`links` ids on the
|
||||
two nodes' `inputs`/`outputs` entries.
|
||||
- To wire INTO an input that would normally be a widget (no `forceInput`),
|
||||
the input entry also needs a `"widget": { "name": "<input name>" }` key -
|
||||
see `browser_tests/assets/vueNodes/linked-int-widget.json`.
|
||||
- Keep it tiny. Two to four nodes proving "this pack executes" is the whole
|
||||
job; feature-depth testing belongs to the pack's own repo.
|
||||
- If the workflow needs a media file, reuse something already under
|
||||
`browser_tests/assets/` (e.g. `plain_video.mp4`) - never commit new binary
|
||||
assets. CI stages `plain_video.mp4` into the backend's `input/` dir; if
|
||||
your workflow needs a different existing asset staged, extend the
|
||||
`Stage run-tier assets` step in
|
||||
`.github/workflows/ci-tests-custom-nodes.yaml`.
|
||||
- A media path in the workflow (e.g. `input/plain_video.mp4`) resolves
|
||||
against the backend process's working directory, not the repo. Locally,
|
||||
copy the file into the `input/` dir of the directory you launched
|
||||
`main.py` from, or the run tier fails validation with
|
||||
`Invalid file path` and the test reports `TIMEOUT`.
|
||||
|
||||
## Step 5 - add the manifest row
|
||||
|
||||
Append one object to `browser_tests/fixtures/data/customNodeManifest.json`:
|
||||
|
||||
| Field | Meaning |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pack` | The pack's directory name under `custom_nodes/` (what `git clone` creates). |
|
||||
| `repo` | The GitHub URL CI clones. Required non-empty. |
|
||||
| `pin` | Commit SHA or tag CI checks out after cloning; `""` = default branch head. Pin when a pack breaks often; `""` also means new upstream regressions surface here first. |
|
||||
| `tiers` | Which tiers run: `load` (registers + renders in both renderers), `connectivity` (typed links + slot drags), `run` (executes the workflow). Use all three unless a tier is impossible for the pack. |
|
||||
| `workflow` | Path relative to `browser_tests/` of the Step 4 file. `""` only while the pack has no `run` tier. |
|
||||
| `expectedNodes` | The Step 2/3 keys. The load tier mounts each in both renderers; the run tier asserts each executes. |
|
||||
| `requiresGpu` | `true` only if execution genuinely needs CUDA. Such packs cannot use the `run` tier on the CPU gate. |
|
||||
| `requiresModels` | Model files the workflow needs (`[]` for the packs onboarded so far - keep it that way whenever possible). |
|
||||
| `timeoutMs` | Per-test budget. `30000` unless the workflow does real work (video decode uses `90000`). |
|
||||
| `vueNodesCompatible` | Optional, default `true`. See the policy below. Only ever set `false`, and only with evidence. |
|
||||
|
||||
`loadManifest()` (`browser_tests/fixtures/customNode/manifest.ts`) validates
|
||||
every row and fails loudly on a missing field, an empty `repo`, a misspelled
|
||||
tier, or a `run` tier with an empty `workflow`.
|
||||
|
||||
## Step 6 - prove it green locally, in both environments
|
||||
|
||||
### 6a - fast loop (dev server)
|
||||
|
||||
```bash
|
||||
pnpm test:custom-nodes
|
||||
```
|
||||
|
||||
Green means: every tier for every pack passes, zero skips, and the suite's
|
||||
zero-visible-errors invariant held (no error overlay, dialog, node error, or
|
||||
error toast at any point). Iterate here - it is the fastest loop.
|
||||
|
||||
### 6b - CI-parity run (required if the pack ships frontend JS)
|
||||
|
||||
The dev server never loads pack frontend JS (its `/extensions` list is
|
||||
core-only), so 6a exercises vanilla nodes. If Step 1 found frontend JS, a
|
||||
6a green proves nothing about the pack's real runtime behavior. CI serves
|
||||
the built frontend from the backend, so reproduce that exactly:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
# relaunch the test backend with the same flags plus:
|
||||
# --front-end-root <repo>/dist
|
||||
# and make sure any run-tier media is in that process's input/ dir
|
||||
PLAYWRIGHT_TEST_URL=http://127.0.0.1:8288 pnpm exec playwright test \
|
||||
browser_tests/tests/customNodes/ --config playwright.chrome.config.ts --workers=1
|
||||
```
|
||||
|
||||
Both real failures during the first 5-pack onboarding only existed here:
|
||||
rgthree's progress bar shifted the canvas and broke slot-drag coordinates,
|
||||
and rgthree's Seed rebuilt a declared input as widget-only. Skipping 6b
|
||||
means discovering that class of problem one CI round at a time.
|
||||
|
||||
### Failure classes and what they mean
|
||||
|
||||
- **T0 fails only in the Vue Nodes pass** (the LiteGraph pass is green):
|
||||
suspected Vue Nodes 2.0 incompatibility. Follow the policy below - do not
|
||||
delete the pack, do not skip the test.
|
||||
- **Run tier fails with `PARTIAL`** (some expected nodes never executed):
|
||||
either the backend is missing `--cache-none` (cached nodes emit no
|
||||
`executing` event) or an expected node is not actually in the workflow.
|
||||
- **Run tier fails with an execution error**: the workflow JSON is wrong
|
||||
(bad key, wrong `widgets_values` order, type-mismatched link) or the pack
|
||||
cannot execute model-free. Fix the workflow or drop the node for a
|
||||
simpler one.
|
||||
- **Connectivity reports zero planned pairs**: the pack's slots are all
|
||||
wildcard or combo typed (both are excluded from pairing by design because
|
||||
they bypass the real type compare). The pack still gets load/run coverage.
|
||||
- **Connectivity logs `widget-only on instance` exclusions**: the pack's own
|
||||
frontend JS rebuilt a declared input as a widget-only control (rgthree's
|
||||
Seed does this to `seed`), so there is no socket to wire. Recorded and
|
||||
excluded, like wildcards - pack design, not a regression.
|
||||
|
||||
## Step 7 - push and watch CI
|
||||
|
||||
The `CI: Tests Custom Nodes` job (gating) re-does Steps 1-6 from scratch on
|
||||
every PR: clones every manifest `repo` at its `pin`, pip-installs under CPU
|
||||
torch constraints, boots the backend, runs the suite, and fails on any
|
||||
install error, any test failure, or any skipped test. A new pack row is
|
||||
automatically picked up; no workflow edit is needed unless you must stage an
|
||||
extra asset (Step 4).
|
||||
|
||||
If CI goes red where local was green, reproduce under the Step 6b
|
||||
environment before changing anything - the first such failure looked like
|
||||
upstream drift but was actually pack frontend JS that never loads under
|
||||
the dev server. Only after 6b reproduces it, decide: adjust the suite's
|
||||
expectation honestly (the way widget-only instance slots became a recorded
|
||||
exclusion) or, for genuine upstream drift (`pin: ""` tracks the pack's
|
||||
default branch head), pin the pack to its last good commit. Never paper
|
||||
over it with a skip.
|
||||
|
||||
## Vue Nodes 2.0 compatibility policy
|
||||
|
||||
Some packs only work under the LiteGraph canvas renderer and fail to mount
|
||||
under Vue Nodes 2.0. The suite must state that fact without producing false
|
||||
failures and without skipping tests:
|
||||
|
||||
1. **Default**: every pack is assumed compatible. New rows omit
|
||||
`vueNodesCompatible`.
|
||||
2. **Evidence rule**: set `"vueNodesCompatible": false` ONLY after the T0
|
||||
Vue pass fails for the pack locally while the LiteGraph pass is green,
|
||||
and the failure reproduces on a retry. A README grumble, a hunch, or an
|
||||
old forum thread is not evidence. Record the evidence (the failing
|
||||
assertion and the pack version) in the PR description of the change that
|
||||
sets the flag.
|
||||
3. **Effect of `false`**: the load tier runs its LiteGraph pass only, and
|
||||
the connectivity drag test does not drag that pack's edges under Vue
|
||||
Nodes. The tests still run and pass their canvas assertions - nothing is
|
||||
`test.skip`ped, so the CI skip gate stays honest. The run tier and the
|
||||
connectivity contract sweep are renderer-independent (they never toggle
|
||||
the Vue Nodes setting) and run for the pack regardless of the flag - a
|
||||
flagged pack must still execute and wire cleanly there.
|
||||
4. **Un-flagging**: if a pack ships Vue Nodes support later, delete the flag
|
||||
and prove T0 green in both passes locally.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Pack installs clean on the test backend (no `IMPORT FAILED`)
|
||||
- [ ] Checked whether the pack ships frontend JS (Step 1 `/extensions` probe)
|
||||
- [ ] `expectedNodes` copied exactly from `/object_info` (Step 2 traps checked)
|
||||
- [ ] All expected nodes are model-free and present in the run workflow
|
||||
- [ ] Workflow JSON under `browser_tests/assets/customNodes/`, no new binaries
|
||||
- [ ] Any media staged into the backend's own `input/` dir locally (Step 4)
|
||||
- [ ] Manifest row appended with every field (Step 5 table)
|
||||
- [ ] `vueNodesCompatible` omitted, or set `false` with recorded evidence
|
||||
- [ ] 6a green: `pnpm test:custom-nodes` against the dev server, zero skips
|
||||
- [ ] 6b green when the pack ships frontend JS: built dist + backend-served run
|
||||
- [ ] Pushed; `CI: Tests Custom Nodes` green on the PR
|
||||
104
browser_tests/tests/customNodes/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Custom-node regression suite
|
||||
|
||||
Proves community custom-node packs work against this frontend across both
|
||||
renderers: nodes register, render under LiteGraph (canvas) AND Vue Nodes 2.0
|
||||
(DOM), and execute real workflows end to end. Manifest-driven: adding a pack
|
||||
is one JSON row, no new test code.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A ComfyUI backend on `127.0.0.1:8288` with every manifest pack (the
|
||||
`pack` entries in `browser_tests/fixtures/data/customNodeManifest.json`)
|
||||
and ComfyUI_devtools
|
||||
installed. Launch it with `--multi-user` (the repo-wide browser-test
|
||||
prerequisite; the fixture writes per-worker user settings and the suite
|
||||
depends on them landing), `--cache-none` (repeat runs must re-execute
|
||||
every node or the executed-set check fails honestly with `PARTIAL`), and
|
||||
with `browser_tests/assets/plain_video.mp4` copied into its `input/` dir.
|
||||
2. The dev server proxying that backend:
|
||||
`DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8288 pnpm dev`
|
||||
|
||||
## Running
|
||||
|
||||
| Script | What it does |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `pnpm test:custom-nodes` | whole suite headless - the pass/fail gate (every tier passes, zero skips) |
|
||||
| `pnpm test:custom-nodes:watch` | headed slow-motion run of the browser tiers, hands-off watching |
|
||||
| `pnpm test:custom-nodes:debug` | step through the browser tiers in the Playwright Inspector (F10 step, F8 resume) |
|
||||
| `pnpm test:custom-nodes:impact-render` | Impact nodes render in both renderers (Inspector) |
|
||||
| `pnpm test:custom-nodes:impact-run` | Impact group workflow executes on the backend (Inspector) |
|
||||
| `pnpm test:custom-nodes:vhs-render` | VHS nodes render in both renderers (Inspector) |
|
||||
| `pnpm test:custom-nodes:vhs-run` | VHS decodes a real video through its node chain (Inspector) |
|
||||
| `pnpm test:custom-nodes:connectivity` | slot/type contract: type-paired links + real slot drags in both renderers (Inspector) |
|
||||
| `pnpm test:custom-nodes:self-check` | watches the harness catch a deliberate execution error |
|
||||
|
||||
Example - watch the VHS video-decode run step by step:
|
||||
|
||||
```bash
|
||||
pnpm test:custom-nodes:vhs-run
|
||||
```
|
||||
|
||||
Two windows open: the app under test and the Playwright Inspector. Press F10
|
||||
to execute one robot action at a time (workflow loads, queue fires, backend
|
||||
decodes the video), F8 to run to the end. While paused, look but do not click
|
||||
inside the app window - your clicks change the state the next assertion
|
||||
checks.
|
||||
|
||||
Any `-g` pattern works against the generic scripts, e.g.
|
||||
`pnpm test:custom-nodes:debug -g "Impact-Pack.*T0"`.
|
||||
|
||||
## What the tests assert
|
||||
|
||||
- **T0 load**: pack nodes are registered in `/object_info`, added to a
|
||||
cleared graph, counted exactly, and each added node's own `[data-node-id]`
|
||||
element mounts under Vue Nodes 2.0. Both renderer passes - unless the pack
|
||||
declares `vueNodesCompatible: false` in the manifest (evidence required;
|
||||
see [ADDING_PACKS.md](ADDING_PACKS.md)), in which case its tests run their
|
||||
LiteGraph-canvas assertions only. Never a skip.
|
||||
- **T1 run**: the manifest workflow is loaded and queued; the backend's
|
||||
`executing` event stream must contain every expected node id, and the run
|
||||
must end in `execution_success`.
|
||||
- **connectivity (contract)**: wiring-only, no execution. A
|
||||
type-pairing generator (`fixtures/customNode/typePairing.ts`) indexes
|
||||
`/object_info` producers/consumers and plans one representative typed edge
|
||||
per slot (wildcard `*` slots excluded - they bypass the real type compare
|
||||
and prove nothing). Each planned edge must connect through the real
|
||||
`isValidConnection` veto, then survive `serialize()` -> `configure()` and
|
||||
appear in `graphToPrompt()` output. A curated subset is additionally
|
||||
dragged for real - slot dot to slot dot - under both renderers. Orphan
|
||||
types (no partner in the corpus) are reported, never fake-failed. One
|
||||
representative edge per slot bounds cost; it does not prove all pairs.
|
||||
- **Zero visible errors, always**: every browser test asserts the app's
|
||||
error surfaces (error overlay, error dialog, node render errors, error
|
||||
toasts) are absent at start and after every pass. A run is green only if a
|
||||
human watching the screen sees no errors. The self-check inverts this: it
|
||||
forces a real execution error and asserts the overlay IS visible, proving
|
||||
the selectors stay live.
|
||||
|
||||
## Adding a pack
|
||||
|
||||
One manifest row plus one small workflow JSON - no new test code. The
|
||||
authoritative step-by-step process (verifying the pack's real node keys,
|
||||
authoring the run workflow, the `vueNodesCompatible` evidence rule, what CI
|
||||
does with the row) lives in [ADDING_PACKS.md](ADDING_PACKS.md). Follow it
|
||||
exactly; the traps it lists all shipped in real packs.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Pack frontend JS does not load under the Vite dev server.** The dev
|
||||
server's `/extensions` endpoint lists core extensions only, so nodes render
|
||||
vanilla locally even when the backend has the packs installed. CI serves
|
||||
the built frontend from the backend, where every pack's JS loads and can
|
||||
restyle nodes, rebuild widgets, or inject page chrome. Before pushing
|
||||
changes that could interact with pack JS, reproduce CI locally:
|
||||
`pnpm build`, relaunch the backend with `--front-end-root <repo>/dist`,
|
||||
and run the suite with `PLAYWRIGHT_TEST_URL` pointed at the backend.
|
||||
- Do not run with `--trace on` against system Chrome
|
||||
(`playwright.chrome.config.ts` pins trace off): the trace recorder crashes
|
||||
pages under the branded Chrome channel and every test reports a bogus 15s
|
||||
timeout.
|
||||
- In a git worktree whose `node_modules` is symlinked from another checkout,
|
||||
prefix scripts with `pnpm --config.verify-deps-before-run=false ...` to
|
||||
skip pnpm's auto-install check.
|
||||
- First run against a cold dev server can exceed the 15s per-test setup
|
||||
budget while Vite compiles; just run again.
|
||||
443
browser_tests/tests/customNodes/connectivity.spec.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
customNodeSuiteSettings,
|
||||
dismissTemplatesDialog
|
||||
} from '@e2e/fixtures/utils/customNodeSuite'
|
||||
import { loadManifest } from '@e2e/fixtures/customNode/manifest'
|
||||
import type {
|
||||
ConnectivityOutcome,
|
||||
PlannedPair,
|
||||
RawNodeDef
|
||||
} from '@e2e/fixtures/customNode/typePairing'
|
||||
import {
|
||||
isWildcard,
|
||||
normalizeNodeDefs,
|
||||
planPairs
|
||||
} from '@e2e/fixtures/customNode/typePairing'
|
||||
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
|
||||
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
|
||||
|
||||
const CORE_PROOF_NODE_COUNT = 16
|
||||
// A node may legitimately veto a wiring via onConnectInput; committed
|
||||
// entries here must name the veto. Green means actual rejections are a
|
||||
// subset of this list.
|
||||
const CONNECT_REJECTED_ALLOWLIST: string[] = []
|
||||
|
||||
test.use({ initialSettings: customNodeSuiteSettings })
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await dismissTemplatesDialog(comfyPage)
|
||||
})
|
||||
|
||||
async function expectNoVisibleErrors(
|
||||
page: Page,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
for (const [surface, locator] of Object.entries(errorSurfaces(page)))
|
||||
await expect(locator, `${context}: ${surface}`).toHaveCount(0)
|
||||
}
|
||||
|
||||
function concrete(slot: { type: string }): boolean {
|
||||
return !isWildcard(slot.type)
|
||||
}
|
||||
|
||||
function isEntryInstalled(
|
||||
nodeTypes: Set<string>,
|
||||
entry: { expectedNodes: string[] }
|
||||
): boolean {
|
||||
return entry.expectedNodes.every((type) => nodeTypes.has(type))
|
||||
}
|
||||
|
||||
const connectivityEntries = loadManifest().filter((entry) =>
|
||||
entry.tiers.includes('connectivity')
|
||||
)
|
||||
|
||||
test('connectivity: every type-paired link survives model, serialize, and prompt round-trips', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
const defs = (await comfyPage.page.evaluate(() =>
|
||||
window.app!.api.getNodeDefs()
|
||||
)) as unknown as Record<string, RawNodeDef>
|
||||
const nodes = normalizeNodeDefs(defs)
|
||||
|
||||
// Pack-specific expectations apply only where the pack is installed; on a
|
||||
// backend without it (e.g. a generic CI runner) the core sweep still runs
|
||||
// and the absence is reported, never fake-failed or fake-passed.
|
||||
const nodeTypes = new Set(nodes.map((node) => node.type))
|
||||
const installedEntries = connectivityEntries.filter((entry) =>
|
||||
isEntryInstalled(nodeTypes, entry)
|
||||
)
|
||||
for (const entry of connectivityEntries)
|
||||
if (!installedEntries.includes(entry))
|
||||
console.log(`connectivity: ${entry.pack} not installed on this backend`)
|
||||
const packTypes = installedEntries.flatMap((entry) => entry.expectedNodes)
|
||||
const coreProof = nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
node.pack === 'core' &&
|
||||
node.inputs.some(concrete) &&
|
||||
node.outputs.some(concrete)
|
||||
)
|
||||
.map((node) => node.type)
|
||||
.sort()
|
||||
.slice(0, CORE_PROOF_NODE_COUNT)
|
||||
const plan = planPairs(nodes, [...packTypes, ...coreProof])
|
||||
|
||||
expect(plan.pairs.length, 'pairing produced no edges').toBeGreaterThan(0)
|
||||
console.log(
|
||||
`connectivity plan: ${plan.pairs.length} pairs, ${plan.orphans.length} orphan slots, ${plan.wildcards.length} wildcard + ${plan.combos.length} combo slots (excluded by design)`
|
||||
)
|
||||
|
||||
for (const entry of installedEntries) {
|
||||
expect(
|
||||
plan.pairs.some(
|
||||
(pair) =>
|
||||
pair.producer.pack === entry.pack || pair.consumer.pack === entry.pack
|
||||
),
|
||||
`${entry.pack} contributes no pairs - corpus or pack attribution broke`
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
const consoleErrors = collectConsoleErrors(comfyPage.page)
|
||||
const results = await runPairsInPage(comfyPage.page, plan.pairs)
|
||||
consoleErrors.stop()
|
||||
expect(consoleErrors.errors, 'console errors during breadth sweep').toEqual(
|
||||
[]
|
||||
)
|
||||
|
||||
const widgetOnly = results.filter(
|
||||
(result) =>
|
||||
result.outcome ===
|
||||
('WIDGET_ONLY_ON_INSTANCE' satisfies ConnectivityOutcome)
|
||||
)
|
||||
if (widgetOnly.length > 0)
|
||||
console.log(
|
||||
`connectivity sweep: ${widgetOnly.length} pair(s) excluded - pack JS made the declared input widget-only: ${widgetOnly.map((result) => result.key).join('; ')}`
|
||||
)
|
||||
const failures = results.filter(
|
||||
(result) =>
|
||||
result.outcome !== ('PASS' satisfies ConnectivityOutcome) &&
|
||||
result.outcome !==
|
||||
('WIDGET_ONLY_ON_INSTANCE' satisfies ConnectivityOutcome) &&
|
||||
!(
|
||||
result.outcome === ('CONNECT_REJECTED' satisfies ConnectivityOutcome) &&
|
||||
CONNECT_REJECTED_ALLOWLIST.includes(result.key)
|
||||
)
|
||||
)
|
||||
const passed = results.filter((result) => result.outcome === 'PASS').length
|
||||
console.log(`connectivity sweep: ${passed}/${results.length} pairs PASS`)
|
||||
expect(failures, JSON.stringify(failures, null, 1)).toEqual([])
|
||||
expect(passed).toBeGreaterThan(0)
|
||||
await expectNoVisibleErrors(comfyPage.page, 'after breadth sweep')
|
||||
})
|
||||
|
||||
// Instance-level probe for the drag test: the first planned pair whose
|
||||
// producer output AND consumer input both exist on freshly created node
|
||||
// instances (pack JS can rebuild declared inputs as widget-only controls).
|
||||
function firstMaterializedPair(
|
||||
page: Page,
|
||||
pairs: PlannedPair[]
|
||||
): Promise<PlannedPair | null> {
|
||||
return page.evaluate((pairsInPage) => {
|
||||
for (const pair of pairsInPage) {
|
||||
const producer = window.LiteGraph!.createNode(pair.producer.nodeType)
|
||||
const consumer = window.LiteGraph!.createNode(pair.consumer.nodeType)
|
||||
const outFound = producer?.outputs.some(
|
||||
(slot) => slot.name === pair.producer.slotName
|
||||
)
|
||||
const inFound = consumer?.inputs.some(
|
||||
(slot) => slot.name === pair.consumer.slotName
|
||||
)
|
||||
if (outFound && inFound) return pair
|
||||
}
|
||||
return null
|
||||
}, pairs)
|
||||
}
|
||||
|
||||
// The self-check below runs THIS SAME executor on poisoned pairs; if it stops
|
||||
// being able to reject, every green sweep above is meaningless.
|
||||
function runPairsInPage(
|
||||
page: Page,
|
||||
pairs: PlannedPair[]
|
||||
): Promise<Array<{ key: string; outcome: string; detail?: string }>> {
|
||||
return page.evaluate(async (pairsInPage) => {
|
||||
const graph = window.app!.graph
|
||||
const report: Array<{
|
||||
key: string
|
||||
outcome: string
|
||||
detail?: string
|
||||
}> = []
|
||||
for (const pair of pairsInPage) {
|
||||
const key = `${pair.producer.nodeType}.${pair.producer.slotName} -> ${pair.consumer.nodeType}.${pair.consumer.slotName}`
|
||||
try {
|
||||
graph.clear()
|
||||
const producer = window.LiteGraph!.createNode(pair.producer.nodeType)
|
||||
const consumer = window.LiteGraph!.createNode(pair.consumer.nodeType)
|
||||
if (!producer || !consumer) {
|
||||
report.push({
|
||||
key,
|
||||
outcome: 'SLOT_CONTRACT_MISMATCH',
|
||||
detail: 'createNode returned null for a registered type'
|
||||
})
|
||||
continue
|
||||
}
|
||||
graph.add(producer)
|
||||
graph.add(consumer)
|
||||
const outIndex = producer.outputs.findIndex(
|
||||
(slot) => slot.name === pair.producer.slotName
|
||||
)
|
||||
const inIndex = consumer.inputs.findIndex(
|
||||
(slot) => slot.name === pair.consumer.slotName
|
||||
)
|
||||
if (outIndex < 0 || inIndex < 0) {
|
||||
// A pack's own frontend JS may rebuild a declared input as a
|
||||
// widget-only control (rgthree's Seed does this to `seed`). That is
|
||||
// pack design, not a wiring regression - excluded like wildcards.
|
||||
// A name that exists NEITHER as slot nor widget stays a hard fail.
|
||||
const widgetOnly =
|
||||
outIndex >= 0 &&
|
||||
(consumer.widgets ?? []).some(
|
||||
(widget) => widget.name === pair.consumer.slotName
|
||||
)
|
||||
report.push({
|
||||
key,
|
||||
outcome: widgetOnly
|
||||
? 'WIDGET_ONLY_ON_INSTANCE'
|
||||
: 'SLOT_CONTRACT_MISMATCH',
|
||||
detail: `declared slot missing on instance (out=${outIndex}, in=${inIndex})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
const link = producer.connect(outIndex, consumer, inIndex)
|
||||
if (!link || consumer.inputs[inIndex]?.link == null) {
|
||||
report.push({ key, outcome: 'CONNECT_REJECTED' })
|
||||
continue
|
||||
}
|
||||
const serialized = graph.serialize()
|
||||
graph.configure(serialized)
|
||||
const restored = graph.getNodeById(consumer.id)
|
||||
if (restored?.inputs?.[inIndex]?.link == null) {
|
||||
report.push({
|
||||
key,
|
||||
outcome: 'ROUNDTRIP_LOST',
|
||||
detail: 'serialize/configure dropped the link'
|
||||
})
|
||||
continue
|
||||
}
|
||||
const prompt = (await window.app!.graphToPrompt()) as {
|
||||
output?: Record<string, { inputs?: Record<string, unknown> }>
|
||||
}
|
||||
const promptInput =
|
||||
prompt.output?.[String(consumer.id)]?.inputs?.[pair.consumer.slotName]
|
||||
if (!Array.isArray(promptInput)) {
|
||||
report.push({
|
||||
key,
|
||||
outcome: 'ROUNDTRIP_LOST',
|
||||
detail: 'link missing from graphToPrompt output'
|
||||
})
|
||||
continue
|
||||
}
|
||||
report.push({ key, outcome: 'PASS' })
|
||||
} catch (error) {
|
||||
report.push({
|
||||
key,
|
||||
outcome: 'SLOT_CONTRACT_MISMATCH',
|
||||
detail: `threw: ${String(error)}`
|
||||
})
|
||||
}
|
||||
}
|
||||
graph.clear()
|
||||
return report
|
||||
}, pairs)
|
||||
}
|
||||
|
||||
test('connectivity self-check: the executor rejects broken pairs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const slot = (nodeType: string, slotName: string, slotType: string) => ({
|
||||
nodeType,
|
||||
pack: 'core',
|
||||
slotName,
|
||||
slotType
|
||||
})
|
||||
const results = await runPairsInPage(comfyPage.page, [
|
||||
{
|
||||
producer: slot('CheckpointLoaderSimple', 'MODEL', 'MODEL'),
|
||||
consumer: slot('KSampler', 'latent_image', 'LATENT')
|
||||
},
|
||||
{
|
||||
producer: slot('EmptyLatentImage', 'LATENT', 'LATENT'),
|
||||
consumer: slot('KSampler', 'does_not_exist', 'LATENT')
|
||||
}
|
||||
])
|
||||
expect(results.map((result) => result.outcome)).toEqual([
|
||||
'CONNECT_REJECTED',
|
||||
'SLOT_CONTRACT_MISMATCH'
|
||||
])
|
||||
})
|
||||
|
||||
test('connectivity drags: curated slot-to-slot wires connect under both renderers', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
const defs = (await comfyPage.page.evaluate(() =>
|
||||
window.app!.api.getNodeDefs()
|
||||
)) as unknown as Record<string, RawNodeDef>
|
||||
const nodes = normalizeNodeDefs(defs)
|
||||
|
||||
// Native anchor pair plus one in-pack, link-typed pair per connectivity
|
||||
// pack (derived from the same generator the breadth sweep uses).
|
||||
const dragEdges: PlannedPair[] = [
|
||||
{
|
||||
producer: {
|
||||
nodeType: 'EmptyLatentImage',
|
||||
pack: 'core',
|
||||
slotName: 'LATENT',
|
||||
slotType: 'LATENT'
|
||||
},
|
||||
consumer: {
|
||||
nodeType: 'KSampler',
|
||||
pack: 'core',
|
||||
slotName: 'latent_image',
|
||||
slotType: 'LATENT'
|
||||
}
|
||||
}
|
||||
]
|
||||
const nodeTypes = new Set(nodes.map((node) => node.type))
|
||||
for (const entry of connectivityEntries) {
|
||||
if (!isEntryInstalled(nodeTypes, entry)) {
|
||||
console.log(
|
||||
`connectivity drag: ${entry.pack} not installed on this backend`
|
||||
)
|
||||
continue
|
||||
}
|
||||
// Restrict the partner pool to the pack itself so the drag proves an
|
||||
// in-pack wiring; widget-backed primitive inputs render real slot dots
|
||||
// in Vue (verified empirically), so no slot type is excluded at plan time.
|
||||
const packPlan = planPairs(
|
||||
nodes.filter((node) => node.pack === entry.pack),
|
||||
entry.expectedNodes
|
||||
)
|
||||
expect(
|
||||
packPlan.pairs.length,
|
||||
`${entry.pack} has no in-pack draggable pair - drag coverage lost`
|
||||
).toBeGreaterThan(0)
|
||||
// The plan comes from object_info, but a pack's own JS can rebuild a
|
||||
// declared input as widget-only on the instance (rgthree's Seed does).
|
||||
// Drag the first pair whose slots actually materialize; a pack whose
|
||||
// every planned pair is customized away has no socket contract to drag.
|
||||
const inPack = await firstMaterializedPair(comfyPage.page, packPlan.pairs)
|
||||
if (!inPack) {
|
||||
console.log(
|
||||
`connectivity drag: ${entry.pack} planned pairs are widget-only on instances; drag not applicable`
|
||||
)
|
||||
continue
|
||||
}
|
||||
dragEdges.push(inPack)
|
||||
}
|
||||
|
||||
const vueIncompatiblePacks = new Set(
|
||||
connectivityEntries
|
||||
.filter((entry) => entry.vueNodesCompatible === false)
|
||||
.map((entry) => entry.pack)
|
||||
)
|
||||
for (const vueNodesEnabled of [false, true]) {
|
||||
const consoleErrors = collectConsoleErrors(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
vueNodesEnabled
|
||||
)
|
||||
|
||||
for (const edge of dragEdges) {
|
||||
if (vueNodesEnabled && vueIncompatiblePacks.has(edge.producer.pack)) {
|
||||
console.log(
|
||||
`connectivity drag: ${edge.producer.pack} declares vueNodesCompatible=false; Vue drag not applicable`
|
||||
)
|
||||
continue
|
||||
}
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const producer = await comfyPage.nodeOps.addNode(
|
||||
edge.producer.nodeType,
|
||||
undefined,
|
||||
{ x: 150, y: 200 }
|
||||
)
|
||||
const consumer = await comfyPage.nodeOps.addNode(
|
||||
edge.consumer.nodeType,
|
||||
undefined,
|
||||
{ x: 700, y: 200 }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const [outIndex, inIndex] = await comfyPage.page.evaluate(
|
||||
([producerId, consumerId, outName, inName]) => {
|
||||
const byId = (id: string) =>
|
||||
window.app!.graph.nodes.find((node) => String(node.id) === id)!
|
||||
const src = byId(producerId)
|
||||
const dst = byId(consumerId)
|
||||
return [
|
||||
src.outputs.findIndex((slot) => slot.name === outName),
|
||||
dst.inputs.findIndex((slot) => slot.name === inName)
|
||||
]
|
||||
},
|
||||
[
|
||||
String(producer.id),
|
||||
String(consumer.id),
|
||||
edge.producer.slotName,
|
||||
edge.consumer.slotName
|
||||
] as const
|
||||
)
|
||||
const key = `${edge.producer.nodeType}.${edge.producer.slotName} -> ${edge.consumer.nodeType}.${edge.consumer.slotName}`
|
||||
expect(outIndex, `${key}: producer slot on instance`).toBeGreaterThan(-1)
|
||||
expect(inIndex, `${key}: consumer slot on instance`).toBeGreaterThan(-1)
|
||||
|
||||
if (vueNodesEnabled) {
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
// Output-side mirror of getInputSlotConnectionDot, addressed by
|
||||
// data-slot-key so shared-label ambiguity cannot misfire the drag.
|
||||
const outDot = comfyPage.page
|
||||
.locator(`[data-node-id="${String(producer.id)}"]`)
|
||||
.locator('.lg-slot--output')
|
||||
.filter({
|
||||
has: comfyPage.page.locator(
|
||||
`[data-slot-key="${String(producer.id)}-out-${outIndex}"]`
|
||||
)
|
||||
})
|
||||
.getByTestId('slot-connection-dot')
|
||||
const inDot = comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
String(consumer.id),
|
||||
inIndex
|
||||
)
|
||||
await outDot.dragTo(inDot)
|
||||
} else {
|
||||
await producer.connectOutput(outIndex, consumer, inIndex)
|
||||
}
|
||||
|
||||
const linked = await comfyPage.page.evaluate(
|
||||
([consumerId, index]) => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(candidate) => String(candidate.id) === consumerId
|
||||
)
|
||||
return node?.inputs?.[Number(index)]?.link != null
|
||||
},
|
||||
[String(consumer.id), String(inIndex)] as const
|
||||
)
|
||||
expect(linked, `${key} with VueNodes=${vueNodesEnabled}`).toBe(true)
|
||||
}
|
||||
|
||||
consoleErrors.stop()
|
||||
expect(
|
||||
consoleErrors.errors,
|
||||
`console errors with VueNodes=${vueNodesEnabled}`
|
||||
).toEqual([])
|
||||
await expectNoVisibleErrors(
|
||||
comfyPage.page,
|
||||
`after drag pass VueNodes=${vueNodesEnabled}`
|
||||
)
|
||||
}
|
||||
})
|
||||
58
browser_tests/tests/customNodes/coreSmoke.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
customNodeSuiteSettings,
|
||||
dismissTemplatesDialog
|
||||
} from '@e2e/fixtures/utils/customNodeSuite'
|
||||
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
|
||||
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
// Core-only, model-free workflow: the bundled default template references
|
||||
// model files a scoped test backend does not have, which rightly trips the
|
||||
// error surfaces this suite asserts are clean.
|
||||
const smokeWorkflow = JSON.parse(
|
||||
readFileSync(resolve(assetPath('customNodes/core_smoke.json')), 'utf-8')
|
||||
) as ComfyWorkflowJSON
|
||||
|
||||
test.use({ initialSettings: customNodeSuiteSettings })
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await dismissTemplatesDialog(comfyPage)
|
||||
})
|
||||
|
||||
test.describe('smoke: core workflow', () => {
|
||||
test('loads without console errors in both renderers', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
for (const vueNodesEnabled of [false, true]) {
|
||||
const consoleErrors = collectConsoleErrors(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
vueNodesEnabled
|
||||
)
|
||||
await comfyPage.workflow.loadGraphData(smokeWorkflow)
|
||||
await comfyPage.nextFrame()
|
||||
consoleErrors.stop()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBeGreaterThan(0)
|
||||
expect(
|
||||
consoleErrors.errors,
|
||||
`console errors (VueNodes=${vueNodesEnabled})`
|
||||
).toEqual([])
|
||||
for (const [surface, locator] of Object.entries(
|
||||
errorSurfaces(comfyPage.page)
|
||||
))
|
||||
await expect(
|
||||
locator,
|
||||
`${surface} (VueNodes=${vueNodesEnabled})`
|
||||
).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
191
browser_tests/tests/customNodes/customNode.regression.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/* oxlint-disable playwright/no-skipped-test -- tiers conditionally skip when the target backend lacks the required packs (installed custom nodes or devtools); this is the framework's designed environment gating, not a disabled test */
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
customNodeSuiteSettings,
|
||||
dismissTemplatesDialog
|
||||
} from '@e2e/fixtures/utils/customNodeSuite'
|
||||
import { LocalDesktopTarget } from '@e2e/fixtures/customNode/ComfyTarget'
|
||||
import {
|
||||
loadManifest,
|
||||
rendererPassesFor
|
||||
} from '@e2e/fixtures/customNode/manifest'
|
||||
import { expectedNodesPresent } from '@e2e/fixtures/customNode/objectInfoValidator'
|
||||
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
|
||||
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
const target = new LocalDesktopTarget()
|
||||
const OBJECT_INFO_SANITY_FLOOR = 50
|
||||
|
||||
test.use({ initialSettings: customNodeSuiteSettings })
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await dismissTemplatesDialog(comfyPage)
|
||||
})
|
||||
|
||||
async function expectNoVisibleErrors(
|
||||
page: Page,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
for (const [surface, locator] of Object.entries(errorSurfaces(page)))
|
||||
await expect(locator, `${context}: ${surface}`).toHaveCount(0)
|
||||
}
|
||||
|
||||
function readWorkflow(relativePath: string): ComfyWorkflowJSON {
|
||||
return JSON.parse(
|
||||
readFileSync(resolve(relativePath), 'utf-8')
|
||||
) as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
async function nodeIdsByType(
|
||||
page: Page,
|
||||
classTypes: string[]
|
||||
): Promise<string[]> {
|
||||
return await page.evaluate((types) => {
|
||||
const nodes = window.app!.graph.nodes ?? []
|
||||
return nodes
|
||||
.filter((node) => {
|
||||
const n = node as { comfyClass?: string; type?: string }
|
||||
return types.includes(n.comfyClass ?? n.type ?? '')
|
||||
})
|
||||
.map((node) => String(node.id))
|
||||
}, classTypes)
|
||||
}
|
||||
|
||||
for (const entry of loadManifest()) {
|
||||
const workflowRelative = `browser_tests/${entry.workflow}`
|
||||
|
||||
test.describe(`custom node: ${entry.pack}`, () => {
|
||||
test('T0 load: expected nodes register and render in both renderers', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(entry.timeoutMs)
|
||||
const objectInfo = await target.getObjectInfo(comfyPage.page)
|
||||
expect(
|
||||
Object.keys(objectInfo).length,
|
||||
'object_info sanity floor'
|
||||
).toBeGreaterThan(OBJECT_INFO_SANITY_FLOOR)
|
||||
const { missing } = expectedNodesPresent(objectInfo, entry.expectedNodes)
|
||||
test.skip(
|
||||
missing.length > 0,
|
||||
`${entry.pack} not installed on this backend (missing: ${missing.join(', ')})`
|
||||
)
|
||||
await expectNoVisibleErrors(comfyPage.page, 'at startup')
|
||||
|
||||
// A pack that declares vueNodesCompatible: false is exercised under the
|
||||
// LiteGraph canvas only - rendering its nodes under Vue Nodes 2.0 would
|
||||
// fail for a known pack limitation, not a frontend regression. This is
|
||||
// conditional coverage, not a test skip: the test still runs and gates.
|
||||
const rendererPasses = rendererPassesFor(entry)
|
||||
if (entry.vueNodesCompatible === false)
|
||||
console.log(
|
||||
`${entry.pack} declares vueNodesCompatible=false; Vue Nodes pass not applicable`
|
||||
)
|
||||
for (const vueNodesEnabled of rendererPasses) {
|
||||
const consoleErrors = collectConsoleErrors(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
vueNodesEnabled
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
|
||||
const addedIds: string[] = []
|
||||
for (const classType of entry.expectedNodes) {
|
||||
const node = await comfyPage.nodeOps.addNode(classType)
|
||||
addedIds.push(String(node.id))
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
entry.expectedNodes.length
|
||||
)
|
||||
// Vue Nodes 2.0 mounts each node as a [data-node-id] element; assert
|
||||
// the pack's own nodes rendered, not just any node count.
|
||||
if (vueNodesEnabled)
|
||||
for (const id of addedIds)
|
||||
await expect(comfyPage.vueNodes.getNodeLocator(id)).toBeVisible()
|
||||
|
||||
consoleErrors.stop()
|
||||
expect(
|
||||
consoleErrors.errors,
|
||||
`console errors with VueNodes=${vueNodesEnabled}`
|
||||
).toEqual([])
|
||||
await expectNoVisibleErrors(
|
||||
comfyPage.page,
|
||||
`after VueNodes=${vueNodesEnabled} pass`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('T1 run: workflow executes without error', async ({ comfyPage }) => {
|
||||
test.setTimeout(entry.timeoutMs + 15_000)
|
||||
const objectInfo = await target.getObjectInfo(comfyPage.page)
|
||||
const { missing } = expectedNodesPresent(objectInfo, entry.expectedNodes)
|
||||
test.skip(
|
||||
!entry.tiers.includes('run') ||
|
||||
missing.length > 0 ||
|
||||
entry.requiresGpu ||
|
||||
entry.requiresModels.length > 0 ||
|
||||
!entry.workflow ||
|
||||
!existsSync(resolve(workflowRelative)),
|
||||
`run tier unavailable for ${entry.pack}`
|
||||
)
|
||||
await expectNoVisibleErrors(comfyPage.page, 'at startup')
|
||||
|
||||
await comfyPage.workflow.loadGraphData(readWorkflow(workflowRelative))
|
||||
const result = await target.runWorkflow(comfyPage.page, {
|
||||
expectedNodeIds: await nodeIdsByType(
|
||||
comfyPage.page,
|
||||
entry.expectedNodes
|
||||
),
|
||||
timeoutMs: entry.timeoutMs
|
||||
})
|
||||
|
||||
expect(result.outcome, JSON.stringify(result.error ?? {})).toBe('PASS')
|
||||
await expectNoVisibleErrors(comfyPage.page, 'after run')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('harness self-check: captures a real execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30_000)
|
||||
const objectInfo = await target.getObjectInfo(comfyPage.page)
|
||||
expect(
|
||||
Object.keys(objectInfo).length,
|
||||
'object_info sanity floor'
|
||||
).toBeGreaterThan(OBJECT_INFO_SANITY_FLOOR)
|
||||
test.skip(
|
||||
!('DevToolsErrorRaiseNode' in objectInfo),
|
||||
'ComfyUI_devtools not installed on this backend'
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadGraphData(
|
||||
readWorkflow(assetPath('nodes/execution_error.json'))
|
||||
)
|
||||
const result = await target.runWorkflow(comfyPage.page, {
|
||||
expectedNodeIds: [],
|
||||
timeoutMs: 15000
|
||||
})
|
||||
|
||||
expect(result.outcome).toBe('EXECUTION_ERROR')
|
||||
expect(result.error?.exceptionType).toBeTruthy()
|
||||
// Proves the event tap captures node ids from the live `executing` stream
|
||||
// (its detail is a bare string): the failing node starts before it raises.
|
||||
expect(result.executedNodes.length).toBeGreaterThan(0)
|
||||
// Positive control for the zero-visible-errors invariant: a real execution
|
||||
// error MUST surface in the app's error overlay. If this fails, the
|
||||
// expectNoVisibleErrors selectors have rotted and every clean assertion in
|
||||
// this suite is meaningless.
|
||||
await expect(errorSurfaces(comfyPage.page).errorOverlay).toBeVisible()
|
||||
})
|
||||
29
browser_tests/tests/customNodes/manifest.pure.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
loadManifest,
|
||||
rendererPassesFor
|
||||
} from '@e2e/fixtures/customNode/manifest'
|
||||
|
||||
test.describe('customNode manifest', () => {
|
||||
test('loads entries with the shape the regression spec depends on', () => {
|
||||
const entries = loadManifest()
|
||||
expect(entries.length).toBeGreaterThan(0)
|
||||
for (const entry of entries) {
|
||||
expect(entry.pack).toBeTruthy()
|
||||
expect(entry.expectedNodes.length).toBeGreaterThan(0)
|
||||
expect(entry.tiers.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('rendererPassesFor drops only the Vue pass, only on an explicit false', () => {
|
||||
expect(rendererPassesFor({})).toEqual([false, true])
|
||||
expect(rendererPassesFor({ vueNodesCompatible: true })).toEqual([
|
||||
false,
|
||||
true
|
||||
])
|
||||
expect(rendererPassesFor({ vueNodesCompatible: false })).toEqual([false])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ObjectInfo } from '@e2e/fixtures/customNode/objectInfoValidator'
|
||||
import {
|
||||
expectedNodesPresent,
|
||||
preValidate
|
||||
} from '@e2e/fixtures/customNode/objectInfoValidator'
|
||||
|
||||
const objectInfo: ObjectInfo = {
|
||||
KSampler: { input: { required: { model: {}, seed: {} } } }
|
||||
}
|
||||
|
||||
test.describe('objectInfoValidator', () => {
|
||||
test('expectedNodesPresent splits present from missing', () => {
|
||||
const { present, missing } = expectedNodesPresent(objectInfo, [
|
||||
'KSampler',
|
||||
'Missing (rgthree)'
|
||||
])
|
||||
expect(present).toEqual(['KSampler'])
|
||||
expect(missing).toEqual(['Missing (rgthree)'])
|
||||
})
|
||||
|
||||
test('preValidate returns MISSING_NODE for an unregistered class', () => {
|
||||
const failure = preValidate(objectInfo, [
|
||||
{ id: '1', classType: 'Ghost', inputs: {} }
|
||||
])
|
||||
expect(failure?.outcome).toBe('MISSING_NODE')
|
||||
})
|
||||
|
||||
test('preValidate returns VALIDATION_FAIL naming the missing required input', () => {
|
||||
const failure = preValidate(objectInfo, [
|
||||
{ id: '3', classType: 'KSampler', inputs: { model: 0 } }
|
||||
])
|
||||
expect(failure?.outcome).toBe('VALIDATION_FAIL')
|
||||
expect(failure?.message).toContain('missing required input "seed"')
|
||||
})
|
||||
|
||||
test('preValidate passes when every required input is present', () => {
|
||||
expect(
|
||||
preValidate(objectInfo, [
|
||||
{ id: '3', classType: 'KSampler', inputs: { model: 0, seed: 1 } }
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
71
browser_tests/tests/customNodes/runResult.pure.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { classifyRun } from '@e2e/fixtures/customNode/runResult'
|
||||
|
||||
test.describe('classifyRun', () => {
|
||||
test('PASS when every expected node appears in the executing stream', () => {
|
||||
const result = classifyRun({
|
||||
events: [
|
||||
{ type: 'execution_start' },
|
||||
{ type: 'executing', node: '1' },
|
||||
{ type: 'executing', node: '2' },
|
||||
{ type: 'executing', node: null },
|
||||
{ type: 'execution_success' }
|
||||
],
|
||||
expectedNodeIds: ['1', '2']
|
||||
})
|
||||
expect(result.outcome).toBe('PASS')
|
||||
expect(result.executedNodes).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
test('PARTIAL when a succeeding run replays a cached node that never emitted executing', () => {
|
||||
const result = classifyRun({
|
||||
events: [{ type: 'executing', node: '1' }, { type: 'execution_success' }],
|
||||
expectedNodeIds: ['1', '2']
|
||||
})
|
||||
expect(result.outcome).toBe('PARTIAL')
|
||||
expect(result.executedNodes).toEqual(['1'])
|
||||
})
|
||||
|
||||
test('EXECUTION_ERROR captures the failing node details', () => {
|
||||
const result = classifyRun({
|
||||
events: [
|
||||
{ type: 'executing', node: '1' },
|
||||
{
|
||||
type: 'execution_error',
|
||||
error: { exceptionType: 'ValueError', nodeId: '1' }
|
||||
}
|
||||
],
|
||||
expectedNodeIds: ['1']
|
||||
})
|
||||
expect(result.outcome).toBe('EXECUTION_ERROR')
|
||||
expect(result.error?.exceptionType).toBe('ValueError')
|
||||
})
|
||||
|
||||
test('EXECUTION_ERROR when the run is interrupted', () => {
|
||||
const result = classifyRun({
|
||||
events: [
|
||||
{ type: 'executing', node: '1' },
|
||||
{ type: 'execution_interrupted' }
|
||||
],
|
||||
expectedNodeIds: ['1']
|
||||
})
|
||||
expect(result.outcome).toBe('EXECUTION_ERROR')
|
||||
})
|
||||
|
||||
test('TIMEOUT when flagged or when no terminal event arrived', () => {
|
||||
const flagged = classifyRun({
|
||||
events: [{ type: 'executing', node: '1' }],
|
||||
expectedNodeIds: ['1'],
|
||||
timedOut: true
|
||||
})
|
||||
const noTerminal = classifyRun({
|
||||
events: [{ type: 'executing', node: '1' }],
|
||||
expectedNodeIds: ['1']
|
||||
})
|
||||
expect(flagged.outcome).toBe('TIMEOUT')
|
||||
expect(noTerminal.outcome).toBe('TIMEOUT')
|
||||
})
|
||||
})
|
||||
139
browser_tests/tests/customNodes/typePairing.pure.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { RawNodeDef } from '@e2e/fixtures/customNode/typePairing'
|
||||
import {
|
||||
isTypeCompatible,
|
||||
normalizeNodeDefs,
|
||||
packOf,
|
||||
planPairs
|
||||
} from '@e2e/fixtures/customNode/typePairing'
|
||||
|
||||
const DEFS: Record<string, RawNodeDef> = {
|
||||
LatentSource: {
|
||||
input: { required: {} },
|
||||
output: ['LATENT'],
|
||||
output_name: ['LATENT'],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
LatentSink: {
|
||||
input: { required: { latent: ['LATENT', {}] } },
|
||||
output: [],
|
||||
python_module: 'custom_nodes.SomePack'
|
||||
},
|
||||
UnionSource: {
|
||||
input: { required: {} },
|
||||
output: ['STRING,INT'],
|
||||
output_name: ['value'],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
IntSink: {
|
||||
input: { required: { value: ['int', {}] } },
|
||||
output: [],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
ComboNode: {
|
||||
input: { required: { choice: [['a', 'b'], {}] } },
|
||||
output: [],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
SocketlessNode: {
|
||||
input: { required: { hidden: ['STRING', { socketless: true }] } },
|
||||
output: [],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
WildcardNode: {
|
||||
input: { required: { anything: ['*', {}] } },
|
||||
output: ['*'],
|
||||
output_name: ['out'],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
OrphanNode: {
|
||||
input: { required: {} },
|
||||
output: ['NOBODY_CONSUMES_THIS'],
|
||||
output_name: ['orphan'],
|
||||
python_module: 'custom_nodes.OrphanPack'
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('typePairing', () => {
|
||||
test('isTypeCompatible mirrors the real validator semantics', () => {
|
||||
expect(isTypeCompatible('LATENT', 'LATENT')).toBe(true)
|
||||
expect(isTypeCompatible('latent', 'LATENT')).toBe(true)
|
||||
expect(isTypeCompatible('LATENT', 'IMAGE')).toBe(false)
|
||||
expect(isTypeCompatible('STRING,INT', 'INT')).toBe(true)
|
||||
expect(isTypeCompatible('STRING,INT', 'FLOAT')).toBe(false)
|
||||
expect(isTypeCompatible('*', 'ANYTHING')).toBe(true)
|
||||
expect(isTypeCompatible('', 'ANYTHING')).toBe(true)
|
||||
})
|
||||
|
||||
test('packOf attributes core vs custom pack', () => {
|
||||
expect(packOf('nodes')).toBe('core')
|
||||
expect(packOf('comfy_extras.nodes_x')).toBe('core')
|
||||
expect(packOf('custom_nodes.ComfyUI-Impact-Pack')).toBe(
|
||||
'ComfyUI-Impact-Pack'
|
||||
)
|
||||
expect(packOf(undefined)).toBe('core')
|
||||
})
|
||||
|
||||
test('normalize maps COMBO literals and drops socketless inputs', () => {
|
||||
const nodes = normalizeNodeDefs(DEFS)
|
||||
const combo = nodes.find((n) => n.type === 'ComboNode')!
|
||||
expect(combo.inputs).toEqual([{ name: 'choice', type: 'COMBO' }])
|
||||
const socketless = nodes.find((n) => n.type === 'SocketlessNode')!
|
||||
expect(socketless.inputs).toEqual([])
|
||||
})
|
||||
|
||||
test('planPairs pairs exact and union types, deterministically', () => {
|
||||
const nodes = normalizeNodeDefs(DEFS)
|
||||
const plan = planPairs(nodes, ['LatentSink', 'IntSink'])
|
||||
const keys = plan.pairs.map(
|
||||
(p) =>
|
||||
`${p.producer.nodeType}.${p.producer.slotName}->${p.consumer.nodeType}.${p.consumer.slotName}`
|
||||
)
|
||||
expect(keys).toContain('LatentSource.LATENT->LatentSink.latent')
|
||||
expect(keys).toContain('UnionSource.value->IntSink.value')
|
||||
const again = planPairs(nodes, ['LatentSink', 'IntSink'])
|
||||
expect(again.pairs).toEqual(plan.pairs)
|
||||
})
|
||||
|
||||
test('COMBO literals are excluded from pairing with names coerced to strings', () => {
|
||||
const nodes = normalizeNodeDefs({
|
||||
ComboSource: {
|
||||
input: { required: {} },
|
||||
output: [['A', 'B', 'C']],
|
||||
output_name: [['A', 'B', 'C'] as unknown as string],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
...DEFS
|
||||
})
|
||||
const source = nodes.find((n) => n.type === 'ComboSource')!
|
||||
expect(source.outputs).toEqual([{ name: 'COMBO', type: 'COMBO' }])
|
||||
const plan = planPairs(nodes, ['ComboSource', 'ComboNode'])
|
||||
expect(plan.pairs).toEqual([])
|
||||
expect(plan.combos.map((s) => `${s.nodeType}.${s.slotName}`)).toEqual([
|
||||
'ComboSource.COMBO',
|
||||
'ComboNode.choice'
|
||||
])
|
||||
})
|
||||
|
||||
test('wildcard slots are excluded, orphan types recorded not failed', () => {
|
||||
const nodes = normalizeNodeDefs(DEFS)
|
||||
const plan = planPairs(nodes, ['WildcardNode', 'OrphanNode'])
|
||||
expect(plan.wildcards.map((w) => w.nodeType)).toEqual([
|
||||
'WildcardNode',
|
||||
'WildcardNode'
|
||||
])
|
||||
expect(plan.orphans).toEqual([
|
||||
{
|
||||
nodeType: 'OrphanNode',
|
||||
pack: 'OrphanPack',
|
||||
slotName: 'orphan',
|
||||
slotType: 'NOBODY_CONSUMES_THIS',
|
||||
dir: 'out'
|
||||
}
|
||||
])
|
||||
expect(plan.pairs).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
{
|
||||
|
||||
10
package.json
@@ -52,7 +52,17 @@
|
||||
"test:browser": "pnpm exec playwright test",
|
||||
"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:custom-nodes": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/customNodes/ --config playwright.chrome.config.ts --workers=1",
|
||||
"test:custom-nodes:watch": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:5173 PLAYWRIGHT_LOCAL=1 SLOW_MO=300 pnpm exec playwright test browser_tests/tests/customNodes/customNode.regression.spec.ts browser_tests/tests/customNodes/connectivity.spec.ts --config playwright.chrome.config.ts --workers=1 --headed",
|
||||
"test:custom-nodes:debug": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/customNodes/customNode.regression.spec.ts browser_tests/tests/customNodes/connectivity.spec.ts --config playwright.chrome.config.ts --workers=1 --debug",
|
||||
"test:custom-nodes:impact-render": "pnpm test:custom-nodes:debug -g \"ComfyUI-Impact-Pack.*T0\"",
|
||||
"test:custom-nodes:impact-run": "pnpm test:custom-nodes:debug -g \"ComfyUI-Impact-Pack.*T1\"",
|
||||
"test:custom-nodes:vhs-render": "pnpm test:custom-nodes:debug -g \"VideoHelperSuite.*T0\"",
|
||||
"test:custom-nodes:vhs-run": "pnpm test:custom-nodes:debug -g \"VideoHelperSuite.*T1\"",
|
||||
"test:custom-nodes:connectivity": "pnpm test:custom-nodes:debug -g \"connectivity\"",
|
||||
"test:custom-nodes:self-check": "pnpm test:custom-nodes:watch -g \"self-check\"",
|
||||
"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",
|
||||
|
||||
10
playwright.chrome.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
import base from './playwright.config'
|
||||
|
||||
// Run against the system-installed Google Chrome (no bundled-chromium download).
|
||||
// trace stays off: Playwright's trace recorder crashes pages under the branded
|
||||
// Chrome channel on this machine (instant browser close, reported as timeout).
|
||||
export default defineConfig(base, {
|
||||
use: { channel: 'chrome', video: 'off', trace: 'off' }
|
||||
})
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
BillingStatus,
|
||||
@@ -75,9 +76,10 @@ export interface BillingActions {
|
||||
*/
|
||||
requireActiveSubscription: () => Promise<void>
|
||||
/**
|
||||
* Shows the subscription dialog.
|
||||
* Shows the subscription dialog. Pass a reason so the paywall open and any
|
||||
* downstream checkout stay attributed to the triggering product moment.
|
||||
*/
|
||||
showSubscriptionDialog: () => void
|
||||
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
|
||||
}
|
||||
|
||||
export interface BillingState {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
SubscribeOptions
|
||||
@@ -281,8 +282,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.requireActiveSubscription()
|
||||
}
|
||||
|
||||
function showSubscriptionDialog() {
|
||||
return activeContext.value.showSubscriptionDialog()
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
|
||||
return activeContext.value.showSubscriptionDialog(options)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
@@ -189,12 +190,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
legacyShowSubscriptionDialog()
|
||||
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
legacyShowSubscriptionDialog()
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
|
||||
legacyShowSubscriptionDialog(options)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,12 +3,13 @@ import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: { linkIds?: number[] }
|
||||
_subgraphSlot?: SubgraphInput
|
||||
}
|
||||
|
||||
const componentIconSvg = new Image()
|
||||
|
||||
@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
storeSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
ui: {
|
||||
settings: {
|
||||
dispatchChange: vi.fn()
|
||||
}
|
||||
},
|
||||
rootGraph: {
|
||||
events: new EventTarget(),
|
||||
nodes: []
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
function createSelectedCanvas() {
|
||||
const graph = new LGraph()
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_events: true,
|
||||
skip_render: true
|
||||
})
|
||||
const node = new LGraphNode('Selected Node')
|
||||
graph.add(node)
|
||||
canvas.selectedItems.add(node)
|
||||
node.selected = true
|
||||
|
||||
return { canvas, node }
|
||||
}
|
||||
|
||||
describe('useViewErrorsInGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
apiMock.getSettings.mockResolvedValue({})
|
||||
apiMock.storeSetting.mockResolvedValue(undefined)
|
||||
apiMock.storeSettings.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('opens graph errors and clears app-mode error UI state', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { canvas, node } = createSelectedCanvas()
|
||||
workflowStore.activeWorkflow = {
|
||||
activeMode: 'app'
|
||||
} as typeof workflowStore.activeWorkflow
|
||||
canvasStore.canvas = canvas
|
||||
canvasStore.selectedItems = [node]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
useViewErrorsInGraph().viewErrorsInGraph()
|
||||
|
||||
expect(node.selected).toBe(false)
|
||||
expect(canvasStore.linearMode).toBe(false)
|
||||
expect(canvasStore.selectedItems).toEqual([])
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens graph errors when the canvas is not initialized', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.canvas = null
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
|
||||
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||