Compare commits

..

9 Commits

Author SHA1 Message Date
Simon Pinfold
c9649228bd fix: always toggle unified Model Library tab from sidebar button
The model-library toggle command redirected to Comfy.BrowseModelAssets
whenever the UseAssetAPI setting was on. On cloud that command toggles
the same tab anyway; on local builds it opened the legacy asset-browser
overlay, making the new tab unreachable for anyone who had enabled the
asset API experiment. Drop the redirect so the button always toggles
the tab.
2026-06-10 17:53:23 +12:00
Simon Pinfold
bf481f2c71 fix: adapt FormDropdown to useTransformCompatOverlayProps removal
Main removed the composable in #12513 (dropdowns now append to body
directly). The picker already used a constant appendTo:'body' override,
so pass it inline.
2026-06-10 10:42:24 +12:00
Simon Pinfold
45fef1d89c merge: PR #12635 feat: redesign in-node model/media picker 2026-06-10 10:38:23 +12:00
Simon Pinfold
cf996d8e39 merge: PR #12634 feat: add Model Library sidebar tab (cloud + local) 2026-06-10 10:37:48 +12:00
Simon Pinfold
ebf70db149 merge: PR #12633 feat: add Model Library data foundation 2026-06-10 10:37:05 +12:00
shrimbly
6c0c603576 feat: redesign in-node model/media picker
Reworks the widget select dropdown into a model/media picker:

- FormDropdown with a new FormDropdownActionPopover and menu/actions/
  filter/item subcomponents, shared sort helpers, and types.
- WidgetSelect / WidgetSelectDropdown wire asset-mode browsing:
  useAssetWidgetData sources cloud or on-disk local models, with
  base-model grouping, recently-used pins, and the media import button.

Reconciles upstream's FE-227 hash fallback in useWidgetSelectItems and
removes the final knip stacked-PR ignore now that useRecentlyUsedModels
is consumed.
2026-06-04 10:05:09 +12:00
shrimbly
a13d6cf99e refactor: decompose Model Library sidebar tab
Split the 867-line CloudModelLibrarySidebarTab into focused units:

- modelLibraryGrouping: pure asset→group heuristics (firstNonModelsTag,
  groupIdForAsset, looksLikeVae, groupLabelForAsset, partnerKind).
- modelLibrarySort: pure sort/section assembly (buildProviderGroups now
  takes mode/isSearching as params instead of reading refs).
- useModelLibraryHoverPopover: the shared row→popover hover bridge.
- useModelLibraryLeaf: shared row wiring + class constants, removing the
  duplication between CloudModelLeaf and CloudPartnerLeaf.

Adds unit tests for the grouping and sorting heuristics (previously
untested), reuses partnerKind in PartnerNodeHoverPreview, and removes the
dead preview-container div.
2026-06-04 08:49:52 +12:00
shrimbly
c7873ac7ed feat: add Model Library sidebar tab (cloud + local)
Replaces the legacy TreeExplorer Model Library tab with a new browser:

- CloudModelLibrarySidebarTab with grouped cloud + local model browsing,
  category/partner leaves (CloudModelLeaf, CloudPartnerLeaf), and rich
  hover previews (AssetHoverPreview, PartnerNodeHoverPreview).
- Registration via sidebarTabStore (the legacy useModelLibrarySidebarTab
  is removed), wired through SidebarTabTemplate, useCoreCommands, and
  useCanvasDrop for drag-to-canvas.
- Tints the shared LGraphNodePreview header so node previews read as real
  canvas nodes (also improves the node-library hover cards).

Drops the foundation PR's knip stacked-PR scaffolding for the files and
exports this tab now consumes; only useRecentlyUsedModels stays ignored
for the follow-up in-node picker PR.
2026-06-04 08:34:18 +12:00
shrimbly
87625d852b feat: add model-library data foundation
Shared data layer for the Model Library feature, with no app-visible UI:

- Asset metadata/sort/filter helpers (assetService, assetMetadataUtils,
  assetSortUtils, filterTypes) extended for model browsing.
- Base-model inference with curated overrides (baseModelInference,
  baseModelOverrides, baseModelCategoryOverrides, comfyOrgProviderOverrides).
- Model grouping and provider formatting (modelGroups), category icon
  helpers (categoryUtil), and category placeholder support.
- Model Library data sources: cloud (useModelLibrarySource) and on-disk
  local (useLocalModelLibrarySource), plus useRecentlyUsedModels and the
  node-preview drag image helper.

Consumers land in stacked follow-up PRs (sidebar tab, in-node picker).
Until then their orphaned files/exports are scoped under the existing
knip stacked-PR conventions (ignore list + @knipIgnoreUsedByStackedPR),
to be removed as each consumer arrives.
2026-06-04 08:32:55 +12:00
215 changed files with 5385 additions and 5485 deletions

View File

