Compare commits

...

5 Commits

Author SHA1 Message Date
Michael B
456b7b2cae update featured workflow demo video 2026-05-27 13:38:35 -04:00
Michael B
dd475b7f7b feat: tutorial video dialog + shared Badge (WIP)
Open tutorials in a modal dialog with autoplay, add Badge component for
tag pills, and let tutorials configure the poster frame timestamp.
2026-05-27 10:54:41 -04:00
Michael B
5adcf0ad50 fix: drop unused LearningTutorial export to satisfy knip 2026-05-26 16:40:36 -04:00
Michael B
47314746ce feat: tutorials section + localized data files (WIP) 2026-05-26 16:36:47 -04:00
Michael B
2d91640423 feat: scaffold learning page (WIP)
Add learning page in en + zh-CN with hero, featured workflow, shared
call-to-action and events sections. Introduces reusable
CallToActionSection and EventsSection in common/ for use on other pages.
2026-05-26 13:03:59 -04:00
12 changed files with 715 additions and 3 deletions

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: string }>()
</script>
<template>
<span
:class="
cn(
'text-primary-warm-gray bg-transparency-ink-t80 inline-flex items-center rounded-full px-4 py-1 text-xs',
className
)
"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
const {
locale = 'en',
headingKey,
primaryLabelKey,
primaryHref,
secondaryLabelKey,
secondaryHref
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
primaryLabelKey: TranslationKey
primaryHref?: string
secondaryLabelKey?: TranslationKey
secondaryHref?: string
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
<BrandButton
:href="primaryHref"
variant="solid"
size="xs"
class="uppercase"
>
{{ t(primaryLabelKey, locale) }}
</BrandButton>
<BrandButton
v-if="secondaryLabelKey"
:href="secondaryHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(secondaryLabelKey, locale) }}
</BrandButton>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
export type EventItem = {
label: LocalizedText
title: LocalizedText
cta: LocalizedText
href: string
}
const {
locale = 'en',
headingKey,
descriptionKey,
notifyLabelKey,
notifyHref,
events
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
descriptionKey: TranslationKey
notifyLabelKey: TranslationKey
notifyHref?: string
events: readonly EventItem[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-12">
<div
class="bg-transparency-white-t4 rounded-4xl px-6 py-12 lg:px-16 lg:py-20"
>
<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"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
<div>
<BrandButton
:href="notifyHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(notifyLabelKey, locale) }}
</BrandButton>
</div>
</div>
<div class="flex flex-col">
<a
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"
>
<span
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
>
{{ event.label[locale] }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title[locale] }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta[locale] }}
<svg
class="size-4 transition-transform group-hover:translate-x-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</span>
</a>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const tags = ['Seadance 2.0', 'Image To Video']
const demoVideoSrc =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4'
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<div>
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t('learning.featured.title', locale) }}
</h2>
<p class="text-primary-warm-gray mt-4 text-sm lg:text-base">
{{ t('learning.featured.author', locale) }}
</p>
</div>
<p
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
>
{{ t('learning.featured.description', locale) }}
</p>
<div class="flex flex-wrap gap-3">
<BrandButton variant="solid" size="xs" class="uppercase">
{{ t('learning.featured.watchDemo', locale) }}
</BrandButton>
<BrandButton variant="outline" size="xs" class="uppercase">
{{ t('cta.tryWorkflow', locale) }}
</BrandButton>
</div>
<div class="mt-2 flex flex-wrap gap-3">
<span
v-for="tag in tags"
:key="tag"
class="text-primary-warm-gray border-primary-warm-gray/40 rounded-full border px-4 py-1.5 text-xs"
>
{{ tag }}
</span>
</div>
</div>
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
<VideoPlayer :locale :src="demoVideoSrc" minimal />
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
>
<h1
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span
>{{ t('learning.heroTitle.after', locale) }}
<br />
{{ t('learning.heroTitle.line2', locale) }}
</h1>
</section>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: LearningTutorial
locale?: Locale
}>()
const emit = defineEmits<{ close: [] }>()
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
const playFromStart = () => {
const video = videoRef.value
if (!video) return
video.currentTime = 0
void video.play().catch(() => {})
}
watch(
() => tutorial.id,
() => {
playFromStart()
}
)
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) emit('close')
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
lockScroll()
dialogRef.value?.showModal()
playFromStart()
})
onUnmounted(() => {
unlockScroll()
})
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title[locale]"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@close="emit('close')"
>
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
>
<video
ref="videoRef"
:src="tutorial.videoSrc"
:poster="tutorial.poster"
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
controls
autoplay
playsinline
></video>
</div>
<h2
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h2>
</dialog>
</Teleport>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeTutorialId = ref<string | null>(null)
const activeTutorial = () =>
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 pb-16 lg:pb-24">
<ul
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"
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"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
>
<svg
class="ml-1 size-5 text-white lg:size-6"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col space-y-3 p-4">
<div class="flex items-center justify-between gap-4">
<h3
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h3>
<a
:href="tutorial.href"
class="text-primary-comfy-yellow group flex shrink-0 items-center gap-2 text-xs font-medium tracking-wide uppercase lg:text-sm"
>
<span
class="bg-primary-comfy-yellow flex size-6 items-center justify-center rounded-full transition-transform group-hover:translate-x-0.5 lg:size-7"
>
<svg
class="text-primary-comfy-ink size-3 lg:size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</span>
{{ t('cta.tryWorkflow', locale) }}
</a>
</div>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
</li>
</ul>
<TutorialDetailDialog
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialId = null"
/>
</section>
</template>

