Compare commits

..

5 Commits

Author SHA1 Message Date
Michael B
5fa74d2d6b refactor(website): wire ModelCreationsSection through the gallery collection
Move the five Grok showcase items previously hardcoded inside
ModelCreationsSection.vue into the canonical gallery Content Collection
as visible: false entries (subway-swan, milos-little-wonder,
amber-passage, neon-revenant, midnight-umami). Curation moves to the
.astro page: featuredCreationSlugs is declared inline and resolved
through getGalleryByIds — the helper added in slice 1, which preserves
input slug order.

ModelCreationsSection.vue is now a generic "render these items" view
that accepts items: readonly GalleryItem[] as a prop. Section copy
(modelName, ctaHref) stays inline — only the gallery-shaped data
moves out.

zh-CN/models.astro queries 'en' to preserve current behavior, since
the original hardcoded titles were English-only plain strings.
Translating these four/five items is explicitly out of scope per the
issue.

Note: the issue body says "four" Grok items, but the inline array has
five — neon-revenant by Eric Solorio. Slice 1's browser verification
confirmed five cards rendering today. The migration includes all five
to preserve current rendering; the AC's "same creations in same order
with same media as before" requires it.

No new tests are added (per the issue's AC #8): this slice reuses the
getGalleryByIds tracer test and gallery schema coverage from slice 1.
2026-06-11 14:08:39 -04:00
Michael B
93ff253625 refactor(website): migrate tutorials to Astro Content Collection
Move the six learning tutorials from src/data/learningTutorials.ts
into a tutorials Astro Content Collection, completing the gallery /
events / tutorials migration started in earlier slices. Frontmatter-
only Markdown is used (not JSON) so a prose body can be added later
without restructuring.

Schema, generated type, and ResolvedTutorial alias (= LearningTutorial
& { slug: string }) live in src/content.config.ts. The pure helper
getTutorialPosterSrc moves to src/utils/tutorial.ts so the function
stops being co-located with data declarations.

src/content/queries.ts gains getTutorialsByLocale(locale), which
filters by locale prefix and sorts by data.order ascending (same fix
as gallery and events).

Tags collapse from the global TranslationKey union to plain locale-
anchored strings in frontmatter — render path drops t(tag, locale)
and emits <Badge>{{ tag }}</Badge> directly. Tag translation keys
tags.partnerNodes and tags.imageToVideo in src/i18n/translations.ts
become orphaned and are left in place; cleanup belongs with the
future tags-as-reference-collection promotion (declared out of scope
in the PRD).

TutorialsSection and TutorialDetailDialog now take resolved tutorials
as a prop and key the active-tutorial state by the stable slug rather
than the old id field. The .astro pages do the entry → { slug, ...data }
merge so the Vue components never see Astro's CollectionEntry shape.

Slug rename: the old _v03-suffixed snake_case ids
(cleanplate_walkthrough_v03, etc.) become clean kebab-case
content-oriented slugs (cleanplate-walkthrough, etc.). No external
consumer references the previous ids, so this is safe; future asset
re-versioning will not force a slug change.

preservePathId generalised from /\.json$/ to /\.[^.]+$/ so it strips
both .json and .md extensions for the shared generateId override.

src/data/learningTutorials.ts is deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 13:50:39 -04:00
Michael B
2b6cc16575 refactor(website): migrate events to Astro Content Collection
Move the three learning-page events from src/data/events.ts into an
events Astro Content Collection, following the pattern established by
the gallery slice. Per-locale JSON files live under
src/content/events/<locale>/<slug>.json. The EventItem type, which
used to live inside EventsSection.vue with LocalizedText fields, is
now inferred from the eventsSchema in src/content.config.ts and
exposes plain string fields — the .astro page resolves locale at the
data boundary.

src/content/queries.ts gains getEventsByLocale(locale), which filters
by locale prefix and sorts by data.order ascending (so the home page
order survives Astro's lexicographic getCollection default — same fix
as gallery).

src/pages/zh-CN/learning.astro now mounts <EventsSection> with the
resolved events. Until now it imported EventsSection and learningEvents
but never used them — typecheck warned on both. Side-effect: events
become visible on /zh-CN/learning where they weren't before.

Schema notes:
- href accepts both URLs and the literal "#" so the placeholder seed
  data parses, while truly malformed input is still rejected.
- The generateId override that preserves the zh-CN/ path verbatim is
  extracted to a shared preservePathId helper used by both collections;
  slice 3 will reuse it.

The English /learning page is untouched — its existing EventsSection
and learningEvents imports are still commented out per the PRD's
explicit out-of-scope note.

src/data/events.ts is deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 13:26:25 -04:00
Michael B
85ad748c06 fix(website): preserve source order and zh-CN locale in gallery collection
Live verification of the previous commit found two regressions:

1. getCollection returned entries lexicographically by id, so the
   gallery rendered alphabetically instead of in the curator-defined
   order. The repeating row layout (full / 2-col / 3-col / large-left /
   large-right) depends on item order.

2. The default glob loader generateId lowercases path segments, so
   `zh-CN/<slug>` became `zh-cn/<slug>` and the locale filter matched
   zero entries. /zh-CN/gallery rendered empty.

Adds an explicit `order: z.number().int()` field to the gallery schema,
backfills order 1-18 in all 36 JSON files matching today's source order,
sorts in getVisibleGalleryByLocale by data.order ascending, and overrides
generateId on the glob loader to preserve the original path verbatim.

Adds a tracer test for the sort behavior. Mark issue 01 done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 12:53:48 -04:00
Michael B
4b81b127dd refactor(website): migrate gallery to Astro Content Collection
Move the gallery from a hand-written src/data/gallery.ts array into an
Astro Content Collection with a Zod schema as the single source of
truth. Per-locale JSON files live under src/content/gallery/<locale>/,
with the filename as the stable cross-locale slug. A new
src/content/queries.ts module exposes getVisibleGalleryByLocale,
getGalleryByIds, and slugOf for use by Astro pages.

Consumers (GalleryCard, GallerySection, GalleryItemAttribution,
GalleryDetailModal, ModelCreationsSection) now import GalleryItem from
src/content.config.ts. GallerySection becomes prop-driven; the
gallery.astro pages resolve items for the current locale and pass them
in. zh-CN/ mirrors en/ content to preserve byte-identical rendering on
/zh-CN/gallery; translators can replace zh-CN files individually.

Also tells ESLint's import-x/no-unresolved to ignore the astro:
virtual-module prefix in the website package, since astro:content and
friends are injected by the Astro build and not seen by the TS
resolver.

First slice of the gallery/events/tutorials content-collection
migration (PRD in apps/website/.scratch/content-collections/PRD.md).
ModelCreationsSection keeps its inline items for now — slice 4 wires
models.astro to query the gallery by slug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 12:31:16 -04:00
131 changed files with 1601 additions and 2040 deletions

View File

@@ -1,20 +1,10 @@
<script setup lang="ts">
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import type { EventItem } from '../../content.config'
import type { Locale, 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,
@@ -40,12 +30,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-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
@@ -66,20 +56,20 @@ const {
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
>
<span
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
>
{{ event.label[locale] }}
{{ event.label }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title[locale] }}
{{ event.title }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta[locale] }}
{{ event.cta }}
<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 '../../data/gallery'
import type { GalleryItem } from '../../content.config'
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-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<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-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<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 '../../data/gallery'
import type { GalleryItem } from '../../content.config'
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 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"
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"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
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 text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
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"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"
@@ -170,7 +170,7 @@ onUnmounted(() => {
<!-- Right: large image -->
<div
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"
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"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -197,7 +197,7 @@ onUnmounted(() => {
>
<!-- Image -->
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
class="border-primary-comfy-yellow flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 bg-primary-comfy-ink p-3"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -223,7 +223,7 @@ onUnmounted(() => {
<!-- Info card -->
<div
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
class="bg-primary-comfy-yellow w-full rounded-4xl p-6 text-primary-comfy-ink"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"

View File

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

View File

@@ -2,13 +2,15 @@
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import { visibleGalleryItems as items } from '../../data/gallery'
import type { GalleryItem } from '../../data/gallery'
import type { GalleryItem } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import GalleryCard from './GalleryCard.vue'
import GalleryDetailModal from './GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { items, locale = 'en' } = defineProps<{
items: GalleryItem[]
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 { LearningTutorial } from '../../data/learningTutorials'
import type { ResolvedTutorial } from '../../content.config'
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: LearningTutorial
tutorial: ResolvedTutorial
locale?: Locale
}>()
@@ -39,7 +39,7 @@ onUnmounted(() => {
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title[locale]"
:aria-label="tutorial.title"
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.id"
:key="tutorial.slug"
: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[locale] }}
{{ tutorial.title }}
</h2>
</dialog>
</Teleport>

View File

@@ -1,22 +1,23 @@
<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 { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { tutorials, locale = 'en' } = defineProps<{
tutorials: readonly ResolvedTutorial[]
locale?: Locale
}>()
const activeTutorialId = ref<string | null>(null)
const activeTutorialSlug = ref<string | null>(null)
const activeTutorial = () =>
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
tutorials.find((tutorial) => tutorial.slug === activeTutorialSlug.value)
</script>
<template>
@@ -31,15 +32,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 learningTutorials"
:key="tutorial.id"
v-for="tutorial in tutorials"
:key="tutorial.slug"
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[locale]}`"
@click="activeTutorialId = tutorial.id"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title}`"
@click="activeTutorialSlug = tutorial.slug"
>
<video
:src="getTutorialPosterSrc(tutorial)"
@@ -74,7 +75,7 @@ const activeTutorial = () =>
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
{{ tutorial.title }}
</h3>
<MaskRevealButton
v-if="tutorial.href"
@@ -103,7 +104,7 @@ const activeTutorial = () =>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ t(tag, locale) }}</Badge>
<Badge>{{ tag }}</Badge>
</li>
</ul>
</div>
@@ -114,7 +115,7 @@ const activeTutorial = () =>
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialId = null"
@close="activeTutorialSlug = null"
/>
</section>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../data/gallery'
import type { GalleryItem } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -9,64 +9,14 @@ import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { items, locale = 'en' } = defineProps<{
items: readonly GalleryItem[]
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

@@ -8,7 +8,6 @@ import {
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -70,7 +69,6 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img

View File

@@ -0,0 +1,131 @@
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

@@ -0,0 +1,78 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,11 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,10 @@
{
"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

@@ -0,0 +1,10 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,10 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,10 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -0,0 +1,264 @@
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

@@ -0,0 +1,61 @@
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

@@ -0,0 +1,9 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -0,0 +1,9 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -0,0 +1,10 @@
---
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

@@ -1,31 +0,0 @@
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

@@ -1,191 +0,0 @@
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

@@ -1,84 +0,0 @@
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,10 +3,14 @@ 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 client:load />
<GallerySection items={items} client:load />
<ContactSection />
</BaseLayout>

View File

@@ -8,14 +8,20 @@ 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 client:visible />
<TutorialsSection tutorials={tutorials} client:visible />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"

View File

@@ -3,7 +3,18 @@ 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 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
@@ -16,7 +27,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection client:load />
<ModelCreationsSection items={featuredItems} client:load />
<AIModelsSection client:load />
<ProductShowcaseSection client:load />
</BaseLayout>

View File

@@ -3,10 +3,14 @@ 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" client:load />
<GallerySection locale="zh-CN" items={items} client:load />
<ContactSection locale="zh-CN" />
</BaseLayout>

View File

@@ -6,15 +6,34 @@ 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 { learningEvents } from '../../data/events'
import {
getEventsByLocale,
getTutorialsByLocale,
slugOf
} from '../../content/queries'
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" 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
/>
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"

View File

@@ -4,6 +4,17 @@ 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
@@ -17,7 +28,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
/>
<ModelCreationsSection client:load locale="zh-CN" />
<ModelCreationsSection items={featuredItems} client:load locale="zh-CN" />
<AIModelsSection client:load locale="zh-CN" />
<ProductShowcaseSection client:load locale="zh-CN" />
</BaseLayout>

View File

@@ -53,28 +53,3 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -38,12 +38,3 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: string) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,8 @@
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,6 +1,5 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
@@ -18,87 +17,245 @@ test.describe('Background Image Upload', () => {
await comfyPage.settings.setSetting('Comfy.Canvas.BackgroundImage', '')
})
const openBackgroundImageSetting = async (comfyPage: ComfyPage) => {
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.locator('text=Appearance').click()
return comfyPage.page.locator('#Comfy\\.Canvas\\.BackgroundImage')
}
test('should show background image upload component in settings', async ({
comfyPage
}) => {
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
await expect(backgroundImageSetting).toBeVisible()
// With no image set: placeholder shown, no remove button
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
await expect(
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
).toBeHidden()
// Verify the component has the expected elements using semantic selectors
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toBeVisible()
await expect(urlInput).toHaveAttribute('placeholder')
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
await expect(uploadButton).toBeVisible()
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeDisabled() // Should be disabled when no image
})
test('should upload image file and set as background', async ({
comfyPage
}) => {
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Clicking the row opens the system file browser
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Click the upload button to trigger file input
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
// Set up file upload handler
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await backgroundImageSetting
.getByRole('button', { name: 'Choose image' })
.click()
await uploadButton.click()
const fileChooser = await fileChooserPromise
// Upload the test image
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
// The row shows the uploaded file's base name and a remove button
await expect(
backgroundImageSetting.getByText('image32x32.webp')
).toBeVisible()
await expect(
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
).toBeVisible()
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
// The setting value points at the uploaded file
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Verify the setting value was actually set
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
})
test('should show the base name of an existing background image', async ({
test('should accept URL input for background image', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.BackgroundImage',
'/api/view?filename=backgrounds%2Ftest-image.png&type=input&subfolder=backgrounds'
)
const testImageUrl = 'https://example.com/test-image.png'
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
await expect(
backgroundImageSetting.getByText('test-image.png')
).toBeVisible()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Enter URL in the input field
const urlInput = backgroundImageSetting.getByRole('textbox')
await urlInput.fill(testImageUrl)
// Trigger blur event to ensure the value is set
await urlInput.blur()
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Verify the setting value was updated
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe(testImageUrl)
})
test('should clear background image with the remove button', async ({
test('should clear background image when clear button is clicked', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// First set a background image
await comfyPage.settings.setSetting(
'Comfy.Canvas.BackgroundImage',
'/api/view?filename=test-image.png&type=input'
testImageUrl
)
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
await backgroundImageSetting
.getByRole('button', { name: 'Remove image' })
.click()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Placeholder returns, remove button disappears, setting cleared
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
await expect(
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
).toBeHidden()
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.Canvas.BackgroundImage'))
.toBe('')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Verify the input has the test URL
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toHaveValue(testImageUrl)
// Verify clear button is enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Click the clear button
await clearButton.click()
// Verify the input is now empty
await expect(urlInput).toHaveValue('')
// Verify clear button is now disabled
await expect(clearButton).toBeDisabled()
// Verify the setting value was cleared
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe('')
})
test('should show tooltip on upload and clear buttons', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Hover over upload button and verify tooltip appears
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
await uploadButton.hover()
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(uploadTooltip).toBeVisible()
// Move away to hide tooltip
await comfyPage.page.locator('body').hover()
// Set a background to enable clear button
const urlInput = backgroundImageSetting.getByRole('textbox')
await urlInput.fill('https://example.com/test.png')
await urlInput.blur()
// Hover over clear button and verify tooltip appears
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await clearButton.hover()
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(clearTooltip).toBeVisible()
})
test('should maintain reactive updates between URL input and clear button state', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
const urlInput = backgroundImageSetting.getByRole('textbox')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
// Initially clear button should be disabled
await expect(clearButton).toBeDisabled()
// Type some text - clear button should become enabled
await urlInput.fill('test')
await expect(clearButton).toBeEnabled()
// Clear the text manually - clear button should become disabled again
await urlInput.fill('')
await expect(clearButton).toBeDisabled()
// Add text again - clear button should become enabled
await urlInput.fill('https://example.com/image.png')
await expect(clearButton).toBeEnabled()
// Use clear button - should clear input and disable itself
await clearButton.click()
await expect(urlInput).toHaveValue('')
await expect(clearButton).toBeDisabled()
})
})

View File

@@ -292,10 +292,6 @@ test.describe('Node library sidebar', () => {
const dialog = comfyPage.page.getByRole('dialog', {
name: 'Customize Folder'
})
// Capture the dialog header position before opening the modal color
// picker: while the picker is open it sets aria-hidden on the dialog,
// so it can no longer be located by role.
const dialogBox = await dialog.boundingBox()
await dialog
.locator('.color-customization-selector-container > button')
.last()
@@ -304,17 +300,6 @@ test.describe('Node library sidebar', () => {
.getByLabel('Color saturation and brightness')
.click({ position: { x: 10, y: 10 } })
// The color picker popover is modal: while it is open the rest of the
// dialog is inert (pointer-events disabled), so dismiss it before
// interacting with other controls. A coordinate click on the dialog
// header lands on the dismiss layer and closes the popover.
if (dialogBox) {
await comfyPage.page.mouse.click(dialogBox.x + 40, dialogBox.y + 16)
}
await expect(
comfyPage.page.getByLabel('Color saturation and brightness')
).toBeHidden()
// Select Folder icon (2nd button in Icon group)
const iconGroup = dialog.getByText('Icon').locator('..').getByRole('group')
await iconGroup.getByRole('button').nth(1).click()

View File

@@ -421,6 +421,15 @@ 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

@@ -111,7 +111,6 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,15 +591,7 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

View File

@@ -22,7 +22,7 @@
},
"litegraph_base": {
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=",
"CLEAR_BACKGROUND_COLOR": "#222222",
"CLEAR_BACKGROUND_COLOR": "#141414",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_COLOR": "#AAA",

View File

@@ -1,23 +1,58 @@
<template>
<ImageUpload
v-model="modelValue"
:loading="isUploading"
@file-selected="handleFileUpload"
/>
<div class="flex gap-2">
<InputText
v-model="modelValue"
class="flex-1"
:placeholder="$t('g.imageUrl')"
/>
<Button
v-tooltip="$t('g.upload')"
variant="secondary"
size="sm"
:aria-label="$t('g.upload')"
:disabled="isUploading"
@click="triggerFileInput"
>
<i :class="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" />
</Button>
<Button
v-tooltip="$t('g.clear')"
variant="destructive"
size="sm"
:aria-label="$t('g.clear')"
:disabled="!modelValue"
@click="clearImage"
>
<i class="pi pi-trash" />
</Button>
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/*"
@change="handleFileUpload"
/>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import ImageUpload from '@/components/ui/image-upload/ImageUpload.vue'
import Button from '@/components/ui/button/Button.vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
const modelValue = defineModel<string>()
const fileInput = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const triggerFileInput = () => {
fileInput.value?.click()
}
const uploadFile = async (file: File): Promise<string | null> => {
const body = new FormData()
body.append('image', file)
@@ -39,24 +74,36 @@ const uploadFile = async (file: File): Promise<string | null> => {
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
const handleFileUpload = async (file: File) => {
isUploading.value = true
try {
const uploadedPath = await uploadFile(file)
if (uploadedPath) {
// Set the value to the API view URL with subfolder parameter
const params = new URLSearchParams({
filename: uploadedPath,
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
isUploading.value = true
try {
const uploadedPath = await uploadFile(file)
if (uploadedPath) {
// Set the value to the API view URL with subfolder parameter
const params = new URLSearchParams({
filename: uploadedPath,
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {
useToastStore().addAlert(`Upload error: ${String(error)}`)
} finally {
isUploading.value = false
}
} catch (error) {
useToastStore().addAlert(`Upload error: ${String(error)}`)
} finally {
isUploading.value = false
}
}
const clearImage = () => {
modelValue.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>

View File

@@ -376,17 +376,13 @@ watch(
)
watch(
[
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
() => settingStore.get('Comfy.Canvas.BackgroundPattern'),
() => settingStore.get('Comfy.Canvas.BackgroundColor')
],
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
async () => {
if (!canvasStore.canvas) return
const currentPaletteId = colorPaletteStore.activePaletteId
if (!currentPaletteId) return
// Reload color palette to apply background image/pattern/color
// Reload color palette to apply background image
await colorPaletteService.loadColorPalette(currentPaletteId)
// Mark background canvas as dirty
canvasStore.canvas.setDirty(false, true)

View File

@@ -23,8 +23,6 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@@ -88,7 +86,7 @@
/>
<RecordingControls
v-if="canUseRecording && !isPreview"
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -119,18 +117,9 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
const props = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -141,11 +130,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = resolveNode(nodeId) ?? null
node.value = resolveNode(props.nodeId!) ?? null
})
}

View File

@@ -1,47 +0,0 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -1,21 +0,0 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,7 +52,6 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -82,7 +81,6 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -131,16 +129,12 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}>()

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="showBackgroundImage && !hasBackgroundImage">
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,16 +83,12 @@
</div>
<PopupSlider
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -118,9 +114,8 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
const { hdriActive = false } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,12 +1,10 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue'
import Button from '@/components/ui/button/Button.vue'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
@@ -14,9 +12,6 @@ import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { CanvasBackgroundPattern } from '@/utils/canvasPatternUtil'
import { getEffectiveCanvasBackgroundColor } from '@/utils/canvasPatternUtil'
import { cn } from '@comfyorg/tailwind-utils'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
@@ -45,77 +40,6 @@ const nodes2Enabled = computed({
})
// CANVAS settings
const colorPaletteStore = useColorPaletteStore()
type CanvasBackgroundMode = CanvasBackgroundPattern | 'image'
// Keeps the Image option selected while no image is set, e.g. before the
// first upload or right after removing the current one.
const imageModeSelected = ref(false)
const backgroundImage = computed({
get: () => settingStore.get('Comfy.Canvas.BackgroundImage') ?? '',
set: (value) => {
if (!value) imageModeSelected.value = true
settingStore.set('Comfy.Canvas.BackgroundImage', value)
}
})
const isBackgroundImageSet = computed(() => !!backgroundImage.value)
const backgroundMode = computed<CanvasBackgroundMode>({
get: () =>
isBackgroundImageSet.value || imageModeSelected.value
? 'image'
: settingStore.get('Comfy.Canvas.BackgroundPattern'),
set: (value) => {
if (value === 'image') {
imageModeSelected.value = true
return
}
imageModeSelected.value = false
if (isBackgroundImageSet.value) {
settingStore.set('Comfy.Canvas.BackgroundImage', '')
}
settingStore.set('Comfy.Canvas.BackgroundPattern', value)
}
})
const backgroundOptions = computed(() => [
{
value: 'dots',
label: t('settings.Comfy_Canvas_BackgroundPattern.options.Dots')
},
{
value: 'grid',
label: t('settings.Comfy_Canvas_BackgroundPattern.options.Grid')
},
{ value: 'none', label: t('g.none') },
{ value: 'image', label: t('rightSidePanel.globalSettings.image') }
])
const hasCustomBackgroundColor = computed(
() => settingStore.get('Comfy.Canvas.BackgroundColor') !== ''
)
const backgroundColor = computed({
get: () =>
getEffectiveCanvasBackgroundColor(
settingStore.get('Comfy.Canvas.BackgroundColor'),
colorPaletteStore.completedActivePalette.colors.litegraph_base
.CLEAR_BACKGROUND_COLOR
),
set: (value) =>
settingStore.set(
'Comfy.Canvas.BackgroundColor',
value.replace(/^#/, '').slice(0, 6)
)
})
async function resetBackgroundColor() {
await settingStore.set('Comfy.Canvas.BackgroundColor', '')
}
const gridSpacing = computed({
get: () => settingStore.get('Comfy.SnapToGrid.GridSize'),
set: (value) => settingStore.set('Comfy.SnapToGrid.GridSize', value)
@@ -204,55 +128,6 @@ function openFullSettings() {
{{ t('rightSidePanel.globalSettings.canvas') }}
</template>
<div class="space-y-4 px-4 py-3">
<LayoutField
:label="t('rightSidePanel.globalSettings.background')"
:tooltip="t('settings.Comfy_Canvas_BackgroundPattern.tooltip')"
>
<Select
v-model="backgroundMode"
:options="backgroundOptions"
:aria-label="t('rightSidePanel.globalSettings.background')"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: cn('min-w-[4ch] truncate', $slots.default && 'mr-5'),
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
option-label="label"
option-value="value"
/>
</LayoutField>
<BackgroundImageUpload
v-if="backgroundMode === 'image'"
v-model="backgroundImage"
/>
<LayoutField
v-else
:label="t('rightSidePanel.globalSettings.backgroundColor')"
:tooltip="t('settings.Comfy_Canvas_BackgroundColor.tooltip')"
>
<div class="flex items-center gap-1">
<ColorPicker
v-model="backgroundColor"
class="min-w-0 grow"
:aria-label="t('rightSidePanel.globalSettings.backgroundColor')"
/>
<Button
v-if="hasCustomBackgroundColor"
variant="muted-textonly"
size="icon"
:aria-label="
t('rightSidePanel.globalSettings.resetBackgroundColor')
"
@click="resetBackgroundColor"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
</Button>
</div>
</LayoutField>
<LayoutField :label="t('rightSidePanel.globalSettings.gridSpacing')">
<div
:class="

View File

@@ -1,34 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import ColorPicker from './ColorPicker.vue'
describe('ColorPicker', () => {
it('does not echo a write back when the model is changed externally', async () => {
const onUpdate = vi.fn()
const { rerender } = render(ColorPicker, {
props: { modelValue: '#823182', 'onUpdate:modelValue': onUpdate }
})
await rerender({ modelValue: '' })
await nextTick()
await nextTick()
expect(onUpdate).not.toHaveBeenCalled()
})
it('shows the latest external color without writing back', async () => {
const onUpdate = vi.fn()
const { rerender } = render(ColorPicker, {
props: { modelValue: '#823182', 'onUpdate:modelValue': onUpdate }
})
await rerender({ modelValue: '#222222' })
await nextTick()
await nextTick()
expect(onUpdate).not.toHaveBeenCalled()
expect(screen.getByText('#222222')).toBeTruthy()
})
})

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