mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 17:52:19 +00:00
Compare commits
6 Commits
v1.48.1
...
feat/vfx-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
672b1363e8 | ||
|
|
2c14ee428a | ||
|
|
1d07692f5e | ||
|
|
ceb5ae1eba | ||
|
|
a539e02613 | ||
|
|
aacde8db0c |
@@ -39,3 +39,9 @@ export const NoBackdrop: Story = {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const DarkTone: Story = {
|
||||
args: {
|
||||
tone: 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
18
apps/website/src/components/blocks/LogosAll01.stories.ts
Normal file
18
apps/website/src/components/blocks/LogosAll01.stories.ts
Normal 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 = {}
|
||||
83
apps/website/src/components/blocks/LogosAll01.vue
Normal file
83
apps/website/src/components/blocks/LogosAll01.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
apps/website/src/components/common/TutorialCard.stories.ts
Normal file
19
apps/website/src/components/common/TutorialCard.stories.ts
Normal 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 = {}
|
||||
120
apps/website/src/components/common/TutorialCard.vue
Normal file
120
apps/website/src/components/common/TutorialCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
43
apps/website/src/components/vfx/HeroSection.vue
Normal file
43
apps/website/src/components/vfx/HeroSection.vue
Normal file
@@ -0,0 +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>
|
||||
<HeroBackdrop01
|
||||
tone="dark"
|
||||
:backdrop="backdrop"
|
||||
:mobile-backdrop="mobileBackdrop"
|
||||
:title="t('vfx.hero.title', locale)"
|
||||
:subtitle="t('vfx.hero.subtitle', locale)"
|
||||
>
|
||||
<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>
|
||||
53
apps/website/src/components/vfx/TutorialsSection.vue
Normal file
53
apps/website/src/components/vfx/TutorialsSection.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { vfxTutorials } from '../../data/vfxTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import TutorialCard from '../common/TutorialCard.vue'
|
||||
import TutorialDetailDialog from '../common/TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeTutorialId = ref<string | null>(null)
|
||||
const activeTutorial = () =>
|
||||
vfxTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
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">
|
||||
<TutorialCard
|
||||
:tutorial="tutorial"
|
||||
:locale="locale"
|
||||
title-prefix-key="vfx.tutorials.titlePrefix"
|
||||
@play="activeTutorialId = tutorial.id"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<TutorialDetailDialog
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
title-prefix-key="vfx.tutorials.titlePrefix"
|
||||
@close="activeTutorialId = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -14,6 +14,7 @@ const baseRoutes = {
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
vfx: '/vfx',
|
||||
termsOfService: '/terms-of-service',
|
||||
enterpriseMsa: '/enterprise-msa',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
|
||||
20
apps/website/src/data/clientLogos.ts
Normal file
20
apps/website/src/data/clientLogos.ts
Normal 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
|
||||
}))
|
||||
@@ -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': '净板演练' },
|
||||
|
||||
118
apps/website/src/data/vfxTutorials.ts
Normal file
118
apps/website/src/data/vfxTutorials.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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 vfxTutorials: readonly Tutorial[] = [
|
||||
{
|
||||
id: 'skyreplacement_smaller_v06',
|
||||
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06.mp4',
|
||||
poster:
|
||||
'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/vfx/skyreplacement_smaller_v06_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'cleanplate_walkthrough_v03',
|
||||
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_thumbnail.jpg',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'deaging_workflow_v03',
|
||||
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
|
||||
videoSrc: 'https://media.comfy.org/website/vfx/deaging_workflow_v03.mp4',
|
||||
poster:
|
||||
'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/vfx/deaging_workflow_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'frame_adjustments_demo_v03',
|
||||
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03.mp4',
|
||||
poster:
|
||||
'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/vfx/frame_adjustments_demo_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'mattes_and_utilities_v03',
|
||||
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/mattes_and_utilities_v03.mp4',
|
||||
poster:
|
||||
'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/vfx/mattes_and_utilities_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'seedance_demo_comfyui_v03',
|
||||
title: {
|
||||
en: 'Seedance Demo (ComfyUI)',
|
||||
'zh-CN': 'Seedance 演示(ComfyUI)'
|
||||
},
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/seedance_demo_comfyui_v03.mp4',
|
||||
poster:
|
||||
'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/vfx/seedance_demo_comfyui_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
}
|
||||
] as const
|
||||
@@ -1546,6 +1546,46 @@ const translations = {
|
||||
},
|
||||
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
|
||||
|
||||
// VfxHeroSection
|
||||
'vfx.hero.title': {
|
||||
en: 'ComfyUI for\nVisual Effects',
|
||||
'zh-CN': '面向视觉特效的\nComfyUI'
|
||||
},
|
||||
'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':
|
||||
'从创意到成片,ComfyUI 为视觉特效团队带来更快的工作流与更可控的产出——让最终交付与你的构想分毫不差。'
|
||||
},
|
||||
'vfx.hero.tryNow': {
|
||||
en: 'Try Now',
|
||||
'zh-CN': '立即体验'
|
||||
},
|
||||
|
||||
// VfxTutorialsSection
|
||||
'vfx.tutorials.heading': {
|
||||
en: 'See What’s 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:',
|
||||
'zh-CN': '学习如何:'
|
||||
},
|
||||
|
||||
// VfxCallToActionSection
|
||||
'vfx.cta.heading': {
|
||||
en: 'Bring your ideas to life with ComfyUI, exactly as intended.',
|
||||
'zh-CN': '用 ComfyUI 将创意变为现实,一切尽如所想。'
|
||||
},
|
||||
'vfx.cta.contactSales': {
|
||||
en: 'Contact Sales',
|
||||
'zh-CN': '联系销售'
|
||||
},
|
||||
|
||||
// GalleryHeroSection
|
||||
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
|
||||
'gallery.heroTitle.before': {
|
||||
|
||||
25
apps/website/src/pages/vfx.astro
Normal file
25
apps/website/src/pages/vfx.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/vfx/HeroSection.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'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout title="VFX — Comfy">
|
||||
<HeroSection client:load />
|
||||
<SocialProofBarSection client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<CallToActionSection
|
||||
headingKey="vfx.cta.heading"
|
||||
primaryLabelKey="vfx.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
25
apps/website/src/pages/zh-CN/vfx.astro
Normal file
25
apps/website/src/pages/zh-CN/vfx.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/vfx/HeroSection.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'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout title="视效 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection client:visible />
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="vfx.cta.heading"
|
||||
primaryLabelKey="vfx.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
|
||||
>
|
||||
<div class="dark-theme flex max-h-full w-full max-w-md flex-col px-4 sm:px-6">
|
||||
<h1
|
||||
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden transition-[height] duration-300 ease-out"
|
||||
class="max-h-[45vh] overflow-y-auto transition-[height] duration-300 ease-out sm:max-h-[55vh]"
|
||||
:style="animatedHeightStyle"
|
||||
>
|
||||
<div ref="questionContent" class="relative">
|
||||
|
||||
Reference in New Issue
Block a user