View File

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

View File

@@ -0,0 +1,72 @@
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
id: string
title: LocalizedText
videoSrc: string
poster?: string
posterTime?: number
href: string
tags: readonly TranslationKey[]
}
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.videoSrc
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
export const learningTutorials: readonly LearningTutorial[] = [
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Title here', 'zh-CN': '标题占位' },
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'deaging_workflow_v03',
title: { en: 'Title here', 'zh-CN': '标题占位' },
videoSrc:
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'frame_adjustments_demo_v03',
title: { en: 'Title here', 'zh-CN': '标题占位' },
videoSrc:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'mattes_and_utilities_v03',
title: { en: 'Title here', 'zh-CN': '标题占位' },
videoSrc:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'seedance_demo_comfyui_v03',
title: { en: 'Title here', 'zh-CN': '标题占位' },
videoSrc:
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'skyreplacement_smaller_v06',
title: { en: 'Title here', 'zh-CN': '标题占位' },
videoSrc:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
href: '#',
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -1,6 +1,22 @@
type Locale = 'en' | 'zh-CN'
const translations = {
// Tags (global, reusable across sections)
'tags.partnerNodes': {
en: 'Partner Nodes',
'zh-CN': '合作伙伴节点'
},
'tags.imageToVideo': {
en: 'Image To Video',
'zh-CN': '图像生成视频'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
@@ -1435,6 +1451,58 @@ const translations = {
'player.subtitlesOn': { en: 'Subtitles on', 'zh-CN': '开启字幕' },
'player.subtitlesOff': { en: 'Subtitles off', 'zh-CN': '关闭字幕' },
// LearningHeroSection
'learning.heroTitle.before': { en: 'Learn', 'zh-CN': '学习' },
'learning.heroTitle.after': { en: '.', 'zh-CN': '。' },
'learning.heroTitle.line2': {
en: 'Build what doesnt exist yet.',
'zh-CN': '构建尚未存在之物。'
},
// LearningFeaturedWorkflowSection
'learning.featured.title': {
en: 'Sky Replacement',
'zh-CN': '天空替换'
},
'learning.featured.author': {
en: 'by Doug Hogan',
'zh-CN': '作者Doug Hogan'
},
'learning.featured.description': {
en: 'Detailed explanation here fpo lorem ipsum Detailed explanation here fpo lorem ipsum Detailed explem ipsum Detailed explanation here fpo lorem ipsum',
'zh-CN':
'此处为详细说明的占位文本,待补充实际内容。此处为详细说明的占位文本,待补充实际内容。'
},
'learning.featured.watchDemo': {
en: 'Watch Demo',
'zh-CN': '观看演示'
},
// LearningTutorialsSection
'learning.tutorials.titlePrefix': {
en: 'Learn how to:',
'zh-CN': '学习如何:'
},
// LearningCallToActionSection
'learning.cta.heading': {
en: 'Schedule a demo and see how ComfyUI fits your teams creative needs.',
'zh-CN': '预约演示,了解 ComfyUI 如何契合你的团队创作需求。'
},
'learning.cta.contactSales': {
en: 'Contact Sales',
'zh-CN': '联系销售'
},
// LearningEventsSection
'learning.events.heading': { en: 'Events', 'zh-CN': '活动' },
'learning.events.description': {
en: 'Check out our upcoming live streams and community meetings. Were always open to your questions, ideas, and conversations.',
'zh-CN':
'查看我们即将举办的直播和社区聚会。我们随时欢迎你的提问、想法和交流。'
},
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
// GalleryHeroSection
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
'gallery.heroTitle.before': {
@@ -1471,9 +1539,13 @@ const translations = {
},
'about.hero.body': {
en: 'The team behind Comfy is small, intense, and building what we intend to be our life\u2019s work.',
'zh-CN': 'Comfy 背后的团队规模虽小,但充满热情,致力于打造我们毕生的事业。'
'zh-CN':
'Comfy \u80cc\u540e\u7684\u56e2\u961f\u89c4\u6a21\u867d\u5c0f\uff0c\u4f46\u5145\u6ee1\u70ed\u60c5\uff0c\u81f4\u529b\u4e8e\u6253\u9020\u6211\u4eec\u6bd5\u751f\u7684\u4e8b\u4e1a\u3002'
},
'about.hero.cta': {
en: 'SEE OPEN ROLES',
'zh-CN': '\u67e5\u770b\u5f00\u653e\u804c\u4f4d'
},
'about.hero.cta': { en: 'SEE OPEN ROLES', 'zh-CN': '查看开放职位' },
// AboutStorySection
'about.story.label': { en: 'OUR STORY', 'zh-CN': '我们的故事' },
@@ -4240,6 +4312,8 @@ const translations = {
type TranslationKey = keyof typeof translations
type LocalizedText = Record<Locale, string>
export function t(key: TranslationKey, locale: Locale = 'en'): string {
return translations[key][locale] ?? translations[key].en
}
@@ -4250,4 +4324,4 @@ export function hasKey(key: string): boolean {
return key in translations
}
export type { Locale, TranslationKey }
export type { Locale, LocalizedText, TranslationKey }

View File

@@ -0,0 +1,34 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../components/learning/TutorialsSection.vue'
import CallToActionSection from '../components/common/CallToActionSection.vue'
import EventsSection from '../components/common/EventsSection.vue'
import { getRoutes } from '../config/routes'
import { learningEvents } from '../data/events'
const routes = getRoutes('en')
---
<BaseLayout title="Learning — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={routes.demos}
client:visible
/>
<EventsSection
headingKey="learning.events.heading"
descriptionKey="learning.events.description"
notifyLabelKey="learning.events.getNotified"
notifyHref={routes.contact}
events={learningEvents}
client:visible
/>
</BaseLayout>

View File

@@ -0,0 +1,36 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../../components/learning/TutorialsSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
import EventsSection from '../../components/common/EventsSection.vue'
import { getRoutes } from '../../config/routes'
import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={routes.demos}
client:visible
/>
<EventsSection
locale="zh-CN"
headingKey="learning.events.heading"
descriptionKey="learning.events.description"
notifyLabelKey="learning.events.getNotified"
notifyHref={routes.contact}
events={learningEvents}
client:visible
/>
</BaseLayout>