mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-12 01:09:36 +00:00
Compare commits
5 Commits
fix/hero-a
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fa74d2d6b | ||
|
|
93ff253625 | ||
|
|
2b6cc16575 | ||
|
|
85ad748c06 | ||
|
|
4b81b127dd |
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,19 +25,19 @@ const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
v-show="!logoLoaded"
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
|
||||
alt="Comfy logo"
|
||||
class="w-full"
|
||||
class="w-3/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 px-6 py-12 lg:px-16">
|
||||
<h1
|
||||
class="text-4xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-6xl"
|
||||
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line lg:text-6xl"
|
||||
>
|
||||
{{ t('hero.title', locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mt-8 max-w-lg text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
class="text-primary-comfy-canvas mt-8 max-w-lg text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type * as THREE from 'three'
|
||||
import * as THREE from 'three'
|
||||
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
|
||||
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
|
||||
@@ -43,12 +44,34 @@ function buildImageUrls(): string[] {
|
||||
})
|
||||
}
|
||||
|
||||
function yieldToMain(): Promise<void> {
|
||||
const sched = (
|
||||
window as unknown as { scheduler?: { yield?: () => Promise<void> } }
|
||||
).scheduler
|
||||
if (sched && typeof sched.yield === 'function') return sched.yield()
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
})
|
||||
return shapes
|
||||
}
|
||||
|
||||
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
|
||||
return Promise.all(
|
||||
urls.map(
|
||||
(url) =>
|
||||
new Promise<THREE.Texture | null>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const tex = new THREE.Texture(img)
|
||||
tex.needsUpdate = true
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
resolve(tex)
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
)
|
||||
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
|
||||
}
|
||||
|
||||
export function useHeroLogo(
|
||||
@@ -58,70 +81,12 @@ export function useHeroLogo(
|
||||
const cfg = { ...DEFAULTS, ...config }
|
||||
const loaded = ref(false)
|
||||
let cleanup: (() => void) | undefined
|
||||
let unmounted = false
|
||||
let idleHandle: number | undefined
|
||||
let timeoutHandle: number | undefined
|
||||
|
||||
const cancelScheduled = () => {
|
||||
if (
|
||||
idleHandle !== undefined &&
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.cancelIdleCallback === 'function'
|
||||
) {
|
||||
window.cancelIdleCallback(idleHandle)
|
||||
}
|
||||
idleHandle = undefined
|
||||
if (timeoutHandle !== undefined) {
|
||||
window.clearTimeout(timeoutHandle)
|
||||
timeoutHandle = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const setup = async () => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (unmounted) return
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
|
||||
const [THREE, svgLoaderMod] = await Promise.all([
|
||||
import('three'),
|
||||
import('three/addons/loaders/SVGLoader.js')
|
||||
])
|
||||
if (unmounted) return
|
||||
|
||||
const parseShapes = (): THREE.Shape[] => {
|
||||
const { SVGLoader } = svgLoaderMod
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
})
|
||||
return shapes
|
||||
}
|
||||
|
||||
const loadTextures = (urls: string[]): Promise<THREE.Texture[]> => {
|
||||
return Promise.all(
|
||||
urls.map(
|
||||
(url) =>
|
||||
new Promise<THREE.Texture | null>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const tex = new THREE.Texture(img)
|
||||
tex.needsUpdate = true
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
resolve(tex)
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
)
|
||||
).then((results) =>
|
||||
results.filter((t): t is THREE.Texture => t !== null)
|
||||
)
|
||||
}
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
@@ -160,9 +125,6 @@ export function useHeroLogo(
|
||||
)
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
@@ -173,15 +135,15 @@ export function useHeroLogo(
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
tempGeo.dispose()
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
void loadTextures(urls.slice(1)).then((rest) => {
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
@@ -205,9 +167,6 @@ export function useHeroLogo(
|
||||
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
|
||||
scene.add(bgPlane)
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// Logo group
|
||||
const group = new THREE.Group()
|
||||
scene.add(group)
|
||||
@@ -230,9 +189,6 @@ export function useHeroLogo(
|
||||
logoMesh.renderOrder = 2
|
||||
group.add(logoMesh)
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// Extrusion stencil mask
|
||||
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
|
||||
depth,
|
||||
@@ -256,9 +212,6 @@ export function useHeroLogo(
|
||||
extrudeMesh.renderOrder = 0
|
||||
group.add(extrudeMesh)
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// Interaction
|
||||
let isDragging = false
|
||||
let previousX = 0
|
||||
@@ -308,7 +261,6 @@ export function useHeroLogo(
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
let firstFrameRendered = false
|
||||
|
||||
function animate() {
|
||||
if (disposed) return
|
||||
@@ -342,12 +294,6 @@ export function useHeroLogo(
|
||||
}
|
||||
|
||||
renderer.render(scene, camera)
|
||||
|
||||
if (!firstFrameRendered) {
|
||||
firstFrameRendered = true
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
@@ -372,29 +318,9 @@ export function useHeroLogo(
|
||||
console.error('[useHeroLogo] initialization failed:', err)
|
||||
cleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
idleHandle = window.requestIdleCallback(
|
||||
() => {
|
||||
idleHandle = undefined
|
||||
void setup()
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
} else {
|
||||
timeoutHandle = window.setTimeout(() => {
|
||||
timeoutHandle = undefined
|
||||
void setup()
|
||||
}, 200)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unmounted = true
|
||||
cancelScheduled()
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
|
||||
131
apps/website/src/content.config.test.ts
Normal file
131
apps/website/src/content.config.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
78
apps/website/src/content.config.ts
Normal file
78
apps/website/src/content.config.ts
Normal 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 }
|
||||
7
apps/website/src/content/events/en/event-london.json
Normal file
7
apps/website/src/content/events/en/event-london.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 2,
|
||||
"label": "Event 1",
|
||||
"title": "Lorem ipsum dollar sita met",
|
||||
"cta": "London, UK",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 3,
|
||||
"label": "Event 2",
|
||||
"title": "Lorem ipsum dollar sita met",
|
||||
"cta": "San Francisco",
|
||||
"href": "#"
|
||||
}
|
||||
7
apps/website/src/content/events/en/zero-to-node.json
Normal file
7
apps/website/src/content/events/en/zero-to-node.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 1,
|
||||
"label": "Live Stream:",
|
||||
"title": "Zero to Node: Building Your First Workflow",
|
||||
"cta": "Link",
|
||||
"href": "#"
|
||||
}
|
||||
7
apps/website/src/content/events/zh-CN/event-london.json
Normal file
7
apps/website/src/content/events/zh-CN/event-london.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 2,
|
||||
"label": "活动 1",
|
||||
"title": "此处为活动描述的占位文本",
|
||||
"cta": "英国伦敦",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 3,
|
||||
"label": "活动 2",
|
||||
"title": "此处为活动描述的占位文本",
|
||||
"cta": "旧金山",
|
||||
"href": "#"
|
||||
}
|
||||
7
apps/website/src/content/events/zh-CN/zero-to-node.json
Normal file
7
apps/website/src/content/events/zh-CN/zero-to-node.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 1,
|
||||
"label": "直播:",
|
||||
"title": "从零到节点:构建你的第一个工作流",
|
||||
"cta": "链接",
|
||||
"href": "#"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/amber-astronaut.json
Normal file
9
apps/website/src/content/gallery/en/amber-astronaut.json
Normal 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"
|
||||
}
|
||||
11
apps/website/src/content/gallery/en/amber-passage.json
Normal file
11
apps/website/src/content/gallery/en/amber-passage.json
Normal 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
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/animation-reel.json
Normal file
9
apps/website/src/content/gallery/en/animation-reel.json
Normal 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"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/autopoiesis.json
Normal file
9
apps/website/src/content/gallery/en/autopoiesis.json
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/ddu-du-ddu-du.json
Normal file
9
apps/website/src/content/gallery/en/ddu-du-ddu-du.json
Normal 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"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/desert-landing.json
Normal file
9
apps/website/src/content/gallery/en/desert-landing.json
Normal 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"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/eat-it-dance.json
Normal file
9
apps/website/src/content/gallery/en/eat-it-dance.json
Normal 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/"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/fall.json
Normal file
9
apps/website/src/content/gallery/en/fall.json
Normal 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/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/goodbye-beijing.json
Normal file
9
apps/website/src/content/gallery/en/goodbye-beijing.json
Normal 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"
|
||||
}
|
||||
10
apps/website/src/content/gallery/en/midnight-umami.json
Normal file
10
apps/website/src/content/gallery/en/midnight-umami.json
Normal 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
|
||||
}
|
||||
10
apps/website/src/content/gallery/en/milos-little-wonder.json
Normal file
10
apps/website/src/content/gallery/en/milos-little-wonder.json
Normal 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
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/neon-nights.json
Normal file
9
apps/website/src/content/gallery/en/neon-nights.json
Normal 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/"
|
||||
}
|
||||
10
apps/website/src/content/gallery/en/neon-revenant.json
Normal file
10
apps/website/src/content/gallery/en/neon-revenant.json
Normal 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
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/origami-world.json
Normal file
9
apps/website/src/content/gallery/en/origami-world.json
Normal 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/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/shot-on-instax.json
Normal file
9
apps/website/src/content/gallery/en/shot-on-instax.json
Normal 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/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
10
apps/website/src/content/gallery/en/subway-swan.json
Normal file
10
apps/website/src/content/gallery/en/subway-swan.json
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
9
apps/website/src/content/gallery/zh-CN/autopoiesis.json
Normal file
9
apps/website/src/content/gallery/zh-CN/autopoiesis.json
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
9
apps/website/src/content/gallery/zh-CN/eat-it-dance.json
Normal file
9
apps/website/src/content/gallery/zh-CN/eat-it-dance.json
Normal 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/"
|
||||
}
|
||||
9
apps/website/src/content/gallery/zh-CN/fall.json
Normal file
9
apps/website/src/content/gallery/zh-CN/fall.json
Normal 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/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
9
apps/website/src/content/gallery/zh-CN/neon-nights.json
Normal file
9
apps/website/src/content/gallery/zh-CN/neon-nights.json
Normal 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/"
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
264
apps/website/src/content/queries.test.ts
Normal file
264
apps/website/src/content/queries.test.ts
Normal 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'
|
||||
])
|
||||
})
|
||||
})
|
||||
61
apps/website/src/content/queries.ts
Normal file
61
apps/website/src/content/queries.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
---
|
||||
10
apps/website/src/content/tutorials/en/deaging-workflow.md
Normal file
10
apps/website/src/content/tutorials/en/deaging-workflow.md
Normal 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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
10
apps/website/src/content/tutorials/en/sky-replacement.md
Normal file
10
apps/website/src/content/tutorials/en/sky-replacement.md
Normal 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/
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
10
apps/website/src/content/tutorials/zh-CN/deaging-workflow.md
Normal file
10
apps/website/src/content/tutorials/zh-CN/deaging-workflow.md
Normal 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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
10
apps/website/src/content/tutorials/zh-CN/sky-replacement.md
Normal file
10
apps/website/src/content/tutorials/zh-CN/sky-replacement.md
Normal 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/
|
||||
---
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
apps/website/src/utils/tutorial.test.ts
Normal file
40
apps/website/src/utils/tutorial.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
8
apps/website/src/utils/tutorial.ts
Normal file
8
apps/website/src/utils/tutorial.ts
Normal 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}`
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
LOAD3D_NONE_MODEL,
|
||||
SUPPORTED_EXTENSIONS_ACCEPT
|
||||
} from '@/extensions/core/load3d/constants'
|
||||
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -414,10 +413,16 @@ useExtensionService().registerExtension({
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const { camera_info, model_3d_info } = snapshotLoad3dState(
|
||||
node,
|
||||
currentLoad3d
|
||||
)
|
||||
const cameraConfig: CameraConfig = (node.properties[
|
||||
'Camera Config'
|
||||
] as CameraConfig | undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
currentLoad3d.stopRecording()
|
||||
|
||||
const {
|
||||
scene: imageData,
|
||||
@@ -436,11 +441,16 @@ useExtensionService().registerExtension({
|
||||
|
||||
currentLoad3d.handleResize()
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
const returnVal: Load3dCachedOutput = {
|
||||
image: `threed/${data.name} [temp]`,
|
||||
mask: `threed/${dataMask.name} [temp]`,
|
||||
normal: `threed/${dataNormal.name} [temp]`,
|
||||
camera_info,
|
||||
camera_info:
|
||||
(node.properties['Camera Config'] as CameraConfig | undefined)
|
||||
?.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ const mtlLoaderStub = {
|
||||
const objLoaderStub = {
|
||||
setWorkerUrl: vi.fn(),
|
||||
setMaterials: vi.fn(),
|
||||
setBaseObject3d: vi.fn(),
|
||||
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
|
||||
}
|
||||
|
||||
@@ -59,7 +58,6 @@ vi.mock('wwobjloader2', () => ({
|
||||
OBJLoader2Parallel: class {
|
||||
setWorkerUrl = objLoaderStub.setWorkerUrl
|
||||
setMaterials = objLoaderStub.setMaterials
|
||||
setBaseObject3d = objLoaderStub.setBaseObject3d
|
||||
loadAsync = objLoaderStub.loadAsync
|
||||
},
|
||||
MtlObjBridge: {
|
||||
@@ -249,24 +247,6 @@ describe('MeshModelAdapter', () => {
|
||||
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resets baseObject3d on every load so meshes do not accumulate across calls', async () => {
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext('wireframe')
|
||||
await adapter.load(ctx, '/api/view/', 'first.obj')
|
||||
await adapter.load(ctx, '/api/view/', 'second.obj')
|
||||
|
||||
expect(objLoaderStub.setBaseObject3d).toHaveBeenCalledTimes(2)
|
||||
const bases = objLoaderStub.setBaseObject3d.mock.calls.map(
|
||||
([base]) => base
|
||||
)
|
||||
expect(bases[0]).toBeInstanceOf(THREE.Object3D)
|
||||
expect(bases[1]).toBeInstanceOf(THREE.Object3D)
|
||||
// Each call should hand the loader a fresh container, not the same one.
|
||||
expect(bases[0]).not.toBe(bases[1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GLTF loader path', () => {
|
||||
|
||||
@@ -102,8 +102,6 @@ export class MeshModelAdapter implements ModelAdapter {
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.objLoader.setBaseObject3d(new THREE.Object3D())
|
||||
|
||||
if (ctx.materialMode === 'original') {
|
||||
try {
|
||||
this.mtlLoader.setPath(path)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
|
||||
import type { CameraState } from '@/extensions/core/load3d/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
function makeNode(props: Record<string, unknown> = {}): LGraphNode {
|
||||
return { properties: { ...props } } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
const baseCameraState: CameraState = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 1,
|
||||
cameraType: 'perspective'
|
||||
} as unknown as CameraState
|
||||
|
||||
function makeLoad3d({
|
||||
cameraType = 'perspective',
|
||||
fov = 35,
|
||||
modelInfo = { transform: { position: [0, 0, 0] } } as unknown
|
||||
}: {
|
||||
cameraType?: string
|
||||
fov?: number
|
||||
modelInfo?: unknown
|
||||
} = {}) {
|
||||
return {
|
||||
getCurrentCameraType: vi.fn(() => cameraType),
|
||||
cameraManager: { perspectiveCamera: { fov } },
|
||||
getCameraState: vi.fn(() => baseCameraState),
|
||||
stopRecording: vi.fn(),
|
||||
getModelInfo: vi.fn(() => modelInfo)
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
describe('snapshotLoad3dState', () => {
|
||||
it('returns only camera_info and model_3d_info', () => {
|
||||
const result = snapshotLoad3dState(makeNode(), makeLoad3d())
|
||||
expect(Object.keys(result).sort()).toEqual(['camera_info', 'model_3d_info'])
|
||||
})
|
||||
|
||||
it('writes the camera state into properties["Camera Config"]', () => {
|
||||
const node = makeNode()
|
||||
snapshotLoad3dState(node, makeLoad3d({ fov: 42 }))
|
||||
const cfg = node.properties['Camera Config'] as Record<string, unknown>
|
||||
expect(cfg).toMatchObject({
|
||||
cameraType: 'perspective',
|
||||
fov: 42,
|
||||
state: baseCameraState
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an existing Camera Config object instead of replacing it', () => {
|
||||
const existing = { cameraType: 'orthographic', fov: 99 }
|
||||
const node = makeNode({ 'Camera Config': existing })
|
||||
snapshotLoad3dState(node, makeLoad3d())
|
||||
// Same object reference (mutated in place), with state attached.
|
||||
expect(node.properties['Camera Config']).toBe(existing)
|
||||
expect(
|
||||
(node.properties['Camera Config'] as Record<string, unknown>).state
|
||||
).toBe(baseCameraState)
|
||||
})
|
||||
|
||||
it('stops in-progress recording as a side effect', () => {
|
||||
const load3d = makeLoad3d()
|
||||
snapshotLoad3dState(makeNode(), load3d)
|
||||
expect(load3d.stopRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns model_3d_info as a single-element list when a model is loaded', () => {
|
||||
const info = { transform: { position: [1, 2, 3] } }
|
||||
const result = snapshotLoad3dState(
|
||||
makeNode(),
|
||||
makeLoad3d({ modelInfo: info })
|
||||
)
|
||||
expect(result.model_3d_info).toEqual([info])
|
||||
})
|
||||
|
||||
it('returns an empty model_3d_info list when no model is loaded', () => {
|
||||
const result = snapshotLoad3dState(
|
||||
makeNode(),
|
||||
makeLoad3d({ modelInfo: null })
|
||||
)
|
||||
expect(result.model_3d_info).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
export type Load3dSerializedBase = {
|
||||
camera_info: CameraState | null
|
||||
model_3d_info: Model3DInfo
|
||||
}
|
||||
|
||||
export function snapshotLoad3dState(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d
|
||||
): Load3dSerializedBase {
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: load3d.getCurrentCameraType(),
|
||||
fov: load3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = load3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
load3d.stopRecording()
|
||||
|
||||
const modelInfo = load3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
camera_info: cameraConfig.state ?? null,
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,7 @@ const LOAD3D_PREVIEW_NODES = new Set([
|
||||
'PreviewPointCloud'
|
||||
])
|
||||
|
||||
const LOAD3D_ALL_NODES = new Set([
|
||||
...LOAD3D_PREVIEW_NODES,
|
||||
'Load3D',
|
||||
'Load3DAdvanced',
|
||||
'SaveGLB'
|
||||
])
|
||||
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
|
||||
|
||||
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
|
||||
LOAD3D_PREVIEW_NODES.has(nodeType)
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
|
||||
const inputSpecLoad3DAdvanced: CustomInputSpec = {
|
||||
name: 'viewport_state',
|
||||
type: 'LOAD_3D_ADVANCED',
|
||||
isPreview: false
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Load3DAdvanced',
|
||||
|
||||
beforeRegisterNodeDef(_nodeType, nodeData) {
|
||||
if (nodeData.name !== 'Load3DAdvanced') return
|
||||
if (!nodeData.input?.required) return
|
||||
nodeData.input.required.viewport_state = ['LOAD_3D_ADVANCED', {}]
|
||||
},
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Load3DAdvanced') return []
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
LOAD_3D_ADVANCED(node) {
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: 'viewport_state',
|
||||
component: Load3DAdvanced,
|
||||
inputSpec: inputSpecLoad3DAdvanced,
|
||||
options: {}
|
||||
})
|
||||
|
||||
widget.type = 'load3DAdvanced'
|
||||
|
||||
addWidget(node, widget)
|
||||
|
||||
return { widget }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Load3DAdvanced') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 600)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
const width = node.widgets?.find((w) => w.name === 'width')
|
||||
const height = node.widgets?.find((w) => w.name === 'height')
|
||||
if (!modelWidget || !width || !height) return
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configure({
|
||||
loadFolder: 'input',
|
||||
modelWidget,
|
||||
cameraState,
|
||||
width,
|
||||
height
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d(() => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
|
||||
if (!sceneWidget) return
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
return snapshotLoad3dState(node, currentLoad3d)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -37,7 +37,6 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
|
||||
// Import extensions - they self-register via useExtensionService()
|
||||
await Promise.all([
|
||||
import('./load3d'),
|
||||
import('./load3dAdvanced'),
|
||||
import('./load3dPreviewExtensions'),
|
||||
import('./saveMesh')
|
||||
])
|
||||
@@ -67,12 +66,6 @@ useExtensionService().registerExtension({
|
||||
modelFile[1].mesh_upload = true
|
||||
modelFile[1].upload_subfolder = '3d'
|
||||
}
|
||||
} else if (nodeData.name === 'Load3DAdvanced') {
|
||||
const modelFile = nodeData.input?.required?.model_file
|
||||
if (modelFile?.[1]) {
|
||||
modelFile[1].mesh_upload = true
|
||||
modelFile[1].upload_subfolder = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user