mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-07 14:34:41 +00:00
Compare commits
42 Commits
test/cover
...
glary/clou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bee3fc784 | ||
|
|
69144c0be7 | ||
|
|
2c61847478 | ||
|
|
da50f8bb54 | ||
|
|
ce8d959479 | ||
|
|
77c3a46d27 | ||
|
|
3e95adcaa2 | ||
|
|
e29d6201bf | ||
|
|
7aacdbf3c7 | ||
|
|
9225eaf3a9 | ||
|
|
75ca2ef71a | ||
|
|
296b12d5d8 | ||
|
|
53531f23f0 | ||
|
|
d00a9f22d9 | ||
|
|
1b6c36283e | ||
|
|
465a7b95c5 | ||
|
|
040600bdb4 | ||
|
|
794bba923b | ||
|
|
7d4dfeceed | ||
|
|
829e90e425 | ||
|
|
cbe81db30d | ||
|
|
33093b04c5 | ||
|
|
9be2abce8d | ||
|
|
99dfc33812 | ||
|
|
deba5ab023 | ||
|
|
5a30a41a47 | ||
|
|
027ac98a8d | ||
|
|
cf1dafdce8 | ||
|
|
abd1ca00ab | ||
|
|
36adcb9181 | ||
|
|
8ee6b967a4 | ||
|
|
eccc036fef | ||
|
|
488523f765 | ||
|
|
362bc9b713 | ||
|
|
89dae18a73 | ||
|
|
d698a4313e | ||
|
|
187db14de9 | ||
|
|
30734804b5 | ||
|
|
04918360eb | ||
|
|
af70d88860 | ||
|
|
c955309b26 | ||
|
|
7abd9d12c8 |
BIN
.github/pr-images/fe-237-before-after.png
vendored
Normal file
BIN
.github/pr-images/fe-237-before-after.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -113,6 +113,12 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## Cloud nodes integration
|
||||
|
||||
`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
|
||||
|
||||
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
@@ -146,3 +152,4 @@ renders the documented embed container.
|
||||
- `pnpm test:unit` — Vitest unit tests
|
||||
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
|
||||
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
|
||||
- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot
|
||||
|
||||
169
apps/website/e2e/cloud-nodes.spec.ts
Normal file
169
apps/website/e2e/cloud-nodes.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Cloud nodes page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud/supported-nodes')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Custom-node packs on Comfy Cloud — supported by default'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders at least one pack card', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
await expect(cards.first()).toBeVisible()
|
||||
expect(await cards.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('search input filters cards down', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
const initialCount = await cards.count()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('impact')
|
||||
const filteredCards = page.getByTestId('cloud-node-pack-card')
|
||||
await expect(filteredCards.first()).toBeVisible()
|
||||
const filteredCount = await filteredCards.count()
|
||||
|
||||
expect(filteredCount).toBeGreaterThan(0)
|
||||
expect(filteredCount).toBeLessThanOrEqual(initialCount)
|
||||
})
|
||||
|
||||
test('renders banner image or fallback element', async ({ page }) => {
|
||||
const banners = page.getByTestId('cloud-node-pack-banner')
|
||||
await expect(banners.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens pack detail page from first card', async ({ page }) => {
|
||||
const firstCard = page.getByTestId('cloud-node-pack-card').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
await firstCard.locator('a').first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
})
|
||||
|
||||
test('direct pack detail route renders node entries', async ({ page }) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-detail-node').first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('search with no matches shows empty state', async ({ page }) => {
|
||||
await page
|
||||
.getByTestId('cloud-nodes-search')
|
||||
.fill('zzzz-this-pack-does-not-exist')
|
||||
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0)
|
||||
await expect(page.getByText('No matching packs')).toBeVisible()
|
||||
})
|
||||
|
||||
test('clearing search restores the full list', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
const initialCount = await cards.count()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('impact')
|
||||
await expect(cards.first()).toBeVisible()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('')
|
||||
await expect(cards).toHaveCount(initialCount)
|
||||
})
|
||||
|
||||
test('search matches against node display names, not just pack names', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByTestId('cloud-nodes-search').fill('FaceDetailer')
|
||||
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1)
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-card-link').first()
|
||||
).toContainText('Impact Pack')
|
||||
})
|
||||
|
||||
test('switching sort to A → Z reorders cards alphabetically', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.locator('#cloud-nodes-sort').selectOption('az')
|
||||
const firstName = await page
|
||||
.getByTestId('cloud-node-pack-card-link')
|
||||
.first()
|
||||
.textContent()
|
||||
expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/)
|
||||
})
|
||||
|
||||
test('list grid carries a localized aria-label', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('list', {
|
||||
name: 'Custom-node packs supported on Comfy Cloud'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking the back link returns to the index from a detail page', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
await page.getByRole('link', { name: 'Back to all packs' }).click()
|
||||
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('detail page renders publisher and external repo link', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
const repoLink = page.getByRole('link', {
|
||||
name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/
|
||||
})
|
||||
await expect(repoLink).toBeVisible()
|
||||
await expect(repoLink).toHaveAttribute('rel', /noopener/)
|
||||
})
|
||||
|
||||
test('unknown pack slug 404s', async ({ page }) => {
|
||||
const response = await page.goto(
|
||||
'/cloud/supported-nodes/this-pack-does-not-exist'
|
||||
)
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('JSON-LD ItemList is emitted on the index page', async ({ page }) => {
|
||||
const jsonLd = page.locator('script[type="application/ld+json"]')
|
||||
const ldBlocks = await jsonLd.allTextContents()
|
||||
expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy()
|
||||
})
|
||||
|
||||
test('JSON-LD payload escapes <-sequences', async ({ page }) => {
|
||||
const ldBlocks = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.allTextContents()
|
||||
for (const block of ldBlocks) {
|
||||
expect(block).not.toContain('</script')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud nodes page (zh-CN) @smoke', () => {
|
||||
test('renders localized title and packs', async ({ page }) => {
|
||||
await page.goto('/zh-CN/cloud/supported-nodes')
|
||||
await expect(page).toHaveTitle('Comfy Cloud 自定义节点包合集——开箱即用')
|
||||
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-banner').first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens pack detail page from first card', async ({ page }) => {
|
||||
await page.goto('/zh-CN/cloud/supported-nodes')
|
||||
const firstCard = page.getByTestId('cloud-node-pack-card').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
await firstCard.locator('a').first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN\/cloud\/supported-nodes\/[a-z0-9-]+$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -15,11 +15,14 @@
|
||||
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
|
||||
"test:visual": "playwright test --project visual",
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 81 KiB |
32
apps/website/scripts/refresh-cloud-nodes-snapshot.ts
Normal file
32
apps/website/scripts/refresh-cloud-nodes-snapshot.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { renameSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes'
|
||||
|
||||
const snapshotPath = fileURLToPath(
|
||||
new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url)
|
||||
)
|
||||
const tempPath = `${snapshotPath}.tmp`
|
||||
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
|
||||
if (outcome.status !== 'fresh') {
|
||||
const reason = 'reason' in outcome ? outcome.reason : '(none)'
|
||||
console.error(
|
||||
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n'
|
||||
|
||||
writeFileSync(tempPath, serialized, 'utf8')
|
||||
renameSync(tempPath, snapshotPath)
|
||||
|
||||
const totalNodes = outcome.snapshot.packs.reduce(
|
||||
(n, pack) => n + pack.nodes.length,
|
||||
0
|
||||
)
|
||||
process.stdout.write(
|
||||
`Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n`
|
||||
)
|
||||
30
apps/website/src/components/cloud-nodes/HeroSection.vue
Normal file
30
apps/website/src/components/cloud-nodes/HeroSection.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 pt-24 pb-14 md:px-20 md:pt-32 md:pb-20">
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-7">
|
||||
<SectionLabel>
|
||||
{{ t('cloudNodes.hero.label', locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-4xl/tight md:text-6xl"
|
||||
>
|
||||
{{ t('cloudNodes.hero.heading', locale) }}
|
||||
</h1>
|
||||
|
||||
<p class="text-primary-warm-gray max-w-3xl text-base/relaxed md:text-lg">
|
||||
{{ t('cloudNodes.hero.body', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
49
apps/website/src/components/cloud-nodes/NodeList.vue
Normal file
49
apps/website/src/components/cloud-nodes/NodeList.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { PackNode } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { useNodesByCategory } from '../../composables/useNodesByCategory'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en', nodes } = defineProps<{
|
||||
locale?: Locale
|
||||
nodes: readonly PackNode[]
|
||||
}>()
|
||||
|
||||
const { groupedNodes } = useNodesByCategory(() => nodes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details
|
||||
class="group border-primary-warm-gray/20 rounded-2xl border px-4 py-3"
|
||||
>
|
||||
<summary
|
||||
class="text-primary-comfy-canvas cursor-pointer list-none text-sm font-semibold"
|
||||
>
|
||||
{{ t('cloudNodes.card.nodesHeading', locale) }}
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-5">
|
||||
<div
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.category"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<h4
|
||||
class="text-primary-warm-gray text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ group.category }}
|
||||
</h4>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="text-primary-comfy-canvas text-sm/relaxed"
|
||||
>
|
||||
{{ node.displayName }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
52
apps/website/src/components/cloud-nodes/PackBanner.vue
Normal file
52
apps/website/src/components/cloud-nodes/PackBanner.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { useBannerImage } from '../../composables/useBannerImage'
|
||||
|
||||
const { bannerUrl, iconUrl, name } = defineProps<{
|
||||
bannerUrl?: string
|
||||
iconUrl?: string
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const {
|
||||
DEFAULT_BANNER,
|
||||
isImageError,
|
||||
showDefaultBanner,
|
||||
imgSrc,
|
||||
onImageError
|
||||
} = useBannerImage({
|
||||
bannerUrl: () => bannerUrl,
|
||||
iconUrl: () => iconUrl
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="z-0 aspect-7/3 w-full overflow-hidden"
|
||||
data-testid="cloud-node-pack-banner"
|
||||
>
|
||||
<div v-if="showDefaultBanner" class="size-full">
|
||||
<img
|
||||
:src="DEFAULT_BANNER"
|
||||
:alt="`${name} banner`"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative size-full">
|
||||
<div
|
||||
v-if="imgSrc && !isImageError"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
:style="{ backgroundImage: `url(${imgSrc})`, filter: 'blur(10px)' }"
|
||||
/>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="`${name} banner`"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative z-10 size-full object-cover'
|
||||
: 'relative z-10 size-full object-contain'
|
||||
"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
80
apps/website/src/components/cloud-nodes/PackCard.vue
Normal file
80
apps/website/src/components/cloud-nodes/PackCard.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import NodeList from './NodeList.vue'
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const { locale = 'en', pack } = defineProps<{
|
||||
locale?: Locale
|
||||
pack: Pack
|
||||
}>()
|
||||
|
||||
const detailHref =
|
||||
locale === 'zh-CN'
|
||||
? `/zh-CN/cloud/supported-nodes/${pack.id}`
|
||||
: `/cloud/supported-nodes/${pack.id}`
|
||||
|
||||
function nodeCountLabel(nodeCount: number): string {
|
||||
const key =
|
||||
new Intl.PluralRules(locale).select(nodeCount) === 'one'
|
||||
? 'cloudNodes.card.nodeCountOne'
|
||||
: 'cloudNodes.card.nodeCountOther'
|
||||
return t(key, locale).replace('{count}', String(nodeCount))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/20 flex h-full flex-col overflow-hidden rounded-3xl border"
|
||||
data-testid="cloud-node-pack-card"
|
||||
>
|
||||
<PackBanner
|
||||
:banner-url="pack.bannerUrl"
|
||||
:icon-url="pack.iconUrl"
|
||||
:name="pack.displayName"
|
||||
/>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-5 p-5 md:p-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-primary-comfy-canvas text-2xl/tight font-semibold">
|
||||
<a
|
||||
:href="detailHref"
|
||||
class="hover:text-primary-comfy-yellow"
|
||||
data-testid="cloud-node-pack-card-link"
|
||||
>
|
||||
{{ pack.displayName }}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-primary-warm-gray text-sm/relaxed">
|
||||
{{
|
||||
pack.description ||
|
||||
t('cloudNodes.card.unavailableDescription', locale)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
<a
|
||||
v-if="pack.repoUrl"
|
||||
:href="pack.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
|
||||
>
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</a>
|
||||
<span v-else class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray">•</span>
|
||||
<span class="text-primary-comfy-canvas">{{
|
||||
nodeCountLabel(pack.nodes.length)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<NodeList :locale="locale" :nodes="pack.nodes" class="mt-auto" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
203
apps/website/src/components/cloud-nodes/PackDetail.vue
Normal file
203
apps/website/src/components/cloud-nodes/PackDetail.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber
|
||||
} from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
import { useNodesByCategory } from '../../composables/useNodesByCategory'
|
||||
import { t } from '../../i18n/translations'
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const { pack, locale = 'en' } = defineProps<{
|
||||
pack: Pack
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const backHref =
|
||||
locale === 'zh-CN' ? '/zh-CN/cloud/supported-nodes' : '/cloud/supported-nodes'
|
||||
|
||||
const { groupedNodes } = useNodesByCategory(() => pack.nodes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="px-6 pb-20 md:px-20 md:pb-28"
|
||||
data-testid="cloud-node-pack-detail"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<a
|
||||
:href="backHref"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 w-fit text-sm font-semibold underline"
|
||||
>
|
||||
{{ t('cloudNodes.detail.back', locale) }}
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/20 overflow-hidden rounded-3xl border"
|
||||
>
|
||||
<PackBanner
|
||||
:banner-url="pack.bannerUrl"
|
||||
:icon-url="pack.iconUrl"
|
||||
:name="pack.displayName"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-7 p-5 md:p-6">
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-semibold md:text-4xl"
|
||||
>
|
||||
{{ pack.displayName }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-primary-warm-gray text-sm/relaxed md:text-base/relaxed"
|
||||
>
|
||||
{{
|
||||
pack.description ||
|
||||
t('cloudNodes.card.unavailableDescription', locale)
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</dt>
|
||||
<dd>
|
||||
<a
|
||||
v-if="pack.repoUrl"
|
||||
:href="pack.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
|
||||
>
|
||||
{{ pack.repoUrl }}
|
||||
</a>
|
||||
<span v-else class="text-primary-comfy-canvas">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.publisher', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.publisher?.name || pack.publisher?.id || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.downloads', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedNumber(pack.downloads, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.stars', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedNumber(pack.githubStars, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.latestVersion', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.latestVersion || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.license', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.license || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.lastUpdated', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedMediumDate(pack.lastUpdated, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-xl font-semibold md:text-2xl"
|
||||
>
|
||||
{{ t('cloudNodes.detail.nodesHeading', locale) }}
|
||||
</h2>
|
||||
|
||||
<section
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.category"
|
||||
class="border-primary-warm-gray/20 rounded-2xl border p-4"
|
||||
>
|
||||
<h3 class="text-primary-comfy-canvas text-base font-semibold">
|
||||
{{ group.category }}
|
||||
</h3>
|
||||
<ul class="mt-3 flex flex-col gap-3">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="border-primary-warm-gray/20 rounded-xl border p-3"
|
||||
data-testid="cloud-node-pack-detail-node"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-sm font-semibold"
|
||||
>
|
||||
{{ node.displayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="node.experimental"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary-comfy-yellow/20 text-primary-comfy-canvas rounded-full px-2 py-0.5 text-xs font-semibold'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('cloudNodes.detail.experimental', locale) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="node.deprecated"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-comfy-canvas rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-semibold'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('cloudNodes.detail.deprecated', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="node.description"
|
||||
class="text-primary-warm-gray mt-2 text-sm/relaxed"
|
||||
>
|
||||
{{ node.description }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
113
apps/website/src/components/cloud-nodes/PackGridSection.vue
Normal file
113
apps/website/src/components/cloud-nodes/PackGridSection.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { useFilteredPacks } from '../../composables/useFilteredPacks'
|
||||
import type { PackSortMode } from '../../composables/useFilteredPacks'
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import PackCard from './PackCard.vue'
|
||||
|
||||
const { locale = 'en', packs } = defineProps<{
|
||||
locale?: Locale
|
||||
packs: readonly Pack[]
|
||||
}>()
|
||||
|
||||
const query = defineModel<string>('query', { default: '' })
|
||||
const sortMode = ref<PackSortMode>('downloads')
|
||||
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs: () => packs,
|
||||
query,
|
||||
sortMode
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 pb-20 md:px-20 md:pb-32">
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<SectionLabel>
|
||||
{{ t('cloudNodes.hero.label', locale) }}
|
||||
</SectionLabel>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-medium md:text-4xl"
|
||||
>
|
||||
{{ t('cloudNodes.section.heading', locale) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<label for="cloud-nodes-search" class="sr-only">
|
||||
{{ t('cloudNodes.search.label', locale) }}
|
||||
</label>
|
||||
<input
|
||||
id="cloud-nodes-search"
|
||||
v-model="query"
|
||||
:placeholder="t('cloudNodes.search.placeholder', locale)"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas placeholder:text-primary-warm-gray/80 w-full rounded-2xl border px-4 py-3 text-sm md:max-w-md'
|
||||
)
|
||||
"
|
||||
data-testid="cloud-nodes-search"
|
||||
/>
|
||||
|
||||
<label for="cloud-nodes-sort" class="sr-only">
|
||||
{{ t('cloudNodes.sort.label', locale) }}
|
||||
</label>
|
||||
<select
|
||||
id="cloud-nodes-sort"
|
||||
v-model="sortMode"
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas w-full appearance-none rounded-2xl border bg-size-[0.65rem_0.65rem] bg-position-[right_1rem_center] bg-no-repeat py-3 pr-12 pl-4 text-sm md:w-64"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'url(\'data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 12 12%22 fill=%22%23a39b8d%22><path d=%22M6 9.2L1.4 4.6 2.8 3.2 6 6.4l3.2-3.2 1.4 1.4z%22/></svg>\')'
|
||||
}"
|
||||
>
|
||||
<option value="downloads">
|
||||
{{ t('cloudNodes.sort.downloads', locale) }}
|
||||
</option>
|
||||
<option value="mostNodes">
|
||||
{{ t('cloudNodes.sort.mostNodes', locale) }}
|
||||
</option>
|
||||
<option value="az">{{ t('cloudNodes.sort.az', locale) }}</option>
|
||||
<option value="recentlyUpdated">
|
||||
{{ t('cloudNodes.sort.recentlyUpdated', locale) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="filteredPacks.length === 0"
|
||||
class="text-primary-warm-gray rounded-2xl border border-dashed border-current/30 px-5 py-6 text-sm"
|
||||
>
|
||||
<span class="text-primary-comfy-canvas block text-base font-semibold">
|
||||
{{ t('cloudNodes.empty.heading', locale) }}
|
||||
</span>
|
||||
<span class="mt-2 block">{{ t('cloudNodes.empty.body', locale) }}</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-5 md:grid-cols-2"
|
||||
role="list"
|
||||
:aria-label="t('cloudNodes.list.ariaLabel', locale)"
|
||||
>
|
||||
<PackCard
|
||||
v-for="pack in filteredPacks"
|
||||
:key="pack.id"
|
||||
:pack="pack"
|
||||
:locale="locale"
|
||||
role="listitem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
52
apps/website/src/composables/useBannerImage.test.ts
Normal file
52
apps/website/src/composables/useBannerImage.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useBannerImage } from './useBannerImage'
|
||||
|
||||
describe('useBannerImage', () => {
|
||||
it('shows the default banner when neither url is provided', () => {
|
||||
const { showDefaultBanner, imgSrc } = useBannerImage({})
|
||||
expect(showDefaultBanner.value).toBe(true)
|
||||
expect(imgSrc.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers bannerUrl over iconUrl when both are provided', () => {
|
||||
const { showDefaultBanner, imgSrc } = useBannerImage({
|
||||
bannerUrl: 'https://example.com/banner.png',
|
||||
iconUrl: 'https://example.com/icon.png'
|
||||
})
|
||||
expect(showDefaultBanner.value).toBe(false)
|
||||
expect(imgSrc.value).toBe('https://example.com/banner.png')
|
||||
})
|
||||
|
||||
it('falls back to iconUrl when bannerUrl is missing', () => {
|
||||
const { imgSrc } = useBannerImage({
|
||||
iconUrl: 'https://example.com/icon.png'
|
||||
})
|
||||
expect(imgSrc.value).toBe('https://example.com/icon.png')
|
||||
})
|
||||
|
||||
it('reactively updates when sources change', () => {
|
||||
const banner = ref<string | undefined>(undefined)
|
||||
const { showDefaultBanner, imgSrc } = useBannerImage({ bannerUrl: banner })
|
||||
|
||||
expect(showDefaultBanner.value).toBe(true)
|
||||
banner.value = 'https://example.com/new.png'
|
||||
expect(showDefaultBanner.value).toBe(false)
|
||||
expect(imgSrc.value).toBe('https://example.com/new.png')
|
||||
})
|
||||
|
||||
it('flips isImageError when onImageError is called', () => {
|
||||
const { isImageError, onImageError } = useBannerImage({
|
||||
bannerUrl: 'x'
|
||||
})
|
||||
expect(isImageError.value).toBe(false)
|
||||
onImageError()
|
||||
expect(isImageError.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes the default banner constant for consumers', () => {
|
||||
const { DEFAULT_BANNER } = useBannerImage({})
|
||||
expect(DEFAULT_BANNER).toBe('/assets/images/fallback-gradient-avatar.svg')
|
||||
})
|
||||
})
|
||||
30
apps/website/src/composables/useBannerImage.ts
Normal file
30
apps/website/src/composables/useBannerImage.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
interface UseBannerImageInput {
|
||||
bannerUrl?: MaybeRefOrGetter<string | undefined>
|
||||
iconUrl?: MaybeRefOrGetter<string | undefined>
|
||||
}
|
||||
|
||||
export function useBannerImage({ bannerUrl, iconUrl }: UseBannerImageInput) {
|
||||
const isImageError = ref(false)
|
||||
|
||||
const showDefaultBanner = computed(
|
||||
() => !toValue(bannerUrl) && !toValue(iconUrl)
|
||||
)
|
||||
const imgSrc = computed(() => toValue(bannerUrl) || toValue(iconUrl))
|
||||
|
||||
function onImageError() {
|
||||
isImageError.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
DEFAULT_BANNER,
|
||||
isImageError,
|
||||
showDefaultBanner,
|
||||
imgSrc,
|
||||
onImageError
|
||||
}
|
||||
}
|
||||
144
apps/website/src/composables/useFilteredPacks.test.ts
Normal file
144
apps/website/src/composables/useFilteredPacks.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Pack, PackNode } from '../data/cloudNodes'
|
||||
|
||||
import { useFilteredPacks } from './useFilteredPacks'
|
||||
import type { PackSortMode } from './useFilteredPacks'
|
||||
|
||||
function pack(overrides: Partial<Pack> = {}): Pack {
|
||||
return {
|
||||
id: overrides.id ?? 'pack',
|
||||
displayName: overrides.displayName ?? 'Pack',
|
||||
nodes: overrides.nodes ?? [],
|
||||
downloads: overrides.downloads,
|
||||
lastUpdated: overrides.lastUpdated,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function node(name: string, displayName: string): PackNode {
|
||||
return { name, displayName, category: 'x' }
|
||||
}
|
||||
|
||||
describe('useFilteredPacks', () => {
|
||||
const packs: readonly Pack[] = [
|
||||
pack({
|
||||
id: 'a',
|
||||
displayName: 'Alpha',
|
||||
downloads: 100,
|
||||
lastUpdated: '2025-01-01T00:00:00Z',
|
||||
nodes: [node('aa', 'Aardvark')]
|
||||
}),
|
||||
pack({
|
||||
id: 'b',
|
||||
displayName: 'Beta',
|
||||
downloads: 300,
|
||||
lastUpdated: '2025-06-01T00:00:00Z',
|
||||
nodes: [node('bb', 'Beaver'), node('bb2', 'Bumblebee')]
|
||||
}),
|
||||
pack({
|
||||
id: 'c',
|
||||
displayName: 'Gamma',
|
||||
downloads: 200,
|
||||
lastUpdated: '2025-03-01T00:00:00Z',
|
||||
nodes: [
|
||||
node('cc', 'Cat'),
|
||||
node('cc2', 'Crocodile'),
|
||||
node('cc3', 'Capybara')
|
||||
]
|
||||
})
|
||||
]
|
||||
|
||||
it('sorts by downloads desc by default', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: '',
|
||||
sortMode: 'downloads' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('sorts most-nodes places highest count first', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: '',
|
||||
sortMode: 'mostNodes' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c', 'b', 'a'])
|
||||
})
|
||||
|
||||
it('sorts A → Z by display name', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: '',
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.displayName)).toEqual([
|
||||
'Alpha',
|
||||
'Beta',
|
||||
'Gamma'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts recently updated newest first', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: '',
|
||||
sortMode: 'recentlyUpdated' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('treats invalid lastUpdated as 0', () => {
|
||||
const broken = [
|
||||
pack({ id: 'x', lastUpdated: 'nonsense' }),
|
||||
pack({ id: 'y', lastUpdated: '2025-01-01T00:00:00Z' })
|
||||
]
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs: broken,
|
||||
query: '',
|
||||
sortMode: 'recentlyUpdated' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value[0].id).toBe('y')
|
||||
})
|
||||
|
||||
it('matches the search query against pack display names', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: 'beta',
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('matches the search query against node display names', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: 'CAPYBARA',
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c'])
|
||||
})
|
||||
|
||||
it('returns empty when nothing matches', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: 'zzz-no-such-thing',
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reacts when the query ref changes', () => {
|
||||
const query = ref('beta')
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query,
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value).toHaveLength(1)
|
||||
query.value = ''
|
||||
expect(filteredPacks.value).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
53
apps/website/src/composables/useFilteredPacks.ts
Normal file
53
apps/website/src/composables/useFilteredPacks.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { Pack } from '../data/cloudNodes'
|
||||
|
||||
export type PackSortMode = 'downloads' | 'mostNodes' | 'az' | 'recentlyUpdated'
|
||||
|
||||
interface UseFilteredPacksInput {
|
||||
packs: MaybeRefOrGetter<readonly Pack[]>
|
||||
query: MaybeRefOrGetter<string>
|
||||
sortMode: MaybeRefOrGetter<PackSortMode>
|
||||
}
|
||||
|
||||
function matchesQuery(pack: Pack, normalizedQuery: string): boolean {
|
||||
if (pack.displayName.toLowerCase().includes(normalizedQuery)) return true
|
||||
return pack.nodes.some((node) =>
|
||||
node.displayName.toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
}
|
||||
|
||||
function safeTimestamp(value: string | undefined): number {
|
||||
if (!value) return 0
|
||||
const ts = Date.parse(value)
|
||||
return Number.isNaN(ts) ? 0 : ts
|
||||
}
|
||||
|
||||
export function useFilteredPacks(input: UseFilteredPacksInput) {
|
||||
const filteredPacks = computed<Pack[]>(() => {
|
||||
const allPacks = toValue(input.packs)
|
||||
const normalizedQuery = toValue(input.query).trim().toLowerCase()
|
||||
|
||||
const matching =
|
||||
normalizedQuery.length === 0
|
||||
? [...allPacks]
|
||||
: allPacks.filter((pack) => matchesQuery(pack, normalizedQuery))
|
||||
|
||||
const mode = toValue(input.sortMode)
|
||||
if (mode === 'az') {
|
||||
return matching.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
if (mode === 'recentlyUpdated') {
|
||||
return matching.sort(
|
||||
(a, b) => safeTimestamp(b.lastUpdated) - safeTimestamp(a.lastUpdated)
|
||||
)
|
||||
}
|
||||
if (mode === 'mostNodes') {
|
||||
return matching.sort((a, b) => b.nodes.length - a.nodes.length)
|
||||
}
|
||||
return matching.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
|
||||
})
|
||||
|
||||
return { filteredPacks }
|
||||
}
|
||||
68
apps/website/src/composables/useNodesByCategory.test.ts
Normal file
68
apps/website/src/composables/useNodesByCategory.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { PackNode } from '../data/cloudNodes'
|
||||
|
||||
import { useNodesByCategory } from './useNodesByCategory'
|
||||
|
||||
function node(name: string, displayName: string, category: string): PackNode {
|
||||
return { name, displayName, category }
|
||||
}
|
||||
|
||||
describe('useNodesByCategory', () => {
|
||||
it('groups nodes by category', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [
|
||||
node('A', 'A', 'cat-1'),
|
||||
node('B', 'B', 'cat-2'),
|
||||
node('C', 'C', 'cat-1')
|
||||
])
|
||||
expect(groupedNodes.value).toHaveLength(2)
|
||||
expect(groupedNodes.value[0]).toMatchObject({
|
||||
category: 'cat-1',
|
||||
nodes: [
|
||||
expect.objectContaining({ name: 'A' }),
|
||||
expect.objectContaining({ name: 'C' })
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts nodes alphabetically by display name within a category', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [
|
||||
node('z', 'Zulu', 'x'),
|
||||
node('a', 'Alpha', 'x'),
|
||||
node('m', 'Mike', 'x')
|
||||
])
|
||||
expect(groupedNodes.value[0].nodes.map((n) => n.displayName)).toEqual([
|
||||
'Alpha',
|
||||
'Mike',
|
||||
'Zulu'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts categories alphabetically', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [
|
||||
node('a', 'A', 'beta'),
|
||||
node('b', 'B', 'alpha'),
|
||||
node('c', 'C', 'gamma')
|
||||
])
|
||||
expect(groupedNodes.value.map((g) => g.category)).toEqual([
|
||||
'alpha',
|
||||
'beta',
|
||||
'gamma'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to a placeholder for missing categories', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [node('a', 'A', '')])
|
||||
expect(groupedNodes.value[0].category).toBe('—')
|
||||
})
|
||||
|
||||
it('reacts to ref changes', () => {
|
||||
const nodes = ref<PackNode[]>([node('a', 'A', 'x')])
|
||||
const { groupedNodes } = useNodesByCategory(nodes)
|
||||
expect(groupedNodes.value).toHaveLength(1)
|
||||
|
||||
nodes.value = [node('a', 'A', 'x'), node('b', 'B', 'y')]
|
||||
expect(groupedNodes.value).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
40
apps/website/src/composables/useNodesByCategory.ts
Normal file
40
apps/website/src/composables/useNodesByCategory.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { PackNode } from '../data/cloudNodes'
|
||||
|
||||
const UNCATEGORIZED = '—'
|
||||
|
||||
interface NodeCategoryGroup {
|
||||
category: string
|
||||
nodes: PackNode[]
|
||||
}
|
||||
|
||||
export function useNodesByCategory(
|
||||
nodes: MaybeRefOrGetter<readonly PackNode[]>
|
||||
) {
|
||||
const groupedNodes = computed<NodeCategoryGroup[]>(() => {
|
||||
const byCategory = new Map<string, PackNode[]>()
|
||||
|
||||
for (const node of toValue(nodes)) {
|
||||
const category = node.category || UNCATEGORIZED
|
||||
const existing = byCategory.get(category)
|
||||
if (existing) {
|
||||
existing.push(node)
|
||||
continue
|
||||
}
|
||||
byCategory.set(category, [node])
|
||||
}
|
||||
|
||||
return [...byCategory.entries()]
|
||||
.map(([category, items]) => ({
|
||||
category,
|
||||
nodes: [...items].sort((a, b) =>
|
||||
a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
}))
|
||||
.sort((a, b) => a.category.localeCompare(b.category))
|
||||
})
|
||||
|
||||
return { groupedNodes }
|
||||
}
|
||||
@@ -1,24 +1,10 @@
|
||||
{
|
||||
"fetchedAt": "2026-04-24T18:59:03.989Z",
|
||||
"fetchedAt": "2026-05-02T20:15:18.321Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
"key": "design",
|
||||
"roles": [
|
||||
{
|
||||
"id": "4c5d6afb78652df7",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||
},
|
||||
{
|
||||
"id": "0f5256cf302e552b",
|
||||
"title": "Creative Artist",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||
},
|
||||
{
|
||||
"id": "e915f2c78b17f93b",
|
||||
"title": "Senior Product Designer",
|
||||
@@ -33,13 +19,6 @@
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
|
||||
},
|
||||
{
|
||||
"id": "5746486d87874937",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||
},
|
||||
{
|
||||
"id": "547b6ba622c800a5",
|
||||
"title": "Senior Product Designer - Craft",
|
||||
@@ -115,6 +94,13 @@
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
|
||||
},
|
||||
{
|
||||
"id": "2eb53e8943cc9396",
|
||||
"title": "Growth Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -122,6 +108,27 @@
|
||||
"name": "MARKETING",
|
||||
"key": "marketing",
|
||||
"roles": [
|
||||
{
|
||||
"id": "4c5d6afb78652df7",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||
},
|
||||
{
|
||||
"id": "0f5256cf302e552b",
|
||||
"title": "Creative Artist",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||
},
|
||||
{
|
||||
"id": "5746486d87874937",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||
},
|
||||
{
|
||||
"id": "b5803a0d4785d406",
|
||||
"title": "Lifecycle Growth Marketer",
|
||||
@@ -144,7 +151,7 @@
|
||||
"roles": [
|
||||
{
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Senior Technical Recruiter",
|
||||
"title": "Talent Lead",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
|
||||
394
apps/website/src/data/cloud-nodes.snapshot.json
Normal file
394
apps/website/src/data/cloud-nodes.snapshot.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-04T16:29:55.587Z",
|
||||
"packs": [
|
||||
{
|
||||
"id": "comfyui-impact-pack",
|
||||
"registryId": "comfyui-impact-pack",
|
||||
"displayName": "ComfyUI Impact Pack",
|
||||
"description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
|
||||
"repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
|
||||
"publisher": {
|
||||
"id": "drltdata",
|
||||
"name": "Dr.Lt.Data"
|
||||
},
|
||||
"downloads": 2618646,
|
||||
"githubStars": 3092,
|
||||
"latestVersion": "8.28.3",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-19T17:08:04.993918Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "FaceDetailer",
|
||||
"displayName": "FaceDetailer",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Detect and refine faces with iterative passes."
|
||||
},
|
||||
{
|
||||
"name": "DetailerForEach",
|
||||
"displayName": "DetailerForEach",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Run iterative detail refinement over detected SEG regions."
|
||||
},
|
||||
{
|
||||
"name": "UltralyticsDetectorProvider",
|
||||
"displayName": "UltralyticsDetectorProvider",
|
||||
"category": "ImpactPack/Detector",
|
||||
"description": "Provide detector models powered by Ultralytics YOLO."
|
||||
},
|
||||
{
|
||||
"name": "SAMLoader",
|
||||
"displayName": "SAMLoader",
|
||||
"category": "ImpactPack/Detector",
|
||||
"description": "Load Segment Anything models for high-fidelity masking."
|
||||
},
|
||||
{
|
||||
"name": "MaskToSEGS",
|
||||
"displayName": "MaskToSEGS",
|
||||
"category": "ImpactPack/Operation",
|
||||
"description": "Convert binary masks into SEGS regions for the detailer pipeline."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ComfyUI-Crystools",
|
||||
"registryId": "ComfyUI-Crystools",
|
||||
"displayName": "ComfyUI-Crystools",
|
||||
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.",
|
||||
"iconUrl": "https://raw.githubusercontent.com/crystian/ComfyUI-Crystools/main/docs/screwdriver.png",
|
||||
"repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
|
||||
"publisher": {
|
||||
"id": "crystian",
|
||||
"name": "Crystian"
|
||||
},
|
||||
"downloads": 1671447,
|
||||
"githubStars": 1855,
|
||||
"latestVersion": "1.27.4",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2025-10-26T19:11:09.943366Z",
|
||||
"supportedOs": ["OS Independent"],
|
||||
"supportedAccelerators": ["GPU :: NVIDIA CUDA"],
|
||||
"nodes": [
|
||||
{
|
||||
"name": "CCrystools_Show_Resources",
|
||||
"displayName": "CCrystools_Show_Resources",
|
||||
"category": "crystools/show",
|
||||
"description": "Display GPU, RAM and disk usage live in the workflow."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Show_Image",
|
||||
"displayName": "CCrystools_Show_Image",
|
||||
"category": "crystools/show",
|
||||
"description": "Inspect images at full resolution with metadata overlays."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Json",
|
||||
"displayName": "CCrystools_Json",
|
||||
"category": "crystools/json",
|
||||
"description": "Compose and parse JSON inline for advanced workflows."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Pipe_To_Any",
|
||||
"displayName": "CCrystools_Pipe_To_Any",
|
||||
"category": "crystools/pipe",
|
||||
"description": "Convert a pipe bus into individual outputs."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Save_Metadata",
|
||||
"displayName": "CCrystools_Save_Metadata",
|
||||
"category": "crystools/save",
|
||||
"description": "Save images with workflow metadata embedded."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rgthree-comfy",
|
||||
"registryId": "rgthree-comfy",
|
||||
"displayName": "rgthree-comfy",
|
||||
"description": "Quality-of-life nodes that make complex workflows readable: Power Lora Loader, group bypassers, smarter reroutes, and inline debug widgets.",
|
||||
"iconUrl": "https://comfy.rgthree.com/media/rgthree.svg",
|
||||
"repoUrl": "https://github.com/rgthree/rgthree-comfy",
|
||||
"publisher": {
|
||||
"id": "rgthree"
|
||||
},
|
||||
"downloads": 3025389,
|
||||
"githubStars": 3028,
|
||||
"latestVersion": "1.0.2604070017",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-07T04:19:24.689627Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Power Lora Loader (rgthree)",
|
||||
"displayName": "Power Lora Loader (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Stack multiple LoRAs in a single, foldable widget."
|
||||
},
|
||||
{
|
||||
"name": "Fast Groups Bypasser (rgthree)",
|
||||
"displayName": "Fast Groups Bypasser (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Toggle whole groups on or off without rewiring."
|
||||
},
|
||||
{
|
||||
"name": "Seed (rgthree)",
|
||||
"displayName": "Seed (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "A predictable seed control with quick reset."
|
||||
},
|
||||
{
|
||||
"name": "Reroute (rgthree)",
|
||||
"displayName": "Reroute (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "A clean reroute alternative with persistent labels."
|
||||
},
|
||||
{
|
||||
"name": "Display Any (rgthree)",
|
||||
"displayName": "Display Any (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Inspect any value with a compact debug widget."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-kjnodes",
|
||||
"registryId": "comfyui-kjnodes",
|
||||
"displayName": "ComfyUI-KJNodes",
|
||||
"description": "Daily-driver utilities for image, latent and string handling: color matching, batch counters, resize helpers, and prompt presets.",
|
||||
"iconUrl": "https://avatars.githubusercontent.com/u/40791699",
|
||||
"repoUrl": "https://github.com/kijai/ComfyUI-KJNodes",
|
||||
"publisher": {
|
||||
"id": "kijai",
|
||||
"name": "Kijai"
|
||||
},
|
||||
"downloads": 3319866,
|
||||
"githubStars": 2544,
|
||||
"latestVersion": "1.3.9",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-24T09:32:28.326616Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "ColorMatch",
|
||||
"displayName": "ColorMatch",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Match the colors of one image to another using statistics."
|
||||
},
|
||||
{
|
||||
"name": "ImageResizeKJ",
|
||||
"displayName": "ImageResizeKJ",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Resize images with intuitive size and divisibility controls."
|
||||
},
|
||||
{
|
||||
"name": "StringConstantMultiline",
|
||||
"displayName": "StringConstantMultiline",
|
||||
"category": "KJNodes/string",
|
||||
"description": "A multi-line string constant suitable for prompts."
|
||||
},
|
||||
{
|
||||
"name": "EmptyLatentImagePresets",
|
||||
"displayName": "EmptyLatentImagePresets",
|
||||
"category": "KJNodes/latent",
|
||||
"description": "Quickly create empty latents at common resolutions."
|
||||
},
|
||||
{
|
||||
"name": "GetImageSizeAndCount",
|
||||
"displayName": "GetImageSizeAndCount",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Read width, height and batch size from an image input."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-easy-use",
|
||||
"registryId": "comfyui-easy-use",
|
||||
"displayName": "ComfyUI-Easy-Use",
|
||||
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — full loader, pre-sampling, easy KSampler, and XY plotting.",
|
||||
"iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg",
|
||||
"repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use",
|
||||
"publisher": {
|
||||
"id": "yolain",
|
||||
"name": "yolain"
|
||||
},
|
||||
"downloads": 2767609,
|
||||
"githubStars": 2500,
|
||||
"latestVersion": "1.3.6",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-01-23T06:19:17.505188Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "easy fullLoader",
|
||||
"displayName": "easy fullLoader",
|
||||
"category": "EasyUse/Loaders",
|
||||
"description": "Combined checkpoint, VAE and CLIP loader with sensible defaults."
|
||||
},
|
||||
{
|
||||
"name": "easy preSampling",
|
||||
"displayName": "easy preSampling",
|
||||
"category": "EasyUse/PreSampling",
|
||||
"description": "A unified pre-sampling node bundling common settings."
|
||||
},
|
||||
{
|
||||
"name": "easy kSampler",
|
||||
"displayName": "easy kSampler",
|
||||
"category": "EasyUse/KSampler",
|
||||
"description": "A simplified KSampler with extra quality-of-life options."
|
||||
},
|
||||
{
|
||||
"name": "easy showAnything",
|
||||
"displayName": "easy showAnything",
|
||||
"category": "EasyUse/Util",
|
||||
"description": "Display any value inline for debugging."
|
||||
},
|
||||
{
|
||||
"name": "easy XYPlot",
|
||||
"displayName": "easy XYPlot",
|
||||
"category": "EasyUse/XYPlot",
|
||||
"description": "Compose XY plots over arbitrary parameters."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-advanced-controlnet",
|
||||
"registryId": "comfyui-advanced-controlnet",
|
||||
"displayName": "ComfyUI-Advanced-ControlNet",
|
||||
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — essential for animation and batched-latent workflows.",
|
||||
"repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet",
|
||||
"publisher": {
|
||||
"id": "kosinkadink",
|
||||
"name": "Kosinkadink"
|
||||
},
|
||||
"downloads": 590539,
|
||||
"githubStars": 967,
|
||||
"latestVersion": "1.5.7",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-03-30T01:40:06.836236Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Apply Advanced ControlNet",
|
||||
"displayName": "Apply Advanced ControlNet",
|
||||
"category": "Adv-ControlNet/conditioning",
|
||||
"description": "Apply ControlNet with timestep keyframes and per-frame masks."
|
||||
},
|
||||
{
|
||||
"name": "ControlNetLoaderAdvanced",
|
||||
"displayName": "ControlNetLoaderAdvanced",
|
||||
"category": "Adv-ControlNet/loaders",
|
||||
"description": "Load ControlNet models with the advanced wrapper."
|
||||
},
|
||||
{
|
||||
"name": "Latent Keyframe Group",
|
||||
"displayName": "Latent Keyframe Group",
|
||||
"category": "Adv-ControlNet/keyframes",
|
||||
"description": "Schedule ControlNet strength over a batch of latents."
|
||||
},
|
||||
{
|
||||
"name": "Timestep Keyframe",
|
||||
"displayName": "Timestep Keyframe",
|
||||
"category": "Adv-ControlNet/keyframes",
|
||||
"description": "Set ControlNet strength at a specific timestep."
|
||||
},
|
||||
{
|
||||
"name": "Scaled Soft Mask",
|
||||
"displayName": "Scaled Soft Mask",
|
||||
"category": "Adv-ControlNet/masks",
|
||||
"description": "Apply a soft attention mask to ControlNet conditioning."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "was-node-suite-comfyui",
|
||||
"registryId": "was-node-suite-comfyui",
|
||||
"displayName": "WAS Node Suite",
|
||||
"description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O — the original \"kitchen sink\" pack still relied on by thousands of workflows.",
|
||||
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
|
||||
"publisher": {
|
||||
"id": "was",
|
||||
"name": "WAS"
|
||||
},
|
||||
"downloads": 981051,
|
||||
"githubStars": 1777,
|
||||
"latestVersion": "1.0.1",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2024-08-01T05:28:23.655235Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Image Filter Adjustments",
|
||||
"displayName": "Image Filter Adjustments",
|
||||
"category": "WAS Suite/Image/Adjustment",
|
||||
"description": "Adjust brightness, contrast, saturation and more."
|
||||
},
|
||||
{
|
||||
"name": "Image Blending Mode",
|
||||
"displayName": "Image Blending Mode",
|
||||
"category": "WAS Suite/Image/Compositing",
|
||||
"description": "Composite two images with Photoshop-style blend modes."
|
||||
},
|
||||
{
|
||||
"name": "Text String",
|
||||
"displayName": "Text String",
|
||||
"category": "WAS Suite/Text",
|
||||
"description": "A reusable text constant suitable for prompts."
|
||||
},
|
||||
{
|
||||
"name": "Number to Float",
|
||||
"displayName": "Number to Float",
|
||||
"category": "WAS Suite/Number",
|
||||
"description": "Cast integer or string values to a float."
|
||||
},
|
||||
{
|
||||
"name": "Image Save",
|
||||
"displayName": "Image Save",
|
||||
"category": "WAS Suite/IO",
|
||||
"description": "Save an image to disk with rich filename templating."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui_ipadapter_plus",
|
||||
"registryId": "comfyui_ipadapter_plus",
|
||||
"displayName": "ComfyUI_IPAdapter_plus",
|
||||
"description": "Reference-image conditioning with IPAdapter — style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
|
||||
"repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
|
||||
"publisher": {
|
||||
"id": "matteo",
|
||||
"name": "Matteo"
|
||||
},
|
||||
"downloads": 1208394,
|
||||
"githubStars": 5938,
|
||||
"latestVersion": "2.0.0",
|
||||
"license": "GPL-3.0 license",
|
||||
"lastUpdated": "2024-06-05T06:57:13.485481Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "IPAdapterUnifiedLoader",
|
||||
"displayName": "IPAdapterUnifiedLoader",
|
||||
"category": "ipadapter",
|
||||
"description": "Load IPAdapter, image encoder and CLIP vision in one node."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterFaceID",
|
||||
"displayName": "IPAdapterFaceID",
|
||||
"category": "ipadapter/faceid",
|
||||
"description": "Apply Face ID embeddings for high-fidelity portrait reference."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterStyleComposition",
|
||||
"displayName": "IPAdapterStyleComposition",
|
||||
"category": "ipadapter",
|
||||
"description": "Reference an image for style without copying its content."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterAdvanced",
|
||||
"displayName": "IPAdapterAdvanced",
|
||||
"category": "ipadapter",
|
||||
"description": "Full-control IPAdapter with masking, weights, and noise injection."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterEncoder",
|
||||
"displayName": "IPAdapterEncoder",
|
||||
"category": "ipadapter/embeds",
|
||||
"description": "Encode reference images into IPAdapter embeddings for reuse."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
52
apps/website/src/data/cloudNodes.ts
Normal file
52
apps/website/src/data/cloudNodes.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface PackNode {
|
||||
name: string
|
||||
displayName: string
|
||||
category: string
|
||||
description?: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
id: string
|
||||
registryId?: string
|
||||
displayName: string
|
||||
description?: string
|
||||
bannerUrl?: string
|
||||
iconUrl?: string
|
||||
repoUrl?: string
|
||||
publisher?: {
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
downloads?: number
|
||||
githubStars?: number
|
||||
latestVersion?: string
|
||||
license?: string
|
||||
lastUpdated?: string
|
||||
supportedOs?: string[]
|
||||
supportedAccelerators?: string[]
|
||||
nodes: PackNode[]
|
||||
}
|
||||
|
||||
export interface NodesSnapshot {
|
||||
fetchedAt: string
|
||||
packs: Pack[]
|
||||
}
|
||||
|
||||
export function isNodesSnapshot(value: unknown): value is NodesSnapshot {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const candidate = value as { fetchedAt?: unknown; packs?: unknown }
|
||||
if (typeof candidate.fetchedAt !== 'string') return false
|
||||
if (!Array.isArray(candidate.packs)) return false
|
||||
|
||||
return candidate.packs.every((pack) => {
|
||||
if (pack === null || typeof pack !== 'object') return false
|
||||
const p = pack as { id?: unknown; displayName?: unknown; nodes?: unknown }
|
||||
return (
|
||||
typeof p.id === 'string' &&
|
||||
typeof p.displayName === 'string' &&
|
||||
Array.isArray(p.nodes)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -735,6 +735,142 @@ const translations = {
|
||||
'zh-CN': '免费试用 COMFY CLOUD'
|
||||
},
|
||||
|
||||
'cloudNodes.hero.label': {
|
||||
en: 'CLOUD NODES',
|
||||
'zh-CN': '云端节点目录'
|
||||
},
|
||||
'cloudNodes.hero.heading': {
|
||||
en: 'Run your favorite ComfyUI custom nodes on the cloud',
|
||||
'zh-CN': '在云端运行你喜爱的 ComfyUI 自定义节点'
|
||||
},
|
||||
'cloudNodes.hero.body': {
|
||||
en: 'Spin up workflows with hundreds of community-built nodes — detailers, ControlNet preprocessors, animation tools, and quality-of-life utilities — preinstalled on Comfy Cloud and ready to run on managed GPUs.',
|
||||
'zh-CN':
|
||||
'在 Comfy Cloud 托管 GPU 上即开即用,预装数百个社区节点——细节修复、ControlNet 预处理、动画工具与日常便利组件,应有尽有。'
|
||||
},
|
||||
'cloudNodes.section.heading': {
|
||||
en: 'Find a custom-node pack',
|
||||
'zh-CN': '查找自定义节点包'
|
||||
},
|
||||
'cloudNodes.search.placeholder': {
|
||||
en: 'Search packs or nodes',
|
||||
'zh-CN': '搜索节点包或节点名称'
|
||||
},
|
||||
'cloudNodes.sort.downloads': {
|
||||
en: 'Most installed',
|
||||
'zh-CN': '按安装量'
|
||||
},
|
||||
'cloudNodes.sort.mostNodes': {
|
||||
en: 'Most nodes',
|
||||
'zh-CN': '按节点数量'
|
||||
},
|
||||
'cloudNodes.sort.az': {
|
||||
en: 'A → Z',
|
||||
'zh-CN': '按名称 A → Z'
|
||||
},
|
||||
'cloudNodes.sort.recentlyUpdated': {
|
||||
en: 'Recently updated',
|
||||
'zh-CN': '最近更新'
|
||||
},
|
||||
'cloudNodes.search.label': {
|
||||
en: 'Search custom-node packs',
|
||||
'zh-CN': '搜索自定义节点包'
|
||||
},
|
||||
'cloudNodes.sort.label': {
|
||||
en: 'Sort packs',
|
||||
'zh-CN': '排序节点包'
|
||||
},
|
||||
'cloudNodes.list.ariaLabel': {
|
||||
en: 'Custom-node packs supported on Comfy Cloud',
|
||||
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
|
||||
},
|
||||
'cloudNodes.meta.title': {
|
||||
en: 'Custom-node packs on Comfy Cloud — supported by default',
|
||||
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
|
||||
},
|
||||
'cloudNodes.meta.description': {
|
||||
en: 'Browse hundreds of ComfyUI custom-node packs preinstalled on Comfy Cloud. Detailers, ControlNet preprocessors, animation tools, samplers, and more — search by pack or by node name.',
|
||||
'zh-CN':
|
||||
'浏览 Comfy Cloud 预装的数百个 ComfyUI 自定义节点包:细节修复、ControlNet 预处理、动画工具、采样器等——按节点包或节点名搜索。'
|
||||
},
|
||||
'cloudNodes.detail.metaTitle': {
|
||||
en: '{pack} on Comfy Cloud',
|
||||
'zh-CN': '{pack}(Comfy Cloud)'
|
||||
},
|
||||
'cloudNodes.detail.metaDescription': {
|
||||
en: '{pack} is preinstalled on Comfy Cloud — {nodeCount} nodes ready to run on managed GPUs. {description}',
|
||||
'zh-CN':
|
||||
'{pack} 已预装于 Comfy Cloud——{nodeCount} 个节点可在托管 GPU 上即时运行。{description}'
|
||||
},
|
||||
'cloudNodes.empty.heading': {
|
||||
en: 'No matching packs',
|
||||
'zh-CN': '未找到匹配的节点包'
|
||||
},
|
||||
'cloudNodes.empty.body': {
|
||||
en: 'Try a different search term or clear your filters.',
|
||||
'zh-CN': '试试其他关键词,或清空筛选条件。'
|
||||
},
|
||||
'cloudNodes.card.nodeCountOne': {
|
||||
en: '{count} node',
|
||||
'zh-CN': '{count} 个节点'
|
||||
},
|
||||
'cloudNodes.card.nodeCountOther': {
|
||||
en: '{count} nodes',
|
||||
'zh-CN': '{count} 个节点'
|
||||
},
|
||||
'cloudNodes.card.viewRepo': {
|
||||
en: 'View repository',
|
||||
'zh-CN': '查看仓库'
|
||||
},
|
||||
'cloudNodes.card.unavailableDescription': {
|
||||
en: 'Description unavailable.',
|
||||
'zh-CN': '暂无描述信息。'
|
||||
},
|
||||
'cloudNodes.card.nodesHeading': {
|
||||
en: 'Included nodes',
|
||||
'zh-CN': '包含节点'
|
||||
},
|
||||
'cloudNodes.detail.back': {
|
||||
en: 'Back to all packs',
|
||||
'zh-CN': '返回所有节点包'
|
||||
},
|
||||
'cloudNodes.detail.publisher': {
|
||||
en: 'Publisher',
|
||||
'zh-CN': '发布者'
|
||||
},
|
||||
'cloudNodes.detail.downloads': {
|
||||
en: 'Downloads',
|
||||
'zh-CN': '下载量'
|
||||
},
|
||||
'cloudNodes.detail.stars': {
|
||||
en: 'GitHub stars',
|
||||
'zh-CN': 'GitHub 星标'
|
||||
},
|
||||
'cloudNodes.detail.latestVersion': {
|
||||
en: 'Latest version',
|
||||
'zh-CN': '最新版本'
|
||||
},
|
||||
'cloudNodes.detail.license': {
|
||||
en: 'License',
|
||||
'zh-CN': '许可证'
|
||||
},
|
||||
'cloudNodes.detail.lastUpdated': {
|
||||
en: 'Last updated',
|
||||
'zh-CN': '最后更新'
|
||||
},
|
||||
'cloudNodes.detail.deprecated': {
|
||||
en: 'Deprecated',
|
||||
'zh-CN': '已弃用'
|
||||
},
|
||||
'cloudNodes.detail.experimental': {
|
||||
en: 'Experimental',
|
||||
'zh-CN': '实验性'
|
||||
},
|
||||
'cloudNodes.detail.nodesHeading': {
|
||||
en: 'Nodes in this pack',
|
||||
'zh-CN': '此节点包中的节点'
|
||||
},
|
||||
|
||||
// Cloud – ReasonSection
|
||||
'cloud.reason.heading': {
|
||||
en: 'Why\nprofessionals\nchoose ',
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import SiteNav from '../components/common/SiteNav.vue'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
interface Props {
|
||||
@@ -89,8 +90,9 @@ const websiteJsonLd = {
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
{gtmEnabled && (
|
||||
|
||||
42
apps/website/src/pages/cloud/supported-nodes.astro
Normal file
42
apps/website/src/pages/cloud/supported-nodes.astro
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Custom-node packs supported on Comfy Cloud',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'en')}
|
||||
description={t('cloudNodes.meta.description', 'en')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection client:visible />
|
||||
<PackGridSection packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
48
apps/website/src/pages/cloud/supported-nodes/AGENTS.md
Normal file
48
apps/website/src/pages/cloud/supported-nodes/AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Cloud Nodes Pages
|
||||
|
||||
Build-time catalog of custom-node packs preinstalled on Comfy Cloud. Index at `/cloud/supported-nodes`, per-pack details at `/cloud/supported-nodes/[pack]`, both also under `/zh-CN/`.
|
||||
|
||||
## Sources
|
||||
|
||||
- **Cloud `/api/object_info`** — authoritative list of nodes available on Comfy Cloud (auth: `WEBSITE_CLOUD_API_KEY`)
|
||||
- **ComfyUI Custom Node Registry** ([dashboard](https://registry.comfy.org), API at `https://api.comfy.org/nodes`) — public pack metadata (banner, icon, description, downloads, stars, license, version, repo, publisher)
|
||||
|
||||
The registry is the same one the in-app Manager dialog reads from. For reference and additional reading, see the existing client wrappers in `src/`:
|
||||
|
||||
- [`src/services/comfyRegistryService.ts`](../../../../../../src/services/comfyRegistryService.ts) — typed wrappers around `/nodes`, `/nodes/search`, `/nodes/{id}`, `/nodes/{id}/versions/{version}/comfy-nodes`, etc.
|
||||
- [`src/stores/comfyRegistryStore.ts`](../../../../../../src/stores/comfyRegistryStore.ts) — cached store + `getPacksByIds` batch helper
|
||||
- [`packages/registry-types/src/comfyRegistryTypes.ts`](../../../../../../packages/registry-types/src/comfyRegistryTypes.ts) — generated OpenAPI types
|
||||
- Public docs: <https://docs.comfy.org/registry>
|
||||
|
||||
## Build pipeline
|
||||
|
||||
| File | Role |
|
||||
| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [`pages/cloud/supported-nodes.astro`](./supported-nodes.astro) and [`[pack].astro`](./supported-nodes/%5Bpack%5D.astro) | Page shells (and `zh-CN` twins) |
|
||||
| [`utils/cloudNodes.build.ts`](../../../utils/cloudNodes.build.ts) | `loadPacksForBuild()` shared by index + detail routes |
|
||||
| [`utils/cloudNodes.ts`](../../../utils/cloudNodes.ts) | Cloud `object_info` fetcher with retry, sanitization, snapshot fallback |
|
||||
| [`utils/cloudNodes.registry.ts`](../../../utils/cloudNodes.registry.ts) | Registry enrichment (batches of 50, soft-fail) |
|
||||
| [`utils/cloudNodes.ci.ts`](../../../utils/cloudNodes.ci.ts) | GitHub Actions annotations + step summary |
|
||||
| [`utils/escapeJsonLd.ts`](../../../utils/escapeJsonLd.ts) | XSS-safe `<script type="application/ld+json">` serializer |
|
||||
| [`composables/useFilteredPacks.ts`](../../../composables/useFilteredPacks.ts) | Search + sort logic for the index page |
|
||||
| [`composables/useNodesByCategory.ts`](../../../composables/useNodesByCategory.ts) | Group nodes by category, alphabetized |
|
||||
| [`scripts/refresh-cloud-nodes-snapshot.ts`](../../../../scripts/refresh-cloud-nodes-snapshot.ts) | Manual `pnpm cloud-nodes:refresh-snapshot` |
|
||||
| [`packages/object-info-parser`](../../../../../../packages/object-info-parser) | Shared Zod schemas, classifier, `sanitizeUserContent` |
|
||||
|
||||
## Key invariants
|
||||
|
||||
- **Sanitization is mandatory.** The cloud `/api/object_info` endpoint mixes the calling user's uploaded files into combo input lists; `sanitizeUserContent()` strips them before the snapshot is written. Tests in [`packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts`](../../../../../../packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts).
|
||||
- **All remote data is Zod-validated.** `cloudNodes.schema.ts` validates the cloud envelope; `@comfyorg/object-info-parser` validates each node def; `cloudNodes.registry.ts` validates the registry response shape with a passthrough Zod schema (defense-in-depth on top of the generated OpenAPI types). The fetcher never trusts a network response.
|
||||
- **JSON-LD must be escaped.** Pack metadata is registry-controlled; route every `<script type="application/ld+json">` payload through `escapeJsonLd()`.
|
||||
- **Index and detail share one fetch.** Both call `loadPacksForBuild()` so the static routes can never diverge from the rendered list.
|
||||
- **No `PUBLIC_` prefix on env vars.** Astro inlines `PUBLIC_*` into the client bundle; build-time secrets never start with that prefix.
|
||||
|
||||
## Why `Pack` is a domain projection, not the raw registry `Node`
|
||||
|
||||
`apps/website/src/data/cloudNodes.ts` defines its own `Pack` and `PackNode` types instead of re-exporting `components['schemas']['Node']` from `@comfyorg/registry-types`. That is intentional:
|
||||
|
||||
- A `Pack` joins **two upstream sources** — the cloud `object_info` shape (which has `python_module`, `category`, `display_name`, etc.) and the registry `Node` shape (which has `banner_url`, `downloads`, `github_stars`, etc.). Neither shape on its own captures both.
|
||||
- A `Pack` is **post-sanitization**. Combo input lists, user-uploaded filenames, and any other build-time-stripped data are guaranteed gone by the time the type appears. The raw `Node` carries fields we never expose.
|
||||
- A `Pack` adds **safe-URL invariants** (banner / icon / repo all flow through `safeExternalUrl()` before becoming part of the type), which the registry's optional `string` fields don't encode.
|
||||
|
||||
The `Pack`/`PackNode` types live next to the snapshot they describe; the validated build-time projections feed Astro's static routes directly. The OpenAPI-generated `Node` type is still consumed inside `cloudNodes.registry.ts` as `RegistryPack` for the upstream fetch.
|
||||
63
apps/website/src/pages/cloud/supported-nodes/[pack].astro
Normal file
63
apps/website/src/pages/cloud/supported-nodes/[pack].astro
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
|
||||
import type { Pack } from '../../../data/cloudNodes'
|
||||
|
||||
import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
return packs.map((pack) => ({
|
||||
params: { pack: pack.id },
|
||||
props: { pack }
|
||||
}))
|
||||
}
|
||||
|
||||
const { pack } = Astro.props as { pack: Pack }
|
||||
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'en')
|
||||
const description = pack.description || fallbackDescription
|
||||
const title = t('cloudNodes.detail.metaTitle', 'en').replace(
|
||||
'{pack}',
|
||||
pack.displayName
|
||||
)
|
||||
const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
|
||||
.replace('{pack}', pack.displayName)
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} />
|
||||
</BaseLayout>
|
||||
42
apps/website/src/pages/zh-CN/cloud/supported-nodes.astro
Normal file
42
apps/website/src/pages/zh-CN/cloud/supported-nodes.astro
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Comfy Cloud 支持的自定义节点包',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'zh-CN')}
|
||||
description={t('cloudNodes.meta.description', 'zh-CN')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection locale="zh-CN" client:visible />
|
||||
<PackGridSection locale="zh-CN" packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
|
||||
import type { Pack } from '../../../../data/cloudNodes'
|
||||
|
||||
import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
return packs.map((pack) => ({
|
||||
params: { pack: pack.id },
|
||||
props: { pack }
|
||||
}))
|
||||
}
|
||||
|
||||
const { pack } = Astro.props as { pack: Pack }
|
||||
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'zh-CN')
|
||||
const description = pack.description || fallbackDescription
|
||||
const title = t('cloudNodes.detail.metaTitle', 'zh-CN').replace(
|
||||
'{pack}',
|
||||
pack.displayName
|
||||
)
|
||||
const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
|
||||
.replace('{pack}', pack.displayName)
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
27
apps/website/src/utils/cloudNodes.build.ts
Normal file
27
apps/website/src/utils/cloudNodes.build.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Pack } from '../data/cloudNodes'
|
||||
|
||||
import { fetchCloudNodesForBuild } from './cloudNodes'
|
||||
import { reportCloudNodesOutcome } from './cloudNodes.ci'
|
||||
|
||||
/**
|
||||
* Resolve the list of packs to render at build time.
|
||||
*
|
||||
* Used by both the index page and the per-pack detail pages so that the
|
||||
* static index and the static detail routes are always derived from the
|
||||
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
|
||||
* `inflight` promise, so repeated calls in the same build process share a
|
||||
* single network round-trip and the same outcome.
|
||||
*/
|
||||
export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
reportCloudNodesOutcome(outcome)
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot.'
|
||||
)
|
||||
}
|
||||
|
||||
return outcome.snapshot.packs
|
||||
}
|
||||
121
apps/website/src/utils/cloudNodes.ci.test.ts
Normal file
121
apps/website/src/utils/cloudNodes.ci.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
|
||||
import {
|
||||
reportCloudNodesOutcome,
|
||||
resetCloudNodesReporterForTests
|
||||
} from './cloudNodes.ci'
|
||||
|
||||
function baseSnapshot(): NodesSnapshot {
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
packs: [
|
||||
{
|
||||
id: 'pack-id',
|
||||
displayName: 'Pack',
|
||||
nodes: [
|
||||
{
|
||||
name: 'NodeClass',
|
||||
displayName: 'Node Class',
|
||||
category: 'misc'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function freshOutcome(droppedCount = 0): FetchOutcome {
|
||||
return {
|
||||
status: 'fresh',
|
||||
droppedCount,
|
||||
droppedNodes:
|
||||
droppedCount === 0
|
||||
? []
|
||||
: [{ name: 'BadNode', reason: 'invalid display_name' }],
|
||||
snapshot: baseSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
describe('reportCloudNodesOutcome', () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>
|
||||
let summaryDir: string
|
||||
let summaryPath: string
|
||||
const originalSummary = process.env.GITHUB_STEP_SUMMARY
|
||||
|
||||
beforeEach(() => {
|
||||
resetCloudNodesReporterForTests()
|
||||
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
summaryDir = mkdtempSync(join(tmpdir(), 'cloud-nodes-summary-'))
|
||||
summaryPath = join(summaryDir, 'summary.md')
|
||||
writeFileSync(summaryPath, '')
|
||||
process.env.GITHUB_STEP_SUMMARY = summaryPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore()
|
||||
rmSync(summaryDir, { recursive: true, force: true })
|
||||
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
|
||||
else process.env.GITHUB_STEP_SUMMARY = originalSummary
|
||||
})
|
||||
|
||||
it('emits nothing on a clean fresh outcome', () => {
|
||||
reportCloudNodesOutcome(freshOutcome(0))
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
|
||||
})
|
||||
|
||||
it('emits exactly one set of annotations across repeated calls', () => {
|
||||
reportCloudNodesOutcome(freshOutcome(1))
|
||||
reportCloudNodesOutcome(freshOutcome(1))
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1)
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain(
|
||||
'::warning title=Cloud nodes: dropped 1 invalid'
|
||||
)
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
|
||||
})
|
||||
|
||||
it('emits ::error for auth failures in a stale outcome', () => {
|
||||
reportCloudNodesOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 401 Unauthorized',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain(
|
||||
'::error title=Cloud nodes authentication failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits ::warning for missing-env stale outcomes', () => {
|
||||
reportCloudNodesOutcome({
|
||||
status: 'stale',
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain('::warning title=Cloud nodes integration')
|
||||
})
|
||||
|
||||
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
|
||||
reportCloudNodesOutcome({
|
||||
status: 'failed',
|
||||
reason: 'HTTP 500 Server Error'
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain('::error title=Cloud nodes fetch failed')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
|
||||
})
|
||||
|
||||
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
|
||||
delete process.env.GITHUB_STEP_SUMMARY
|
||||
expect(() => reportCloudNodesOutcome(freshOutcome(0))).not.toThrow()
|
||||
})
|
||||
})
|
||||
115
apps/website/src/utils/cloudNodes.ci.ts
Normal file
115
apps/website/src/utils/cloudNodes.ci.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
|
||||
let hasReported = false
|
||||
|
||||
export function resetCloudNodesReporterForTests(): void {
|
||||
hasReported = false
|
||||
}
|
||||
|
||||
export function reportCloudNodesOutcome(outcome: FetchOutcome): void {
|
||||
if (hasReported) return
|
||||
hasReported = true
|
||||
|
||||
const lines = buildAnnotations(outcome)
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath) {
|
||||
try {
|
||||
appendFileSync(summaryPath, buildStepSummary(outcome))
|
||||
} catch {
|
||||
// Writing the summary is best-effort; do not fail the build if the
|
||||
// runner's summary file is unavailable (e.g. local dev).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
if (outcome.status === 'fresh') {
|
||||
if (outcome.droppedCount === 0) return []
|
||||
const nodeCount = outcome.droppedCount === 1 ? 'node' : 'nodes'
|
||||
const drops = outcome.droppedNodes
|
||||
.map((d) => {
|
||||
const name = escapeAnnotation(d.name ? `"${d.name}"` : '(unnamed)')
|
||||
const reason = escapeAnnotation(d.reason)
|
||||
return ` - ${name}: ${reason}`
|
||||
})
|
||||
.join('%0A')
|
||||
return [
|
||||
`::warning title=Cloud nodes: dropped ${outcome.droppedCount} invalid ${nodeCount}::Dropped nodes:%0A${drops}%0A%0AAction items:%0A 1. Verify node definitions returned by cloud /api/object_info.%0A 2. If a valid node shape changed, update @comfyorg/object-info-parser/src/schemas/nodeDefSchema.ts and add tests.%0A 3. Dropped nodes are not shown on /cloud/supported-nodes until fixed.`
|
||||
]
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale') {
|
||||
return [staleAnnotation(outcome.reason)]
|
||||
}
|
||||
|
||||
return [
|
||||
`::error title=Cloud nodes fetch failed and no snapshot is available::Cannot build cloud nodes page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot\` locally with a valid WEBSITE_CLOUD_API_KEY.%0A 2. Commit apps/website/src/data/cloud-nodes.snapshot.json.%0A 3. Push and re-run CI.`
|
||||
]
|
||||
}
|
||||
|
||||
function staleAnnotation(reason: string): string {
|
||||
const escaped = escapeAnnotation(reason)
|
||||
if (reason.startsWith('missing ')) {
|
||||
return `::warning title=Cloud nodes integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_CLOUD_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
|
||||
}
|
||||
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
|
||||
return `::error title=Cloud nodes authentication failed::${escaped}. The WEBSITE_CLOUD_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Verify the Cloud API key is active and scoped for /api/object_info.%0A 2. Update the \`WEBSITE_CLOUD_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
|
||||
}
|
||||
if (reason.startsWith('envelope')) {
|
||||
return `::error title=Cloud nodes schema mismatch::${escaped}. The Cloud API contract likely changed. Build continues with the snapshot, but future updates will fail until schema is fixed.%0A%0AAction items:%0A 1. Check cloud/services/ingest/openapi.yaml for /api/object_info changes.%0A 2. Update apps/website/src/utils/cloudNodes.schema.ts and @comfyorg/object-info-parser schema as needed.`
|
||||
}
|
||||
return `::warning title=Cloud nodes API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check cloud service health.%0A 2. Re-run this workflow once cloud.comfy.org is healthy.`
|
||||
}
|
||||
|
||||
function escapeAnnotation(value: string): string {
|
||||
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
|
||||
}
|
||||
|
||||
function buildStepSummary(outcome: FetchOutcome): string {
|
||||
const header = '## ☁️ Cloud nodes\n'
|
||||
const rows: Array<[string, string]> = []
|
||||
|
||||
if (outcome.status === 'fresh') {
|
||||
rows.push(['Status', '✅ Fresh (fetched from Cloud API)'])
|
||||
rows.push(['Packs', String(outcome.snapshot.packs.length)])
|
||||
rows.push([
|
||||
'Nodes',
|
||||
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
|
||||
])
|
||||
rows.push(['Dropped', String(outcome.droppedCount)])
|
||||
} else if (outcome.status === 'stale') {
|
||||
rows.push(['Status', '⚠️ Stale (using snapshot — Cloud fetch failed)'])
|
||||
rows.push(['Packs', String(outcome.snapshot.packs.length)])
|
||||
rows.push([
|
||||
'Nodes',
|
||||
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
|
||||
])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
|
||||
} else {
|
||||
rows.push(['Status', '❌ Failed (no snapshot available)'])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
}
|
||||
|
||||
const table =
|
||||
'| | |\n|---|---|\n' +
|
||||
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
|
||||
'\n'
|
||||
|
||||
return `${header}${table}\n`
|
||||
}
|
||||
|
||||
function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
}
|
||||
77
apps/website/src/utils/cloudNodes.registry.test.ts
Normal file
77
apps/website/src/utils/cloudNodes.registry.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_REGISTRY_BASE_URL,
|
||||
fetchRegistryPacks
|
||||
} from './cloudNodes.registry'
|
||||
|
||||
function jsonResponse(
|
||||
body: unknown,
|
||||
init: Partial<ResponseInit> = {}
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchRegistryPacks', () => {
|
||||
it('requests node ids in batches of 50', async () => {
|
||||
const ids = Array.from({ length: 120 }, (_, i) => `pack-${i}`)
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
const batchIds = url.searchParams.getAll('node_id')
|
||||
return jsonResponse({
|
||||
nodes: batchIds.map((id) => ({ id, name: id })),
|
||||
total: batchIds.length,
|
||||
page: 1,
|
||||
limit: 50
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacks(ids, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3)
|
||||
expect(result.size).toBe(120)
|
||||
const firstCallUrl = new URL(String(fetchImpl.mock.calls[0]?.[0]))
|
||||
expect(firstCallUrl.origin).toBe(DEFAULT_REGISTRY_BASE_URL)
|
||||
expect(firstCallUrl.pathname).toBe('/nodes')
|
||||
expect(firstCallUrl.searchParams.getAll('node_id')).toHaveLength(50)
|
||||
})
|
||||
|
||||
it('retries a failed batch once and then succeeds', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
nodes: [{ id: 'pack-1', name: 'Pack One' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchRegistryPacks(['pack-1'], {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
expect(result.get('pack-1')?.name).toBe('Pack One')
|
||||
})
|
||||
|
||||
it('returns an empty map when all batches fail (soft failure)', async () => {
|
||||
const ids = ['pack-a', 'pack-b']
|
||||
const fetchImpl = vi.fn(async () => new Response('{}', { status: 500 }))
|
||||
|
||||
const result = await fetchRegistryPacks(ids, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
189
apps/website/src/utils/cloudNodes.registry.ts
Normal file
189
apps/website/src/utils/cloudNodes.registry.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { components } from '@comfyorg/registry-types'
|
||||
|
||||
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 5_000
|
||||
const BATCH_SIZE = 50
|
||||
|
||||
export type RegistryPack = components['schemas']['Node']
|
||||
|
||||
const RegistryPackSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
banner_url: z.string().optional(),
|
||||
repository: z.string().optional(),
|
||||
license: z.string().optional(),
|
||||
downloads: z.number().optional(),
|
||||
github_stars: z.number().optional(),
|
||||
created_at: z.string().optional(),
|
||||
supported_os: z.array(z.string()).optional(),
|
||||
supported_accelerators: z.array(z.string()).optional(),
|
||||
publisher: z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
latest_version: z
|
||||
.object({
|
||||
version: z.string().optional(),
|
||||
createdAt: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RegistryListResponseSchema = z
|
||||
.object({
|
||||
nodes: z.array(RegistryPackSchema)
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
interface FetchRegistryOptions {
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export async function fetchRegistryPacks(
|
||||
packIds: readonly string[],
|
||||
options: FetchRegistryOptions = {}
|
||||
): Promise<Map<string, RegistryPack | null>> {
|
||||
const uniquePackIds = [...new Set(packIds.filter((id) => id.length > 0))]
|
||||
if (uniquePackIds.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
|
||||
const timeoutMs = clampTimeoutMs(options.timeoutMs)
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
|
||||
const batches = chunk(uniquePackIds, BATCH_SIZE)
|
||||
const resolved = new Map<string, RegistryPack | null>()
|
||||
let successCount = 0
|
||||
|
||||
for (const batch of batches) {
|
||||
const nodes = await fetchBatchWithRetry(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
batch,
|
||||
timeoutMs
|
||||
)
|
||||
if (!nodes) {
|
||||
for (const packId of batch) {
|
||||
resolved.set(packId, null)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
successCount += 1
|
||||
const nodesById = new Map(
|
||||
nodes
|
||||
.map((node) => [node.id, node] as const)
|
||||
.filter(([id]) => typeof id === 'string' && id.length > 0)
|
||||
)
|
||||
|
||||
for (const packId of batch) {
|
||||
resolved.set(packId, nodesById.get(packId) ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function fetchBatchWithRetry(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packIds: readonly string[],
|
||||
timeoutMs: number
|
||||
): Promise<RegistryPack[] | null> {
|
||||
const firstAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
|
||||
if (firstAttempt.kind === 'ok') {
|
||||
return firstAttempt.nodes
|
||||
}
|
||||
if (!firstAttempt.retryable) {
|
||||
return null
|
||||
}
|
||||
|
||||
const secondAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
|
||||
if (secondAttempt.kind === 'ok') {
|
||||
return secondAttempt.nodes
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type BatchResponse =
|
||||
| { kind: 'ok'; nodes: RegistryPack[] }
|
||||
| { kind: 'err'; retryable: boolean }
|
||||
|
||||
async function fetchBatch(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packIds: readonly string[],
|
||||
timeoutMs: number
|
||||
): Promise<BatchResponse> {
|
||||
const params = new URLSearchParams()
|
||||
for (const packId of packIds) {
|
||||
params.append('node_id', packId)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const res = await fetchImpl(`${baseUrl}/nodes?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
kind: 'err',
|
||||
retryable: res.status === 429 || (res.status >= 500 && res.status < 600)
|
||||
}
|
||||
}
|
||||
|
||||
const rawBody: unknown = await res.json()
|
||||
const parsed = RegistryListResponseSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return { kind: 'err', retryable: false }
|
||||
}
|
||||
return { kind: 'ok', nodes: parsed.data.nodes as RegistryPack[] }
|
||||
} catch {
|
||||
return { kind: 'err', retryable: true }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function chunk<T>(values: readonly T[], size: number): T[][] {
|
||||
const chunks: T[][] = []
|
||||
for (let i = 0; i < values.length; i += size) {
|
||||
chunks.push(values.slice(i, i + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function clampTimeoutMs(candidate: number | undefined): number {
|
||||
if (
|
||||
typeof candidate !== 'number' ||
|
||||
!Number.isFinite(candidate) ||
|
||||
candidate <= 0
|
||||
) {
|
||||
return DEFAULT_TIMEOUT_MS
|
||||
}
|
||||
return Math.floor(candidate)
|
||||
}
|
||||
3
apps/website/src/utils/cloudNodes.schema.ts
Normal file
3
apps/website/src/utils/cloudNodes.schema.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CloudNodesEnvelopeSchema = z.record(z.unknown())
|
||||
292
apps/website/src/utils/cloudNodes.test.ts
Normal file
292
apps/website/src/utils/cloudNodes.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
|
||||
|
||||
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
|
||||
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./cloudNodes.registry', () => ({
|
||||
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
|
||||
fetchRegistryPacks: fetchRegistryPacksMock
|
||||
}))
|
||||
|
||||
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof ObjectInfoParser
|
||||
return {
|
||||
...actual,
|
||||
sanitizeUserContent: (
|
||||
defs: Parameters<typeof actual.sanitizeUserContent>[0]
|
||||
) => {
|
||||
sanitizeCallSpy(defs)
|
||||
return actual.sanitizeUserContent(defs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
fetchCloudNodesForBuild,
|
||||
resetCloudNodesFetcherForTests
|
||||
} from './cloudNodes'
|
||||
|
||||
const BASE_URL = 'https://cloud.test'
|
||||
const KEY = 'cloud-secret'
|
||||
|
||||
function validNode(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
name: 'ImpactNode',
|
||||
display_name: 'Impact Node',
|
||||
description: 'Node description',
|
||||
category: 'impact/testing',
|
||||
output_node: false,
|
||||
python_module: 'custom_nodes.comfyui-impact-pack.nodes',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
function makeSnapshot(packCount = 1): NodesSnapshot {
|
||||
const packs = Array.from({ length: packCount }, (_, i) => ({
|
||||
id: `snapshot-pack-${i}`,
|
||||
displayName: `Snapshot Pack ${i}`,
|
||||
nodes: [
|
||||
{
|
||||
name: `SnapshotNode${i}`,
|
||||
displayName: `Snapshot Node ${i}`,
|
||||
category: 'snapshot'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
return {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs
|
||||
}
|
||||
}
|
||||
|
||||
function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cloud-nodes-test-'))
|
||||
const file = join(dir, 'cloud-nodes.snapshot.json')
|
||||
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
||||
return pathToFileURL(file)
|
||||
}
|
||||
|
||||
describe('fetchCloudNodesForBuild', () => {
|
||||
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
|
||||
|
||||
beforeEach(() => {
|
||||
resetCloudNodesFetcherForTests()
|
||||
fetchRegistryPacksMock.mockReset()
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
sanitizeCallSpy.mockReset()
|
||||
delete process.env.WEBSITE_CLOUD_API_KEY
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
|
||||
})
|
||||
|
||||
it('returns fresh when API succeeds', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'comfyui-impact-pack',
|
||||
{
|
||||
id: 'comfyui-impact-pack',
|
||||
name: 'ComfyUI Impact Pack',
|
||||
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
expect(outcome.snapshot.packs).toHaveLength(1)
|
||||
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
||||
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops invalid nodes individually and keeps valid nodes', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
ValidNode: validNode({ name: 'ValidNode' }),
|
||||
BrokenNode: {
|
||||
name: 'BrokenNode',
|
||||
python_module: 'custom_nodes.some-pack'
|
||||
}
|
||||
})
|
||||
)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(1)
|
||||
expect(outcome.droppedNodes[0]?.name).toBe('BrokenNode')
|
||||
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('applies sanitizer before grouping', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
LoadImage: validNode({
|
||||
name: 'LoadImage',
|
||||
python_module: 'nodes',
|
||||
input: {
|
||||
required: {
|
||||
image: [['private.png', 'public.webp'], {}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
ImpactNode: validNode({
|
||||
input: {
|
||||
required: {
|
||||
choice: [['safe', 'movie.mov'], {}]
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(sanitizeCallSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns stale with missing env when snapshot is present', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const snapshotUrl = withSnapshotDir(snapshot)
|
||||
const fetchImpl = vi.fn()
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^missing /)
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns failed when env and snapshot are missing', async () => {
|
||||
const snapshotUrl = withSnapshotDir(null)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('failed')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not retry on HTTP 401', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^HTTP 401/)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('retries 5xx then falls back to snapshot', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
|
||||
const sleep = vi.fn(async () => undefined)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
retryDelaysMs: [1, 1, 1],
|
||||
sleep,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4)
|
||||
expect(sleep).toHaveBeenCalledTimes(3)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('falls back to snapshot on envelope schema mismatch', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response(['unexpected-array-envelope']))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^envelope schema/)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('memoizes within a single process', async () => {
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const opts = {
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
}
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
fetchCloudNodesForBuild(opts),
|
||||
fetchCloudNodesForBuild(opts)
|
||||
])
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns fresh even when registry enrichment fails', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
})
|
||||
})
|
||||
332
apps/website/src/utils/cloudNodes.ts
Normal file
332
apps/website/src/utils/cloudNodes.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import {
|
||||
groupNodesByPack,
|
||||
sanitizeUserContent,
|
||||
validateComfyNodeDef
|
||||
} from '@comfyorg/object-info-parser'
|
||||
|
||||
import type { RegistryPack } from './cloudNodes.registry'
|
||||
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
|
||||
|
||||
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
|
||||
import { isNodesSnapshot } from '../data/cloudNodes'
|
||||
import { fetchRegistryPacks } from './cloudNodes.registry'
|
||||
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedNode {
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type FetchOutcome =
|
||||
| {
|
||||
status: 'fresh'
|
||||
snapshot: NodesSnapshot
|
||||
droppedCount: number
|
||||
droppedNodes: DroppedNode[]
|
||||
}
|
||||
| { status: 'stale'; snapshot: NodesSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
|
||||
interface FetchCloudNodesOptions {
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
retryDelaysMs?: readonly number[]
|
||||
fetchImpl?: typeof fetch
|
||||
snapshotUrl?: URL
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
let inflight: Promise<FetchOutcome> | undefined
|
||||
|
||||
export function resetCloudNodesFetcherForTests(): void {
|
||||
inflight = undefined
|
||||
}
|
||||
|
||||
export function fetchCloudNodesForBuild(
|
||||
options: FetchCloudNodesOptions = {}
|
||||
): Promise<FetchOutcome> {
|
||||
inflight ??= doFetchCloudNodesForBuild(options)
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function doFetchCloudNodesForBuild(
|
||||
options: FetchCloudNodesOptions
|
||||
): Promise<FetchOutcome> {
|
||||
const apiKey = options.apiKey ?? process.env.WEBSITE_CLOUD_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
return fallback('missing WEBSITE_CLOUD_API_KEY', options.snapshotUrl)
|
||||
}
|
||||
|
||||
const result = await tryFetchAndParse(apiKey, options)
|
||||
if (result.kind === 'ok') {
|
||||
return {
|
||||
status: 'fresh',
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
packs: result.packs
|
||||
},
|
||||
droppedCount: result.droppedNodes.length,
|
||||
droppedNodes: result.droppedNodes
|
||||
}
|
||||
}
|
||||
|
||||
return fallback(result.reason, options.snapshotUrl)
|
||||
}
|
||||
|
||||
async function fallback(
|
||||
reason: string,
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<FetchOutcome> {
|
||||
const snapshot = await readSnapshot(snapshotUrl)
|
||||
if (snapshot) return { status: 'stale', snapshot, reason }
|
||||
return { status: 'failed', reason }
|
||||
}
|
||||
|
||||
interface FetchOk {
|
||||
kind: 'ok'
|
||||
packs: Pack[]
|
||||
droppedNodes: DroppedNode[]
|
||||
}
|
||||
|
||||
interface FetchErr {
|
||||
kind: 'err'
|
||||
reason: string
|
||||
}
|
||||
|
||||
async function tryFetchAndParse(
|
||||
apiKey: string,
|
||||
options: FetchCloudNodesOptions
|
||||
): Promise<FetchOk | FetchErr> {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const sleep = options.sleep ?? defaultSleep
|
||||
|
||||
const url = `${baseUrl}/api/object_info`
|
||||
|
||||
let lastReason = 'unknown error'
|
||||
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
|
||||
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
|
||||
|
||||
const response = await callOnce(fetchImpl, url, apiKey, timeoutMs)
|
||||
if (response.kind === 'err') {
|
||||
lastReason = response.reason
|
||||
if (!response.retryable) return response
|
||||
continue
|
||||
}
|
||||
|
||||
const envelope = CloudNodesEnvelopeSchema.safeParse(response.body)
|
||||
if (!envelope.success) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `envelope schema validation failed: ${envelope.error.issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')}`
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(envelope.data).length === 0) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason:
|
||||
'envelope schema validation failed: <root>: expected non-empty object'
|
||||
}
|
||||
}
|
||||
|
||||
return parseCloudNodes(envelope.data, options)
|
||||
}
|
||||
|
||||
return { kind: 'err', reason: lastReason }
|
||||
}
|
||||
|
||||
type CallResponse =
|
||||
| { kind: 'ok'; body: unknown }
|
||||
| { kind: 'err'; reason: string; retryable: boolean }
|
||||
|
||||
async function callOnce(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
apiKey: string,
|
||||
timeoutMs: number
|
||||
): Promise<CallResponse> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-API-Key': apiKey
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
if (res.ok) {
|
||||
return { kind: 'ok', body: await res.json() }
|
||||
}
|
||||
const retryable =
|
||||
res.status === 429 || (res.status >= 500 && res.status < 600)
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
|
||||
retryable
|
||||
}
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? `network error: ${error.message}`
|
||||
: 'network error'
|
||||
return { kind: 'err', reason, retryable: true }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function parseCloudNodes(
|
||||
envelope: Record<string, unknown>,
|
||||
options: FetchCloudNodesOptions
|
||||
): Promise<FetchOk> {
|
||||
const validDefs: Record<string, ReturnType<typeof validateComfyNodeDef>> = {}
|
||||
const droppedNodes: DroppedNode[] = []
|
||||
|
||||
for (const [name, rawDef] of Object.entries(envelope)) {
|
||||
let validationError = 'unknown validation error'
|
||||
const parsed = validateComfyNodeDef(rawDef, (error) => {
|
||||
validationError = error
|
||||
})
|
||||
if (!parsed) {
|
||||
droppedNodes.push({ name, reason: validationError })
|
||||
continue
|
||||
}
|
||||
validDefs[name] = parsed
|
||||
}
|
||||
|
||||
const sanitizedDefs = sanitizeUserContent(
|
||||
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
|
||||
)
|
||||
const grouped = groupNodesByPack(sanitizedDefs)
|
||||
|
||||
let registryMap = new Map<string, RegistryPack | null>()
|
||||
try {
|
||||
registryMap = await fetchRegistryPacks(
|
||||
grouped.map((pack) => pack.id),
|
||||
{ fetchImpl: options.fetchImpl }
|
||||
)
|
||||
} catch {
|
||||
registryMap = new Map()
|
||||
}
|
||||
|
||||
const packs = grouped.map((pack) =>
|
||||
toDomainPack(
|
||||
pack.id,
|
||||
pack.displayName,
|
||||
pack.nodes,
|
||||
registryMap.get(pack.id)
|
||||
)
|
||||
)
|
||||
|
||||
return { kind: 'ok', packs, droppedNodes }
|
||||
}
|
||||
|
||||
function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
|
||||
if (!url.host) return undefined
|
||||
return url.toString()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function toDomainPack(
|
||||
packId: string,
|
||||
fallbackDisplayName: string,
|
||||
nodes: Array<{
|
||||
className: string
|
||||
def: {
|
||||
display_name: string
|
||||
category: string
|
||||
description: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
}>,
|
||||
registryPack: RegistryPack | null | undefined
|
||||
): Pack {
|
||||
return {
|
||||
id: packId,
|
||||
registryId: registryPack?.id,
|
||||
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
|
||||
description: registryPack?.description?.trim() || undefined,
|
||||
bannerUrl: safeExternalUrl(registryPack?.banner_url),
|
||||
iconUrl: safeExternalUrl(registryPack?.icon),
|
||||
repoUrl: safeExternalUrl(registryPack?.repository),
|
||||
publisher: registryPack?.publisher?.id
|
||||
? {
|
||||
id: registryPack.publisher.id,
|
||||
name: registryPack.publisher.name
|
||||
}
|
||||
: undefined,
|
||||
downloads: registryPack?.downloads,
|
||||
githubStars: registryPack?.github_stars,
|
||||
latestVersion: registryPack?.latest_version?.version,
|
||||
license: registryPack?.license,
|
||||
lastUpdated:
|
||||
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
|
||||
supportedOs: registryPack?.supported_os,
|
||||
supportedAccelerators: registryPack?.supported_accelerators,
|
||||
nodes: nodes
|
||||
.map((node) => toDomainNode(node.className, node.def))
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
}
|
||||
|
||||
function toDomainNode(
|
||||
className: string,
|
||||
def: {
|
||||
display_name: string
|
||||
category: string
|
||||
description: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
): PackNode {
|
||||
return {
|
||||
name: className,
|
||||
displayName: def.display_name,
|
||||
category: def.category,
|
||||
description: def.description || undefined,
|
||||
deprecated: def.deprecated,
|
||||
experimental: def.experimental
|
||||
}
|
||||
}
|
||||
|
||||
async function readSnapshot(
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<NodesSnapshot | null> {
|
||||
if (!snapshotUrl) {
|
||||
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
|
||||
}
|
||||
try {
|
||||
const text = await readFile(snapshotUrl, 'utf8')
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (isNodesSnapshot(parsed)) return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
24
apps/website/src/utils/escapeJsonLd.ts
Normal file
24
apps/website/src/utils/escapeJsonLd.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Serialize an object to a JSON string safe to embed inside an inline
|
||||
* `<script type="application/ld+json">` tag.
|
||||
*
|
||||
* Pack metadata flows in from the public ComfyUI registry, where any user
|
||||
* can publish a pack with a `displayName`, `description`, or
|
||||
* `publisher.name` that contains `</script>` or `<!--`. Without escaping,
|
||||
* those sequences would close the surrounding `<script>` tag and allow
|
||||
* stored XSS.
|
||||
*
|
||||
* Escapes:
|
||||
* - Every `<` (covers `</script>`, `<script`, `<!--`)
|
||||
* - U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR (which are
|
||||
* line terminators in HTML script context but valid characters
|
||||
* elsewhere in JSON, so `JSON.stringify` does not escape them)
|
||||
*
|
||||
* Reference: https://v8.dev/features/subsume-json
|
||||
*/
|
||||
export function escapeJsonLd(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029')
|
||||
}
|
||||
@@ -60,6 +60,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
|
||||
30
packages/object-info-parser/package.json
Normal file
30
packages/object-info-parser/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@comfyorg/object-info-parser",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared object_info schemas and helpers",
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
"zod-validation-error": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:util"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { groupNodesByPack } from '../helpers/groupNodesByPack'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
|
||||
function makeNodeDef(
|
||||
name: string,
|
||||
pythonModule: string,
|
||||
displayName = name
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: displayName,
|
||||
description: '',
|
||||
category: 'test',
|
||||
output_node: false,
|
||||
python_module: pythonModule
|
||||
}
|
||||
}
|
||||
|
||||
describe('groupNodesByPack', () => {
|
||||
it('excludes core nodes and groups custom nodes by pack id', () => {
|
||||
const grouped = groupNodesByPack({
|
||||
CoreNode: makeNodeDef('CoreNode', 'nodes'),
|
||||
ImpactA: makeNodeDef(
|
||||
'ImpactA',
|
||||
'custom_nodes.comfyui-impact-pack.nodes',
|
||||
'Impact A'
|
||||
),
|
||||
ImpactB: makeNodeDef(
|
||||
'ImpactB',
|
||||
'custom_nodes.comfyui-impact-pack.nodes',
|
||||
'Impact B'
|
||||
),
|
||||
AuxNode: makeNodeDef(
|
||||
'AuxNode',
|
||||
'custom_nodes.comfyui-controlnet-aux.nodes',
|
||||
'Aux Node'
|
||||
)
|
||||
})
|
||||
|
||||
expect(grouped).toHaveLength(2)
|
||||
expect(grouped.map((pack) => pack.id)).toEqual([
|
||||
'comfyui-controlnet-aux',
|
||||
'comfyui-impact-pack'
|
||||
])
|
||||
expect(
|
||||
grouped.find((pack) => pack.id === 'comfyui-impact-pack')?.nodes
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { validateComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { validateComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
|
||||
const EXAMPLE_NODE_DEF: ComfyNodeDef = {
|
||||
input: {
|
||||
@@ -23,29 +23,26 @@ const EXAMPLE_NODE_DEF: ComfyNodeDef = {
|
||||
}
|
||||
|
||||
describe('validateNodeDef', () => {
|
||||
it('Should accept a valid node definition', async () => {
|
||||
it('accepts a valid node definition', () => {
|
||||
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
|
||||
})
|
||||
|
||||
describe.each([
|
||||
[{ ckpt_name: ['foo', { default: 1 }] }, ['foo', { default: 1 }]],
|
||||
// Extra input spec should be preserved
|
||||
[{ ckpt_name: ['foo', { bar: 1 }] }, ['foo', { bar: 1 }]],
|
||||
[{ ckpt_name: ['INT', { bar: 1 }] }, ['INT', { bar: 1 }]],
|
||||
[{ ckpt_name: [[1, 2, 3], { bar: 1 }] }, [[1, 2, 3], { bar: 1 }]]
|
||||
])(
|
||||
'validateComfyNodeDef with various input spec formats',
|
||||
(inputSpec, expected) => {
|
||||
it(`should accept input spec format: ${JSON.stringify(inputSpec)}`, async () => {
|
||||
expect(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
validateComfyNodeDef({
|
||||
...EXAMPLE_NODE_DEF,
|
||||
input: {
|
||||
required: inputSpec
|
||||
}
|
||||
}).input.required.ckpt_name
|
||||
).toEqual(expected)
|
||||
it(`accepts input spec format: ${JSON.stringify(inputSpec)}`, () => {
|
||||
const parsed = validateComfyNodeDef({
|
||||
...EXAMPLE_NODE_DEF,
|
||||
input: {
|
||||
required: inputSpec
|
||||
}
|
||||
})
|
||||
expect(parsed?.input?.required?.ckpt_name).toEqual(expected)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -54,21 +51,17 @@ describe('validateNodeDef', () => {
|
||||
[{ ckpt_name: { 'model1.safetensors': 'foo' } }],
|
||||
[{ ckpt_name: ['*', ''] }],
|
||||
[{ ckpt_name: ['foo', { default: 1 }, { default: 2 }] }],
|
||||
// Should reject incorrect default value type.
|
||||
[{ ckpt_name: ['INT', { default: '124' }] }]
|
||||
])(
|
||||
'validateComfyNodeDef rejects with various input spec formats',
|
||||
(inputSpec) => {
|
||||
it(`should accept input spec format: ${JSON.stringify(inputSpec)}`, async () => {
|
||||
expect(
|
||||
validateComfyNodeDef({
|
||||
...EXAMPLE_NODE_DEF,
|
||||
input: {
|
||||
required: inputSpec
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
}
|
||||
)
|
||||
])('validateComfyNodeDef rejects invalid input specs', (inputSpec) => {
|
||||
it(`rejects input spec format: ${JSON.stringify(inputSpec)}`, () => {
|
||||
expect(
|
||||
validateComfyNodeDef({
|
||||
...EXAMPLE_NODE_DEF,
|
||||
input: {
|
||||
required: inputSpec
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
getNodeSource,
|
||||
isCustomNode,
|
||||
isEssentialNode
|
||||
} from '@/types/nodeSource'
|
||||
import type { NodeSource } from '@/types/nodeSource'
|
||||
} from '../classifiers/nodeSource'
|
||||
import type { NodeSource } from '../classifiers/nodeSource'
|
||||
|
||||
describe('getNodeSource', () => {
|
||||
it('should return UNKNOWN_NODE_SOURCE when python_module is undefined', () => {
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { sanitizeUserContent } from '../helpers/sanitizeUserContent'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
|
||||
function makeNodeDef(
|
||||
name: string,
|
||||
pythonModule: string,
|
||||
input: ComfyNodeDef['input']
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
description: '',
|
||||
category: 'test',
|
||||
output_node: false,
|
||||
python_module: pythonModule,
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
describe('sanitizeUserContent', () => {
|
||||
it('strips known user filenames from combo inputs across all nodes', () => {
|
||||
const defs = {
|
||||
CustomCombo: makeNodeDef('CustomCombo', 'custom_nodes.some-pack', {
|
||||
required: {
|
||||
choice: [['my-secret.png', 'safe-option', 'video.mp4', 42], {}],
|
||||
choiceV2: ['COMBO', { options: ['a.jpg', 'keep-me', 'b'] }]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sanitized = sanitizeUserContent(defs)
|
||||
const required = sanitized.CustomCombo.input?.required
|
||||
expect(required?.choice).toEqual([['safe-option', 42], {}])
|
||||
expect(required?.choiceV2).toEqual(['COMBO', { options: ['keep-me', 'b'] }])
|
||||
})
|
||||
|
||||
it('zeros combo lists for known upload nodes in required/optional/hidden sections', () => {
|
||||
const defs = {
|
||||
LoadImage: makeNodeDef('LoadImage', 'nodes', {
|
||||
required: {
|
||||
image: [['personal.png', 'public.png'], { image_upload: true }]
|
||||
},
|
||||
optional: {
|
||||
mask: ['COMBO', { options: ['another.jpg', 'value'] }]
|
||||
},
|
||||
hidden: {
|
||||
cache: [['movie.mov', 'keep'], {}],
|
||||
hiddenV2: ['COMBO', { options: ['private.wav', 'other'] }]
|
||||
}
|
||||
}),
|
||||
LoadVideo: makeNodeDef('LoadVideo', 'nodes', {
|
||||
required: {
|
||||
video: [['clip.mp4', 'clip2.webm'], {}]
|
||||
}
|
||||
}),
|
||||
LoadAudio: makeNodeDef('LoadAudio', 'nodes', {
|
||||
required: {
|
||||
audio: [['song.mp3', 'song.flac'], {}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sanitized = sanitizeUserContent(defs)
|
||||
|
||||
expect(sanitized.LoadImage.input?.required?.image).toEqual([
|
||||
[],
|
||||
{ image_upload: true }
|
||||
])
|
||||
expect(sanitized.LoadImage.input?.optional?.mask).toEqual([
|
||||
'COMBO',
|
||||
{ options: [] }
|
||||
])
|
||||
expect(sanitized.LoadImage.input?.hidden?.cache).toEqual([[], {}])
|
||||
expect(sanitized.LoadImage.input?.hidden?.hiddenV2).toEqual([
|
||||
'COMBO',
|
||||
{ options: [] }
|
||||
])
|
||||
expect(sanitized.LoadVideo.input?.required?.video).toEqual([[], {}])
|
||||
expect(sanitized.LoadAudio.input?.required?.audio).toEqual([[], {}])
|
||||
})
|
||||
})
|
||||
97
packages/object-info-parser/src/classifiers/nodeSource.ts
Normal file
97
packages/object-info-parser/src/classifiers/nodeSource.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export const BLUEPRINT_CATEGORY = 'Subgraph Blueprints'
|
||||
|
||||
export enum NodeSourceType {
|
||||
Core = 'core',
|
||||
CustomNodes = 'custom_nodes',
|
||||
Blueprint = 'blueprint',
|
||||
Essentials = 'essentials',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
export const CORE_NODE_MODULES = ['nodes', 'comfy_extras', 'comfy_api_nodes']
|
||||
|
||||
export type NodeSource = {
|
||||
type: NodeSourceType
|
||||
className: string
|
||||
displayText: string
|
||||
badgeText: string
|
||||
}
|
||||
|
||||
const UNKNOWN_NODE_SOURCE: NodeSource = {
|
||||
type: NodeSourceType.Unknown,
|
||||
className: 'comfy-unknown',
|
||||
displayText: 'Unknown',
|
||||
badgeText: '?'
|
||||
}
|
||||
|
||||
function shortenNodeName(name: string) {
|
||||
return name
|
||||
.replace(/^(ComfyUI-|ComfyUI_|Comfy-|Comfy_)/, '')
|
||||
.replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '')
|
||||
}
|
||||
|
||||
export function getNodeSource(
|
||||
python_module?: string,
|
||||
essentials_category?: string
|
||||
): NodeSource {
|
||||
if (!python_module) {
|
||||
return UNKNOWN_NODE_SOURCE
|
||||
}
|
||||
const modules = python_module.split('.')
|
||||
if (essentials_category) {
|
||||
const moduleName = modules[1] ?? modules[0] ?? 'essentials'
|
||||
const displayName = shortenNodeName(moduleName.split('@')[0])
|
||||
return {
|
||||
type: NodeSourceType.Essentials,
|
||||
className: 'comfy-essentials',
|
||||
displayText: displayName,
|
||||
badgeText: displayName
|
||||
}
|
||||
} else if (CORE_NODE_MODULES.includes(modules[0])) {
|
||||
return {
|
||||
type: NodeSourceType.Core,
|
||||
className: 'comfy-core',
|
||||
displayText: 'Comfy Core',
|
||||
badgeText: '🦊'
|
||||
}
|
||||
} else if (modules[0] === 'blueprint') {
|
||||
return {
|
||||
type: NodeSourceType.Blueprint,
|
||||
className: 'blueprint',
|
||||
displayText: 'Blueprint',
|
||||
badgeText: 'bp'
|
||||
}
|
||||
} else if (modules[0] === 'custom_nodes') {
|
||||
const moduleName = modules[1]
|
||||
if (!moduleName) {
|
||||
return UNKNOWN_NODE_SOURCE
|
||||
}
|
||||
const customNodeName = moduleName.split('@')[0]
|
||||
const displayName = shortenNodeName(customNodeName)
|
||||
return {
|
||||
type: NodeSourceType.CustomNodes,
|
||||
className: 'comfy-custom-nodes',
|
||||
displayText: displayName,
|
||||
badgeText: displayName
|
||||
}
|
||||
} else {
|
||||
return UNKNOWN_NODE_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeDefLike {
|
||||
nodeSource: NodeSource
|
||||
}
|
||||
|
||||
export function isEssentialNode(node: NodeDefLike): boolean {
|
||||
return node.nodeSource.type === NodeSourceType.Essentials
|
||||
}
|
||||
|
||||
export function isCustomNode(node: NodeDefLike): boolean {
|
||||
return node.nodeSource.type === NodeSourceType.CustomNodes
|
||||
}
|
||||
|
||||
export enum NodeBadgeMode {
|
||||
None = 'None',
|
||||
ShowAll = 'Show all',
|
||||
HideBuiltIn = 'Hide built-in'
|
||||
}
|
||||
47
packages/object-info-parser/src/helpers/groupNodesByPack.ts
Normal file
47
packages/object-info-parser/src/helpers/groupNodesByPack.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
|
||||
export interface PackedNode {
|
||||
className: string
|
||||
def: ComfyNodeDef
|
||||
}
|
||||
|
||||
export interface NodePack {
|
||||
id: string
|
||||
displayName: string
|
||||
nodes: PackedNode[]
|
||||
}
|
||||
|
||||
export function groupNodesByPack(
|
||||
defs: Record<string, ComfyNodeDef>
|
||||
): NodePack[] {
|
||||
const byPackId = new Map<string, NodePack>()
|
||||
|
||||
for (const [className, def] of Object.entries(defs)) {
|
||||
const source = getNodeSource(def.python_module, def.essentials_category)
|
||||
if (source.type !== NodeSourceType.CustomNodes) {
|
||||
continue
|
||||
}
|
||||
|
||||
const packId = def.python_module.split('.')[1]?.split('@')[0]
|
||||
if (!packId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = byPackId.get(packId)
|
||||
const node = { className, def }
|
||||
|
||||
if (existing) {
|
||||
existing.nodes.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
byPackId.set(packId, {
|
||||
id: packId,
|
||||
displayName: source.displayText,
|
||||
nodes: [node]
|
||||
})
|
||||
}
|
||||
|
||||
return [...byPackId.values()].sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
134
packages/object-info-parser/src/helpers/sanitizeUserContent.ts
Normal file
134
packages/object-info-parser/src/helpers/sanitizeUserContent.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
ComfyNodeDef,
|
||||
ComfyInputsSpec,
|
||||
InputSpec,
|
||||
ComboInputSpec,
|
||||
ComboInputSpecV2
|
||||
} from '../schemas/nodeDefSchema'
|
||||
import {
|
||||
isComboInputSpecV1,
|
||||
isComboInputSpecV2
|
||||
} from '../schemas/nodeDefSchema'
|
||||
|
||||
const USER_CONTENT_REGEX =
|
||||
/\.(png|jpe?g|webp|gif|mp4|mov|webm|wav|mp3|flac|ogg|safetensors|ckpt|pt)$/i
|
||||
|
||||
const KNOWN_USER_UPLOAD_NODES = new Set([
|
||||
'LoadImage',
|
||||
'LoadImageMask',
|
||||
'LoadImageOutput',
|
||||
'LoadVideo',
|
||||
'LoadAudio'
|
||||
])
|
||||
|
||||
export function sanitizeUserContent(
|
||||
defs: Record<string, ComfyNodeDef>
|
||||
): Record<string, ComfyNodeDef> {
|
||||
const nextEntries = Object.entries(defs).map(([className, def]) => [
|
||||
className,
|
||||
sanitizeNode(def)
|
||||
])
|
||||
return Object.fromEntries(nextEntries) as Record<string, ComfyNodeDef>
|
||||
}
|
||||
|
||||
function sanitizeNode(def: ComfyNodeDef): ComfyNodeDef {
|
||||
if (!def.input) return def
|
||||
|
||||
const shouldClearAllComboOptions =
|
||||
def.python_module === 'nodes' && KNOWN_USER_UPLOAD_NODES.has(def.name)
|
||||
|
||||
return {
|
||||
...def,
|
||||
input: {
|
||||
...def.input,
|
||||
required: sanitizeInputSpecSection(
|
||||
def.input.required,
|
||||
shouldClearAllComboOptions
|
||||
),
|
||||
optional: sanitizeInputSpecSection(
|
||||
def.input.optional,
|
||||
shouldClearAllComboOptions
|
||||
),
|
||||
hidden: sanitizeHiddenSection(
|
||||
def.input.hidden,
|
||||
shouldClearAllComboOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeInputSpecSection(
|
||||
section: ComfyInputsSpec['required'] | ComfyInputsSpec['optional'],
|
||||
forceEmpty: boolean
|
||||
): ComfyInputsSpec['required'] | ComfyInputsSpec['optional'] {
|
||||
if (!section) return section
|
||||
|
||||
const nextEntries = Object.entries(section).map(([key, value]) => {
|
||||
const sanitized = sanitizeInputSpec(value, forceEmpty) as InputSpec
|
||||
return [key, sanitized] as const
|
||||
})
|
||||
|
||||
return Object.fromEntries(nextEntries)
|
||||
}
|
||||
|
||||
function sanitizeHiddenSection(
|
||||
section: ComfyInputsSpec['hidden'],
|
||||
forceEmpty: boolean
|
||||
): ComfyInputsSpec['hidden'] {
|
||||
if (!section) return section
|
||||
|
||||
const nextEntries = Object.entries(section).map(([key, value]) => [
|
||||
key,
|
||||
sanitizeInputSpec(value, forceEmpty)
|
||||
])
|
||||
|
||||
return Object.fromEntries(nextEntries)
|
||||
}
|
||||
|
||||
function sanitizeInputSpec(inputSpec: unknown, forceEmpty: boolean): unknown {
|
||||
if (!Array.isArray(inputSpec)) {
|
||||
return inputSpec
|
||||
}
|
||||
|
||||
if (isComboInputSpecV1(inputSpec as InputSpec)) {
|
||||
return sanitizeComboInputSpecV1(inputSpec as ComboInputSpec, forceEmpty)
|
||||
}
|
||||
|
||||
if (isComboInputSpecV2(inputSpec as InputSpec)) {
|
||||
return sanitizeComboInputSpecV2(inputSpec as ComboInputSpecV2, forceEmpty)
|
||||
}
|
||||
|
||||
return inputSpec
|
||||
}
|
||||
|
||||
function sanitizeComboInputSpecV1(
|
||||
inputSpec: ComboInputSpec,
|
||||
forceEmpty: boolean
|
||||
): ComboInputSpec {
|
||||
const [comboValues, options] = inputSpec
|
||||
const sanitizedValues = forceEmpty ? [] : filterComboValues(comboValues)
|
||||
return [sanitizedValues, options]
|
||||
}
|
||||
|
||||
function sanitizeComboInputSpecV2(
|
||||
inputSpec: ComboInputSpecV2,
|
||||
forceEmpty: boolean
|
||||
): ComboInputSpecV2 {
|
||||
const [comboTag, options] = inputSpec
|
||||
if (!options?.options) {
|
||||
return inputSpec
|
||||
}
|
||||
|
||||
const nextOptions = {
|
||||
...options,
|
||||
options: forceEmpty ? [] : filterComboValues(options.options)
|
||||
}
|
||||
|
||||
return [comboTag, nextOptions]
|
||||
}
|
||||
|
||||
function filterComboValues(values: (number | string)[]): (number | string)[] {
|
||||
return values.filter((value) =>
|
||||
typeof value === 'string' ? !USER_CONTENT_REGEX.test(value) : true
|
||||
)
|
||||
}
|
||||
4
packages/object-info-parser/src/index.ts
Normal file
4
packages/object-info-parser/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './schemas/nodeDefSchema'
|
||||
export * from './classifiers/nodeSource'
|
||||
export * from './helpers/groupNodesByPack'
|
||||
export * from './helpers/sanitizeUserContent'
|
||||
384
packages/object-info-parser/src/schemas/nodeDefSchema.ts
Normal file
384
packages/object-info-parser/src/schemas/nodeDefSchema.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
const CONTROL_OPTIONS = [
|
||||
'fixed',
|
||||
'increment',
|
||||
'decrement',
|
||||
'randomize'
|
||||
] as const
|
||||
const RESULT_ITEM_TYPE = z.enum(['input', 'output', 'temp'])
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
response_key: z.string().optional(),
|
||||
query_params: z.record(z.string(), z.string()).optional(),
|
||||
refresh_button: z.boolean().optional(),
|
||||
control_after_refresh: z.enum(['first', 'last']).optional(),
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
const zMultiSelectOption = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
chip: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zBaseInputOptions = z
|
||||
.object({
|
||||
default: z.any().optional(),
|
||||
defaultInput: z.boolean().optional(),
|
||||
display_name: z.string().optional(),
|
||||
forceInput: z.boolean().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
socketless: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
advanced: z.boolean().optional(),
|
||||
widgetType: z.string().optional(),
|
||||
/** Backend-only properties. */
|
||||
rawLink: z.boolean().optional(),
|
||||
lazy: z.boolean().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zNumericInputOptions = zBaseInputOptions.extend({
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
step: z.number().optional(),
|
||||
/** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
|
||||
default: z.union([z.number(), z.array(z.number())]).optional(),
|
||||
display: z.enum(['slider', 'number', 'knob', 'gradientslider']).optional()
|
||||
})
|
||||
|
||||
export const zIntInputOptions = zNumericInputOptions.extend({
|
||||
/**
|
||||
* If true, a linked widget will be added to the node to select the mode
|
||||
* of `control_after_generate`.
|
||||
*/
|
||||
control_after_generate: z
|
||||
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zColorStop = z.object({
|
||||
offset: z.number(),
|
||||
color: z.tuple([z.number(), z.number(), z.number()])
|
||||
})
|
||||
|
||||
export const zFloatInputOptions = zNumericInputOptions.extend({
|
||||
round: z.union([z.number(), z.literal(false)]).optional(),
|
||||
gradient_stops: z.array(zColorStop).optional()
|
||||
})
|
||||
|
||||
export const zBooleanInputOptions = zBaseInputOptions.extend({
|
||||
label_on: z.string().optional(),
|
||||
label_off: z.string().optional(),
|
||||
default: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zStringInputOptions = zBaseInputOptions.extend({
|
||||
default: z.string().optional(),
|
||||
multiline: z.boolean().optional(),
|
||||
dynamicPrompts: z.boolean().optional(),
|
||||
|
||||
// Multiline-only fields
|
||||
defaultVal: z.string().optional(),
|
||||
placeholder: z.string().optional()
|
||||
})
|
||||
|
||||
export const zComboInputOptions = zBaseInputOptions.extend({
|
||||
control_after_generate: z
|
||||
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
|
||||
.optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
image_folder: RESULT_ITEM_TYPE.optional(),
|
||||
allow_batch: z.boolean().optional(),
|
||||
video_upload: z.boolean().optional(),
|
||||
audio_upload: z.boolean().optional(),
|
||||
mesh_upload: z.boolean().optional(),
|
||||
upload_subfolder: z.string().optional(),
|
||||
animated_image_upload: z.boolean().optional(),
|
||||
options: z.array(zComboOption).optional(),
|
||||
remote: zRemoteWidgetConfig.optional(),
|
||||
/** Whether the widget is a multi-select widget. */
|
||||
multi_select: zMultiSelectOption.optional()
|
||||
})
|
||||
|
||||
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
|
||||
const zFloatInputSpec = z.tuple([
|
||||
z.literal('FLOAT'),
|
||||
zFloatInputOptions.optional()
|
||||
])
|
||||
const zBooleanInputSpec = z.tuple([
|
||||
z.literal('BOOLEAN'),
|
||||
zBooleanInputOptions.optional()
|
||||
])
|
||||
const zStringInputSpec = z.tuple([
|
||||
z.literal('STRING'),
|
||||
zStringInputOptions.optional()
|
||||
])
|
||||
/**
|
||||
* Legacy combo syntax.
|
||||
* @deprecated Use `zComboInputSpecV2` instead.
|
||||
*/
|
||||
const zComboInputSpec = z.tuple([
|
||||
z.array(zComboOption),
|
||||
zComboInputOptions.optional()
|
||||
])
|
||||
const zComboInputSpecV2 = z.tuple([
|
||||
z.literal('COMBO'),
|
||||
zComboInputOptions.optional()
|
||||
])
|
||||
|
||||
export function isComboInputSpecV1(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec {
|
||||
return Array.isArray(inputSpec[0])
|
||||
}
|
||||
|
||||
export function isIntInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is IntInputSpec {
|
||||
return inputSpec[0] === 'INT'
|
||||
}
|
||||
|
||||
export function isFloatInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is FloatInputSpec {
|
||||
return inputSpec[0] === 'FLOAT'
|
||||
}
|
||||
|
||||
export function isComboInputSpecV2(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpecV2 {
|
||||
return inputSpec[0] === 'COMBO'
|
||||
}
|
||||
|
||||
export function isComboInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec | ComboInputSpecV2 {
|
||||
return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec)
|
||||
}
|
||||
|
||||
export function isMediaUploadComboInput(inputSpec: InputSpec): boolean {
|
||||
const [inputName, inputOptions] = inputSpec
|
||||
if (!inputOptions) return false
|
||||
|
||||
const isUploadInput =
|
||||
inputOptions['image_upload'] === true ||
|
||||
inputOptions['video_upload'] === true ||
|
||||
inputOptions['animated_image_upload'] === true
|
||||
|
||||
return (
|
||||
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of an input spec.
|
||||
*
|
||||
* @param inputSpec - The input spec to get the type of.
|
||||
* @returns The type of the input spec.
|
||||
*/
|
||||
export function getInputSpecType(inputSpec: InputSpec): string {
|
||||
return isComboInputSpec(inputSpec) ? 'COMBO' : inputSpec[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the combo options from a combo input spec.
|
||||
*
|
||||
* @param inputSpec - The input spec to get the combo options from.
|
||||
* @returns The combo options.
|
||||
*/
|
||||
export function getComboSpecComboOptions(
|
||||
inputSpec: ComboInputSpec | ComboInputSpecV2
|
||||
): (number | string)[] {
|
||||
return (
|
||||
(isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
|
||||
const zCustomInputSpec = z.tuple([
|
||||
z.string().refine((value) => !excludedLiterals.has(value)),
|
||||
zBaseInputOptions.optional()
|
||||
])
|
||||
|
||||
const zInputSpec = z.union([
|
||||
zIntInputSpec,
|
||||
zFloatInputSpec,
|
||||
zBooleanInputSpec,
|
||||
zStringInputSpec,
|
||||
zComboInputSpec,
|
||||
zComboInputSpecV2,
|
||||
zCustomInputSpec
|
||||
])
|
||||
|
||||
export const zComfyInputsSpec = z.object({
|
||||
required: z.record(zInputSpec).optional(),
|
||||
optional: z.record(zInputSpec).optional(),
|
||||
// Frontend repo is not using it, but some custom nodes are using the
|
||||
// hidden field to pass various values.
|
||||
hidden: z.record(z.any()).optional()
|
||||
})
|
||||
|
||||
const zComfyNodeDataType = z.string()
|
||||
const zComfyComboOutput = z.array(zComboOption)
|
||||
export const zComfyOutputTypesSpec = z.array(
|
||||
z.union([zComfyNodeDataType, zComfyComboOutput])
|
||||
)
|
||||
|
||||
/**
|
||||
* Widget dependency with type information.
|
||||
* Provides strong type enforcement for JSONata evaluation context.
|
||||
*/
|
||||
const zWidgetDependency = z.object({
|
||||
name: z.string(),
|
||||
type: z.string()
|
||||
})
|
||||
|
||||
export type WidgetDependency = z.infer<typeof zWidgetDependency>
|
||||
|
||||
/**
|
||||
* Schema for price badge depends_on field.
|
||||
* Specifies which widgets and inputs the pricing expression depends on.
|
||||
* Widgets must be specified as objects with name and type.
|
||||
*/
|
||||
const zPriceBadgeDepends = z.object({
|
||||
widgets: z.array(zWidgetDependency).optional().default([]),
|
||||
inputs: z.array(z.string()).optional().default([]),
|
||||
/**
|
||||
* Autogrow input group names to track.
|
||||
* For each group, the count of connected inputs will be available in the
|
||||
* JSONata context as `g.<groupName>`.
|
||||
* Example: `input_groups: ["reference_videos"]` makes `g.reference_videos`
|
||||
* available with the count of connected inputs like `reference_videos.character1`, etc.
|
||||
*/
|
||||
input_groups: z.array(z.string()).optional().default([])
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for price badge definition.
|
||||
* Used to calculate and display pricing information for API nodes.
|
||||
* The `expr` field contains a JSONata expression that returns a PricingResult.
|
||||
*/
|
||||
const zPriceBadge = z.object({
|
||||
engine: z.literal('jsonata').optional().default('jsonata'),
|
||||
depends_on: zPriceBadgeDepends
|
||||
.optional()
|
||||
.default({ widgets: [], inputs: [], input_groups: [] }),
|
||||
expr: z.string()
|
||||
})
|
||||
|
||||
export type PriceBadge = z.infer<typeof zPriceBadge>
|
||||
|
||||
export const zComfyNodeDef = z.object({
|
||||
input: zComfyInputsSpec.optional(),
|
||||
output: zComfyOutputTypesSpec.optional(),
|
||||
output_is_list: z.array(z.boolean()).optional(),
|
||||
output_name: z.array(z.string()).optional(),
|
||||
output_tooltips: z.array(z.string()).optional(),
|
||||
output_matchtypes: z.array(z.string().optional()).optional(),
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string(),
|
||||
help: z.string().optional(),
|
||||
category: z.string(),
|
||||
main_category: z.string().optional(),
|
||||
output_node: z.boolean(),
|
||||
python_module: z.string(),
|
||||
deprecated: z.boolean().optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
dev_only: z.boolean().optional(),
|
||||
/**
|
||||
* Whether the node is an API node. Running API nodes requires login to
|
||||
* Comfy Org account.
|
||||
* https://docs.comfy.org/tutorials/api-nodes/overview
|
||||
*/
|
||||
api_node: z.boolean().optional(),
|
||||
/**
|
||||
* Specifies the order of inputs for each input category.
|
||||
* Used to ensure consistent widget ordering regardless of JSON serialization.
|
||||
* Keys are 'required', 'optional', etc., values are arrays of input names.
|
||||
*/
|
||||
input_order: z.record(z.array(z.string())).optional(),
|
||||
/**
|
||||
* Alternative names for search. Useful for synonyms, abbreviations,
|
||||
* or old names after renaming a node.
|
||||
*/
|
||||
search_aliases: z.array(z.string()).optional(),
|
||||
/**
|
||||
* Price badge definition for API nodes.
|
||||
* Contains a JSONata expression to calculate pricing based on widget values
|
||||
* and input connectivity.
|
||||
*/
|
||||
price_badge: zPriceBadge.optional(),
|
||||
/** Category for the Essentials tab. If set, the node appears in Essentials. */
|
||||
essentials_category: z.string().optional(),
|
||||
/** Whether the blueprint is a global/installed blueprint (not user-created). */
|
||||
isGlobal: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zAutogrowOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
template: z.object({
|
||||
input: zComfyInputsSpec,
|
||||
names: z.array(z.string()).optional(),
|
||||
max: z.number().optional(),
|
||||
//Backend defines as mandatory with min 1, Frontend is more forgiving
|
||||
min: z.number().optional(),
|
||||
prefix: z.string().optional()
|
||||
})
|
||||
})
|
||||
|
||||
export const zDynamicComboInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICCOMBO_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
options: z.array(
|
||||
z.object({
|
||||
inputs: zComfyInputsSpec,
|
||||
key: z.string()
|
||||
})
|
||||
)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
template: z.object({
|
||||
allowed_types: z.string(),
|
||||
template_id: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
// `/object_info`
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
|
||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>
|
||||
|
||||
export type IntInputSpec = z.infer<typeof zIntInputSpec>
|
||||
export type FloatInputSpec = z.infer<typeof zFloatInputSpec>
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ComboInputSpecV2 = z.infer<typeof zComboInputSpecV2>
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
|
||||
export function validateComfyNodeDef(
|
||||
data: unknown,
|
||||
onError: (error: string) => void = console.warn
|
||||
): ComfyNodeDef | null {
|
||||
const result = zComfyNodeDef.safeParse(data)
|
||||
if (!result.success) {
|
||||
const zodError = fromZodError(result.error)
|
||||
onError(
|
||||
`Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
8
packages/object-info-parser/tsconfig.json
Normal file
8
packages/object-info-parser/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "vitest.config.ts"]
|
||||
}
|
||||
9
packages/object-info-parser/vitest.config.ts
Normal file
9
packages/object-info-parser/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/__tests__/**/*.test.ts'],
|
||||
globals: false
|
||||
}
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
@@ -388,4 +390,34 @@ describe('formatUtil', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatLocalizedNumber', () => {
|
||||
it('formats numbers using the given locale', () => {
|
||||
expect(formatLocalizedNumber(2618646, 'en')).toBe('2,618,646')
|
||||
expect(formatLocalizedNumber(2618646, 'de')).toBe('2.618.646')
|
||||
})
|
||||
|
||||
it('returns an em-dash for undefined / NaN / Infinity', () => {
|
||||
expect(formatLocalizedNumber(undefined, 'en')).toBe('—')
|
||||
expect(formatLocalizedNumber(Number.NaN, 'en')).toBe('—')
|
||||
expect(formatLocalizedNumber(Number.POSITIVE_INFINITY, 'en')).toBe('—')
|
||||
})
|
||||
|
||||
it('formats zero as "0"', () => {
|
||||
expect(formatLocalizedNumber(0, 'en')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatLocalizedMediumDate', () => {
|
||||
it('formats an ISO date with the medium style', () => {
|
||||
expect(formatLocalizedMediumDate('2026-04-19T00:00:00Z', 'en')).toMatch(
|
||||
/Apr \d{1,2}, 2026/
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an em-dash for undefined or unparseable input', () => {
|
||||
expect(formatLocalizedMediumDate(undefined, 'en')).toBe('—')
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -651,3 +651,32 @@ export function formatTime(seconds: number): string {
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with the given BCP-47 locale.
|
||||
* Returns an em-dash for non-numeric, NaN, or infinite inputs.
|
||||
*/
|
||||
export function formatLocalizedNumber(
|
||||
value: number | undefined,
|
||||
locale: string
|
||||
): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
|
||||
return new Intl.NumberFormat(locale).format(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO 8601 date string with the given BCP-47 locale using the
|
||||
* `medium` date style (e.g. "Apr 19, 2026"). Returns an em-dash for missing
|
||||
* or unparseable inputs.
|
||||
*/
|
||||
export function formatLocalizedMediumDate(
|
||||
value: string | undefined,
|
||||
locale: string
|
||||
): string {
|
||||
if (!value) return '—'
|
||||
const timestamp = Date.parse(value)
|
||||
if (Number.isNaN(timestamp)) return '—'
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(
|
||||
timestamp
|
||||
)
|
||||
}
|
||||
|
||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -434,6 +434,9 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/object-info-parser':
|
||||
specifier: workspace:*
|
||||
version: link:packages/object-info-parser
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
@@ -943,6 +946,12 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/design-system
|
||||
'@comfyorg/object-info-parser':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/object-info-parser
|
||||
'@comfyorg/shared-frontend-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-frontend-utils
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/tailwind-utils
|
||||
@@ -1034,6 +1043,22 @@ importers:
|
||||
specifier: 0.93.0
|
||||
version: 0.93.0(magicast@0.5.1)(typescript@5.9.3)
|
||||
|
||||
packages/object-info-parser:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 3.25.76
|
||||
zod-validation-error:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.0(zod@3.25.76)
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
packages/registry-types: {}
|
||||
|
||||
packages/shared-frontend-utils:
|
||||
@@ -9883,8 +9908,8 @@ packages:
|
||||
vue-component-type-helpers@3.2.6:
|
||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
||||
|
||||
vue-component-type-helpers@3.2.7:
|
||||
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
|
||||
vue-component-type-helpers@3.2.8:
|
||||
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -13405,7 +13430,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.2.7
|
||||
vue-component-type-helpers: 3.2.8
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -14189,7 +14214,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -20530,7 +20555,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.6: {}
|
||||
|
||||
vue-component-type-helpers@3.2.7: {}
|
||||
vue-component-type-helpers@3.2.8: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
51
src/components/toast/ProgressToastItem.test.ts
Normal file
51
src/components/toast/ProgressToastItem.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
|
||||
import ProgressToastItem from './ProgressToastItem.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
progressToast: {
|
||||
finished: 'Finished',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function completedJob(): AssetDownload {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
assetId: 'asset-1',
|
||||
assetName: 'controlnet-canny.safetensors',
|
||||
bytesTotal: 100,
|
||||
bytesDownloaded: 100,
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
lastUpdate: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProgressToastItem — completed state', () => {
|
||||
it('keeps the finished badge outside the dimmed (opacity-50) subtree', () => {
|
||||
render(ProgressToastItem, {
|
||||
props: { job: completedJob() },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
const badge = screen.getByText('Finished')
|
||||
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
|
||||
expect(badge.closest('.opacity-50')).toBeNull()
|
||||
|
||||
const assetName = screen.getByText('controlnet-canny.safetensors')
|
||||
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
|
||||
expect(assetName.closest('.opacity-50')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -22,14 +22,9 @@ const isPending = computed(() => job.status === 'created')
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
|
||||
isCompleted && 'opacity-50'
|
||||
)
|
||||
"
|
||||
class="flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div :class="cn('min-w-0 flex-1', isCompleted && 'opacity-50')">
|
||||
<span class="block truncate text-sm text-base-foreground">{{
|
||||
job.assetName
|
||||
}}</span>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ref } from 'vue'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -36,34 +35,17 @@ vi.mock('@/scripts/app', () => {
|
||||
const mockCanvas = {
|
||||
subgraph: undefined,
|
||||
selectedItems: new Set(),
|
||||
selected_nodes: null as Record<string, unknown> | null,
|
||||
copyToClipboard: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
selectItems: vi.fn(),
|
||||
ds: mockDs,
|
||||
deleteSelected: vi.fn(),
|
||||
setDirty: vi.fn(),
|
||||
fitViewToSelectionAnimated: vi.fn(),
|
||||
empty: false,
|
||||
state: {
|
||||
readOnly: false,
|
||||
selectionChanged: false
|
||||
},
|
||||
graph: {
|
||||
add: vi.fn(),
|
||||
convertToSubgraph: vi.fn(),
|
||||
rootGraph: {}
|
||||
},
|
||||
select: vi.fn(),
|
||||
canvas: {
|
||||
dispatchEvent: vi.fn()
|
||||
},
|
||||
setGraph: vi.fn()
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
app: {
|
||||
clean: vi.fn(() => {
|
||||
// Simulate app.clean() calling graph.clear() only when not in subgraph
|
||||
if (!mockCanvas.subgraph) {
|
||||
mockGraphClear()
|
||||
}
|
||||
@@ -72,11 +54,8 @@ vi.mock('@/scripts/app', () => {
|
||||
refreshComboInNodes: vi.fn().mockResolvedValue(undefined),
|
||||
canvas: mockCanvas,
|
||||
rootGraph: {
|
||||
clear: mockGraphClear,
|
||||
_nodes: []
|
||||
},
|
||||
queuePrompt: vi.fn(),
|
||||
ui: { loadFile: vi.fn() }
|
||||
clear: mockGraphClear
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -84,9 +63,7 @@ vi.mock('@/scripts/app', () => {
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
dispatchCustomEvent: vi.fn(),
|
||||
apiURL: vi.fn(() => 'http://localhost:8188'),
|
||||
interrupt: vi.fn(),
|
||||
freeMemory: vi.fn()
|
||||
apiURL: vi.fn(() => 'http://localhost:8188')
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -124,15 +101,11 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
trackWorkflowCreated: vi.fn(),
|
||||
trackRunButton: vi.fn(),
|
||||
trackWorkflowExecution: vi.fn(),
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
trackEnterLinear: vi.fn()
|
||||
}))
|
||||
const mockTrackHelpResourceClicked = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackHelpResourceClicked: mockTrackHelpResourceClicked
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockShowAbout = vi.hoisted(() => vi.fn())
|
||||
@@ -148,18 +121,12 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => mockToastStore)
|
||||
vi.mock('@/stores/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const mockChangeTracker = vi.hoisted(() => ({
|
||||
captureCanvasState: vi.fn(),
|
||||
checkState: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn()
|
||||
captureCanvasState: vi.fn()
|
||||
}))
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: {
|
||||
@@ -174,29 +141,22 @@ vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const mockCanvasStore = vi.hoisted(() => ({
|
||||
getCanvas: vi.fn(),
|
||||
canvas: null as unknown,
|
||||
linearMode: false,
|
||||
updateSelectedItems: vi.fn()
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore),
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
getCanvas: () => app.canvas,
|
||||
canvas: app.canvas
|
||||
})),
|
||||
useTitleEditorStore: vi.fn(() => ({
|
||||
titleEditorTarget: null
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: vi.fn(() => ({
|
||||
completedActivePalette: { id: 'dark-default', light_theme: false }
|
||||
}))
|
||||
useColorPaletteStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: vi.fn(() => ({
|
||||
logout: vi.fn()
|
||||
}))
|
||||
useAuthActions: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
@@ -206,73 +166,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockIsActiveSubscription = vi.hoisted(() => ({ value: true }))
|
||||
const mockShowSubscriptionDialog = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog
|
||||
isActiveSubscription: { value: true },
|
||||
showSubscriptionDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
userEmail: ref(''),
|
||||
resolvedUserInfo: ref(null)
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockSelectedItems = vi.hoisted(() => ({
|
||||
getSelectedNodes: vi.fn((): unknown[] => []),
|
||||
toggleSelectedNodesMode: vi.fn()
|
||||
}))
|
||||
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
|
||||
useSelectedLiteGraphItems: vi.fn(() => mockSelectedItems)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
|
||||
useSubgraphOperations: vi.fn(() => ({
|
||||
unpackSubgraph: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
staticUrls: {
|
||||
githubIssues: 'https://github.com/issues',
|
||||
discord: 'https://discord.gg/test',
|
||||
forum: 'https://forum.test.com'
|
||||
},
|
||||
buildDocsUrl: vi.fn(() => 'https://docs.test.com')
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useModelSelectorDialog', () => ({
|
||||
useModelSelectorDialog: vi.fn(() => ({
|
||||
show: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
|
||||
useWorkflowTemplateSelectorDialog: vi.fn(() => ({
|
||||
show: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({
|
||||
useAssetBrowserDialog: vi.fn(() => ({
|
||||
browse: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/createModelNodeFromAsset', () => ({
|
||||
createModelNodeFromAsset: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/support/config', () => ({
|
||||
buildSupportUrl: vi.fn(() => 'https://support.test.com')
|
||||
}))
|
||||
|
||||
describe('useCoreCommands', () => {
|
||||
const createMockNode = (id: number, comfyClass: string): LGraphNode => {
|
||||
const baseNode = createMockLGraphNode({ id })
|
||||
@@ -286,9 +186,13 @@ describe('useCoreCommands', () => {
|
||||
|
||||
const createMockSubgraph = () => {
|
||||
const mockNodes = [
|
||||
// Mock input node
|
||||
createMockNode(1, 'SubgraphInputNode'),
|
||||
// Mock output node
|
||||
createMockNode(2, 'SubgraphOutputNode'),
|
||||
// Mock user node
|
||||
createMockNode(3, 'SomeUserNode'),
|
||||
// Another mock user node
|
||||
createMockNode(4, 'AnotherUserNode')
|
||||
]
|
||||
|
||||
@@ -357,38 +261,31 @@ describe('useCoreCommands', () => {
|
||||
} satisfies ReturnType<typeof useSettingStore>
|
||||
}
|
||||
|
||||
function findCommand(id: string) {
|
||||
const cmd = useCoreCommands().find((c) => c.id === id)
|
||||
if (!cmd) throw new Error(`Command '${id}' not found`)
|
||||
return cmd
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Set up Pinia
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Reset app state
|
||||
app.canvas.subgraph = undefined
|
||||
app.canvas.selectedItems = new Set()
|
||||
app.canvas.state.readOnly = false
|
||||
app.canvas.state.selectionChanged = false
|
||||
Object.defineProperty(app.canvas, 'empty', { value: false, writable: true })
|
||||
mockCanvasStore.linearMode = false
|
||||
mockCanvasStore.getCanvas.mockReturnValue(app.canvas)
|
||||
mockIsActiveSubscription.value = true
|
||||
|
||||
// Mock settings store
|
||||
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false))
|
||||
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true))
|
||||
vi.stubGlobal(
|
||||
'open',
|
||||
vi.fn().mockReturnValue({ focus: vi.fn(), closed: false })
|
||||
)
|
||||
// Mock global confirm
|
||||
global.confirm = vi.fn().mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('ClearWorkflow command', () => {
|
||||
it('should clear main graph when not in subgraph', async () => {
|
||||
await findCommand('Comfy.ClearWorkflow').function()
|
||||
const commands = useCoreCommands()
|
||||
const clearCommand = commands.find(
|
||||
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
|
||||
)!
|
||||
|
||||
// Execute the command
|
||||
await clearCommand.function()
|
||||
|
||||
expect(app.clean).toHaveBeenCalled()
|
||||
expect(app.rootGraph.clear).toHaveBeenCalled()
|
||||
@@ -396,29 +293,46 @@ describe('useCoreCommands', () => {
|
||||
})
|
||||
|
||||
it('should preserve input/output nodes when clearing subgraph', async () => {
|
||||
// Set up subgraph context
|
||||
app.canvas.subgraph = mockSubgraph
|
||||
|
||||
await findCommand('Comfy.ClearWorkflow').function()
|
||||
const commands = useCoreCommands()
|
||||
const clearCommand = commands.find(
|
||||
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
|
||||
)!
|
||||
|
||||
// Execute the command
|
||||
await clearCommand.function()
|
||||
|
||||
expect(app.clean).toHaveBeenCalled()
|
||||
expect(app.rootGraph.clear).not.toHaveBeenCalled()
|
||||
|
||||
// Should only remove user nodes, not input/output nodes
|
||||
const subgraph = app.canvas.subgraph!
|
||||
expect(subgraph.remove).toHaveBeenCalledTimes(2)
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2])
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3])
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0])
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1])
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2]) // user1
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3]) // user2
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0]) // input1
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1]) // output1
|
||||
|
||||
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
|
||||
})
|
||||
|
||||
it('should respect confirmation setting', async () => {
|
||||
// Mock confirmation required
|
||||
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false))
|
||||
|
||||
await findCommand('Comfy.ClearWorkflow').function()
|
||||
global.confirm = vi.fn().mockReturnValue(false) // User cancels
|
||||
|
||||
const commands = useCoreCommands()
|
||||
const clearCommand = commands.find(
|
||||
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
|
||||
)!
|
||||
|
||||
// Execute the command
|
||||
await clearCommand.function()
|
||||
|
||||
// Should not clear anything when user cancels
|
||||
expect(app.clean).not.toHaveBeenCalled()
|
||||
expect(app.rootGraph.clear).not.toHaveBeenCalled()
|
||||
expect(api.dispatchCustomEvent).not.toHaveBeenCalled()
|
||||
@@ -426,6 +340,17 @@ describe('useCoreCommands', () => {
|
||||
})
|
||||
|
||||
describe('Canvas clipboard commands', () => {
|
||||
function findCommand(id: string) {
|
||||
return useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
app.canvas.selectedItems = new Set()
|
||||
vi.mocked(app.canvas.copyToClipboard).mockClear()
|
||||
vi.mocked(app.canvas.pasteFromClipboard).mockClear()
|
||||
vi.mocked(app.canvas.selectItems).mockClear()
|
||||
})
|
||||
|
||||
it('should copy selected items when selection exists', async () => {
|
||||
app.canvas.selectedItems = new Set([
|
||||
{}
|
||||
@@ -448,541 +373,14 @@ describe('useCoreCommands', () => {
|
||||
expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should paste with connect option', async () => {
|
||||
await findCommand('Comfy.Canvas.PasteFromClipboardWithConnect').function()
|
||||
|
||||
expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith({
|
||||
connectInputs: true
|
||||
})
|
||||
})
|
||||
|
||||
it('should select all items', async () => {
|
||||
await findCommand('Comfy.Canvas.SelectAll').function()
|
||||
|
||||
// No arguments means "select all items on canvas"
|
||||
expect(app.canvas.selectItems).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Undo/Redo commands', () => {
|
||||
it('Undo should call changeTracker.undo', async () => {
|
||||
await findCommand('Comfy.Undo').function()
|
||||
|
||||
expect(mockChangeTracker.undo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Redo should call changeTracker.redo', async () => {
|
||||
await findCommand('Comfy.Redo').function()
|
||||
|
||||
expect(mockChangeTracker.redo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zoom commands', () => {
|
||||
it('ZoomIn should increase scale and mark dirty', async () => {
|
||||
await findCommand('Comfy.Canvas.ZoomIn').function()
|
||||
|
||||
expect(app.canvas.ds.changeScale).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ZoomOut should decrease scale and mark dirty', async () => {
|
||||
await findCommand('Comfy.Canvas.ZoomOut').function()
|
||||
|
||||
expect(app.canvas.ds.changeScale).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ToggleLock should toggle readOnly state', async () => {
|
||||
app.canvas.state.readOnly = false
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleLock').function()
|
||||
expect(app.canvas.state.readOnly).toBe(true)
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleLock').function()
|
||||
expect(app.canvas.state.readOnly).toBe(false)
|
||||
})
|
||||
|
||||
it('Lock should set readOnly to true', async () => {
|
||||
await findCommand('Comfy.Canvas.Lock').function()
|
||||
expect(app.canvas.state.readOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('Unlock should set readOnly to false', async () => {
|
||||
app.canvas.state.readOnly = true
|
||||
await findCommand('Comfy.Canvas.Unlock').function()
|
||||
expect(app.canvas.state.readOnly).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas delete command', () => {
|
||||
it('should delete selected items when selection exists', async () => {
|
||||
app.canvas.selectedItems = new Set([
|
||||
{}
|
||||
]) as typeof app.canvas.selectedItems
|
||||
|
||||
await findCommand('Comfy.Canvas.DeleteSelectedItems').function()
|
||||
|
||||
expect(app.canvas.deleteSelected).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should dispatch no-items-selected event when nothing selected', async () => {
|
||||
app.canvas.selectedItems = new Set()
|
||||
|
||||
await findCommand('Comfy.Canvas.DeleteSelectedItems').function()
|
||||
|
||||
expect(app.canvas.canvas.dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'litegraph:no-items-selected' })
|
||||
)
|
||||
expect(app.canvas.deleteSelected).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleLinkVisibility command', () => {
|
||||
it('should hide links when currently visible', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(LiteGraph.SPLINE_LINK)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleLinkVisibility').function()
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.LinkRenderMode',
|
||||
LiteGraph.HIDDEN_LINK
|
||||
)
|
||||
})
|
||||
|
||||
it('should restore links when currently hidden', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(LiteGraph.HIDDEN_LINK)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleLinkVisibility').function()
|
||||
|
||||
const lastSetCall = vi.mocked(mockStore.set).mock.calls.at(-1)
|
||||
expect(lastSetCall?.[0]).toBe('Comfy.LinkRenderMode')
|
||||
expect(lastSetCall?.[1]).not.toBe(LiteGraph.HIDDEN_LINK)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleMinimap command', () => {
|
||||
it('should toggle minimap visibility setting', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(false)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleMinimap').function()
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith('Comfy.Minimap.Visible', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('QueuePrompt commands', () => {
|
||||
it('should show subscription dialog when not subscribed', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
|
||||
await findCommand('Comfy.QueuePrompt').function()
|
||||
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
|
||||
expect(app.queuePrompt).not.toHaveBeenCalled()
|
||||
|
||||
mockIsActiveSubscription.value = true
|
||||
})
|
||||
|
||||
it('should queue prompt when subscribed', async () => {
|
||||
await findCommand('Comfy.QueuePrompt').function()
|
||||
|
||||
expect(app.queuePrompt).toHaveBeenCalledWith(0, 1)
|
||||
expect(mockTelemetry.trackRunButton).toHaveBeenCalled()
|
||||
expect(mockTelemetry.trackWorkflowExecution).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should queue prompt at front', async () => {
|
||||
await findCommand('Comfy.QueuePromptFront').function()
|
||||
|
||||
expect(app.queuePrompt).toHaveBeenCalledWith(-1, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('QueueSelectedOutputNodes command', () => {
|
||||
it('should show error toast when no output nodes selected', async () => {
|
||||
await findCommand('Comfy.QueueSelectedOutputNodes').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(app.queuePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MoveSelectedNodes commands', () => {
|
||||
function setupMoveTest() {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
mockNode.pos = [100, 200] as [number, number]
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
|
||||
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(10)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
return mockNode
|
||||
}
|
||||
|
||||
it('should move nodes up by grid size', async () => {
|
||||
const mockNode = setupMoveTest()
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Up').function()
|
||||
|
||||
expect(mockNode.pos).toEqual([100, 190])
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should move nodes down by grid size', async () => {
|
||||
const mockNode = setupMoveTest()
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Down').function()
|
||||
|
||||
expect(mockNode.pos).toEqual([100, 210])
|
||||
})
|
||||
|
||||
it('should move nodes left by grid size', async () => {
|
||||
const mockNode = setupMoveTest()
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Left').function()
|
||||
|
||||
expect(mockNode.pos).toEqual([90, 200])
|
||||
})
|
||||
|
||||
it('should move nodes right by grid size', async () => {
|
||||
const mockNode = setupMoveTest()
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Right').function()
|
||||
|
||||
expect(mockNode.pos).toEqual([110, 200])
|
||||
})
|
||||
|
||||
it('should not move when no nodes selected', async () => {
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([])
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Up').function()
|
||||
|
||||
expect(app.canvas.setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleLinear command', () => {
|
||||
it('should toggle linear mode and track telemetry when entering', async () => {
|
||||
mockCanvasStore.linearMode = false
|
||||
|
||||
await findCommand('Comfy.ToggleLinear').function()
|
||||
|
||||
expect(mockCanvasStore.linearMode).toBe(true)
|
||||
expect(mockTelemetry.trackEnterLinear).toHaveBeenCalledWith({
|
||||
source: 'keybind'
|
||||
})
|
||||
})
|
||||
|
||||
it('should use provided source metadata', async () => {
|
||||
mockCanvasStore.linearMode = false
|
||||
|
||||
await findCommand('Comfy.ToggleLinear').function({
|
||||
source: 'menu'
|
||||
})
|
||||
|
||||
expect(mockTelemetry.trackEnterLinear).toHaveBeenCalledWith({
|
||||
source: 'menu'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleQPOV2 command', () => {
|
||||
it('should toggle queue panel v2 setting', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(false)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.ToggleQPOV2').function()
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memory commands', () => {
|
||||
it('UnloadModels should show error when setting is disabled', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(false)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Memory.UnloadModels').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(api.freeMemory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('UnloadModels should call api.freeMemory when setting is enabled', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(true)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Memory.UnloadModels').function()
|
||||
|
||||
expect(api.freeMemory).toHaveBeenCalledWith({
|
||||
freeExecutionCache: false
|
||||
})
|
||||
})
|
||||
|
||||
it('UnloadModelsAndExecutionCache should call api.freeMemory with cache flag', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(true)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Memory.UnloadModelsAndExecutionCache').function()
|
||||
|
||||
expect(api.freeMemory).toHaveBeenCalledWith({
|
||||
freeExecutionCache: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('FitView command', () => {
|
||||
it('should show error toast when canvas is empty', async () => {
|
||||
Object.defineProperty(app.canvas, 'empty', {
|
||||
value: true,
|
||||
writable: true
|
||||
})
|
||||
|
||||
await findCommand('Comfy.Canvas.FitView').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(app.canvas.fitViewToSelectionAnimated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fit view when canvas has content', async () => {
|
||||
Object.defineProperty(app.canvas, 'empty', {
|
||||
value: false,
|
||||
writable: true
|
||||
})
|
||||
|
||||
await findCommand('Comfy.Canvas.FitView').function()
|
||||
|
||||
expect(app.canvas.fitViewToSelectionAnimated).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interrupt command', () => {
|
||||
it('should call api.interrupt and show toast', async () => {
|
||||
await findCommand('Comfy.Interrupt').function()
|
||||
|
||||
expect(api.interrupt).toHaveBeenCalled()
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'info' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenWorkflow command', () => {
|
||||
it('should call app.ui.loadFile', async () => {
|
||||
await findCommand('Comfy.OpenWorkflow').function()
|
||||
|
||||
expect(app.ui.loadFile).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshNodeDefinitions command', () => {
|
||||
it('should call app.refreshComboInNodes', async () => {
|
||||
await findCommand('Comfy.RefreshNodeDefinitions').function()
|
||||
|
||||
expect(app.refreshComboInNodes).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenClipspace command', () => {
|
||||
it('should call app.openClipspace', async () => {
|
||||
await findCommand('Comfy.OpenClipspace').function()
|
||||
|
||||
expect(app.openClipspace).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleTheme command', () => {
|
||||
it('should switch from dark to light theme', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.ToggleTheme').function()
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.ColorPalette',
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleSelectedNodes commands', () => {
|
||||
it('Mute should toggle selected nodes mode and mark dirty', async () => {
|
||||
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Mute').function()
|
||||
|
||||
expect(mockSelectedItems.toggleSelectedNodesMode).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('Bypass should toggle selected nodes mode and mark dirty', async () => {
|
||||
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Bypass').function()
|
||||
|
||||
expect(mockSelectedItems.toggleSelectedNodesMode).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('Pin should toggle pin state on each selected node', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
Object.defineProperty(mockNode, 'pinned', {
|
||||
value: false,
|
||||
writable: true
|
||||
})
|
||||
mockNode.pin = vi.fn()
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Pin').function()
|
||||
|
||||
expect(mockNode.pin).toHaveBeenCalledWith(true)
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('Collapse should collapse each selected node', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
mockNode.collapse = vi.fn()
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Collapse').function()
|
||||
|
||||
expect(mockNode.collapse).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('Resize should compute and set optimal size', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
mockNode.computeSize = vi.fn().mockReturnValue([200, 100])
|
||||
mockNode.setSize = vi.fn()
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
|
||||
|
||||
await findCommand('Comfy.Canvas.Resize').function()
|
||||
|
||||
expect(mockNode.computeSize).toHaveBeenCalled()
|
||||
expect(mockNode.setSize).toHaveBeenCalledWith([200, 100])
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Help commands', () => {
|
||||
it('OpenComfyUIIssues should open GitHub issues and track telemetry', async () => {
|
||||
await findCommand('Comfy.Help.OpenComfyUIIssues').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'github',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://github.com/issues',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('OpenComfyUIDocs should open docs and track telemetry', async () => {
|
||||
await findCommand('Comfy.Help.OpenComfyUIDocs').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'docs',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://docs.test.com',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('OpenComfyOrgDiscord should open Discord and track telemetry', async () => {
|
||||
await findCommand('Comfy.Help.OpenComfyOrgDiscord').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'discord',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://discord.gg/test',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('OpenComfyUIForum should open forum and track telemetry', async () => {
|
||||
await findCommand('Comfy.Help.OpenComfyUIForum').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://forum.test.com',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupSelectedNodes command', () => {
|
||||
it('should show error toast when nothing selected', async () => {
|
||||
app.canvas.selectedItems = new Set()
|
||||
|
||||
await findCommand('Comfy.Graph.GroupSelectedNodes').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConvertToSubgraph command', () => {
|
||||
it('should show error toast when conversion fails', async () => {
|
||||
app.canvas.graph!.convertToSubgraph = vi.fn().mockReturnValue(null)
|
||||
|
||||
await findCommand('Comfy.Graph.ConvertToSubgraph').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should select the new subgraph node on success', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
app.canvas.graph!.convertToSubgraph = vi
|
||||
.fn()
|
||||
.mockReturnValue({ node: mockNode })
|
||||
|
||||
await findCommand('Comfy.Graph.ConvertToSubgraph').function()
|
||||
|
||||
expect(app.canvas.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContactSupport command', () => {
|
||||
it('should open support URL in new window', async () => {
|
||||
await findCommand('Comfy.ContactSupport').function()
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://support.test.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph metadata commands', () => {
|
||||
beforeEach(() => {
|
||||
mockSubgraph.extra = {}
|
||||
@@ -1182,7 +580,7 @@ describe('useCoreCommands', () => {
|
||||
it('Comfy.Help.OpenComfyUIIssues opens the GitHub issues URL and tracks telemetry', async () => {
|
||||
await findCmd('Comfy.Help.OpenComfyUIIssues').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith(
|
||||
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource_type: 'github',
|
||||
is_external: true,
|
||||
@@ -1195,7 +593,7 @@ describe('useCoreCommands', () => {
|
||||
it('Comfy.Help.OpenComfyOrgDiscord opens the Discord URL and tracks telemetry', async () => {
|
||||
await findCmd('Comfy.Help.OpenComfyOrgDiscord').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith(
|
||||
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource_type: 'discord'
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
MISSING_TAG,
|
||||
assetService,
|
||||
isBlake3AssetHash,
|
||||
toBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
@@ -44,6 +49,10 @@ vi.mock('@/i18n', () => ({
|
||||
|
||||
const fetchApiMock = vi.mocked(api.fetchApi)
|
||||
|
||||
const validBlake3Hash =
|
||||
'1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const validBlake3AssetHash = `blake3:${validBlake3Hash}`
|
||||
|
||||
function buildResponse(
|
||||
body: unknown,
|
||||
init: { ok?: boolean; status?: number } = {}
|
||||
@@ -180,9 +189,98 @@ describe(assetService.getAssetMetadata, () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe(isBlake3AssetHash, () => {
|
||||
it('accepts only prefixed 64-character blake3 hashes', () => {
|
||||
expect(isBlake3AssetHash(validBlake3AssetHash)).toBe(true)
|
||||
expect(isBlake3AssetHash('BLAKE3:' + validBlake3Hash.toUpperCase())).toBe(
|
||||
true
|
||||
)
|
||||
expect(isBlake3AssetHash('blake3:abc')).toBe(false)
|
||||
expect(isBlake3AssetHash(validBlake3Hash)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(toBlake3AssetHash, () => {
|
||||
it('normalizes 64-character blake3 hex values to asset hashes', () => {
|
||||
expect(toBlake3AssetHash(validBlake3Hash)).toBe(validBlake3AssetHash)
|
||||
expect(toBlake3AssetHash('abc')).toBeNull()
|
||||
expect(toBlake3AssetHash(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.uploadAssetFromUrl, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
})
|
||||
|
||||
it('does not invalidate cached input assets when the upload response is invalid', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await expect(
|
||||
assetService.uploadAssetFromUrl({
|
||||
url: 'https://example.com/input.png',
|
||||
name: 'input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
).rejects.toThrow('Failed to upload asset')
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('requires upload responses to include created_new', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
|
||||
)
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await expect(
|
||||
assetService.uploadAssetFromUrl({
|
||||
url: 'https://example.com/input.png',
|
||||
name: 'input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
).rejects.toThrow('Failed to upload asset')
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns validated upload responses with created_new', async () => {
|
||||
const uploadedAsset = {
|
||||
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
|
||||
created_new: true
|
||||
}
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse(uploadedAsset))
|
||||
|
||||
await expect(
|
||||
assetService.uploadAssetFromUrl({
|
||||
url: 'https://example.com/input.png',
|
||||
name: 'input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
).resolves.toEqual(uploadedAsset)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.uploadAssetFromBase64, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
})
|
||||
|
||||
it('throws before calling the network when data is not a data URL', async () => {
|
||||
@@ -195,6 +293,63 @@ describe(assetService.uploadAssetFromBase64, () => {
|
||||
|
||||
expect(fetchApiMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not invalidate cached input assets when the upload response is invalid', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await expect(
|
||||
assetService.uploadAssetFromBase64({
|
||||
data: 'data:text/plain;base64,aGVsbG8=',
|
||||
name: 'input.txt',
|
||||
tags: ['input']
|
||||
})
|
||||
).rejects.toThrow('Failed to upload asset')
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
fetchSpy.mockRestore()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects upload responses with a non-boolean created_new', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
|
||||
created_new: 'true'
|
||||
})
|
||||
)
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await expect(
|
||||
assetService.uploadAssetFromBase64({
|
||||
data: 'data:text/plain;base64,aGVsbG8=',
|
||||
name: 'input.txt',
|
||||
tags: ['input']
|
||||
})
|
||||
).rejects.toThrow('Failed to upload asset')
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
fetchSpy.mockRestore()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.uploadAssetAsync, () => {
|
||||
@@ -354,3 +509,391 @@ describe(assetService.getAssetsByTag, () => {
|
||||
expect(params.get('include_public')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.getAllAssetsByTag, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('paginates tagged asset requests with include_public=true', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'c', tags: ['input'] })]
|
||||
})
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['a', 'b', 'c'])
|
||||
|
||||
const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const firstParams = new URL(firstUrl, 'http://localhost').searchParams
|
||||
expect(firstParams.get('include_public')).toBe('true')
|
||||
expect(firstParams.get('limit')).toBe('2')
|
||||
expect(firstParams.has('offset')).toBe(false)
|
||||
|
||||
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
|
||||
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
|
||||
expect(secondParams.get('include_public')).toBe('true')
|
||||
expect(secondParams.get('limit')).toBe('2')
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('paginates from raw response size before filtering missing-tagged assets', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
|
||||
})
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public'])
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
|
||||
if (typeof secondUrl !== 'string') {
|
||||
throw new Error('Expected a second asset request URL')
|
||||
}
|
||||
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('honors has_more when walking tagged asset pages', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'first', tags: ['input'] }),
|
||||
validAsset({ id: 'second', tags: ['input'] })
|
||||
],
|
||||
has_more: true
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
|
||||
has_more: false
|
||||
})
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 3
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['first', 'second', 'later-public'])
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
|
||||
if (typeof secondUrl !== 'string') {
|
||||
throw new Error('Expected a second asset request URL')
|
||||
}
|
||||
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('passes abort signals through paginated requests', async () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })]
|
||||
})
|
||||
)
|
||||
|
||||
await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(expect.any(String), {
|
||||
signal: controller.signal
|
||||
})
|
||||
})
|
||||
|
||||
it('stops pagination when aborted between pages', async () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
return buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2,
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
})
|
||||
|
||||
it('loads input assets with public assets included and reuses the cache', async () => {
|
||||
const assets = [
|
||||
validAsset({ id: 'user-input', tags: ['input'] }),
|
||||
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
|
||||
]
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
|
||||
|
||||
const first = await assetService.getInputAssetsIncludingPublic()
|
||||
const second = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(first).toEqual(assets)
|
||||
expect(second).toBe(first)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
|
||||
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const params = new URL(requestedUrl, 'http://localhost').searchParams
|
||||
expect(params.get('include_public')).toBe('true')
|
||||
expect(params.get('limit')).toBe('500')
|
||||
})
|
||||
|
||||
it('fetches fresh input assets after explicit invalidation', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
const refreshed = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(refreshed).toEqual(freshAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not let one caller abort the shared input asset load for other callers', async () => {
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
|
||||
let resolveResponse!: (response: Response) => void
|
||||
let serviceSignal: AbortSignal | undefined
|
||||
fetchApiMock.mockImplementationOnce(async (_url, options) => {
|
||||
serviceSignal = options?.signal ?? undefined
|
||||
return await new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
})
|
||||
|
||||
const first = assetService.getInputAssetsIncludingPublic(
|
||||
firstController.signal
|
||||
)
|
||||
const second = assetService.getInputAssetsIncludingPublic(
|
||||
secondController.signal
|
||||
)
|
||||
firstController.abort()
|
||||
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(serviceSignal).toBeUndefined()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
|
||||
await expect(second).resolves.toEqual(assets)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the shared input asset load alive after all callers abort', async () => {
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
|
||||
let resolveResponse!: (response: Response) => void
|
||||
fetchApiMock.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = assetService.getInputAssetsIncludingPublic(
|
||||
firstController.signal
|
||||
)
|
||||
const second = assetService.getInputAssetsIncludingPublic(
|
||||
secondController.signal
|
||||
)
|
||||
firstController.abort()
|
||||
secondController.abort()
|
||||
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
await Promise.resolve()
|
||||
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
assets
|
||||
)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not abort in-flight input asset loads when invalidated', async () => {
|
||||
const assets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
let resolveResponse!: (response: Response) => void
|
||||
fetchApiMock
|
||||
.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
|
||||
const inFlight = assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
|
||||
await expect(inFlight).resolves.toEqual(assets)
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
freshAssets
|
||||
)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('invalidates cached input assets after deleting an asset', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse(null))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.deleteAsset('stale-input')
|
||||
const refreshed = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(refreshed).toEqual(freshAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(3)
|
||||
expect(fetchApiMock.mock.calls[1]).toEqual([
|
||||
'/assets/stale-input',
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
])
|
||||
})
|
||||
|
||||
it('invalidates cached input assets after an input asset upload', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
|
||||
const freshAssets = [uploadedAsset]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse(uploadedAsset))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.uploadAssetAsync({
|
||||
source_url: 'https://example.com/input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
const refreshed = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(refreshed).toEqual(freshAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not invalidate cached input assets for pending async input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(
|
||||
{ task_id: 'task-1', status: 'running' },
|
||||
{ ok: true, status: 202 }
|
||||
)
|
||||
)
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.uploadAssetAsync({
|
||||
source_url: 'https://example.com/input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not invalidate cached input assets for non-input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.uploadAssetAsync({
|
||||
source_url: 'https://example.com/model.safetensors',
|
||||
tags: ['models']
|
||||
})
|
||||
const cached = await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
expect(cached).toEqual(staleAssets)
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.checkAssetHash, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[200, 'exists'],
|
||||
[404, 'missing'],
|
||||
[400, 'invalid']
|
||||
] as const)('maps %s responses to %s', async (status, expected) => {
|
||||
const hash =
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status }))
|
||||
|
||||
await expect(assetService.checkAssetHash(hash)).resolves.toBe(expected)
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||
`/assets/hash/${encodeURIComponent(hash)}`,
|
||||
{
|
||||
method: 'HEAD',
|
||||
signal: undefined
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for unexpected responses', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status: 500 }))
|
||||
|
||||
await expect(assetService.checkAssetHash('blake3:abc')).rejects.toThrow(
|
||||
'Unexpected asset hash check status: 500'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
|
||||
@@ -29,9 +30,14 @@ export interface PaginationOptions {
|
||||
offset?: number
|
||||
}
|
||||
|
||||
interface AssetPaginationOptions extends PaginationOptions {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface AssetRequestOptions extends PaginationOptions {
|
||||
includeTags: string[]
|
||||
includePublic?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface AssetExportOptions {
|
||||
@@ -170,10 +176,61 @@ const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
|
||||
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
const DEFAULT_LIMIT = 500
|
||||
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
|
||||
|
||||
export const MODELS_TAG = 'models'
|
||||
/** Asset tag used by the backend for placeholder records that are not installed. */
|
||||
export const MISSING_TAG = 'missing'
|
||||
|
||||
/** Result of a HEAD lookup against an exact asset hash. */
|
||||
export type AssetHashStatus = 'exists' | 'missing' | 'invalid'
|
||||
|
||||
const BLAKE3_ASSET_HASH_PATTERN = /^blake3:[0-9a-f]{64}$/i
|
||||
const BLAKE3_HEX_PATTERN = /^[0-9a-f]{64}$/i
|
||||
const uploadedAssetResponseSchema = assetItemSchema.extend({
|
||||
created_new: z.boolean()
|
||||
})
|
||||
|
||||
/** Returns true for a prefixed BLAKE3 asset hash: `blake3:<64 hex>`. */
|
||||
export function isBlake3AssetHash(value: string): boolean {
|
||||
return BLAKE3_ASSET_HASH_PATTERN.test(value)
|
||||
}
|
||||
|
||||
/** Converts a raw 64-character BLAKE3 hex digest into an asset hash. */
|
||||
export function toBlake3AssetHash(hash: string | undefined): string | null {
|
||||
if (!hash || !BLAKE3_HEX_PATTERN.test(hash)) return null
|
||||
return `blake3:${hash}`
|
||||
}
|
||||
|
||||
function createAbortError(): DOMException {
|
||||
return new DOMException('Aborted', 'AbortError')
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw createAbortError()
|
||||
}
|
||||
|
||||
async function withCallerAbort<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
throwIfAborted(signal)
|
||||
if (!signal) return await promise
|
||||
|
||||
let removeAbortListener = () => {}
|
||||
const abortPromise = new Promise<never>((_, reject) => {
|
||||
const onAbort = () => reject(createAbortError())
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
removeAbortListener = () => signal.removeEventListener('abort', onAbort)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, abortPromise])
|
||||
} finally {
|
||||
removeAbortListener()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates asset response data using Zod schema
|
||||
*/
|
||||
@@ -187,11 +244,43 @@ function validateAssetResponse(data: unknown): AssetResponse {
|
||||
)
|
||||
}
|
||||
|
||||
function validateUploadedAssetResponse(
|
||||
data: unknown
|
||||
): AssetItem & { created_new: boolean } {
|
||||
const result = uploadedAssetResponseSchema.safeParse(data)
|
||||
if (result.success) {
|
||||
return result.data
|
||||
}
|
||||
|
||||
console.error('Invalid asset upload response:', fromZodError(result.error))
|
||||
throw new Error(
|
||||
st(
|
||||
'assetBrowser.errorUploadFailed',
|
||||
'Failed to upload asset. Please try again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Private service for asset-related network requests
|
||||
* Not exposed globally - used internally by ComfyApi
|
||||
*/
|
||||
function createAssetService() {
|
||||
let inputAssetsIncludingPublic: AssetItem[] | null = null
|
||||
let inputAssetsIncludingPublicRequestId = 0
|
||||
let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null
|
||||
|
||||
/** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */
|
||||
function invalidateInputAssetsIncludingPublic(): void {
|
||||
inputAssetsIncludingPublicRequestId++
|
||||
pendingInputAssetsIncludingPublic = null
|
||||
inputAssetsIncludingPublic = null
|
||||
}
|
||||
|
||||
function invalidateInputAssetsCacheIfNeeded(tags?: string[]): void {
|
||||
if (tags?.includes('input')) invalidateInputAssetsIncludingPublic()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles API response with consistent error handling and Zod validation
|
||||
*/
|
||||
@@ -203,7 +292,8 @@ function createAssetService() {
|
||||
includeTags,
|
||||
limit = DEFAULT_LIMIT,
|
||||
offset,
|
||||
includePublic
|
||||
includePublic,
|
||||
signal
|
||||
} = options
|
||||
const queryParams = new URLSearchParams({
|
||||
include_tags: includeTags.join(','),
|
||||
@@ -217,7 +307,9 @@ function createAssetService() {
|
||||
}
|
||||
|
||||
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
|
||||
const res = await api.fetchApi(url)
|
||||
const res = signal
|
||||
? await api.fetchApi(url, { signal })
|
||||
: await api.fetchApi(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`
|
||||
@@ -403,15 +495,16 @@ function createAssetService() {
|
||||
* @param options - Pagination options
|
||||
* @param options.limit - Maximum number of assets to return (default: 500)
|
||||
* @param options.offset - Number of assets to skip (default: 0)
|
||||
* @param options.signal - Optional abort signal for cancelling the request
|
||||
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
|
||||
*/
|
||||
async function getAssetsByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true,
|
||||
{ limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {}
|
||||
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
|
||||
): Promise<AssetItem[]> {
|
||||
const data = await handleAssetRequest(
|
||||
{ includeTags: [tag], limit, offset, includePublic },
|
||||
{ includeTags: [tag], limit, offset, includePublic, signal },
|
||||
`assets for tag ${tag}`
|
||||
)
|
||||
|
||||
@@ -420,6 +513,116 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets every asset for a tag by walking paginated asset API responses.
|
||||
*
|
||||
* @param tag - The tag to filter by (e.g., 'models', 'input')
|
||||
* @param includePublic - Whether to include public assets (default: true)
|
||||
* @param options - Pagination options
|
||||
* @param options.limit - Page size for each request (default: 500)
|
||||
* @param options.signal - Optional abort signal for cancelling requests
|
||||
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag
|
||||
*/
|
||||
async function getAllAssetsByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true,
|
||||
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
|
||||
): Promise<AssetItem[]> {
|
||||
const assets: AssetItem[] = []
|
||||
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
|
||||
let offset = 0
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) throw createAbortError()
|
||||
|
||||
const data = await handleAssetRequest(
|
||||
{
|
||||
includeTags: [tag],
|
||||
limit: pageSize,
|
||||
offset,
|
||||
includePublic,
|
||||
signal
|
||||
},
|
||||
`assets for tag ${tag}`
|
||||
)
|
||||
const batch = data.assets ?? []
|
||||
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
|
||||
|
||||
const noMoreFromServer = data.has_more === false
|
||||
const inferredLastPage =
|
||||
data.has_more === undefined && batch.length < pageSize
|
||||
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
|
||||
return assets
|
||||
}
|
||||
|
||||
offset += batch.length
|
||||
}
|
||||
}
|
||||
|
||||
function startInputAssetsIncludingPublicRequest(): Promise<AssetItem[]> {
|
||||
const requestId = ++inputAssetsIncludingPublicRequestId
|
||||
|
||||
pendingInputAssetsIncludingPublic = getAllAssetsByTag('input', true, {
|
||||
limit: INPUT_ASSETS_WITH_PUBLIC_LIMIT
|
||||
})
|
||||
.then((assets) => {
|
||||
if (requestId === inputAssetsIncludingPublicRequestId) {
|
||||
inputAssetsIncludingPublic = assets
|
||||
}
|
||||
return assets
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === inputAssetsIncludingPublicRequestId) {
|
||||
pendingInputAssetsIncludingPublic = null
|
||||
}
|
||||
})
|
||||
|
||||
void pendingInputAssetsIncludingPublic.catch(() => {})
|
||||
return pendingInputAssetsIncludingPublic
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cached input assets including public assets for missing media checks.
|
||||
* Caller aborts cancel only that caller; shared fetches are invalidated
|
||||
* through invalidateInputAssetsIncludingPublic().
|
||||
*/
|
||||
async function getInputAssetsIncludingPublic(
|
||||
signal?: AbortSignal
|
||||
): Promise<AssetItem[]> {
|
||||
throwIfAborted(signal)
|
||||
if (inputAssetsIncludingPublic) return inputAssetsIncludingPublic
|
||||
|
||||
const request =
|
||||
pendingInputAssetsIncludingPublic ??
|
||||
startInputAssetsIncludingPublicRequest()
|
||||
return await withCallerAbort(request, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an asset exists for an exact asset hash.
|
||||
*
|
||||
* Uses the HEAD /assets/hash/{hash} endpoint and maps status-only responses:
|
||||
* 200 -> exists, 404 -> missing, and 400 -> invalid hash format.
|
||||
*/
|
||||
async function checkAssetHash(
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<AssetHashStatus> {
|
||||
const response = await api.fetchApi(
|
||||
`${ASSETS_ENDPOINT}/hash/${encodeURIComponent(assetHash)}`,
|
||||
{
|
||||
method: 'HEAD',
|
||||
signal
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status === 200) return 'exists'
|
||||
if (response.status === 404) return 'missing'
|
||||
if (response.status === 400) return 'invalid'
|
||||
|
||||
throw new Error(`Unexpected asset hash check status: ${response.status}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an asset by ID
|
||||
* Only available in cloud environment
|
||||
@@ -438,6 +641,8 @@ function createAssetService() {
|
||||
`Unable to delete asset ${id}: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
|
||||
invalidateInputAssetsIncludingPublic()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,7 +750,9 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
const asset = validateUploadedAssetResponse(await res.json())
|
||||
invalidateInputAssetsCacheIfNeeded(params.tags)
|
||||
return asset
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,7 +805,9 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
const asset = validateUploadedAssetResponse(await res.json())
|
||||
invalidateInputAssetsCacheIfNeeded(params.tags)
|
||||
return asset
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -628,6 +837,7 @@ function createAssetService() {
|
||||
if (!parseResult.success) {
|
||||
throw fromZodError(parseResult.error)
|
||||
}
|
||||
invalidateInputAssetsIncludingPublic()
|
||||
return parseResult.data
|
||||
}
|
||||
|
||||
@@ -658,6 +868,7 @@ function createAssetService() {
|
||||
if (!parseResult.success) {
|
||||
throw fromZodError(parseResult.error)
|
||||
}
|
||||
invalidateInputAssetsIncludingPublic()
|
||||
return parseResult.data
|
||||
}
|
||||
|
||||
@@ -709,6 +920,13 @@ function createAssetService() {
|
||||
)
|
||||
)
|
||||
}
|
||||
if (
|
||||
params.tags?.includes('input') &&
|
||||
result.data.type === 'async' &&
|
||||
result.data.task.status === 'completed'
|
||||
) {
|
||||
invalidateInputAssetsIncludingPublic()
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
@@ -724,6 +942,7 @@ function createAssetService() {
|
||||
)
|
||||
)
|
||||
}
|
||||
invalidateInputAssetsCacheIfNeeded(params.tags)
|
||||
return result.data
|
||||
}
|
||||
|
||||
@@ -764,6 +983,10 @@ function createAssetService() {
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
getAllAssetsByTag,
|
||||
getInputAssetsIncludingPublic,
|
||||
invalidateInputAssetsIncludingPublic,
|
||||
checkAssetHash,
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
addAssetTags,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
scanAllMediaCandidates,
|
||||
scanNodeMediaCandidates,
|
||||
@@ -13,6 +15,13 @@ import {
|
||||
} from './missingMediaScan'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted(
|
||||
() => ({
|
||||
mockCheckAssetHash: vi.fn(),
|
||||
mockGetInputAssetsIncludingPublic: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
|
||||
getExecutionIdByNode: (
|
||||
@@ -21,6 +30,21 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
) => node._testExecutionId ?? String(node.id)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', async () => {
|
||||
const actual = await vi.importActual<typeof AssetServiceModule>(
|
||||
'@/platform/assets/services/assetService'
|
||||
)
|
||||
|
||||
return {
|
||||
...actual,
|
||||
assetService: {
|
||||
...actual.assetService,
|
||||
checkAssetHash: mockCheckAssetHash,
|
||||
getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeCandidate(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
@@ -70,6 +94,16 @@ function makeGraph(nodes: LGraphNode[]): LGraph {
|
||||
return fromAny<LGraph, unknown>({ _testNodes: nodes })
|
||||
}
|
||||
|
||||
function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
asset_hash: assetHash,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
}
|
||||
|
||||
describe('scanNodeMediaCandidates', () => {
|
||||
it('returns candidate for a LoadImage node with missing image', () => {
|
||||
const graph = makeGraph([])
|
||||
@@ -232,37 +266,43 @@ describe('groupCandidatesByMediaType', () => {
|
||||
})
|
||||
|
||||
describe('verifyCloudMediaCandidates', () => {
|
||||
it('marks candidates missing when not in input assets', async () => {
|
||||
const existingHash =
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const missingHash =
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckAssetHash.mockResolvedValue('missing')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('marks candidates missing when the asset hash is not found', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'abc123.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'def456.png', { isMissing: undefined })
|
||||
makeCandidate('1', missingHash, { isMissing: undefined }),
|
||||
makeCandidate('2', existingHash, { isMissing: undefined })
|
||||
]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: [{ asset_hash: 'def456.png', name: 'my-photo.png' }]
|
||||
}
|
||||
const checkAssetHash = vi.fn(async (assetHash: string) =>
|
||||
assetHash === existingHash ? ('exists' as const) : ('missing' as const)
|
||||
)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(candidates[1].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('calls updateInputs before checking assets', async () => {
|
||||
let updateCalled = false
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
|
||||
it('uses assetService.checkAssetHash by default', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('exists')
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {
|
||||
updateCalled = true
|
||||
},
|
||||
inputAssets: []
|
||||
}
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
|
||||
expect(updateCalled).toBe(true)
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
|
||||
})
|
||||
|
||||
it('respects abort signal before execution', async () => {
|
||||
@@ -270,69 +310,221 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
controller.abort()
|
||||
|
||||
const candidates = [
|
||||
makeCandidate('1', 'abc123.png', { isMissing: undefined })
|
||||
makeCandidate('1', missingHash, { isMissing: undefined })
|
||||
]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('respects abort signal after updateInputs', async () => {
|
||||
it('respects abort signal after hash verification', async () => {
|
||||
const controller = new AbortController()
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
const checkAssetHash = vi.fn(async () => {
|
||||
controller.abort()
|
||||
return 'exists' as const
|
||||
})
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {
|
||||
controller.abort()
|
||||
},
|
||||
inputAssets: [{ asset_hash: 'abc.png', name: 'photo.png' }]
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, controller.signal, mockStore)
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
controller.signal,
|
||||
checkAssetHash
|
||||
)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips candidates already resolved as true', async () => {
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
|
||||
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: []
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips candidates already resolved as false', async () => {
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: false })]
|
||||
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: []
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips entirely when no pending candidates', async () => {
|
||||
let updateCalled = false
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
|
||||
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {
|
||||
updateCalled = true
|
||||
},
|
||||
inputAssets: []
|
||||
}
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(updateCalled).toBe(false)
|
||||
it('falls back to input assets for non-blake3 candidate names', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'missing.png', { isMissing: undefined })
|
||||
]
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('stored-photo.png', 'photo.png')
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(candidates[1].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('uses public input assets for default legacy fallback', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'public-photo.png', { isMissing: undefined })
|
||||
]
|
||||
const inputAssets = Array.from({ length: 500 }, (_, index) =>
|
||||
makeAsset(`asset-${index}.png`)
|
||||
)
|
||||
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('silences aborts while loading legacy fallback input assets', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
const fetchInputAssets = vi.fn(async () => {
|
||||
controller.abort()
|
||||
throw abortError
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
controller.signal,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('silences aborts from the default legacy fallback input asset store path', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
throw abortError
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
controller.signal
|
||||
)
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to input assets when the hash endpoint returns 400', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('invalid')
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('photo.png', existingHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to input assets when hash verification fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
const checkAssetHash = vi.fn(async () => {
|
||||
throw new Error('network failed')
|
||||
})
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('photo.png', existingHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
checkAssetHash,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('does not call the hash endpoint for malformed blake3-looking values', async () => {
|
||||
const malformedHash = 'blake3:abc'
|
||||
const candidates = [
|
||||
makeCandidate('1', malformedHash, { isMissing: undefined })
|
||||
]
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('legacy.png', malformedHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates checks for repeated candidate names', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', missingHash, { isMissing: undefined }),
|
||||
makeCandidate('2', missingHash, { isMissing: undefined })
|
||||
]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(candidates[1].isMissing).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
assetService,
|
||||
isBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
|
||||
/** Map of node types to their media widget name and media type. */
|
||||
const MEDIA_NODE_WIDGETS: Record<
|
||||
@@ -106,41 +112,130 @@ export function scanNodeMediaCandidates(
|
||||
return candidates
|
||||
}
|
||||
|
||||
interface InputVerifier {
|
||||
updateInputs: () => Promise<unknown>
|
||||
inputAssets: Array<{ asset_hash?: string | null; name: string }>
|
||||
type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]>
|
||||
|
||||
function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
|
||||
candidatesByHash: Map<string, MissingMediaCandidate[]>
|
||||
legacyCandidates: MissingMediaCandidate[]
|
||||
} {
|
||||
const candidatesByHash = new Map<string, MissingMediaCandidate[]>()
|
||||
const legacyCandidates: MissingMediaCandidate[] = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!isBlake3AssetHash(candidate.name)) {
|
||||
legacyCandidates.push(candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashCandidates = candidatesByHash.get(candidate.name)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(candidate.name, [candidate])
|
||||
}
|
||||
|
||||
return { candidatesByHash, legacyCandidates }
|
||||
}
|
||||
|
||||
async function verifyCandidatesByHash(
|
||||
candidatesByHash: Map<string, MissingMediaCandidate[]>,
|
||||
legacyCandidates: MissingMediaCandidate[],
|
||||
signal: AbortSignal | undefined,
|
||||
checkAssetHash: AssetHashVerifier
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
|
||||
if (signal?.aborted) return
|
||||
|
||||
let status: AssetHashStatus
|
||||
try {
|
||||
status = await checkAssetHash(assetHash, signal)
|
||||
if (signal?.aborted) return
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
console.warn(
|
||||
'[Missing Media Pipeline] Failed to verify asset hash:',
|
||||
err
|
||||
)
|
||||
legacyCandidates.push(...hashCandidates)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'invalid') {
|
||||
legacyCandidates.push(...hashCandidates)
|
||||
return
|
||||
}
|
||||
|
||||
for (const candidate of hashCandidates) {
|
||||
candidate.isMissing = status === 'missing'
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cloud media candidates against the input assets fetched from the
|
||||
* assets store. Mutates candidates' `isMissing` in place.
|
||||
* Verify cloud media candidates by probing the asset hash endpoint first.
|
||||
* Invalid hash values fall back to the legacy input asset list check.
|
||||
*/
|
||||
export async function verifyCloudMediaCandidates(
|
||||
candidates: MissingMediaCandidate[],
|
||||
signal?: AbortSignal,
|
||||
assetsStore?: InputVerifier
|
||||
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash,
|
||||
fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const pending = candidates.filter((c) => c.isMissing === undefined)
|
||||
if (pending.length === 0) return
|
||||
|
||||
const store =
|
||||
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
||||
const { candidatesByHash, legacyCandidates } =
|
||||
groupCandidatesForHashLookup(pending)
|
||||
await verifyCandidatesByHash(
|
||||
candidatesByHash,
|
||||
legacyCandidates,
|
||||
signal,
|
||||
checkAssetHash
|
||||
)
|
||||
|
||||
await store.updateInputs()
|
||||
if (signal?.aborted || legacyCandidates.length === 0) return
|
||||
|
||||
let inputAssets: AssetItem[]
|
||||
try {
|
||||
inputAssets = await fetchInputAssets(signal)
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
throw err
|
||||
}
|
||||
|
||||
if (signal?.aborted) return
|
||||
|
||||
const assetHashes = new Set(
|
||||
store.inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
||||
inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
||||
)
|
||||
|
||||
for (const c of pending) {
|
||||
c.isMissing = !assetHashes.has(c.name)
|
||||
for (const candidate of legacyCandidates) {
|
||||
candidate.isMissing = !assetHashes.has(candidate.name)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMissingInputAssets(
|
||||
signal?: AbortSignal
|
||||
): Promise<AssetItem[]> {
|
||||
return await assetService.getInputAssetsIncludingPublic(signal)
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
err.name === 'AbortError'
|
||||
)
|
||||
}
|
||||
|
||||
/** Group confirmed-missing candidates by file name into view models. */
|
||||
export function groupCandidatesByName(
|
||||
candidates: MissingMediaCandidate[]
|
||||
|
||||
@@ -19,6 +19,11 @@ import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/a
|
||||
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
|
||||
|
||||
const { mockCheckAssetHash } = vi.hoisted(() => ({
|
||||
mockCheckAssetHash: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
|
||||
@@ -28,6 +33,20 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
) => node._testExecutionId ?? String(node.id)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', async () => {
|
||||
const actual = await vi.importActual<typeof AssetServiceModule>(
|
||||
'@/platform/assets/services/assetService'
|
||||
)
|
||||
|
||||
return {
|
||||
...actual,
|
||||
assetService: {
|
||||
...actual.assetService,
|
||||
checkAssetHash: mockCheckAssetHash
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** Helper: create a combo widget mock */
|
||||
function makeComboWidget(
|
||||
name: string,
|
||||
@@ -43,7 +62,7 @@ function makeComboWidget(
|
||||
}
|
||||
|
||||
/** Helper: create an asset widget mock (Cloud combo replacement) */
|
||||
function makeAssetWidget(name: string, value: string): IBaseWidget {
|
||||
function makeAssetWidget(name: string, value: unknown): IBaseWidget {
|
||||
return fromAny<IBaseWidget, unknown>({
|
||||
type: 'asset',
|
||||
name,
|
||||
@@ -551,6 +570,16 @@ describe('scanAllModelCandidates', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should skip asset widgets with non-string values', () => {
|
||||
const graph = makeGraph([
|
||||
makeNode(1, 'SomeNode', [makeAssetWidget('ckpt_name', 123)])
|
||||
])
|
||||
|
||||
const result = scanAllModelCandidates(graph, noAssetSupport)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should scan both combo and asset widgets on the same node', () => {
|
||||
const graph = makeGraph([
|
||||
makeNode(1, 'DualLoaderNode', [
|
||||
@@ -1411,6 +1440,7 @@ function makeAssetCandidate(
|
||||
describe('verifyAssetSupportedCandidates', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckAssetHash.mockResolvedValue('missing')
|
||||
mockIsModelLoading.mockReturnValue(false)
|
||||
mockHasMore.mockReturnValue(false)
|
||||
mockGetAssets.mockReturnValue([])
|
||||
@@ -1428,6 +1458,125 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when the blake3 hash endpoint finds the asset', async () => {
|
||||
const hash =
|
||||
'1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const candidates = [
|
||||
makeAssetCandidate('model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('exists')
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledWith(`blake3:${hash}`, undefined)
|
||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to asset store matching when the blake3 hash is not found', async () => {
|
||||
const hash =
|
||||
'2222222222222222222222222222222222222222222222222222222222222222'
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('missing')
|
||||
mockGetAssets.mockReturnValue([
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to asset store matching when hash verification fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const hash =
|
||||
'3333333333333333333333333333333333333333333333333333333333333333'
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockRejectedValue(new Error('network failed'))
|
||||
mockGetAssets.mockReturnValue([
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('should skip malformed blake3 hashes and use asset store matching', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash: 'abc123',
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockGetAssets.mockReturnValue([
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('should not warn or fall back when hash verification is aborted', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const hash =
|
||||
'4444444444444444444444444444444444444444444444444444444444444444'
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockRejectedValue(abortError)
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when asset with matching hash exists', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('model.safetensors', {
|
||||
@@ -1442,6 +1591,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when asset with matching filename exists', async () => {
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
assetService,
|
||||
toBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
|
||||
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
|
||||
models?: ModelFile[]
|
||||
@@ -177,7 +182,7 @@ function scanAssetWidget(
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
const value = widget.value
|
||||
if (!value.trim()) return null
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
if (!isModelFileName(value)) return null
|
||||
|
||||
return {
|
||||
@@ -445,20 +450,68 @@ interface AssetVerifier {
|
||||
getAssets: (nodeType: string) => AssetItem[] | undefined
|
||||
}
|
||||
|
||||
type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
export async function verifyAssetSupportedCandidates(
|
||||
candidates: MissingModelCandidate[],
|
||||
signal?: AbortSignal,
|
||||
assetsStore?: AssetVerifier
|
||||
assetsStore?: AssetVerifier,
|
||||
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const pendingCandidates = candidates.filter(
|
||||
(c) => c.isAssetSupported && c.isMissing === undefined
|
||||
)
|
||||
if (pendingCandidates.length === 0) return
|
||||
|
||||
const pendingNodeTypes = new Set<string>()
|
||||
for (const c of candidates) {
|
||||
if (c.isAssetSupported && c.isMissing === undefined) {
|
||||
pendingNodeTypes.add(c.nodeType)
|
||||
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
|
||||
|
||||
for (const candidate of pendingCandidates) {
|
||||
const assetHash = getBlake3AssetHash(candidate)
|
||||
if (!assetHash) {
|
||||
pendingNodeTypes.add(candidate.nodeType)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashCandidates = candidatesByHash.get(assetHash)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(assetHash, [candidate])
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
|
||||
if (signal?.aborted) return
|
||||
|
||||
try {
|
||||
const status = await checkAssetHash(assetHash, signal)
|
||||
if (signal?.aborted) return
|
||||
|
||||
if (status === 'exists') {
|
||||
for (const candidate of hashCandidates) {
|
||||
candidate.isMissing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
console.warn(
|
||||
'[Missing Model Pipeline] Failed to verify asset hash:',
|
||||
err
|
||||
)
|
||||
}
|
||||
|
||||
for (const candidate of hashCandidates) {
|
||||
pendingNodeTypes.add(candidate.nodeType)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (signal?.aborted) return
|
||||
if (pendingNodeTypes.size === 0) return
|
||||
|
||||
const store =
|
||||
@@ -491,6 +544,20 @@ export async function verifyAssetSupportedCandidates(
|
||||
}
|
||||
}
|
||||
|
||||
function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
|
||||
if (candidate.hashType?.toLowerCase() !== 'blake3') return null
|
||||
return toBlake3AssetHash(candidate.hash)
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
err.name === 'AbortError'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
@@ -1,379 +1 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { resultItemType } from '@/schemas/apiSchema'
|
||||
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
response_key: z.string().optional(),
|
||||
query_params: z.record(z.string(), z.string()).optional(),
|
||||
refresh_button: z.boolean().optional(),
|
||||
control_after_refresh: z.enum(['first', 'last']).optional(),
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
const zMultiSelectOption = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
chip: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zBaseInputOptions = z
|
||||
.object({
|
||||
default: z.any().optional(),
|
||||
defaultInput: z.boolean().optional(),
|
||||
display_name: z.string().optional(),
|
||||
forceInput: z.boolean().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
socketless: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
advanced: z.boolean().optional(),
|
||||
widgetType: z.string().optional(),
|
||||
/** Backend-only properties. */
|
||||
rawLink: z.boolean().optional(),
|
||||
lazy: z.boolean().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zNumericInputOptions = zBaseInputOptions.extend({
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
step: z.number().optional(),
|
||||
/** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
|
||||
default: z.union([z.number(), z.array(z.number())]).optional(),
|
||||
display: z.enum(['slider', 'number', 'knob', 'gradientslider']).optional()
|
||||
})
|
||||
|
||||
export const zIntInputOptions = zNumericInputOptions.extend({
|
||||
/**
|
||||
* If true, a linked widget will be added to the node to select the mode
|
||||
* of `control_after_generate`.
|
||||
*/
|
||||
control_after_generate: z
|
||||
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zColorStop = z.object({
|
||||
offset: z.number(),
|
||||
color: z.tuple([z.number(), z.number(), z.number()])
|
||||
})
|
||||
|
||||
export const zFloatInputOptions = zNumericInputOptions.extend({
|
||||
round: z.union([z.number(), z.literal(false)]).optional(),
|
||||
gradient_stops: z.array(zColorStop).optional()
|
||||
})
|
||||
|
||||
export const zBooleanInputOptions = zBaseInputOptions.extend({
|
||||
label_on: z.string().optional(),
|
||||
label_off: z.string().optional(),
|
||||
default: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zStringInputOptions = zBaseInputOptions.extend({
|
||||
default: z.string().optional(),
|
||||
multiline: z.boolean().optional(),
|
||||
dynamicPrompts: z.boolean().optional(),
|
||||
|
||||
// Multiline-only fields
|
||||
defaultVal: z.string().optional(),
|
||||
placeholder: z.string().optional()
|
||||
})
|
||||
|
||||
export const zComboInputOptions = zBaseInputOptions.extend({
|
||||
control_after_generate: z
|
||||
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
|
||||
.optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
image_folder: resultItemType.optional(),
|
||||
allow_batch: z.boolean().optional(),
|
||||
video_upload: z.boolean().optional(),
|
||||
audio_upload: z.boolean().optional(),
|
||||
mesh_upload: z.boolean().optional(),
|
||||
upload_subfolder: z.string().optional(),
|
||||
animated_image_upload: z.boolean().optional(),
|
||||
options: z.array(zComboOption).optional(),
|
||||
remote: zRemoteWidgetConfig.optional(),
|
||||
/** Whether the widget is a multi-select widget. */
|
||||
multi_select: zMultiSelectOption.optional()
|
||||
})
|
||||
|
||||
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
|
||||
const zFloatInputSpec = z.tuple([
|
||||
z.literal('FLOAT'),
|
||||
zFloatInputOptions.optional()
|
||||
])
|
||||
const zBooleanInputSpec = z.tuple([
|
||||
z.literal('BOOLEAN'),
|
||||
zBooleanInputOptions.optional()
|
||||
])
|
||||
const zStringInputSpec = z.tuple([
|
||||
z.literal('STRING'),
|
||||
zStringInputOptions.optional()
|
||||
])
|
||||
/**
|
||||
* Legacy combo syntax.
|
||||
* @deprecated Use `zComboInputSpecV2` instead.
|
||||
*/
|
||||
const zComboInputSpec = z.tuple([
|
||||
z.array(zComboOption),
|
||||
zComboInputOptions.optional()
|
||||
])
|
||||
const zComboInputSpecV2 = z.tuple([
|
||||
z.literal('COMBO'),
|
||||
zComboInputOptions.optional()
|
||||
])
|
||||
|
||||
export function isComboInputSpecV1(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec {
|
||||
return Array.isArray(inputSpec[0])
|
||||
}
|
||||
|
||||
export function isIntInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is IntInputSpec {
|
||||
return inputSpec[0] === 'INT'
|
||||
}
|
||||
|
||||
export function isFloatInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is FloatInputSpec {
|
||||
return inputSpec[0] === 'FLOAT'
|
||||
}
|
||||
|
||||
export function isComboInputSpecV2(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpecV2 {
|
||||
return inputSpec[0] === 'COMBO'
|
||||
}
|
||||
|
||||
export function isComboInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec | ComboInputSpecV2 {
|
||||
return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec)
|
||||
}
|
||||
|
||||
export function isMediaUploadComboInput(inputSpec: InputSpec): boolean {
|
||||
const [inputName, inputOptions] = inputSpec
|
||||
if (!inputOptions) return false
|
||||
|
||||
const isUploadInput =
|
||||
inputOptions['image_upload'] === true ||
|
||||
inputOptions['video_upload'] === true ||
|
||||
inputOptions['animated_image_upload'] === true
|
||||
|
||||
return (
|
||||
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of an input spec.
|
||||
*
|
||||
* @param inputSpec - The input spec to get the type of.
|
||||
* @returns The type of the input spec.
|
||||
*/
|
||||
export function getInputSpecType(inputSpec: InputSpec): string {
|
||||
return isComboInputSpec(inputSpec) ? 'COMBO' : inputSpec[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the combo options from a combo input spec.
|
||||
*
|
||||
* @param inputSpec - The input spec to get the combo options from.
|
||||
* @returns The combo options.
|
||||
*/
|
||||
export function getComboSpecComboOptions(
|
||||
inputSpec: ComboInputSpec | ComboInputSpecV2
|
||||
): (number | string)[] {
|
||||
return (
|
||||
(isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
|
||||
const zCustomInputSpec = z.tuple([
|
||||
z.string().refine((value) => !excludedLiterals.has(value)),
|
||||
zBaseInputOptions.optional()
|
||||
])
|
||||
|
||||
const zInputSpec = z.union([
|
||||
zIntInputSpec,
|
||||
zFloatInputSpec,
|
||||
zBooleanInputSpec,
|
||||
zStringInputSpec,
|
||||
zComboInputSpec,
|
||||
zComboInputSpecV2,
|
||||
zCustomInputSpec
|
||||
])
|
||||
|
||||
const zComfyInputsSpec = z.object({
|
||||
required: z.record(zInputSpec).optional(),
|
||||
optional: z.record(zInputSpec).optional(),
|
||||
// Frontend repo is not using it, but some custom nodes are using the
|
||||
// hidden field to pass various values.
|
||||
hidden: z.record(z.any()).optional()
|
||||
})
|
||||
|
||||
const zComfyNodeDataType = z.string()
|
||||
const zComfyComboOutput = z.array(zComboOption)
|
||||
const zComfyOutputTypesSpec = z.array(
|
||||
z.union([zComfyNodeDataType, zComfyComboOutput])
|
||||
)
|
||||
|
||||
/**
|
||||
* Widget dependency with type information.
|
||||
* Provides strong type enforcement for JSONata evaluation context.
|
||||
*/
|
||||
const zWidgetDependency = z.object({
|
||||
name: z.string(),
|
||||
type: z.string()
|
||||
})
|
||||
|
||||
export type WidgetDependency = z.infer<typeof zWidgetDependency>
|
||||
|
||||
/**
|
||||
* Schema for price badge depends_on field.
|
||||
* Specifies which widgets and inputs the pricing expression depends on.
|
||||
* Widgets must be specified as objects with name and type.
|
||||
*/
|
||||
const zPriceBadgeDepends = z.object({
|
||||
widgets: z.array(zWidgetDependency).optional().default([]),
|
||||
inputs: z.array(z.string()).optional().default([]),
|
||||
/**
|
||||
* Autogrow input group names to track.
|
||||
* For each group, the count of connected inputs will be available in the
|
||||
* JSONata context as `g.<groupName>`.
|
||||
* Example: `input_groups: ["reference_videos"]` makes `g.reference_videos`
|
||||
* available with the count of connected inputs like `reference_videos.character1`, etc.
|
||||
*/
|
||||
input_groups: z.array(z.string()).optional().default([])
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for price badge definition.
|
||||
* Used to calculate and display pricing information for API nodes.
|
||||
* The `expr` field contains a JSONata expression that returns a PricingResult.
|
||||
*/
|
||||
const zPriceBadge = z.object({
|
||||
engine: z.literal('jsonata').optional().default('jsonata'),
|
||||
depends_on: zPriceBadgeDepends
|
||||
.optional()
|
||||
.default({ widgets: [], inputs: [], input_groups: [] }),
|
||||
expr: z.string()
|
||||
})
|
||||
|
||||
export type PriceBadge = z.infer<typeof zPriceBadge>
|
||||
|
||||
export const zComfyNodeDef = z.object({
|
||||
input: zComfyInputsSpec.optional(),
|
||||
output: zComfyOutputTypesSpec.optional(),
|
||||
output_is_list: z.array(z.boolean()).optional(),
|
||||
output_name: z.array(z.string()).optional(),
|
||||
output_tooltips: z.array(z.string()).optional(),
|
||||
output_matchtypes: z.array(z.string().optional()).optional(),
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string(),
|
||||
help: z.string().optional(),
|
||||
category: z.string(),
|
||||
main_category: z.string().optional(),
|
||||
output_node: z.boolean(),
|
||||
python_module: z.string(),
|
||||
deprecated: z.boolean().optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
dev_only: z.boolean().optional(),
|
||||
/**
|
||||
* Whether the node is an API node. Running API nodes requires login to
|
||||
* Comfy Org account.
|
||||
* https://docs.comfy.org/tutorials/api-nodes/overview
|
||||
*/
|
||||
api_node: z.boolean().optional(),
|
||||
/**
|
||||
* Specifies the order of inputs for each input category.
|
||||
* Used to ensure consistent widget ordering regardless of JSON serialization.
|
||||
* Keys are 'required', 'optional', etc., values are arrays of input names.
|
||||
*/
|
||||
input_order: z.record(z.array(z.string())).optional(),
|
||||
/**
|
||||
* Alternative names for search. Useful for synonyms, abbreviations,
|
||||
* or old names after renaming a node.
|
||||
*/
|
||||
search_aliases: z.array(z.string()).optional(),
|
||||
/**
|
||||
* Price badge definition for API nodes.
|
||||
* Contains a JSONata expression to calculate pricing based on widget values
|
||||
* and input connectivity.
|
||||
*/
|
||||
price_badge: zPriceBadge.optional(),
|
||||
/** Category for the Essentials tab. If set, the node appears in Essentials. */
|
||||
essentials_category: z.string().optional(),
|
||||
/** Whether the blueprint is a global/installed blueprint (not user-created). */
|
||||
isGlobal: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zAutogrowOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
template: z.object({
|
||||
input: zComfyInputsSpec,
|
||||
names: z.array(z.string()).optional(),
|
||||
max: z.number().optional(),
|
||||
//Backend defines as mandatory with min 1, Frontend is more forgiving
|
||||
min: z.number().optional(),
|
||||
prefix: z.string().optional()
|
||||
})
|
||||
})
|
||||
|
||||
export const zDynamicComboInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICCOMBO_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
options: z.array(
|
||||
z.object({
|
||||
inputs: zComfyInputsSpec,
|
||||
key: z.string()
|
||||
})
|
||||
)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
template: z.object({
|
||||
allowed_types: z.string(),
|
||||
template_id: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
// `/object_info`
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
|
||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>
|
||||
|
||||
export type IntInputSpec = z.infer<typeof zIntInputSpec>
|
||||
export type FloatInputSpec = z.infer<typeof zFloatInputSpec>
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ComboInputSpecV2 = z.infer<typeof zComboInputSpecV2>
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
|
||||
export function validateComfyNodeDef(
|
||||
data: unknown,
|
||||
onError: (error: string) => void = console.warn
|
||||
): ComfyNodeDef | null {
|
||||
const result = zComfyNodeDef.safeParse(data)
|
||||
if (!result.success) {
|
||||
const zodError = fromZodError(result.error)
|
||||
onError(
|
||||
`Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
export * from '@comfyorg/object-info-parser'
|
||||
|
||||
@@ -24,7 +24,9 @@ vi.mock('@/scripts/api', () => ({
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetsByTag: vi.fn(),
|
||||
getAllAssetsByTag: vi.fn(),
|
||||
getAssetsForNodeType: vi.fn(),
|
||||
invalidateInputAssetsIncludingPublic: vi.fn(),
|
||||
updateAsset: vi.fn(),
|
||||
addAssetTags: vi.fn(),
|
||||
removeAssetTags: vi.fn()
|
||||
@@ -1259,6 +1261,9 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
false,
|
||||
{ limit: 100 }
|
||||
)
|
||||
expect(
|
||||
assetService.invalidateInputAssetsIncludingPublic
|
||||
).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
mockIsCloud.value = false
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
state: inputAssets,
|
||||
isLoading: inputLoading,
|
||||
error: inputError,
|
||||
execute: updateInputs
|
||||
execute: executeUpdateInputs
|
||||
} = useAsyncState(fetchInputFiles, [], {
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
@@ -132,6 +132,12 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const updateInputs = async () => {
|
||||
const result = await executeUpdateInputs()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch history assets with pagination support
|
||||
* @param loadMore - true for pagination (append), false for initial load (replace)
|
||||
|
||||
@@ -1,97 +1 @@
|
||||
export const BLUEPRINT_CATEGORY = 'Subgraph Blueprints'
|
||||
|
||||
export enum NodeSourceType {
|
||||
Core = 'core',
|
||||
CustomNodes = 'custom_nodes',
|
||||
Blueprint = 'blueprint',
|
||||
Essentials = 'essentials',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
export const CORE_NODE_MODULES = ['nodes', 'comfy_extras', 'comfy_api_nodes']
|
||||
|
||||
export type NodeSource = {
|
||||
type: NodeSourceType
|
||||
className: string
|
||||
displayText: string
|
||||
badgeText: string
|
||||
}
|
||||
|
||||
const UNKNOWN_NODE_SOURCE: NodeSource = {
|
||||
type: NodeSourceType.Unknown,
|
||||
className: 'comfy-unknown',
|
||||
displayText: 'Unknown',
|
||||
badgeText: '?'
|
||||
}
|
||||
|
||||
function shortenNodeName(name: string) {
|
||||
return name
|
||||
.replace(/^(ComfyUI-|ComfyUI_|Comfy-|Comfy_)/, '')
|
||||
.replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '')
|
||||
}
|
||||
|
||||
export function getNodeSource(
|
||||
python_module?: string,
|
||||
essentials_category?: string
|
||||
): NodeSource {
|
||||
if (!python_module) {
|
||||
return UNKNOWN_NODE_SOURCE
|
||||
}
|
||||
const modules = python_module.split('.')
|
||||
if (essentials_category) {
|
||||
const moduleName = modules[1] ?? modules[0] ?? 'essentials'
|
||||
const displayName = shortenNodeName(moduleName.split('@')[0])
|
||||
return {
|
||||
type: NodeSourceType.Essentials,
|
||||
className: 'comfy-essentials',
|
||||
displayText: displayName,
|
||||
badgeText: displayName
|
||||
}
|
||||
} else if (CORE_NODE_MODULES.includes(modules[0])) {
|
||||
return {
|
||||
type: NodeSourceType.Core,
|
||||
className: 'comfy-core',
|
||||
displayText: 'Comfy Core',
|
||||
badgeText: '🦊'
|
||||
}
|
||||
} else if (modules[0] === 'blueprint') {
|
||||
return {
|
||||
type: NodeSourceType.Blueprint,
|
||||
className: 'blueprint',
|
||||
displayText: 'Blueprint',
|
||||
badgeText: 'bp'
|
||||
}
|
||||
} else if (modules[0] === 'custom_nodes') {
|
||||
const moduleName = modules[1]
|
||||
if (!moduleName) {
|
||||
return UNKNOWN_NODE_SOURCE
|
||||
}
|
||||
const customNodeName = moduleName.split('@')[0]
|
||||
const displayName = shortenNodeName(customNodeName)
|
||||
return {
|
||||
type: NodeSourceType.CustomNodes,
|
||||
className: 'comfy-custom-nodes',
|
||||
displayText: displayName,
|
||||
badgeText: displayName
|
||||
}
|
||||
} else {
|
||||
return UNKNOWN_NODE_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeDefLike {
|
||||
nodeSource: NodeSource
|
||||
}
|
||||
|
||||
export function isEssentialNode(node: NodeDefLike): boolean {
|
||||
return node.nodeSource.type === NodeSourceType.Essentials
|
||||
}
|
||||
|
||||
export function isCustomNode(node: NodeDefLike): boolean {
|
||||
return node.nodeSource.type === NodeSourceType.CustomNodes
|
||||
}
|
||||
|
||||
export enum NodeBadgeMode {
|
||||
None = 'None',
|
||||
ShowAll = 'Show all',
|
||||
HideBuiltIn = 'Hide built-in'
|
||||
}
|
||||
export * from '@comfyorg/object-info-parser'
|
||||
|
||||
Reference in New Issue
Block a user