@@ -1,10 +1,20 @@
<script setup lang="ts">
import type { EventItem } from '../../content.config'
import type { Locale, TranslationKey } from '../../i18n/translations'
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
export type EventItem = {
label: LocalizedText
title: LocalizedText
cta: LocalizedText
href: string
}
const {
locale = 'en',
headingKey,
@@ -30,12 +40,12 @@ const {
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
@@ -56,20 +66,20 @@ const {
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
>
<span
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
>
{{ event.label }}
{{ event.label[locale] }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title }}
{{ event.title[locale] }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta }}
{{ event.cta[locale] }}
<svg
class="size-4 transition-transform group-hover:translate-x-0.5"
viewBox="0 0 24 24"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryItemAttribution from './GalleryItemAttribution.vue'
@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
<div class="flex w-full items-end justify-between p-4">
<div class="gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
<!-- Mobile metadata -->
<div v-if="mobile" class="mt-2 gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>

View File

@@ -12,7 +12,7 @@ import {
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
@@ -123,11 +123,11 @@ onUnmounted(() => {
<!-- Close button -->
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:top-8 lg:right-26"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:top-8 lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
@@ -136,7 +136,7 @@ onUnmounted(() => {
<div class="relative hidden min-h-0 w-full flex-1 pt-12 lg:flex">
<!-- Left: info card -->
<div
class="bg-primary-comfy-yellow rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8 text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"
@@ -170,7 +170,7 @@ onUnmounted(() => {
<!-- Right: large image -->
<div
class="border-primary-comfy-yellow rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-4"
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 p-4"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -197,7 +197,7 @@ onUnmounted(() => {
>
<!-- Image -->
<div
class="border-primary-comfy-yellow flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 bg-primary-comfy-ink p-3"
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -223,7 +223,7 @@ onUnmounted(() => {
<!-- Info card -->
<div
class="bg-primary-comfy-yellow w-full rounded-4xl p-6 text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'

View File

@@ -2,15 +2,13 @@
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { GalleryItem } from '../../content.config'
import { visibleGalleryItems as items } from '../../data/gallery'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryCard from './GalleryCard.vue'
import GalleryDetailModal from './GalleryDetailModal.vue'
const { items, locale = 'en' } = defineProps<{
items: GalleryItem[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modalOpen = ref(false)
const modalIndex = ref(0)

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import type { ResolvedTutorial } from '../../content.config'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
@@ -9,7 +9,7 @@ import { t } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: ResolvedTutorial
tutorial: LearningTutorial
locale?: Locale
}>()
@@ -39,7 +39,7 @@ onUnmounted(() => {
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title"
:aria-label="tutorial.title[locale]"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@@ -60,7 +60,7 @@ onUnmounted(() => {
class="border-primary-comfy-yellow rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-3 lg:p-4"
>
<VideoPlayer
:key="tutorial.slug"
:key="tutorial.id"
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
@@ -73,7 +73,7 @@ onUnmounted(() => {
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title }}
{{ tutorial.title[locale] }}
</h2>
</dialog>
</Teleport>

View File

@@ -1,23 +1,22 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ResolvedTutorial } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import { getTutorialPosterSrc } from '../../utils/tutorial'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { tutorials, locale = 'en' } = defineProps<{
tutorials: readonly ResolvedTutorial[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeTutorialSlug = ref<string | null>(null)
const activeTutorialId = ref<string | null>(null)
const activeTutorial = () =>
tutorials.find((tutorial) => tutorial.slug === activeTutorialSlug.value)
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
</script>
<template>
@@ -32,15 +31,15 @@ const activeTutorial = () =>
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<li
v-for="tutorial in tutorials"
:key="tutorial.slug"
v-for="tutorial in learningTutorials"
:key="tutorial.id"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title}`"
@click="activeTutorialSlug = tutorial.slug"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
@click="activeTutorialId = tutorial.id"
>
<video
:src="getTutorialPosterSrc(tutorial)"
@@ -75,7 +74,7 @@ const activeTutorial = () =>
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title }}
{{ tutorial.title[locale] }}
</h3>
<MaskRevealButton
v-if="tutorial.href"
@@ -104,7 +103,7 @@ const activeTutorial = () =>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ tag }}</Badge>
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
@@ -115,7 +114,7 @@ const activeTutorial = () =>
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialSlug = null"
@close="activeTutorialId = null"
/>
</section>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -9,14 +9,64 @@ import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { items, locale = 'en' } = defineProps<{
items: readonly GalleryItem[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
const items: GalleryItem[] = [
{
id: 'subway-swan',
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
title: 'Subway Swan',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'milos-little-wonder',
video:
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
title: 'Milos Little Wonder',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'amber-passage',
image:
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
title: 'Amber Passage',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats',
objectPosition: 'bottom'
},
{
id: 'neon-revenant',
video:
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
title: 'Neon Revenant',
userAlias: 'Eric Solorio',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.instagram.com/enigmatic_e'
},
{
id: 'midnight-umami',
image:
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
title: 'Midnight Umami',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
}
]
const modalOpen = ref(false)
const modalIndex = ref(0)

View File

@@ -1,131 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('astro:content', () => ({
defineCollection: (config: unknown) => config
}))
vi.mock('astro/loaders', () => ({
glob: () => ({})
}))
import { eventsSchema, gallerySchema, tutorialsSchema } from './content.config'
const validEntry = {
order: 1,
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
href: 'https://www.thinkdiffusion.com/studio'
}
describe('gallerySchema', () => {
it('accepts a valid entry', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEntry
const result = gallerySchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('defaults visible to true when omitted', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(true)
}
})
it('preserves an explicit visible: false', () => {
const result = gallerySchema.safeParse({ ...validEntry, visible: false })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(false)
}
})
it('rejects an invalid URL in image/video/href', () => {
const result = gallerySchema.safeParse({
...validEntry,
href: 'not a url'
})
expect(result.success).toBe(false)
})
})
const validEvent = {
order: 1,
label: 'Live Stream:',
title: 'Zero to Node: Building Your First Workflow',
cta: 'Link',
href: 'https://example.com/event'
}
describe('eventsSchema', () => {
it('accepts a valid entry', () => {
const result = eventsSchema.safeParse(validEvent)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEvent
const result = eventsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('accepts "#" as a placeholder href', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: '#' })
expect(result.success).toBe(true)
})
it('rejects a truly malformed href that is neither a URL nor "#"', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: 'not a url' })
expect(result.success).toBe(false)
})
})
const validTutorial = {
order: 1,
tags: ['Partner Nodes', 'Image To Video'],
title: 'Cleanplate Walkthrough',
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg'
}
describe('tutorialsSchema', () => {
it('accepts a valid entry', () => {
const result = tutorialsSchema.safeParse(validTutorial)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validTutorial
const result = tutorialsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('rejects an invalid videoSrc URL', () => {
const result = tutorialsSchema.safeParse({
...validTutorial,
videoSrc: 'not a url'
})
expect(result.success).toBe(false)
})
})

View File

@@ -1,78 +0,0 @@
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'
export const gallerySchema = z.object({
order: z.number().int(),
image: z.string().url().optional(),
video: z.string().url().optional(),
title: z.string(),
userAlias: z.string(),
teamAlias: z.string(),
tool: z.string(),
href: z.string().url().optional(),
objectPosition: z.string().optional(),
objectFit: z.string().optional(),
visible: z.boolean().default(true)
})
export type GalleryItem = z.infer<typeof gallerySchema>
export const eventsSchema = z.object({
order: z.number().int(),
label: z.string(),
title: z.string(),
cta: z.string(),
href: z.string().url().or(z.literal('#'))
})
export type EventItem = z.infer<typeof eventsSchema>
export const tutorialsSchema = z.object({
order: z.number().int(),
tags: z.array(z.string()),
title: z.string(),
videoSrc: z.string().url(),
href: z.string().url().optional(),
poster: z.string().url().optional(),
posterTime: z.number().optional()
})
export type LearningTutorial = z.infer<typeof tutorialsSchema>
export type ResolvedTutorial = LearningTutorial & { slug: string }
// The default `generateId` lowercases path segments (e.g. `zh-CN/foo` becomes
// `zh-cn/foo`), which collides with the BCP-47 locale codes Astro's i18n
// config uses elsewhere. Strip the file extension to keep the path — and
// therefore the locale prefix — verbatim.
const preservePathId = ({ entry }: { entry: string }): string =>
entry.replace(/\.[^.]+$/, '')
const gallery = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/gallery',
generateId: preservePathId
}),
schema: gallerySchema
})
const events = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/events',
generateId: preservePathId
}),
schema: eventsSchema
})
const tutorials = defineCollection({
loader: glob({
pattern: '**/*.md',
base: './src/content/tutorials',
generateId: preservePathId
}),
schema: tutorialsSchema
})
export const collections = { gallery, events, tutorials }

View File

@@ -1,7 +0,0 @@
{
"order": 2,
"label": "Event 1",
"title": "Lorem ipsum dollar sita met",
"cta": "London, UK",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 3,
"label": "Event 2",
"title": "Lorem ipsum dollar sita met",
"cta": "San Francisco",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 1,
"label": "Live Stream:",
"title": "Zero to Node: Building Your First Workflow",
"cta": "Link",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 2,
"label": "活动 1",
"title": "此处为活动描述的占位文本",
"cta": "英国伦敦",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 3,
"label": "活动 2",
"title": "此处为活动描述的占位文本",
"cta": "旧金山",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 1,
"label": "直播:",
"title": "从零到节点:构建你的第一个工作流",
"cta": "链接",
"href": "#"
}

View File

@@ -1,9 +0,0 @@
{
"order": 17,
"image": "https://media.comfy.org/website/gallery/gallery.webp",
"title": "Amber Astronaut",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,11 +0,0 @@
{
"order": 21,
"image": "https://media.comfy.org/website/gallery/amber-passage_compressed.jpg",
"title": "Amber Passage",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"objectPosition": "bottom",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 16,
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
"title": "Animation Reel",
"userAlias": "Andidea",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
}

View File

@@ -1,9 +0,0 @@
{
"order": 5,
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
"title": "Autopoiesis",
"userAlias": "Yogo",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/visualfrisson/?hl=en"
}

View File

@@ -1,9 +0,0 @@
{
"order": 13,
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
"title": "Cuco - A Love Letter To LA",
"userAlias": "Paul Trillo",
"teamAlias": "CoffeeVectors",
"tool": "ComfyUI",
"href": "https://vimeo.com/1062859798"
}

View File

@@ -1,9 +0,0 @@
{
"order": 12,
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
"title": "DDU-DU DDU-DU",
"userAlias": "Purz",
"teamAlias": "Andidea",
"tool": "Animatediff",
"href": "https://vimeo.com/1019924290"
}

View File

@@ -1,9 +0,0 @@
{
"order": 18,
"image": "https://media.comfy.org/website/gallery/desert.webp",
"title": "Desert Landing",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 6,
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
"title": "Eat It - Dance",
"userAlias": "Johana Lyu",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.joannalyu.com/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 7,
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
"title": "Fall",
"userAlias": "Nathan Shipley",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 11,
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
"title": "It's gonna be a good good summer",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685900"
}

View File

@@ -1,9 +0,0 @@
{
"order": 15,
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
"title": "Goodbye Beijing",
"userAlias": "Rui",
"teamAlias": "makeitrad",
"tool": "Animatediff",
"href": "https://x.com/rui40000"
}

View File

@@ -1,10 +0,0 @@
{
"order": 23,
"image": "https://media.comfy.org/website/gallery/midnight_umami_compressed.png",
"title": "Midnight Umami",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -1,10 +0,0 @@
{
"order": 20,
"video": "https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4",
"title": "Milos Little Wonder",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 3,
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
"title": "Neon Nights",
"userAlias": "ShaneF Motion Design",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
}

View File

@@ -1,10 +0,0 @@
{
"order": 22,
"video": "https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4",
"title": "Neon Revenant",
"userAlias": "Eric Solorio",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.instagram.com/enigmatic_e",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 9,
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
"title": "Origami world",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 2,
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
"title": "Origins - Kyrie Irving",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://vimeo.com/1021360563"
}

View File

@@ -1,9 +0,0 @@
{
"order": 10,
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
"title": "Shot on InstaX",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 14,
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
"title": "Show you my garden",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685479"
}

View File

@@ -1,10 +0,0 @@
{
"order": 19,
"image": "https://media.comfy.org/website/gallery/subway-swan_compressed.png",
"title": "Subway Swan",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 1,
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
"title": "Until Our Eye Interlink harajuku",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
}

View File

@@ -1,9 +0,0 @@
{
"order": 8,
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
"title": "Untitled",
"userAlias": "Nathan Shipley",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 4,
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
"title": "Untitled",
"userAlias": "MidJourney man",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
}

View File

@@ -1,9 +0,0 @@
{
"order": 17,
"image": "https://media.comfy.org/website/gallery/gallery.webp",
"title": "Amber Astronaut",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 16,
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
"title": "Animation Reel",
"userAlias": "Andidea",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
}

View File

@@ -1,9 +0,0 @@
{
"order": 5,
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
"title": "Autopoiesis",
"userAlias": "Yogo",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/visualfrisson/?hl=en"
}

View File

@@ -1,9 +0,0 @@
{
"order": 13,
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
"title": "Cuco - A Love Letter To LA",
"userAlias": "Paul Trillo",
"teamAlias": "CoffeeVectors",
"tool": "ComfyUI",
"href": "https://vimeo.com/1062859798"
}

View File

@@ -1,9 +0,0 @@
{
"order": 12,
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
"title": "DDU-DU DDU-DU",
"userAlias": "Purz",
"teamAlias": "Andidea",
"tool": "Animatediff",
"href": "https://vimeo.com/1019924290"
}

View File

@@ -1,9 +0,0 @@
{
"order": 18,
"image": "https://media.comfy.org/website/gallery/desert.webp",
"title": "Desert Landing",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 6,
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
"title": "Eat It - Dance",
"userAlias": "Johana Lyu",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.joannalyu.com/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 7,
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
"title": "Fall",
"userAlias": "Nathan Shipley",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 11,
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
"title": "It's gonna be a good good summer",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685900"
}

View File

@@ -1,9 +0,0 @@
{
"order": 15,
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
"title": "Goodbye Beijing",
"userAlias": "Rui",
"teamAlias": "makeitrad",
"tool": "Animatediff",
"href": "https://x.com/rui40000"
}

View File

@@ -1,9 +0,0 @@
{
"order": 3,
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
"title": "Neon Nights",
"userAlias": "ShaneF Motion Design",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 9,
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
"title": "Origami world",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 2,
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
"title": "Origins - Kyrie Irving",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://vimeo.com/1021360563"
}

View File

@@ -1,9 +0,0 @@
{
"order": 10,
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
"title": "Shot on InstaX",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 14,
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
"title": "Show you my garden",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685479"
}

View File

@@ -1,9 +0,0 @@
{
"order": 1,
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
"title": "Until Our Eye Interlink harajuku",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
}

View File

@@ -1,9 +0,0 @@
{
"order": 8,
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
"title": "Untitled",
"userAlias": "Nathan Shipley",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 4,
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
"title": "Untitled",
"userAlias": "MidJourney man",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
}

View File

@@ -1,264 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCollection } from 'astro:content'
import {
getEventsByLocale,
getGalleryByIds,
getTutorialsByLocale,
getVisibleGalleryByLocale,
slugOf
} from './queries'
vi.mock('astro:content', () => ({
getCollection: vi.fn()
}))
const getCollectionMock = vi.mocked(getCollection)
interface FixtureEntry {
id: string
collection: 'gallery'
data: {
order: number
title: string
userAlias: string
teamAlias: string
tool: string
image?: string
video?: string
href?: string
objectPosition?: string
objectFit?: string
visible: boolean
}
}
function entry(
id: string,
overrides: Partial<FixtureEntry['data']> = {}
): FixtureEntry {
return {
id,
collection: 'gallery',
data: {
order: 0,
title: 'Title',
userAlias: 'User',
teamAlias: 'Team',
tool: 'ComfyUI',
visible: true,
...overrides
}
}
}
beforeEach(() => {
getCollectionMock.mockReset()
})
describe('slugOf', () => {
it('strips the locale prefix from an entry id', () => {
const entry = { id: 'en/until-our-eye-interlink-harajuku' }
expect(slugOf(entry)).toBe('until-our-eye-interlink-harajuku')
})
it('handles zh-CN prefix', () => {
const entry = { id: 'zh-CN/desert-landing' }
expect(slugOf(entry)).toBe('desert-landing')
})
})
describe('getVisibleGalleryByLocale', () => {
it('returns only entries whose id starts with the requested locale prefix', async () => {
getCollectionMock.mockResolvedValue([
entry('en/alpha'),
entry('en/beta'),
entry('zh-CN/alpha'),
entry('zh-CN/gamma')
] as never)
const en = await getVisibleGalleryByLocale('en')
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
const zh = await getVisibleGalleryByLocale('zh-CN')
expect(zh.map((e) => e.id)).toEqual(['zh-CN/alpha', 'zh-CN/gamma'])
})
it('excludes entries with visible: false', async () => {
getCollectionMock.mockResolvedValue([
entry('en/shown', { visible: true }),
entry('en/hidden', { visible: false }),
entry('en/also-shown', { visible: true })
] as never)
const result = await getVisibleGalleryByLocale('en')
expect(result.map((e) => e.id)).toEqual(['en/shown', 'en/also-shown'])
})
it('sorts entries by the order field ascending, not by id', async () => {
getCollectionMock.mockResolvedValue([
entry('en/charlie', { order: 1 }),
entry('en/alpha', { order: 3 }),
entry('en/bravo', { order: 2 })
] as never)
const result = await getVisibleGalleryByLocale('en')
expect(result.map((e) => e.id)).toEqual([
'en/charlie',
'en/bravo',
'en/alpha'
])
})
})
describe('getGalleryByIds', () => {
it('returns entries in the order of the input slug array', async () => {
getCollectionMock.mockResolvedValue([
entry('en/alpha'),
entry('en/beta'),
entry('en/gamma'),
entry('zh-CN/alpha')
] as never)
const result = await getGalleryByIds(['gamma', 'alpha', 'beta'], 'en')
expect(result.map((e) => e.id)).toEqual(['en/gamma', 'en/alpha', 'en/beta'])
})
it('drops slugs with no matching entry in the requested locale', async () => {
getCollectionMock.mockResolvedValue([
entry('en/alpha'),
entry('zh-CN/beta')
] as never)
const result = await getGalleryByIds(['alpha', 'missing', 'beta'], 'en')
expect(result.map((e) => e.id)).toEqual(['en/alpha'])
})
})
interface EventsFixtureEntry {
id: string
collection: 'events'
data: {
order: number
label: string
title: string
cta: string
href: string
}
}
function eventEntry(
id: string,
overrides: Partial<EventsFixtureEntry['data']> = {}
): EventsFixtureEntry {
return {
id,
collection: 'events',
data: {
order: 0,
label: 'Label',
title: 'Title',
cta: 'CTA',
href: '#',
...overrides
}
}
}
describe('getEventsByLocale', () => {
it('returns only entries whose id starts with the requested locale prefix', async () => {
getCollectionMock.mockResolvedValue([
eventEntry('en/alpha'),
eventEntry('en/beta'),
eventEntry('zh-CN/alpha')
] as never)
const en = await getEventsByLocale('en')
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
})
it('sorts entries by the order field ascending, not by id', async () => {
getCollectionMock.mockResolvedValue([
eventEntry('en/charlie', { order: 1 }),
eventEntry('en/alpha', { order: 3 }),
eventEntry('en/bravo', { order: 2 })
] as never)
const result = await getEventsByLocale('en')
expect(result.map((e) => e.id)).toEqual([
'en/charlie',
'en/bravo',
'en/alpha'
])
})
it('returns an empty array for a locale with no entries', async () => {
getCollectionMock.mockResolvedValue([
eventEntry('en/alpha'),
eventEntry('en/beta')
] as never)
const result = await getEventsByLocale('zh-CN')
expect(result).toEqual([])
})
})
interface TutorialsFixtureEntry {
id: string
collection: 'tutorials'
data: {
order: number
tags: string[]
title: string
videoSrc: string
href?: string
poster?: string
posterTime?: number
}
}
function tutorialEntry(
id: string,
overrides: Partial<TutorialsFixtureEntry['data']> = {}
): TutorialsFixtureEntry {
return {
id,
collection: 'tutorials',
data: {
order: 0,
tags: ['Tag'],
title: 'Title',
videoSrc: 'https://example.com/video.mp4',
...overrides
}
}
}
describe('getTutorialsByLocale', () => {
it('returns only entries whose id starts with the requested locale prefix', async () => {
getCollectionMock.mockResolvedValue([
tutorialEntry('en/alpha'),
tutorialEntry('en/beta'),
tutorialEntry('zh-CN/alpha')
] as never)
const en = await getTutorialsByLocale('en')
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
})
it('sorts entries by the order field ascending, not by id', async () => {
getCollectionMock.mockResolvedValue([
tutorialEntry('en/charlie', { order: 1 }),
tutorialEntry('en/alpha', { order: 3 }),
tutorialEntry('en/bravo', { order: 2 })
] as never)
const result = await getTutorialsByLocale('en')
expect(result.map((e) => e.id)).toEqual([
'en/charlie',
'en/bravo',
'en/alpha'
])
})
})

View File

@@ -1,61 +0,0 @@
import { getCollection } from 'astro:content'
import type { CollectionEntry } from 'astro:content'
import type { Locale } from '../i18n/translations'
export type GalleryEntry = CollectionEntry<'gallery'>
export type EventsEntry = CollectionEntry<'events'>
export type TutorialsEntry = CollectionEntry<'tutorials'>
export function slugOf(entry: { id: string }): string {
const slash = entry.id.indexOf('/')
return slash === -1 ? entry.id : entry.id.slice(slash + 1)
}
export async function getVisibleGalleryByLocale(
locale: Locale
): Promise<GalleryEntry[]> {
const prefix = `${locale}/`
const entries: GalleryEntry[] = await getCollection('gallery')
return entries
.filter(
(entry) => entry.id.startsWith(prefix) && entry.data.visible !== false
)
.sort((a, b) => a.data.order - b.data.order)
}
export async function getGalleryByIds(
slugs: readonly string[],
locale: Locale
): Promise<GalleryEntry[]> {
const entries: GalleryEntry[] = await getCollection('gallery')
const bySlug = new Map<string, GalleryEntry>()
for (const entry of entries) {
if (entry.id.startsWith(`${locale}/`)) {
bySlug.set(slugOf(entry), entry)
}
}
return slugs
.map((slug) => bySlug.get(slug))
.filter((entry): entry is GalleryEntry => entry !== undefined)
}
export async function getEventsByLocale(
locale: Locale
): Promise<EventsEntry[]> {
const prefix = `${locale}/`
const entries: EventsEntry[] = await getCollection('events')
return entries
.filter((entry) => entry.id.startsWith(prefix))
.sort((a, b) => a.data.order - b.data.order)
}
export async function getTutorialsByLocale(
locale: Locale
): Promise<TutorialsEntry[]> {
const prefix = `${locale}/`
const entries: TutorialsEntry[] = await getCollection('tutorials')
return entries
.filter((entry) => entry.id.startsWith(prefix))
.sort((a, b) => a.data.order - b.data.order)
}

View File

@@ -1,9 +0,0 @@
---
order: 1
tags:
- Partner Nodes
- Image To Video
title: Cleanplate Walkthrough
videoSrc: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4
poster: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg
---

View File

@@ -1,10 +0,0 @@
---
order: 2
tags:
- Partner Nodes
- Image To Video
title: Deaging Workflow
videoSrc: https://media.comfy.org/website/learning/deaging_workflow_v03.mp4
poster: https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=93f286fbc2c8
---

View File

@@ -1,10 +0,0 @@
---
order: 3
tags:
- Partner Nodes
- Image To Video
title: Frame Adjustments Demo
videoSrc: https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4
poster: https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=7dca0438edf4
---

View File

@@ -1,10 +0,0 @@
---
order: 4
tags:
- Partner Nodes
- Image To Video
title: Mattes and Utilities
videoSrc: https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4
poster: https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=be0889296f65
---

View File

@@ -1,10 +0,0 @@
---
order: 5
tags:
- Partner Nodes
- Image To Video
title: Seedance Demo ComfyUI
videoSrc: https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4
poster: https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=ef543bd4a773
---

View File

@@ -1,10 +0,0 @@
---
order: 6
tags:
- Partner Nodes
- Image To Video
title: Sky Replacement
videoSrc: https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4
poster: https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg
href: https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/
---

View File

@@ -1,9 +0,0 @@
---
order: 1
tags:
- 合作伙伴节点
- 图像生成视频
title: 净板演练
videoSrc: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4
poster: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg
---

View File

@@ -1,10 +0,0 @@
---
order: 2
tags:
- 合作伙伴节点
- 图像生成视频
title: 减龄工作流
videoSrc: https://media.comfy.org/website/learning/deaging_workflow_v03.mp4
poster: https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=93f286fbc2c8
---

View File

@@ -1,10 +0,0 @@
---
order: 3
tags:
- 合作伙伴节点
- 图像生成视频
title: 帧调整演示
videoSrc: https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4
poster: https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=7dca0438edf4
---

View File

@@ -1,10 +0,0 @@
---
order: 4
tags:
- 合作伙伴节点
- 图像生成视频
title: 遮罩与实用工具
videoSrc: https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4
poster: https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=be0889296f65
---

View File

@@ -1,10 +0,0 @@
---
order: 5
tags:
- 合作伙伴节点
- 图像生成视频
title: Seedance ComfyUI 演示
videoSrc: https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4
poster: https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=ef543bd4a773
---

View File

@@ -1,10 +0,0 @@
---
order: 6
tags:
- 合作伙伴节点
- 图像生成视频
title: 天空替换
videoSrc: https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4
poster: https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg
href: https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/
---

View File

@@ -0,0 +1,31 @@
import type { EventItem } from '../components/common/EventsSection.vue'
export const learningEvents: readonly EventItem[] = [
{
label: { en: 'Live Stream:', 'zh-CN': '直播:' },
title: {
en: 'Zero to Node: Building Your First Workflow',
'zh-CN': '从零到节点:构建你的第一个工作流'
},
cta: { en: 'Link', 'zh-CN': '链接' },
href: '#'
},
{
label: { en: 'Event 1', 'zh-CN': '活动 1' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'London, UK', 'zh-CN': '英国伦敦' },
href: '#'
},
{
label: { en: 'Event 2', 'zh-CN': '活动 2' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'San Francisco', 'zh-CN': '旧金山' },
href: '#'
}
] as const

View File

@@ -0,0 +1,191 @@
export interface GalleryItem {
id: string
image?: string
video?: string
title: string
userAlias: string
teamAlias: string
tool: string
href?: string
objectPosition?: string
objectFit?: string
/** Defaults to true. Set to false to hide this item from rendered lists. */
visible?: boolean
}
const galleryItems: GalleryItem[] = [
{
id: 'until-our-eye-interlink-harajuku',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
},
{
id: 'origins-kyrie-irving',
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
title: 'Origins - Kyrie Irving',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://vimeo.com/1021360563'
},
{
id: 'neon-nights',
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
title: 'Neon Nights',
userAlias: 'ShaneF Motion Design',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
},
{
id: 'untitled-dusk-mountains',
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
title: 'Untitled',
userAlias: 'MidJourney man',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
},
{
id: 'autopoiesis',
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
title: 'Autopoiesis',
userAlias: 'Yogo',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/visualfrisson/?hl=en'
},
{
id: 'eat-it-dance',
video:
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
title: 'Eat It - Dance',
userAlias: 'Johana Lyu',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.joannalyu.com/'
},
{
id: 'fall',
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
title: 'Fall',
userAlias: 'Nathan Shipley',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
},
{
id: 'untitled-buildings',
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
title: 'Untitled',
userAlias: 'Nathan Shipley',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
},
{
id: 'origami-world',
video:
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
title: 'Origami world',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'shot-on-instax',
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
title: 'Shot on InstaX',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'good-good-summer',
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
title: "It's gonna be a good good summer",
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685900'
},
{
id: 'ddu-du-ddu-du',
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
title: 'DDU-DU DDU-DU',
userAlias: 'Purz',
teamAlias: 'Andidea',
tool: 'Animatediff',
href: 'https://vimeo.com/1019924290'
},
{
id: 'cuco-love-letter-to-la',
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
title: 'Cuco - A Love Letter To LA',
userAlias: 'Paul Trillo',
teamAlias: 'CoffeeVectors',
tool: 'ComfyUI',
href: 'https://vimeo.com/1062859798'
},
{
id: 'show-you-my-garden',
video:
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
title: 'Show you my garden',
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685479'
},
{
id: 'goodbye-beijing',
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
title: 'Goodbye Beijing',
userAlias: 'Rui',
teamAlias: 'makeitrad',
tool: 'Animatediff',
href: 'https://x.com/rui40000'
},
{
id: 'animation-reel',
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
title: 'Animation Reel',
userAlias: 'Andidea',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
},
{
id: 'amber-astronaut',
image: 'https://media.comfy.org/website/gallery/gallery.webp',
title: 'Amber Astronaut',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
},
{
id: 'desert-landing',
image: 'https://media.comfy.org/website/gallery/desert.webp',
title: 'Desert Landing',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
}
]
export const visibleGalleryItems: GalleryItem[] = galleryItems.filter(
(item) => item.visible !== false
)
/** @knipIgnoreUsedByStackedPR */
export function getGalleryItemById(id: string): GalleryItem | undefined {
return galleryItems.find((item) => item.id === id)
}

View File

@@ -0,0 +1,84 @@
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
export const learningTutorials: readonly LearningTutorial[] = [
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'deaging_workflow_v03',
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
videoSrc:
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'frame_adjustments_demo_v03',
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
videoSrc:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'mattes_and_utilities_v03',
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
videoSrc:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'seedance_demo_comfyui_v03',
title: { en: 'Seedance Demo ComfyUI', 'zh-CN': 'Seedance ComfyUI 演示' },
videoSrc:
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'skyreplacement_smaller_v06',
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
videoSrc:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -3,14 +3,10 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/gallery/HeroSection.vue'
import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
import { getVisibleGalleryByLocale } from '../content/queries'
const entries = await getVisibleGalleryByLocale('en')
const items = entries.map((entry) => entry.data)
---
<BaseLayout title="Gallery — Comfy">
<HeroSection />
<GallerySection items={items} client:load />
<GallerySection client:load />
<ContactSection />
</BaseLayout>

View File

@@ -8,20 +8,14 @@ import CallToActionSection from '../components/common/CallToActionSection.vue'
import { getRoutes } from '../config/routes'
import { externalLinks } from '../config/routes'
// import { learningEvents } from '../data/events'
import { getTutorialsByLocale, slugOf } from '../content/queries'
const routes = getRoutes('en')
const tutorialEntries = await getTutorialsByLocale('en')
const tutorials = tutorialEntries.map((entry) => ({
slug: slugOf(entry),
...entry.data
}))
---
<BaseLayout title="Learning — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection tutorials={tutorials} client:visible />
<TutorialsSection client:visible />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"

View File

@@ -3,18 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
import { getGalleryByIds } from '../content/queries'
const featuredCreationSlugs = [
'subway-swan',
'milos-little-wonder',
'amber-passage',
'neon-revenant',
'midnight-umami'
]
const featuredEntries = await getGalleryByIds(featuredCreationSlugs, 'en')
const featuredItems = featuredEntries.map((entry) => entry.data)
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
@@ -27,7 +16,7 @@ const featuredItems = featuredEntries.map((entry) => entry.data)
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection items={featuredItems} client:load />
<ModelCreationsSection client:load />
<AIModelsSection client:load />
<ProductShowcaseSection client:load />
</BaseLayout>

View File

@@ -3,14 +3,10 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/gallery/HeroSection.vue'
import GallerySection from '../../components/gallery/GallerySection.vue'
import ContactSection from '../../components/gallery/ContactSection.vue'
import { getVisibleGalleryByLocale } from '../../content/queries'
const entries = await getVisibleGalleryByLocale('zh-CN')
const items = entries.map((entry) => entry.data)
---
<BaseLayout title="作品集 — Comfy">
<HeroSection locale="zh-CN" />
<GallerySection locale="zh-CN" items={items} client:load />
<GallerySection locale="zh-CN" client:load />
<ContactSection locale="zh-CN" />
</BaseLayout>

View File

@@ -6,34 +6,15 @@ import TutorialsSection from '../../components/learning/TutorialsSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
import EventsSection from '../../components/common/EventsSection.vue'
import { getRoutes, externalLinks } from '../../config/routes'
import {
getEventsByLocale,
getTutorialsByLocale,
slugOf
} from '../../content/queries'
import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
const eventEntries = await getEventsByLocale('zh-CN')
const events = eventEntries.map((entry) => entry.data)
const tutorialEntries = await getTutorialsByLocale('zh-CN')
const tutorials = tutorialEntries.map((entry) => ({
slug: slugOf(entry),
...entry.data
}))
---
<BaseLayout title="学习 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" tutorials={tutorials} client:visible />
<EventsSection
locale="zh-CN"
headingKey="learning.events.heading"
descriptionKey="learning.events.description"
notifyLabelKey="learning.events.getNotified"
events={events}
client:visible
/>
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"

View File

@@ -4,17 +4,6 @@ import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
import { getGalleryByIds } from '../../content/queries'
const featuredCreationSlugs = [
'subway-swan',
'milos-little-wonder',
'amber-passage',
'neon-revenant',
'midnight-umami'
]
const featuredEntries = await getGalleryByIds(featuredCreationSlugs, 'en')
const featuredItems = featuredEntries.map((entry) => entry.data)
---
<BaseLayout
@@ -28,7 +17,7 @@ const featuredItems = featuredEntries.map((entry) => entry.data)
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
/>
<ModelCreationsSection items={featuredItems} client:load locale="zh-CN" />
<ModelCreationsSection client:load locale="zh-CN" />
<AIModelsSection client:load locale="zh-CN" />
<ProductShowcaseSection client:load locale="zh-CN" />
</BaseLayout>

View File

@@ -1,40 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getTutorialPosterSrc } from './tutorial'
describe('getTutorialPosterSrc', () => {
it('returns the explicit poster URL when provided', () => {
expect(
getTutorialPosterSrc({
order: 1,
tags: [],
title: 'T',
videoSrc: 'https://example.com/v.mp4',
poster: 'https://example.com/poster.jpg'
})
).toBe('https://example.com/poster.jpg')
})
it('falls back to videoSrc#t=<posterTime> when poster is missing', () => {
expect(
getTutorialPosterSrc({
order: 1,
tags: [],
title: 'T',
videoSrc: 'https://example.com/v.mp4',
posterTime: 7
})
).toBe('https://example.com/v.mp4#t=7')
})
it('uses the default poster time when neither poster nor posterTime is set', () => {
expect(
getTutorialPosterSrc({
order: 1,
tags: [],
title: 'T',
videoSrc: 'https://example.com/v.mp4'
})
).toBe('https://example.com/v.mp4#t=1')
})
})

View File

@@ -1,8 +0,0 @@
import type { LearningTutorial } from '../content.config'
const DEFAULT_POSTER_TIME_SECONDS = 1
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`

View File

@@ -1,48 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "TEST_MISSING_PACK_NODE_A",
"pos": [48, 86],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
},
{
"id": 2,
"type": "TEST_MISSING_PACK_NODE_B",
"pos": [520, 86],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -45,8 +45,6 @@ export const TestIds = {
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
missingNodePackExpand: 'missing-node-pack-expand',
missingNodePackCount: 'missing-node-pack-count',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',

View File

@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
@@ -12,39 +12,27 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
)
})
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
})
test('Should show missing node packs group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should show unknown pack node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
test('Should show subgraph missing node rows by default', async ({
test('Should expand pack group to reveal node type names', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -55,72 +43,66 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByRole('button', {
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
})
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
})
test('Should locate missing node from the row label', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeHidden()
})
test('Should toggle grouped pack nodes from chevron and title', async ({
test('Locate node button is visible for expanded pack nodes', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_same_pack'
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
const packTitle = missingNodeCard.getByRole('button', {
name: 'test-missing-node-pack'
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
})
const expandButton = missingNodeCard.getByTestId(
TestIds.dialogs.missingNodePackExpand
)
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()
await expect(
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
).toHaveText('2')
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await expandButton.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await packTitle.click()
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await packTitle.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
})
})

View File

@@ -425,56 +425,6 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
}
)
test(
'Refreshing a resolved promoted missing model clears the combo invalid state',
{ tag: ['@widget', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
await comfyPage.vueNodes.waitForNodes()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')
.getByRole('combobox', { name: 'ckpt_name', exact: true })
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
const objectInfoRoute = /\/object_info$/
try {
await comfyPage.page.route(objectInfoRoute, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
await comfyPage.page
.getByTestId(TestIds.dialogs.missingModelRefresh)
.click()
await expect(missingModelGroup).toBeHidden()
await expect(promotedModelCombo).toBeVisible()
await expect(promotedModelCombo).not.toHaveAttribute(
'aria-invalid',
'true'
)
} finally {
await comfyPage.page.unroute(objectInfoRoute)
}
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

View File

@@ -1,101 +0,0 @@
import { expect } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
// text-sm rows render a single 20px line; a wrapped name is 40px+.
const SINGLE_LINE_MAX_HEIGHT_PX = 28
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
},
{
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await use(page)
}
})
test.describe('Workspace switcher', { tag: '@cloud' }, () => {
test('renders a long team workspace name on a single line', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByText(PERSONAL_WORKSPACE_NAME).click()
const longName = page.getByText(LONG_WORKSPACE_NAME)
await expect(longName).toBeVisible()
const box = await longName.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
})

View File

@@ -421,15 +421,6 @@ export default defineConfig([
}
},
// Astro virtual modules (astro:content, astro:assets, etc.) are not
// resolvable by the TS resolver — they are injected by the Astro build.
{
files: ['apps/website/**/*.{ts,vue,astro}'],
rules: {
'import-x/no-unresolved': ['error', { ignore: ['^astro:'] }]
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.12",
"version": "1.46.11",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -1,9 +1,9 @@
<svg width="520" height="520" viewBox="0 0 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_227_285" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="520" height="520">
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#F2FF59"/>
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#EEFF30"/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#211927"/>
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F2FF59"/>
<rect y="0.751831" width="520" height="520" fill="#172DD7"/>
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F0FF41"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -2,8 +2,8 @@
"PreviewImage": 4314,
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImageAdvanced": 1763,
"SaveImage": 1762,
"SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,
@@ -14,7 +14,6 @@
"UpscaleModelLoader": 629,
"UNETLoader": 606,
"VAELoader": 604,
"PreviewAny": 528,
"ShowText|pysssss": 527.5526981023964,
"ImageUpscaleWithModel": 523,
"ControlNetApplyAdvanced": 513,
@@ -25,12 +24,10 @@
"VHS_LoadVideo": 440,
"ImpactSwitch": 349,
"Reroute": 348,
"ResizeImageMaskNode": 337,
"ResizeAndPadImage": 336,
"ImageResizeKJv2": 335,
"StringConcatenate": 326,
"Text Concatenate": 325.7030402103206,
"SaveVideo": 321,
"PreviewAny": 319,
"KSamplerAdvanced": 304,
"SDXLPromptStyler": 297.0913411304729,
"Note": 291,
@@ -55,7 +52,6 @@
"CLIPLoader": 202,
"GeminiNode": 202,
"KSampler (Efficient)": 194.01083622636423,
"RemoveBackground": 187,
"ImageRemoveBackground+": 186,
"IPAdapterModelLoader": 184,
"PrimitiveInt": 183,
@@ -63,9 +59,7 @@
"LoadVideo": 179,
"Text Concatenate (JPS)": 175.98154639522735,
"PrimitiveNode": 175,
"PrimitiveStringMultiline": 166,
"Text Multiline": 165,
"GetImageSize": 164,
"Text Multiline": 163.04749064680308,
"GetImageSize+": 163,
"ImageScaleToTotalPixels": 157,
"String Literal": 150.11343489837878,
@@ -74,14 +68,15 @@
"DownloadAndLoadFlorence2Model": 144,
"LoadImageOutput": 143,
"IPAdapterUnifiedLoader": 141,
"BatchImagesNode": 134,
"FluxGuidance": 133,
"ImageBatchMulti": 133,
"FluxGuidance": 132,
"ByteDanceSeedreamNode": 130,
"CR Text Input Switch": 128.16473423438606,
"IPAdapterAdvanced": 128,
"If ANY execute A else B": 127.77279315110049,
"GeminiImage2Node": 124,
"GetImageSize": 121,
"PrimitiveStringMultiline": 120,
"IPAdapter": 118,
"CreateVideo": 116,
"ConditioningZeroOut": 115,
@@ -107,7 +102,6 @@
"DepthAnythingPreprocessor": 100,
"CR Apply LoRA Stack": 96.02556540496816,
"Image Filter Adjustments": 95.24168323839699,
"ComfyMathExpression": 96,
"SimpleMath+": 95,
"GroundingDinoSAMSegment (segment anything)": 93.28197782196906,
"Image Overlay": 93.28197782196906,
@@ -153,6 +147,7 @@
"Image Resize": 63.494455492264656,
"Automatic CFG": 63.494455492264656,
"Canny": 63,
"StringConcatenate": 63,
"DepthAnything_V2": 61,
"ImageCrop+": 60,
"ModelSamplingSD3": 59,
@@ -204,7 +199,6 @@
"BNK_CLIPTextEncodeAdvanced": 45.857106744413365,
"CR SDXL Aspect Ratio": 45.46516566112778,
"LoadAudio": 45,
"ResolutionSelector": 45,
"smZ CLIPTextEncode": 44.68128349455661,
"Bus Node": 44.68128349455661,
"PreviewTextNode": 44.68128349455661,
@@ -395,6 +389,7 @@
"SD_4XUpscale_Conditioning": 21,
"UltimateSDUpscaleCustomSample": 21,
"StyleModelLoader": 21,
"ResizeAndPadImage": 21,
"Text Random Prompt": 20.77287741413597,
"INPAINT_VAEEncodeInpaintConditioning": 20.77287741413597,
"BrushNet": 20.77287741413597,

View File

@@ -71,11 +71,12 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
name: 'MissingPackGroupRow',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
props: ['group', 'showInfoButton'],
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
emits: ['locate-node', 'open-manager-info']
}
}))
@@ -121,6 +122,7 @@ function makePackGroups(count = 2): MissingPackGroup[] {
function renderCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
@@ -128,6 +130,7 @@ function renderCard(
const result = render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
@@ -166,10 +169,12 @@ describe('MissingNodeCard', () => {
it('passes props correctly to MissingPackGroupRow children', () => {
renderCard({
showInfoButton: true
showInfoButton: true,
showNodeIdBadge: true
})
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
})
})
@@ -251,6 +256,7 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
@@ -273,6 +279,7 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},

View File

@@ -56,29 +56,27 @@
>
</template>
</i18n-t>
<div class="flex flex-col gap-1 overflow-hidden py-2">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="secondary"
size="sm"
variant="primary"
:disabled="isRestarting"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<i
v-else
aria-hidden="true"
@@ -107,8 +105,9 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const { showInfoButton, missingPackGroups } = defineProps<{
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()

View File

@@ -61,16 +61,16 @@ const i18n = createI18n({
messages: {
en: {
g: {
install: 'Install',
loading: 'Loading',
search: 'Search'
loading: 'Loading'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
@@ -100,6 +100,7 @@ function renderRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
const user = userEvent.setup()
@@ -109,6 +110,7 @@ function renderRow(
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
onLocateNode,
onOpenManagerInfo,
...props
@@ -116,6 +118,7 @@ function renderRow(
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
@@ -153,22 +156,9 @@ describe('MissingPackGroupRow', () => {
expect(screen.getByText(/Loading/)).toBeInTheDocument()
})
it('does not render header locate while pack metadata is resolving', () => {
renderRow({
group: makeGroup({
isResolving: true,
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders node count', () => {
renderRow()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
})
it('renders count of 5 for 5 nodeTypes', () => {
@@ -181,29 +171,38 @@ describe('MissingPackGroupRow', () => {
}))
})
})
expect(screen.getByText('5')).toBeInTheDocument()
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
})
})
describe('Node Type List', () => {
it('hides multiple nodeTypes behind the expand control by default', () => {
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
})
it('shows unknown pack nodeTypes by default', () => {
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
it('expands when chevron is clicked', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('renders all nodeTypes after expanding', async () => {
it('collapses when chevron is clicked again', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
})
describe('Node Type List', () => {
async function expand(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Expand' }))
}
it('renders all nodeTypes when expanded', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [
@@ -213,87 +212,40 @@ describe('MissingPackGroupRow', () => {
]
})
})
await user.click(screen.getByRole('button', { name: 'Expand' }))
await expand(user)
expect(screen.getByText('NodeA')).toBeInTheDocument()
expect(screen.getByText('NodeB')).toBeInTheDocument()
expect(screen.getByText('NodeC')).toBeInTheDocument()
})
it('hides multiple nodeTypes again after collapsing', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { user } = renderRow({ showNodeIdBadge: true })
await expand(user)
expect(screen.getByText('#10')).toBeInTheDocument()
})
it('hides a single nodeType without an expand control', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
})
})
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Expand' })
).not.toBeInTheDocument()
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { user } = renderRow({ showNodeIdBadge: false })
await expand(user)
expect(screen.queryByText('#10')).not.toBeInTheDocument()
})
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(screen.getByRole('button', { name: 'my-pack' }))
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('moves locate to the header when there is one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('emits locateNode when expanded child Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
it('emits locateNode when Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
await expand(user)
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('emits locateNode when node label is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: 'MissingA' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', () => {
renderRow({
it('does not show Locate for nodeType without nodeId', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(user)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
@@ -301,6 +253,7 @@ describe('MissingPackGroupRow', () => {
it('handles mixed nodeTypes with and without nodeId', async () => {
const { user } = renderRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
@@ -308,7 +261,7 @@ describe('MissingPackGroupRow', () => {
]
})
})
await user.click(screen.getByRole('button', { name: 'Expand' }))
await expand(user)
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
@@ -321,25 +274,21 @@ describe('MissingPackGroupRow', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
renderRow()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
})
it('shows Search when packId exists but pack not in registry', () => {
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
renderRow()
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
})
it('shows "Installed" state when pack is installed', () => {
@@ -363,9 +312,7 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(
screen.getByRole('button', { name: 'Install' })
).toBeInTheDocument()
expect(screen.getByText('Install node pack')).toBeInTheDocument()
})
it('calls installAllPacks when Install button is clicked', async () => {
@@ -373,7 +320,9 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Install' }))
await user.click(
screen.getByRole('button', { name: /Install node pack/ })
)
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
@@ -420,7 +369,7 @@ describe('MissingPackGroupRow', () => {
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
renderRow({ group: makeGroup({ nodeTypes: [] }) })
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
})
})
})

View File

@@ -1,221 +1,187 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div class="mb-2 flex w-full flex-col">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 w-full items-center">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</p>
<Button
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="unset"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleExpand"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
<i
v-if="isUnknownPack"
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes && !group.isResolving"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
:aria-expanded="expanded"
@click="toggleExpand"
>
{{ packDisplayName }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ packDisplayName }}
</button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal"
:class="
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{ packDisplayName }}
</span>
#{{ nodeType.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="showInfoButton && group.packId !== null"
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--info] size-4" />
<i class="icon-[lucide--locate] size-3" />
</Button>
<span
v-if="showNodeCount"
data-testid="missing-node-pack-count"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ group.nodeTypes.length }}
</span>
</span>
</span>
<div v-if="showInstallAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: isPackInstalled
? t('rightSidePanel.missingNodePacks.installed')
: t('g.install')
}}
</span>
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
/>
<i
v-else
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex w-full items-start py-1"
>
<div
v-else-if="showLoadingAction"
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('g.loading') }}
</span>
</div>
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<span class="text-foreground min-w-0 truncate">
{{ t('g.search') }}
</span>
</Button>
</div>
<Button
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none space-y-1 p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<li
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
:class="
cn(
packTextButtonClass,
'text-muted-foreground hover:text-base-foreground'
)
"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
</span>
<Button
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ref, computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -227,9 +193,10 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton } = defineProps<{
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -238,10 +205,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const expandedOverride = ref<boolean | null>(null)
const packTextButtonClass =
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
@@ -256,73 +219,17 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
const isUnknownPack = computed(
() => group.packId === null && !group.isResolving
)
const packDisplayName = computed(() => {
if (group.packId === null) {
return t('rightSidePanel.missingNodePacks.unknownPack')
}
return nodePack.value?.name ?? group.packId
})
const isPackInstalled = computed(
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
)
const showInstallAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
(nodePack.value !== null || isPackInstalled.value)
)
const showLoadingAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
isLoading.value
)
const showSearchAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
!showLoadingAction.value
)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
const expanded = computed(
() =>
expandedOverride.value ??
(isUnknownPack.value && hasMultipleNodeTypes.value)
)
const showNodeTypeList = computed(
() =>
(isUnknownPack.value && group.nodeTypes.length === 1) ||
(hasMultipleNodeTypes.value && expanded.value)
)
const primaryLocatableNodeType = computed(() => {
if (group.isResolving) return null
if (isUnknownPack.value) return null
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function handlePackInstallClick() {
if (!group.packId) return
if (!isPackInstalled.value) {
if (!comfyManagerStore.isPackInstalled(group.packId)) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expandedOverride.value = !expanded.value
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
@@ -334,14 +241,10 @@ function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function isLocatableNodeType(
nodeType: MissingNodeType
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
return typeof nodeType !== 'string' && nodeType.nodeId != null
}
function handleLocateNode(nodeType: MissingNodeType) {
if (!isLocatableNodeType(nodeType)) return
emit('locateNode', String(nodeType.nodeId))
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locateNode', String(nodeType.nodeId))
}
}
</script>

View File

@@ -148,6 +148,7 @@
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:show-node-id-badge="showNodeIdBadge"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"

View File

@@ -20,7 +20,13 @@
</template>
<template #end>
<div
class="flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2"
:class="
cn(
'flex flex-row overflow-hidden transition-all duration-200 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2',
!props.toolButtonsAlwaysVisible &&
'motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100'
)
"
>
<slot name="tool-buttons" />
</div>
@@ -45,6 +51,7 @@ import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<{
title: string
class?: string
toolButtonsAlwaysVisible?: boolean
}>()
const sidebarPt = {
start: 'min-w-0 flex-1 overflow-hidden'

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetHoverPreview from './AssetHoverPreview.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
// An empty `tags` array yields no model type, so the node-preview section stays
// hidden — keeping the component free of the model-to-node store and the heavy
// NodePreview render for these presentational assertions.
const baseAsset: AssetItem = {
id: 'asset-1',
name: 'mymodel.safetensors',
tags: []
}
function renderPreview(asset: AssetItem) {
return render(AssetHoverPreview, {
global: {
plugins: [i18n],
directives: { tooltip: {} }
},
props: { asset }
})
}
describe('AssetHoverPreview', () => {
it('shows the description section when a description is present', () => {
renderPreview({
...baseAsset,
user_metadata: { user_description: 'A cutting-edge model.' }
})
expect(screen.getByText('Description')).toBeInTheDocument()
expect(screen.getByText('A cutting-edge model.')).toBeInTheDocument()
})
it('hides the description section when the description is empty', () => {
renderPreview({ ...baseAsset, user_metadata: { user_description: '' } })
expect(screen.queryByText('Description')).toBeNull()
})
it('renders trigger words as chips under a labelled section', () => {
renderPreview({
...baseAsset,
metadata: { trained_words: ['cat', 'digital art'] }
})
expect(screen.getByText('Trigger words')).toBeInTheDocument()
expect(screen.getByText('cat')).toBeInTheDocument()
expect(screen.getByText('digital art')).toBeInTheDocument()
})
it('omits the trigger words section when there are none', () => {
renderPreview(baseAsset)
expect(screen.queryByText('Trigger words')).toBeNull()
})
})

View File

@@ -0,0 +1,270 @@
<template>
<div
class="flex w-96 flex-col gap-2 overflow-hidden rounded-2xl border border-border-default bg-comfy-menu-bg p-4 text-sm text-base-foreground shadow-lg"
>
<!-- Header -->
<div class="flex w-full items-start gap-2 pb-1">
<div class="flex min-w-0 flex-1 flex-col items-start gap-2">
<div
class="flex w-full flex-col gap-1.5 pr-2 leading-tight wrap-break-word"
>
<span class="font-medium">{{ displayName }}</span>
<span
v-if="filename"
class="font-normal break-all text-muted-foreground"
>
{{ filename }}
</span>
</div>
<div
v-if="baseModels.length || sourceUrl"
class="flex w-full flex-wrap items-start gap-2 pb-1"
>
<span
v-for="baseModel in baseModels"
:key="baseModel"
class="inline-flex h-6 max-w-full items-center rounded-full bg-secondary-background px-2 py-1 text-xs text-base-foreground"
>
<span class="truncate">{{ baseModel }}</span>
</span>
<Button
v-if="sourceUrl"
v-tooltip.bottom="$t('cloudModelLibrary.preview.openUrl')"
variant="secondary"
size="sm"
class="h-6 shrink-0 gap-1 rounded-full px-2 font-normal text-base-foreground"
:aria-label="$t('cloudModelLibrary.preview.openUrl')"
@click="openSourceUrl"
>
{{ $t('cloudModelLibrary.preview.url') }}
<i class="icon-[lucide--external-link] size-3.5" />
</Button>
</div>
</div>
<div
v-if="isCloud"
class="relative size-27 shrink-0 overflow-hidden rounded-sm bg-muted-background"
>
<template v-if="thumbnail">
<Skeleton v-if="!thumbnailLoaded" class="absolute inset-0" />
<img
:src="thumbnail.src"
:alt="displayName"
class="size-full object-cover transition-opacity duration-150"
:class="thumbnailLoaded ? 'opacity-100' : 'opacity-0'"
@load="thumbnailLoaded = true"
@error="onMediaError"
/>
</template>
<CategoryPlaceholder v-else :category="placeholderCategory" />
</div>
</div>
<!-- Divider: header / description -->
<div v-if="description" class="-mx-4 border-t border-border-default" />
<!-- Description -->
<div v-if="description" class="flex w-full flex-col gap-2 py-2">
<span
class="text-xs font-bold tracking-wide text-muted-foreground uppercase"
>
{{ $t('cloudModelLibrary.preview.description') }}
</span>
<p
class="max-h-24 scrollbar-thin overflow-y-auto wrap-break-word text-muted-foreground"
>
{{ description }}
</p>
</div>
<!-- Trigger words -->
<div v-if="triggerPhrases.length" class="flex w-full flex-col gap-2 pb-2">
<div class="flex items-center gap-2.5">
<span
class="flex-1 text-xs font-bold tracking-wide text-muted-foreground uppercase"
>
{{ $t('cloudModelLibrary.preview.triggerWords') }}
</span>
<Button
v-tooltip.top="$t('g.copyAll')"
variant="muted-textonly"
size="icon"
class="rounded-lg"
:aria-label="$t('g.copyAll')"
@click="copyText(triggerPhrases.join(', '))"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="flex flex-wrap gap-2">
<Button
v-for="phrase in triggerPhrases"
:key="phrase"
v-tooltip.bottom="
copiedPhrase === phrase ? $t('g.copied') : $t('g.copyToClipboard')
"
variant="secondary"
size="sm"
class="h-6 rounded-full px-2 font-normal text-base-foreground"
@click="copyTriggerPhrase(phrase, $event)"
>
{{ truncatePhrase(phrase) }}
</Button>
</div>
</div>
<!-- Divider: metadata / node preview -->
<div v-if="previewNodeDef" class="-mx-4 border-t border-border-default" />
<!-- Node preview -->
<div v-if="previewNodeDef" class="flex w-full flex-col gap-2">
<span
class="mt-2 text-xs font-bold tracking-wide text-muted-foreground uppercase"
>
{{ $t('cloudModelLibrary.preview.nodePreview') }}
</span>
<div class="flex w-full justify-center py-2.5">
<div
ref="previewContainerRef"
class="overflow-hidden"
:style="{ width: `${NODE_PREVIEW_WIDTH_PX}px` }"
>
<div
ref="previewWrapperRef"
class="origin-top-left"
:style="{ transform: `scale(${nodePreviewScale})` }"
>
<LGraphNodePreview :node-def="previewNodeDef" position="relative" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import CategoryPlaceholder from '@/components/sidebar/tabs/cloudModelLibrary/CategoryPlaceholder.vue'
import { formatRowDisplayName } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { placeholderCategoryForAsset } from '@/composables/sidebarTabs/useCategoryPlaceholder'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription
} from '@/platform/assets/utils/assetMetadataUtils'
import { isCloud } from '@/platform/distribution/types'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const { asset } = defineProps<{ asset: AssetItem }>()
const rawDisplayName = computed(() => getAssetDisplayName(asset))
const displayName = computed(() => formatRowDisplayName(rawDisplayName.value))
const filename = computed(() => {
const value = getAssetFilename(asset)
return value && value !== rawDisplayName.value ? value : ''
})
const baseModels = computed(() => getAssetBaseModels(asset))
const description = computed(() => getAssetUserDescription(asset))
const triggerPhrases = computed(() => getAssetTriggerPhrases(asset))
const nativePreviewUrl = computed(
() => asset.preview_url ?? asset.thumbnail_url ?? ''
)
const nativeErrored = ref(false)
watch(nativePreviewUrl, () => {
nativeErrored.value = false
})
const thumbnail = computed(() =>
nativePreviewUrl.value && !nativeErrored.value
? { src: nativePreviewUrl.value }
: null
)
const thumbnailLoaded = ref(false)
watch(
() => thumbnail.value?.src,
() => {
thumbnailLoaded.value = false
}
)
const placeholderCategory = computed(() => placeholderCategoryForAsset(asset))
function onMediaError() {
nativeErrored.value = true
}
const sourceUrl = computed(() => getAssetSourceUrl(asset))
function openSourceUrl() {
if (!sourceUrl.value) return
window.open(sourceUrl.value, '_blank', 'noopener,noreferrer')
}
// The plain Load node for the asset's category — surfaced as a live preview so
// the user sees the result before inserting.
const previewNodeDef = computed(() => {
const category = getAssetModelType(asset)
if (!category) return null
return useModelToNodeStore().getNodeProvider(category)?.nodeDef ?? null
})
// LGraphNodePreview renders at a fixed 350px; scale it to the Figma node-preview
// width and compensate the container height so the CSS transform doesn't leave
// empty space below the node.
const NODE_PREVIEW_WIDTH_PX = 268
const NODE_BASE_WIDTH_PX = 350
const nodePreviewScale = NODE_PREVIEW_WIDTH_PX / NODE_BASE_WIDTH_PX
const previewContainerRef = ref<HTMLElement>()
const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
previewContainerRef.value.style.height = `${entry.contentRect.height * nodePreviewScale}px`
}
})
async function copyText(text: string) {
await navigator.clipboard.writeText(text)
}
// Tracks the trigger word most recently copied so its tooltip can flip to
// "Copied" as confirmation.
const copiedPhrase = ref<string | null>(null)
let copiedResetTimer: ReturnType<typeof setTimeout> | null = null
const COPIED_FEEDBACK_MS = 1500
async function copyTriggerPhrase(phrase: string, event: MouseEvent) {
const target = event.currentTarget
await copyText(phrase)
copiedPhrase.value = phrase
// PrimeVue hides the tooltip on click and doesn't refresh a visible tooltip's
// text, so re-trigger it to surface the updated "Copied" label in place.
await nextTick()
if (target instanceof HTMLElement)
target.dispatchEvent(new MouseEvent('mouseenter'))
if (copiedResetTimer) clearTimeout(copiedResetTimer)
copiedResetTimer = setTimeout(() => {
copiedPhrase.value = null
copiedResetTimer = null
}, COPIED_FEEDBACK_MS)
}
onBeforeUnmount(() => {
if (copiedResetTimer) clearTimeout(copiedResetTimer)
})
const TRIGGER_PHRASE_MAX_LENGTH = 20
function truncatePhrase(phrase: string): string {
return phrase.length > TRIGGER_PHRASE_MAX_LENGTH
? `${phrase.slice(0, TRIGGER_PHRASE_MAX_LENGTH)}`
: phrase
}
</script>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { placeholderGradientForCategory } from '@/composables/sidebarTabs/useCategoryPlaceholder'
const { category } = defineProps<{ category: string }>()
const background = computed(() => placeholderGradientForCategory(category))
</script>
<template>
<div class="size-full" :style="{ background }" />
</template>

Some files were not shown because too many files have changed in this diff Show More