feat(website): rebuild vfx page hero, logos, and tutorial cards

- Rebuild vfx HeroSection on HeroBackdrop01 with video backdrop, new
  copy, and Contact Sales / Try Now CTAs; add tone prop (light/dark)
  to HeroBackdrop01 for dark backdrops with white text
- Extract logo marquee into LogosAll01 block with clientLogos data;
  SocialProofBarSection is now a thin wrapper; add logos under vfx hero
- Extract shared TutorialCard and TutorialDetailDialog into common/,
  dedupe Tutorial type and poster helper from both tutorial data files
- Remove FeaturedWorkflowSection from vfx pages; update tutorials
  heading/description and CTA copy (with zh-CN); balance CTA heading
  line wrapping; point vfx tutorial assets at website/vfx

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michael B
2026-07-10 16:21:13 -04:00
parent 1d07692f5e
commit 2c14ee428a
20 changed files with 408 additions and 508 deletions

View File

@@ -39,3 +39,9 @@ export const NoBackdrop: Story = {
backdrop: undefined
}
}
export const DarkTone: Story = {
args: {
tone: 'dark'
}
}

View File

@@ -20,6 +20,7 @@ const {
title,
subtitle,
footnote,
tone = 'light',
class: className
} = defineProps<{
backdrop?: Backdrop
@@ -30,9 +31,19 @@ const {
title: string
subtitle?: string
footnote?: string
tone?: 'light' | 'dark'
class?: HTMLAttributes['class']
}>()
// Light tone assumes a light backdrop: dark ink text on desktop plus a
// warm-white radial scrim for contrast. Dark tone assumes a dark backdrop
// and uses white text at all breakpoints, with no scrim.
const toneTextClass = computed(() =>
tone === 'light'
? 'text-primary-comfy-canvas lg:text-primary-comfy-ink'
: 'text-white'
)
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
@@ -151,6 +162,7 @@ const scrimStyle = {
>
<div class="relative w-full max-w-xl">
<div
v-if="tone === 'light'"
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
@@ -165,22 +177,24 @@ const scrimStyle = {
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
:class="
cn(
'mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line lg:text-6xl/tight',
toneTextClass
)
"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
:class="cn('mt-8 max-w-md text-base lg:text-lg', toneTextClass)"
>
{{ subtitle }}
</p>
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
<p v-if="footnote" :class="cn('mt-10 text-sm', toneTextClass)">
{{ footnote }}
</p>

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { clientLogos } from '../../data/clientLogos'
import LogosAll01 from './LogosAll01.vue'
const meta: Meta<typeof LogosAll01> = {
title: 'Website/Blocks/LogosAll01',
component: LogosAll01,
tags: ['autodocs'],
args: {
logos: clientLogos
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface LogoItem {
src: string
alt: string
}
const { logos, testId = 'logos-all' } = defineProps<{
logos: readonly LogoItem[]
testId?: string
}>()
// On mobile the single row splits into two counter-scrolling halves.
const mobileRow1Logos = computed(() =>
logos.slice(0, Math.ceil(logos.length / 2))
)
const mobileRow2Logos = computed(() => logos.slice(Math.ceil(logos.length / 2)))
</script>
<template>
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div :data-testid="`${testId}-desktop`" class="hidden w-max gap-2 md:flex">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in logos"
:key="logo.src"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="logo.src" :alt="logo.alt" />
</div>
</div>
</div>
<!-- Two rows on mobile -->
<div
:data-testid="`${testId}-mobile`"
class="flex flex-col gap-8 md:hidden"
>
<div class="flex w-max gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in mobileRow1Logos"
:key="logo.src"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="logo.src" :alt="logo.alt" />
</div>
</div>
</div>
<div class="flex w-max gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in mobileRow2Logos"
:key="logo.src"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="logo.src" :alt="logo.alt" />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -25,7 +25,7 @@ const {
<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="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
class="max-w-5xl text-3xl font-light tracking-tight text-balance text-primary-comfy-canvas lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>

View File

@@ -1,82 +1,8 @@
<script setup lang="ts">
const logos = [
'Amazon Studios',
'Apple',
'Autodesk',
'Harman',
'Hp',
'Lucid',
'Netflix',
'Nike',
'Pixomondo',
'Tencent',
'Ubisoft'
]
const mobileRow1Logos = logos.slice(0, 6)
const mobileRow2Logos = logos.slice(6)
import { clientLogos } from '../../data/clientLogos'
import LogosAll01 from '../blocks/LogosAll01.vue'
</script>
<template>
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in logos"
:key="logo"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
<!-- Two rows on mobile -->
<div
data-testid="social-proof-mobile"
class="flex flex-col gap-8 md:hidden"
>
<div class="flex w-max gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in mobileRow1Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
<div class="flex w-max gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in mobileRow2Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
</div>
</section>
<LogosAll01 :logos="clientLogos" test-id="social-proof" />
</template>

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { vfxTutorials } from '../../data/vfxTutorials'
import TutorialCard from './TutorialCard.vue'
const meta: Meta<typeof TutorialCard> = {
title: 'Website/Common/TutorialCard',
component: TutorialCard,
tags: ['autodocs'],
args: {
tutorial: vfxTutorials[0],
titlePrefixKey: 'vfx.tutorials.titlePrefix'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { VideoTrack } from './VideoPlayer.vue'
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Badge from '../ui/badge/Badge.vue'
import { ButtonMask } from '../ui/button-mask'
export interface Tutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
caption?: readonly VideoTrack[]
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
const getTutorialPosterSrc = (tutorial: Tutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
const {
tutorial,
locale = 'en',
titlePrefixKey
} = defineProps<{
tutorial: Tutorial
locale?: Locale
titlePrefixKey: TranslationKey
}>()
const emit = defineEmits<{ play: [] }>()
</script>
<template>
<article
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(titlePrefixKey, locale)} ${tutorial.title[locale]}`"
@click="emit('play')"
>
<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-sm/snug text-primary-comfy-canvas lg:text-base/snug">
{{ t(titlePrefixKey, locale) }}<br />
{{ tutorial.title[locale] }}
</h3>
<ButtonMask
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</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>
</article>
</template>

View File

@@ -1,16 +1,21 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import type { Tutorial } from './TutorialCard.vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
import VideoPlayer from './VideoPlayer.vue'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: LearningTutorial
const {
tutorial,
locale = 'en',
titlePrefixKey
} = defineProps<{
tutorial: Tutorial
locale?: Locale
titlePrefixKey: TranslationKey
}>()
const emit = defineEmits<{ close: [] }>()
@@ -73,7 +78,7 @@ onUnmounted(() => {
<h2
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ t(titlePrefixKey, locale) }}
{{ tutorial.title[locale] }}
</h2>
</dialog>

View File

@@ -3,14 +3,10 @@ import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { learningTutorials } from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import Badge from '../ui/badge/Badge.vue'
import { ButtonMask } from '../ui/button-mask'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
import TutorialCard from '../common/TutorialCard.vue'
import TutorialDetailDialog from '../common/TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -30,84 +26,13 @@ const activeTutorial = () =>
<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-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
</h3>
<ButtonMask
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</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 v-for="tutorial in learningTutorials" :key="tutorial.id">
<TutorialCard
:tutorial="tutorial"
:locale="locale"
title-prefix-key="learning.tutorials.titlePrefix"
@play="activeTutorialId = tutorial.id"
/>
</li>
</ul>
@@ -115,6 +40,7 @@ const activeTutorial = () =>
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
title-prefix-key="learning.tutorials.titlePrefix"
@close="activeTutorialId = null"
/>
</section>

View File

@@ -1,67 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Badge from '../ui/badge/Badge.vue'
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'
const demoVideoPoster =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg'
</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-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t('vfx.featured.title', locale) }}
</h2>
<p class="text-primary-warm-gray mt-4 text-sm lg:text-base">
{{ t('vfx.featured.author', locale) }}
</p>
</div>
<p
class="max-w-md text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t('vfx.featured.description', locale) }}
</p>
<div class="flex flex-wrap gap-3">
<BrandButton
variant="outline"
size="xs"
class="uppercase"
href="https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/"
>
{{ t('cta.tryWorkflow', locale) }}
</BrandButton>
</div>
<ul class="mt-2 flex flex-wrap gap-3">
<li v-for="tag in tags" :key="tag">
<Badge variant="subtle">{{ tag }}</Badge>
</li>
</ul>
</div>
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
<VideoPlayer
:locale
:src="demoVideoSrc"
:poster="demoVideoPoster"
minimal
/>
</div>
</div>
</section>
</template>

View File

@@ -1,23 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import HeroBackdrop01 from '../blocks/HeroBackdrop01.vue'
import Button from '../ui/button/Button.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = computed(() => getRoutes(locale))
const backdrop = {
type: 'video',
src: 'https://media.comfy.org/website/vfx/vfx_hero.mp4'
} as const
const mobileBackdrop = {
type: 'video',
src: 'https://media.comfy.org/website/vfx/vfx_hero-mobile.mp4'
} as const
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
<HeroBackdrop01
tone="dark"
:backdrop="backdrop"
:mobile-backdrop="mobileBackdrop"
:title="t('vfx.hero.title', locale)"
:subtitle="t('vfx.hero.subtitle', locale)"
>
<h1
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t('vfx.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span
>{{ t('vfx.heroTitle.after', locale) }}
<br />
{{ t('vfx.heroTitle.line2', locale) }}
</h1>
</section>
<div class="mt-10 flex flex-wrap gap-4">
<Button as="a" :href="routes.contact">
{{ t('vfx.cta.contactSales', locale) }}
</Button>
<Button as="a" :href="externalLinks.cloud" variant="outline">
{{ t('vfx.hero.tryNow', locale) }}
</Button>
</div>
</HeroBackdrop01>
</template>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import type { VfxTutorial } from '../../data/vfxTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: VfxTutorial
locale?: Locale
}>()
const emit = defineEmits<{ close: [] }>()
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
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()
})
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 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 bg-primary-comfy-ink transition-colors lg:right-26"
@click="emit('close')"
>
<span
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>
<div
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"
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
:tracks="tutorial.caption"
autoplay
class="w-full"
/>
</div>
<h2
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ t('vfx.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h2>
</dialog>
</Teleport>
</template>

View File

@@ -3,11 +3,10 @@ import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { getTutorialPosterSrc, vfxTutorials } from '../../data/vfxTutorials'
import { vfxTutorials } from '../../data/vfxTutorials'
import { t } from '../../i18n/translations'
import Badge from '../ui/badge/Badge.vue'
import { ButtonMask } from '../ui/button-mask'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
import TutorialCard from '../common/TutorialCard.vue'
import TutorialDetailDialog from '../common/TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -19,92 +18,27 @@ const activeTutorial = () =>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-12 text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
class="mb-6 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t('vfx.tutorials.heading', locale) }}
</h2>
<p
class="mx-auto mb-12 max-w-3xl text-center text-base text-primary-comfy-canvas lg:mb-16 lg:text-lg"
>
{{ t('vfx.tutorials.description', locale) }}
</p>
<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 vfxTutorials"
: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('vfx.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-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('vfx.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
</h3>
<ButtonMask
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</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 v-for="tutorial in vfxTutorials" :key="tutorial.id">
<TutorialCard
:tutorial="tutorial"
:locale="locale"
title-prefix-key="vfx.tutorials.titlePrefix"
@play="activeTutorialId = tutorial.id"
/>
</li>
</ul>
@@ -112,6 +46,7 @@ const activeTutorial = () =>
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
title-prefix-key="vfx.tutorials.titlePrefix"
@close="activeTutorialId = null"
/>
</section>

View File

@@ -0,0 +1,20 @@
import type { LogoItem } from '../components/blocks/LogosAll01.vue'
const names = [
'Amazon Studios',
'Apple',
'Autodesk',
'Harman',
'Hp',
'Lucid',
'Netflix',
'Nike',
'Pixomondo',
'Tencent',
'Ubisoft'
]
export const clientLogos: readonly LogoItem[] = names.map((name) => ({
src: `/icons/clients/${name}.svg`,
alt: name
}))

View File

@@ -1,28 +1,10 @@
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
caption?: readonly VideoTrack[]
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
import type { Tutorial } from '../components/common/TutorialCard.vue'
import type { TranslationKey } from '../i18n/translations'
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[] = [
export const learningTutorials: readonly Tutorial[] = [
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },

View File

@@ -1,38 +1,20 @@
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface VfxTutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
caption?: readonly VideoTrack[]
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
import type { Tutorial } from '../components/common/TutorialCard.vue'
import type { TranslationKey } from '../i18n/translations'
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
export const getTutorialPosterSrc = (tutorial: VfxTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
export const vfxTutorials: readonly VfxTutorial[] = [
export const vfxTutorials: readonly Tutorial[] = [
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_thumbnail.jpg',
caption: [
{
src: 'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_vtt.en.vtt',
src: 'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
@@ -44,14 +26,13 @@ export const vfxTutorials: readonly VfxTutorial[] = [
{
id: 'deaging_workflow_v03',
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
videoSrc:
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
videoSrc: 'https://media.comfy.org/website/vfx/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
'https://media.comfy.org/website/vfx/deaging_workflow_v03_thumbnail.jpg',
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
caption: [
{
src: 'https://media.comfy.org/website/learning/deaging_workflow_v03_vtt.en.vtt',
src: 'https://media.comfy.org/website/vfx/deaging_workflow_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
@@ -63,13 +44,13 @@ export const vfxTutorials: readonly VfxTutorial[] = [
id: 'frame_adjustments_demo_v03',
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
videoSrc:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03.mp4',
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
caption: [
{
src: 'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_vtt.en.vtt',
src: 'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
@@ -81,13 +62,13 @@ export const vfxTutorials: readonly VfxTutorial[] = [
id: 'mattes_and_utilities_v03',
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
videoSrc:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
'https://media.comfy.org/website/vfx/mattes_and_utilities_v03.mp4',
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
'https://media.comfy.org/website/vfx/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
caption: [
{
src: 'https://media.comfy.org/website/learning/mattes_and_utilities_v03_vtt.en.vtt',
src: 'https://media.comfy.org/website/vfx/mattes_and_utilities_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
@@ -99,13 +80,13 @@ export const vfxTutorials: readonly VfxTutorial[] = [
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',
'https://media.comfy.org/website/vfx/seedance_demo_comfyui_v03.mp4',
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
'https://media.comfy.org/website/vfx/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
caption: [
{
src: 'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03_vtt.en.vtt',
src: 'https://media.comfy.org/website/vfx/seedance_demo_comfyui_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
@@ -117,13 +98,13 @@ export const vfxTutorials: readonly VfxTutorial[] = [
id: 'skyreplacement_smaller_v06',
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
videoSrc:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06.mp4',
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
caption: [
{
src: 'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_vtt.en.vtt',
src: 'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'

View File

@@ -1547,36 +1547,29 @@ const translations = {
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
// VfxHeroSection
'vfx.heroTitle.before': { en: 'Create VFX', 'zh-CN': '创作视效' },
'vfx.heroTitle.after': { en: '.', 'zh-CN': '。' },
'vfx.heroTitle.line2': {
en: 'Build what doesnt exist yet.',
'zh-CN': '构建尚未存在之物。'
'vfx.hero.title': {
en: 'ComfyUI for\nVisual Effects',
'zh-CN': '面向视觉特效的\nComfyUI'
},
// VfxFeaturedWorkflowSection
'vfx.featured.title': {
en: 'Sky Replacement',
'zh-CN': '天空替换'
},
'vfx.featured.author': {
en: 'by Doug Hogan',
'zh-CN': '作者Doug Hogan'
},
'vfx.featured.description': {
en: 'A sky replacement workflow built on Wan AI models. WanVideoSampler and WanVideoDecode synthesize new sky visuals into existing footage. CLIPVisionLoader and WanVideoClipVisionEncode ensure replacements feel native, not composited.',
'vfx.hero.subtitle': {
en: 'From ideation to production, ComfyUI gives VFX teams faster workflows and more controlled output — so what ships looks exactly like what you envisioned.',
'zh-CN':
'基于 Wan AI 模型构建的天空替换工作流。WanVideoSampler 与 WanVideoDecode 将全新的天空视觉合成到现有素材中。CLIPVisionLoader 与 WanVideoClipVisionEncode 确保替换效果自然融合,而非生硬叠加。'
'从创意到成片ComfyUI 为视觉特效团队带来更快的工作流与更可控的产出——让最终交付与你的构想分毫不差。'
},
'vfx.featured.watchDemo': {
en: 'Watch Demo',
'zh-CN': '观看演示'
'vfx.hero.tryNow': {
en: 'Try Now',
'zh-CN': '立即体验'
},
// VfxTutorialsSection
'vfx.tutorials.heading': {
en: 'Featured Demos',
'zh-CN': '精选演示'
en: 'See Whats Possible',
'zh-CN': '见证无限可能'
},
'vfx.tutorials.description': {
en: 'From concept, storyboarding to texture generation and compositing references — discover workflows built for every stage of VFX production. New workflows added regularly.',
'zh-CN':
'从概念设计、分镜脚本到纹理生成与合成参考——探索覆盖视觉特效制作各个环节的工作流。新工作流持续上新。'
},
'vfx.tutorials.titlePrefix': {
en: 'Learn how to:',
@@ -1585,8 +1578,8 @@ const translations = {
// VfxCallToActionSection
'vfx.cta.heading': {
en: 'Schedule a demo and see how ComfyUI fits your teams creative needs.',
'zh-CN': '预约演示,了解 ComfyUI 如何契合你的团队创作需求。'
en: 'Bring your ideas to life with ComfyUI, exactly as intended.',
'zh-CN': ' ComfyUI 将创意变为现实,一切尽如所想。'
},
'vfx.cta.contactSales': {
en: 'Contact Sales',

View File

@@ -1,8 +1,8 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/vfx/HeroSection.vue'
import FeaturedWorkflowSection from '../components/vfx/FeaturedWorkflowSection.vue'
import TutorialsSection from '../components/vfx/TutorialsSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import CallToActionSection from '../components/common/CallToActionSection.vue'
import { getRoutes } from '../config/routes'
import { externalLinks } from '../config/routes'
@@ -12,7 +12,7 @@ const routes = getRoutes('en')
<BaseLayout title="VFX — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<SocialProofBarSection client:visible />
<TutorialsSection client:visible />
<CallToActionSection
headingKey="vfx.cta.heading"

View File

@@ -1,8 +1,8 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/vfx/HeroSection.vue'
import FeaturedWorkflowSection from '../../components/vfx/FeaturedWorkflowSection.vue'
import TutorialsSection from '../../components/vfx/TutorialsSection.vue'
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
import { getRoutes, externalLinks } from '../../config/routes'
@@ -11,7 +11,7 @@ const routes = getRoutes('zh-CN')
<BaseLayout title="视效 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<SocialProofBarSection client:visible />
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"