mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
8 Commits
codex/rum-
...
feat/websi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0a3c1adfc | ||
|
|
53d3868a9d | ||
|
|
b739bf065f | ||
|
|
a2dbeb1ca7 | ||
|
|
12f8d210f8 | ||
|
|
7969688302 | ||
|
|
231cbb63eb | ||
|
|
d1f33b83e4 |
177
apps/website/e2e/seedance.spec.ts
Normal file
177
apps/website/e2e/seedance.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { externalLinks, getRoutes } from '../src/config/routes'
|
||||
import { seedanceFaqs, seedanceReviews } from '../src/data/seedance'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const PATH = '/seedance-2.5'
|
||||
const HERO_TITLE = t('seedance.hero.title', 'en')
|
||||
const MODELS_HEADING = t('seedance.models.heading', 'en')
|
||||
const MODELS_ROUTE = getRoutes('en').models
|
||||
const CTA_HEADING = t('seedance.cta.heading', 'en')
|
||||
const CTA_PRIMARY = t('seedance.cta.primaryCta', 'en')
|
||||
const WORKFLOWS_URL = externalLinks.workflows
|
||||
const FAQ_COUNT = seedanceFaqs.length
|
||||
const FIRST_FAQ = seedanceFaqs[0]
|
||||
const REVIEWS_HEADING = t('seedance.reviews.heading', 'en')
|
||||
const HIGHLIGHT_CTA = t('seedance.reviews.highlightCta', 'en')
|
||||
const MCP_ROUTE = getRoutes('en').mcp
|
||||
const FIRST_REVIEW = seedanceReviews[0]
|
||||
|
||||
test.describe('Seedance 2.5 page — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading and is indexable', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: HERO_TITLE })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('renders the models section heading', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: MODELS_HEADING
|
||||
})
|
||||
await heading.scrollIntoViewIfNeeded()
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders the reviews section heading and first quote', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: REVIEWS_HEADING
|
||||
})
|
||||
await heading.scrollIntoViewIfNeeded()
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(page.getByText(FIRST_REVIEW.name)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Seedance 2.5 page — link targets', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('breadcrumb trail links to the models catalog', async ({ page }) => {
|
||||
const modelsCrumb = page
|
||||
.getByRole('navigation', { name: 'Breadcrumb' })
|
||||
.getByRole('link', { name: t('models.breadcrumb.models', 'en') })
|
||||
await expect(modelsCrumb).toHaveAttribute('href', MODELS_ROUTE)
|
||||
})
|
||||
|
||||
test('closing CTA links to run Wan 2.2 in a new tab', async ({ page }) => {
|
||||
const ctaSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: CTA_HEADING })
|
||||
})
|
||||
const primary = ctaSection.getByRole('link', { name: CTA_PRIMARY })
|
||||
await primary.scrollIntoViewIfNeeded()
|
||||
await expect(primary).toBeVisible()
|
||||
await expect(primary).toHaveAttribute('href', WORKFLOWS_URL)
|
||||
await expect(primary).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
test('MCP highlight card CTA links to the MCP page', async ({ page }) => {
|
||||
const reviewsSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: REVIEWS_HEADING })
|
||||
})
|
||||
const cta = reviewsSection.getByRole('link', { name: HIGHLIGHT_CTA })
|
||||
await cta.scrollIntoViewIfNeeded()
|
||||
await expect(cta).toHaveAttribute('href', MCP_ROUTE)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Seedance 2.5 page — interactions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('emits FAQPage structured data with one entry per FAQ', async ({
|
||||
page
|
||||
}) => {
|
||||
const faqJsonLd = await page.evaluate(() => {
|
||||
const scripts = Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/ld+json"]'
|
||||
)
|
||||
)
|
||||
const match = scripts.find((s) =>
|
||||
(s.textContent ?? '').includes('FAQPage')
|
||||
)
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
|
||||
'@type': string
|
||||
mainEntity?: unknown[]
|
||||
}[]
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
|
||||
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('FAQ items toggle open and closed on click', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: FIRST_FAQ.question.en
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
test('reviews carousel advances when Next is clicked', async ({ page }) => {
|
||||
const reviewsSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: REVIEWS_HEADING })
|
||||
})
|
||||
const track = reviewsSection.getByRole('article').first().locator('..')
|
||||
const nextButton = reviewsSection.getByRole('button', { name: 'Next' })
|
||||
await nextButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const startScroll = await track.evaluate((el) => el.scrollLeft)
|
||||
await nextButton.click()
|
||||
await expect
|
||||
.poll(() => track.evaluate((el) => el.scrollLeft))
|
||||
.toBeGreaterThan(startScroll)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Seedance 2.5 page — mobile @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading at narrow viewports', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: HERO_TITLE })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('closing CTA heading stays within the viewport width', async ({
|
||||
page
|
||||
}) => {
|
||||
const ctaHeading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: CTA_HEADING
|
||||
})
|
||||
await ctaHeading.scrollIntoViewIfNeeded()
|
||||
await expect(ctaHeading).toBeVisible()
|
||||
|
||||
const box = await ctaHeading.boundingBox()
|
||||
expect(box, 'CTA heading bounding box').not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -16,8 +16,9 @@ type TermsLink = {
|
||||
href: string
|
||||
}
|
||||
|
||||
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
|
||||
const { heading, subtitle, primaryCta, secondaryCta, termsLink } = defineProps<{
|
||||
heading: string
|
||||
subtitle?: string
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
termsLink?: TermsLink
|
||||
@@ -34,6 +35,13 @@ const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-4 max-w-2xl text-base/relaxed font-light text-primary-comfy-canvas/80 lg:text-lg/relaxed"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
|
||||
<Button
|
||||
as="a"
|
||||
|
||||
46
apps/website/src/components/common/BreadcrumbBar.vue
Normal file
46
apps/website/src/components/common/BreadcrumbBar.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
type Crumb = { label: string; href?: string }
|
||||
|
||||
const { crumbs, updated } = defineProps<{
|
||||
crumbs: readonly Crumb[]
|
||||
updated?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="max-w-9xl mx-auto flex items-center justify-between gap-4 px-6 py-3 lg:px-20"
|
||||
>
|
||||
<ol
|
||||
class="text-primary-warm-gray flex flex-wrap items-center gap-2 text-xs tracking-wide uppercase"
|
||||
>
|
||||
<li
|
||||
v-for="(crumb, i) in crumbs"
|
||||
:key="`${i}-${crumb.label}`"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span v-if="i > 0" aria-hidden="true" class="text-primary-warm-gray/60">
|
||||
/
|
||||
</span>
|
||||
<a
|
||||
v-if="crumb.href"
|
||||
:href="crumb.href"
|
||||
class="transition-colors hover:text-primary-comfy-canvas"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</a>
|
||||
<span v-else aria-current="page" class="text-primary-comfy-canvas">
|
||||
{{ crumb.label }}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p
|
||||
v-if="updated"
|
||||
class="text-primary-warm-gray shrink-0 text-xs tracking-wide uppercase"
|
||||
>
|
||||
{{ updated }}
|
||||
</p>
|
||||
</nav>
|
||||
</template>
|
||||
66
apps/website/src/components/common/MediaCarousel.vue
Normal file
66
apps/website/src/components/common/MediaCarousel.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
const { prevLabel, nextLabel } = defineProps<{
|
||||
prevLabel: string
|
||||
nextLabel: string
|
||||
}>()
|
||||
|
||||
const track = useTemplateRef<HTMLElement>('track')
|
||||
const { x } = useScroll(track)
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const el = track.value
|
||||
if (!el) return '0%'
|
||||
const max = el.scrollWidth - el.clientWidth
|
||||
return `${(max > 0 ? x.value / max : 0) * 100}%`
|
||||
})
|
||||
|
||||
function scroll(direction: -1 | 1) {
|
||||
const el = track.value
|
||||
if (!el) return
|
||||
el.scrollBy({ left: direction * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-10">
|
||||
<div
|
||||
ref="track"
|
||||
class="flex snap-x snap-mandatory scrollbar-none gap-6 overflow-x-auto"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-1 flex-1 rounded-full bg-white/20">
|
||||
<div
|
||||
class="bg-primary-comfy-yellow h-full rounded-full"
|
||||
:style="{ width: progressPercent }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="prevLabel"
|
||||
class="flex size-10 items-center justify-center rounded-full border border-white/20 text-white/60 transition-colors hover:border-white/40"
|
||||
@click="scroll(-1)"
|
||||
>
|
||||
<img
|
||||
src="/icons/arrow-right.svg"
|
||||
alt=""
|
||||
class="size-3 rotate-180 opacity-60 invert"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="nextLabel"
|
||||
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full transition-opacity hover:opacity-90"
|
||||
@click="scroll(1)"
|
||||
>
|
||||
<img src="/icons/arrow-right.svg" alt="" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
24
apps/website/src/components/common/NodeTag.vue
Normal file
24
apps/website/src/components/common/NodeTag.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex h-8 -skew-x-12 items-center justify-center rounded-lg px-3.5',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="ppformula-text-center font-formula skew-x-12 text-sm leading-none font-extrabold tracking-wider uppercase"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -8,9 +8,11 @@ export const brandButtonVariants = cva({
|
||||
solid:
|
||||
'bg-primary-comfy-yellow text-primary-comfy-ink transition-opacity hover:opacity-90',
|
||||
outline:
|
||||
'border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink border',
|
||||
'border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow border hover:text-primary-comfy-ink',
|
||||
'outline-dark':
|
||||
'border-primary-comfy-ink text-primary-comfy-ink hover:bg-primary-comfy-ink hover:text-primary-comfy-yellow border-2 uppercase'
|
||||
'hover:text-primary-comfy-yellow border-2 border-primary-comfy-ink text-primary-comfy-ink uppercase hover:bg-primary-comfy-ink',
|
||||
inverse:
|
||||
'text-primary-comfy-yellow bg-primary-comfy-ink transition-opacity hover:opacity-90'
|
||||
},
|
||||
size: {
|
||||
xs: 'rounded-2xl px-6 py-3 text-xs font-bold',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { localizeHref } from './routes'
|
||||
import { getRoutes, localizeHref } from './routes'
|
||||
|
||||
describe('localizeHref', () => {
|
||||
it('prefixes an internal path for a non-default locale', () => {
|
||||
@@ -21,3 +21,13 @@ describe('localizeHref', () => {
|
||||
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRoutes seedance', () => {
|
||||
it('serves the seedance page at its canonical path for en', () => {
|
||||
expect(getRoutes('en').seedance).toBe('/seedance-2.5')
|
||||
})
|
||||
|
||||
it('serves a localized seedance path for zh-CN', () => {
|
||||
expect(getRoutes('zh-CN').seedance).toBe('/zh-CN/seedance-2.5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,8 @@ const baseRoutes = {
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models',
|
||||
mcp: '/mcp'
|
||||
mcp: '/mcp',
|
||||
seedance: '/seedance-2.5'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
|
||||
368
apps/website/src/data/seedance.ts
Normal file
368
apps/website/src/data/seedance.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../config/routes'
|
||||
|
||||
// Placeholder media reused from existing hosted cloud assets so the page
|
||||
// renders end-to-end. Swap for the final Seedance renders and provider logos
|
||||
// from June before launch (CRE-145).
|
||||
const media = {
|
||||
hero: 'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
example1:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
example2:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
kling: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
wan: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
veo: 'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
hailuo: 'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm'
|
||||
} as const
|
||||
|
||||
export const seedanceHeroVideo = media.hero
|
||||
|
||||
export interface SeedanceStep {
|
||||
id: string
|
||||
title: LocalizedText
|
||||
description: LocalizedText
|
||||
imageSrc: string
|
||||
}
|
||||
|
||||
export const seedanceSteps: readonly SeedanceStep[] = [
|
||||
{
|
||||
id: 'write-the-shot',
|
||||
title: { en: 'Write the shot', 'zh-CN': '写下镜头' },
|
||||
description: {
|
||||
en: 'Camera, subject, framing',
|
||||
'zh-CN': '镜头、主体、构图'
|
||||
},
|
||||
imageSrc: media.example1
|
||||
},
|
||||
{
|
||||
id: 'draft-on-wan',
|
||||
title: { en: 'Draft free on Wan 2.2', 'zh-CN': '用 Wan 2.2 免费打样' },
|
||||
description: {
|
||||
en: 'Same workflow, zero credits',
|
||||
'zh-CN': '相同工作流,零积分消耗'
|
||||
},
|
||||
imageSrc: media.example2
|
||||
},
|
||||
{
|
||||
id: 'switch-to-seedance',
|
||||
title: { en: 'Switch to Seedance 2.5', 'zh-CN': '切换到 Seedance 2.5' },
|
||||
description: {
|
||||
en: 'Final render, up to 4K',
|
||||
'zh-CN': '最终渲染,最高 4K'
|
||||
},
|
||||
imageSrc: media.example1
|
||||
}
|
||||
] as const
|
||||
|
||||
type SeedanceModelTier = 'free' | 'premium'
|
||||
|
||||
export interface SeedanceModel {
|
||||
id: string
|
||||
name: string
|
||||
tier: SeedanceModelTier
|
||||
note: LocalizedText
|
||||
description: LocalizedText
|
||||
imageSrc: string
|
||||
logoSrc: string
|
||||
logoAlt: string
|
||||
href: string
|
||||
}
|
||||
|
||||
// Six cards in a 2x3 grid to match the Figma (which repeats Veo/Hailuo as the
|
||||
// last two). June to supply the final roster + card art.
|
||||
export const seedanceModels: readonly SeedanceModel[] = [
|
||||
{
|
||||
id: 'kling-3',
|
||||
name: 'Kling 3.0',
|
||||
tier: 'free',
|
||||
note: { en: 'Included free', 'zh-CN': '免费包含' },
|
||||
description: {
|
||||
en: 'Precise camera and motion control.',
|
||||
'zh-CN': '精准的镜头与运动控制。'
|
||||
},
|
||||
imageSrc: media.kling,
|
||||
logoSrc: '/icons/ai-models/bytedance.svg',
|
||||
logoAlt: 'Kling',
|
||||
href: externalLinks.workflows
|
||||
},
|
||||
{
|
||||
id: 'wan-22',
|
||||
name: 'Wan 2.2',
|
||||
tier: 'free',
|
||||
note: { en: 'Included free', 'zh-CN': '免费包含' },
|
||||
description: {
|
||||
en: 'Draft free before you spend a credit.',
|
||||
'zh-CN': '在消耗积分前免费打样。'
|
||||
},
|
||||
imageSrc: media.wan,
|
||||
logoSrc: '/icons/ai-models/wan.svg',
|
||||
logoAlt: 'Wan',
|
||||
href: externalLinks.workflows
|
||||
},
|
||||
{
|
||||
id: 'veo-31',
|
||||
name: 'Veo 3.1',
|
||||
tier: 'premium',
|
||||
note: { en: 'Pay-as-you-go', 'zh-CN': '按量付费' },
|
||||
description: {
|
||||
en: 'Native audio with the frame.',
|
||||
'zh-CN': '画面自带原生音频。'
|
||||
},
|
||||
imageSrc: media.veo,
|
||||
logoSrc: '/icons/ai-models/gemini.svg',
|
||||
logoAlt: 'Veo',
|
||||
href: externalLinks.workflows
|
||||
},
|
||||
{
|
||||
id: 'hailuo',
|
||||
name: 'Hailuo',
|
||||
tier: 'premium',
|
||||
note: { en: 'Pay-as-you-go', 'zh-CN': '按量付费' },
|
||||
description: {
|
||||
en: 'Fast, expressive character motion.',
|
||||
'zh-CN': '快速且富有表现力的角色动作。'
|
||||
},
|
||||
imageSrc: media.hailuo,
|
||||
logoSrc: '/icons/ai-models/bytedance.svg',
|
||||
logoAlt: 'Hailuo',
|
||||
href: externalLinks.workflows
|
||||
},
|
||||
{
|
||||
id: 'veo-31-b',
|
||||
name: 'Veo 3.1',
|
||||
tier: 'premium',
|
||||
note: { en: 'Pay-as-you-go', 'zh-CN': '按量付费' },
|
||||
description: {
|
||||
en: 'Native audio with the frame.',
|
||||
'zh-CN': '画面自带原生音频。'
|
||||
},
|
||||
imageSrc: media.veo,
|
||||
logoSrc: '/icons/ai-models/gemini.svg',
|
||||
logoAlt: 'Veo',
|
||||
href: externalLinks.workflows
|
||||
},
|
||||
{
|
||||
id: 'hailuo-b',
|
||||
name: 'Hailuo',
|
||||
tier: 'premium',
|
||||
note: { en: 'Pay-as-you-go', 'zh-CN': '按量付费' },
|
||||
description: {
|
||||
en: 'Fast, expressive character motion.',
|
||||
'zh-CN': '快速且富有表现力的角色动作。'
|
||||
},
|
||||
imageSrc: media.hailuo,
|
||||
logoSrc: '/icons/ai-models/bytedance.svg',
|
||||
logoAlt: 'Hailuo',
|
||||
href: externalLinks.workflows
|
||||
}
|
||||
] as const
|
||||
|
||||
export interface SeedanceExample {
|
||||
id: string
|
||||
prompt: LocalizedText
|
||||
imageSrc: string
|
||||
imageAlt: LocalizedText
|
||||
}
|
||||
|
||||
// Prompt/result pairs are placeholder pending June's final examples.
|
||||
export const seedanceExamples: readonly SeedanceExample[] = [
|
||||
{
|
||||
id: 'dalmatian-loft',
|
||||
prompt: {
|
||||
en: 'A dalmatian stretches across a sunlit loft, slow push-in, shallow depth of field, warm morning light.',
|
||||
'zh-CN':
|
||||
'一只斑点狗在洒满阳光的阁楼里舒展身体,镜头缓缓推进,浅景深,温暖的晨光。'
|
||||
},
|
||||
imageSrc: media.example1,
|
||||
imageAlt: {
|
||||
en: 'Seedance render of a dalmatian in a sunlit loft',
|
||||
'zh-CN': 'Seedance 渲染的阁楼斑点狗画面'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'city-window',
|
||||
prompt: {
|
||||
en: 'Handheld shot drifting past a rain-streaked city window at dusk, neon reflections, cinematic grade.',
|
||||
'zh-CN':
|
||||
'黄昏时分,手持镜头掠过雨痕斑驳的城市窗户,霓虹倒影,电影级调色。'
|
||||
},
|
||||
imageSrc: media.example2,
|
||||
imageAlt: {
|
||||
en: 'Seedance render of a rain-streaked city window at dusk',
|
||||
'zh-CN': 'Seedance 渲染的黄昏雨窗画面'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'studio-portrait',
|
||||
prompt: {
|
||||
en: 'Slow orbit around a model on a seamless studio backdrop, soft key light, subtle rim, editorial motion.',
|
||||
'zh-CN':
|
||||
'镜头缓缓环绕纯色背景前的模特,柔和主光,微妙轮廓光,时尚编辑感的运镜。'
|
||||
},
|
||||
imageSrc: media.veo,
|
||||
imageAlt: {
|
||||
en: 'Seedance render of a studio portrait orbit',
|
||||
'zh-CN': 'Seedance 渲染的影棚人像环绕画面'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'street-tracking',
|
||||
prompt: {
|
||||
en: 'Tracking shot following a runner through a neon night market, shallow focus, motion blur, moody grade.',
|
||||
'zh-CN': '跟拍镜头追随一名跑者穿过霓虹夜市,浅焦,动态模糊,情绪化调色。'
|
||||
},
|
||||
imageSrc: media.kling,
|
||||
imageAlt: {
|
||||
en: 'Seedance render of a runner in a neon night market',
|
||||
'zh-CN': 'Seedance 渲染的霓虹夜市跑者画面'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
|
||||
export interface SeedanceFaq {
|
||||
id: string
|
||||
question: LocalizedText
|
||||
answer: LocalizedText
|
||||
}
|
||||
|
||||
// FAQ questions follow the Figma; answers drafted from the page content
|
||||
// pending June/Nav sign-off (CRE-145).
|
||||
export const seedanceFaqs: readonly SeedanceFaq[] = [
|
||||
{
|
||||
id: 'cost-to-run',
|
||||
question: {
|
||||
en: 'How much does Seedance 2.5 cost to run?',
|
||||
'zh-CN': '运行 Seedance 2.5 的费用是多少?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Seedance runs on pay-as-you-go credits or subscription credits — and you can draft the same shot free on Wan 2.2 before spending anything. No watermark either way.',
|
||||
'zh-CN':
|
||||
'Seedance 采用按量付费积分或订阅积分,你可以先在 Wan 2.2 上免费打样同一镜头,再决定是否消耗积分。两种方式都不带水印。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'generate-4k',
|
||||
question: {
|
||||
en: 'Can Seedance 2.5 generate 4K video?',
|
||||
'zh-CN': 'Seedance 2.5 能生成 4K 视频吗?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Yes. Seedance 2.5 renders up to 4K, with multi-shot sequences directed on the Comfy canvas.',
|
||||
'zh-CN':
|
||||
'可以。Seedance 2.5 最高支持 4K 渲染,并可在 Comfy 画布上执导多镜头序列。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'text-or-image',
|
||||
question: {
|
||||
en: 'Text to video or image to video?',
|
||||
'zh-CN': '是文生视频还是图生视频?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Both. Start from a text prompt or drop in a reference image; you direct the shot either way.',
|
||||
'zh-CN':
|
||||
'两者都支持。你可以从文本提示开始,也可以放入参考图像,无论哪种方式都由你执导镜头。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'clip-length',
|
||||
question: {
|
||||
en: 'How long can clips be?',
|
||||
'zh-CN': '片段可以多长?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Seedance 2.5 builds multi-shot sequences, so you stack shots and direct the full cut on the canvas rather than being capped at a single clip.',
|
||||
'zh-CN':
|
||||
'Seedance 2.5 可构建多镜头序列,你可以在画布上叠加镜头并执导完整成片,而不局限于单个片段。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'commercial-use',
|
||||
question: {
|
||||
en: 'Can I use the videos commercially?',
|
||||
'zh-CN': '视频可以商用吗?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Yes. Renders include commercial use and carry no watermark.',
|
||||
'zh-CN': '可以。渲染结果包含商业使用授权,且不带水印。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'seedance-vs-kling',
|
||||
question: {
|
||||
en: 'Seedance vs Kling — which one for my shot?',
|
||||
'zh-CN': 'Seedance 与 Kling,我的镜头该选哪个?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Kling gives precise camera and motion control; Seedance is built for multi-shot cinematic sequences. Draft on Wan 2.2, then finish on whichever the shot demands.',
|
||||
'zh-CN':
|
||||
'Kling 提供精准的镜头与运动控制,Seedance 专为多镜头电影级序列打造。先用 Wan 2.2 打样,再根据镜头需求选择合适的模型完成。'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
|
||||
export interface SeedanceReview {
|
||||
id: string
|
||||
// Optional headline shown above the body (title-style card). When absent the
|
||||
// body renders as a larger pull quote (name + role card, e.g. Scott Belsky).
|
||||
title?: LocalizedText
|
||||
body: LocalizedText
|
||||
name: string
|
||||
role?: LocalizedText
|
||||
}
|
||||
|
||||
// Customer reviews for the "4+ million Comfy creators say" carousel. The Scott
|
||||
// Belsky quote is curated and already in the Figma. The remaining three are
|
||||
// clearly-marked placeholders pending Nav's hand-curated 5-star picks from G2
|
||||
// (https://www.g2.com/products/comfyui/reviews) — Nav will exclude some, so
|
||||
// these are swapped one-for-one, same as the placeholder media above (CRE-145).
|
||||
export const seedanceReviews: readonly SeedanceReview[] = [
|
||||
{
|
||||
id: 'scott-belsky',
|
||||
body: {
|
||||
en: 'Comfy has innovated a new and powerful ecosystem for creativity without compromising creative control. It has been amazing to watch technical artists and curious creative minds leverage Comfy to explore the full surface area of their ideas.',
|
||||
'zh-CN':
|
||||
'Comfy 打造了一个全新而强大的创意生态,同时毫不牺牲创作掌控力。看着技术型艺术家和充满好奇的创意人借助 Comfy 探索创意的每一个维度,令人惊叹。'
|
||||
},
|
||||
name: 'Scott Belsky',
|
||||
role: { en: 'Founder of Behance', 'zh-CN': 'Behance 创始人' }
|
||||
},
|
||||
{
|
||||
id: 'placeholder-1',
|
||||
title: {
|
||||
en: 'Intuitive UI and powerful node-based workflows',
|
||||
'zh-CN': '直观的界面与强大的节点式工作流'
|
||||
},
|
||||
body: {
|
||||
en: 'Placeholder review — Nav to replace with a curated 5-star review from G2.',
|
||||
'zh-CN': '占位评价 —— 待 Nav 从 G2 挑选并替换为 5 星好评。'
|
||||
},
|
||||
name: 'Customer Name'
|
||||
},
|
||||
{
|
||||
id: 'placeholder-2',
|
||||
title: {
|
||||
en: 'The most flexible creative pipeline we run',
|
||||
'zh-CN': '我们用过最灵活的创意流程'
|
||||
},
|
||||
body: {
|
||||
en: 'Placeholder review — Nav to replace with a curated 5-star review from G2.',
|
||||
'zh-CN': '占位评价 —— 待 Nav 从 G2 挑选并替换为 5 星好评。'
|
||||
},
|
||||
name: 'Customer Name'
|
||||
},
|
||||
{
|
||||
id: 'placeholder-3',
|
||||
title: {
|
||||
en: 'From prompt to final cut without leaving the canvas',
|
||||
'zh-CN': '从提示到成片,全程不离开画布'
|
||||
},
|
||||
body: {
|
||||
en: 'Placeholder review — Nav to replace with a curated 5-star review from G2.',
|
||||
'zh-CN': '占位评价 —— 待 Nav 从 G2 挑选并替换为 5 星好评。'
|
||||
},
|
||||
name: 'Customer Name'
|
||||
}
|
||||
] as const
|
||||
@@ -4446,7 +4446,120 @@ const translations = {
|
||||
'launches.section.title': {
|
||||
en: 'Latest Launches',
|
||||
'zh-CN': '最新发布'
|
||||
}
|
||||
},
|
||||
|
||||
// Seedance 2.5 SEO page (/seedance-2.5). zh-CN hand-translated; some body
|
||||
// copy carries placeholder intent from Figma and may change (June, CRE-145).
|
||||
'seedance.meta.title': {
|
||||
en: 'Seedance 2.5 on Comfy — Cinematic AI Video Model',
|
||||
'zh-CN': 'Comfy 上的 Seedance 2.5 — 电影级 AI 视频模型'
|
||||
},
|
||||
'seedance.meta.description': {
|
||||
en: 'Run ByteDance Seedance 2.5 on Comfy: multi-shot cinematic video from text or image, 1080p to 4K. Draft free on Wan 2.2 and spend credits only on the final render.',
|
||||
'zh-CN':
|
||||
'在 Comfy 上运行字节跳动 Seedance 2.5:从文本或图像生成多镜头电影级视频,支持 1080p 至 4K。先用 Wan 2.2 免费打样,只在最终渲染时消耗积分。'
|
||||
},
|
||||
'seedance.breadcrumb.model': { en: 'Seedance 2.5', 'zh-CN': 'Seedance 2.5' },
|
||||
'seedance.breadcrumb.updated': {
|
||||
en: 'Updated July 2026',
|
||||
'zh-CN': '更新于 2026 年 7 月'
|
||||
},
|
||||
'seedance.hero.badge': { en: 'Now on', 'zh-CN': '现已登陆' },
|
||||
'seedance.hero.title': { en: 'Seedance 2.5', 'zh-CN': 'Seedance 2.5' },
|
||||
'seedance.hero.description': {
|
||||
en: "ByteDance's cinematic video model — multi-shot sequences, 1080p to 4K, text or image in. You direct on the canvas; Seedance renders the cut. Draft free on Wan 2.2 and spend credits only on the final.",
|
||||
'zh-CN':
|
||||
'字节跳动的电影级视频模型:多镜头序列,1080p 至 4K,支持文本或图像输入。你在画布上执导,Seedance 负责渲染成片。先用 Wan 2.2 免费打样,只在最终成片时消耗积分。'
|
||||
},
|
||||
'seedance.hero.primaryCta': {
|
||||
en: 'RUN SEEDANCE 2.5',
|
||||
'zh-CN': '运行 Seedance 2.5'
|
||||
},
|
||||
'seedance.hero.secondaryCta': { en: 'SEE THE PRESETS', 'zh-CN': '查看预设' },
|
||||
'seedance.hero.footnote': {
|
||||
en: 'Pay-as-you-go credits · No watermark · Commercial use included',
|
||||
'zh-CN': '按量付费积分 · 无水印 · 包含商业使用授权'
|
||||
},
|
||||
'seedance.showcase.heading': {
|
||||
en: 'Multi-shot sequences, 4K',
|
||||
'zh-CN': '多镜头序列,4K'
|
||||
},
|
||||
'seedance.showcase.promptLabel': { en: 'Prompt', 'zh-CN': '提示词' },
|
||||
'seedance.showcase.cta': { en: 'RUN SEEDANCE', 'zh-CN': '运行 Seedance' },
|
||||
'seedance.showcase.prev': {
|
||||
en: 'Previous example',
|
||||
'zh-CN': '上一个示例'
|
||||
},
|
||||
'seedance.showcase.next': { en: 'Next example', 'zh-CN': '下一个示例' },
|
||||
'seedance.steps.heading': {
|
||||
en: 'Direct your first Seedance shot',
|
||||
'zh-CN': '执导你的第一个 Seedance 镜头'
|
||||
},
|
||||
'seedance.steps.leadIn': {
|
||||
en: 'The model is the artist. You write the brief.',
|
||||
'zh-CN': '模型即艺术家,你只需写下创意简报。'
|
||||
},
|
||||
'seedance.steps.step': { en: 'Step', 'zh-CN': '步骤' },
|
||||
'seedance.models.eyebrow': {
|
||||
en: 'Every model · one canvas',
|
||||
'zh-CN': '每个模型 · 同一画布'
|
||||
},
|
||||
'seedance.models.heading': {
|
||||
en: 'The rest of the video stack',
|
||||
'zh-CN': '完整的视频模型阵容'
|
||||
},
|
||||
'seedance.models.subtitle': {
|
||||
en: 'Same canvas, same workflow. Draft on the free anchors, finish on the model the shot demands.',
|
||||
'zh-CN':
|
||||
'同一画布,同一工作流。先用免费模型打样,再用最合适的模型完成镜头。'
|
||||
},
|
||||
'seedance.models.tagFree': { en: 'Free', 'zh-CN': '免费' },
|
||||
'seedance.models.tagPremium': { en: 'Premium', 'zh-CN': '高级' },
|
||||
'seedance.faq.heading': {
|
||||
en: 'Q&A',
|
||||
'zh-CN': '问答'
|
||||
},
|
||||
'seedance.cta.heading': {
|
||||
en: 'Multi-shot. One director.',
|
||||
'zh-CN': '多镜头,一位导演。'
|
||||
},
|
||||
'seedance.cta.subtitle': {
|
||||
en: 'Draft free on Wan 2.2 — switch to Seedance when the shot is ready.',
|
||||
'zh-CN': '先用 Wan 2.2 免费打样,镜头就绪后再切换到 Seedance。'
|
||||
},
|
||||
'seedance.cta.primaryCta': {
|
||||
en: 'RUN WAN 2.2 FOR FREE',
|
||||
'zh-CN': '免费运行 Wan 2.2'
|
||||
},
|
||||
'seedance.cta.secondaryCta': {
|
||||
en: 'RUN SEEDANCE 2.5',
|
||||
'zh-CN': '运行 Seedance 2.5'
|
||||
},
|
||||
'seedance.runOptions.heading': {
|
||||
en: 'One engine, every way to run it',
|
||||
'zh-CN': '同一引擎,多种运行方式'
|
||||
},
|
||||
'seedance.runOptions.subtitle': {
|
||||
en: 'Run Seedance in the browser today. Batch campaigns with the API, or bring it in-house.',
|
||||
'zh-CN':
|
||||
'今天就在浏览器中运行 Seedance。用 API 批量制作,或部署到自有环境。'
|
||||
},
|
||||
'seedance.reviews.heading': {
|
||||
en: '4+ million Comfy creators say',
|
||||
'zh-CN': '400 万+ Comfy 创作者这样说'
|
||||
},
|
||||
'seedance.reviews.highlightTitle': {
|
||||
en: 'Comfy MCP: now turn your agent into a creative technologist.',
|
||||
'zh-CN': 'Comfy MCP:让你的智能体成为创意技术专家。'
|
||||
},
|
||||
'seedance.reviews.highlightDescription': {
|
||||
en: 'Your AI assistant can access the ecosystem, build workflows, and generate images, video, audio, or 3D.',
|
||||
'zh-CN':
|
||||
'你的 AI 助手可以接入整个生态、构建工作流,并生成图像、视频、音频或 3D 内容。'
|
||||
},
|
||||
'seedance.reviews.highlightCta': { en: 'GET STARTED', 'zh-CN': '开始使用' },
|
||||
'seedance.reviews.prev': { en: 'Previous', 'zh-CN': '上一条' },
|
||||
'seedance.reviews.next': { en: 'Next', 'zh-CN': '下一条' }
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
type TranslationKey = keyof typeof translations
|
||||
|
||||
70
apps/website/src/pages/seedance-2.5.astro
Normal file
70
apps/website/src/pages/seedance-2.5.astro
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import BreadcrumbBar from '../components/common/BreadcrumbBar.vue'
|
||||
import PriceSection from '../components/pricing/PriceSection.vue'
|
||||
import SeedanceCtaSection from '../templates/seedance/SeedanceCtaSection.vue'
|
||||
import SeedanceFaqSection from '../templates/seedance/SeedanceFaqSection.vue'
|
||||
import SeedanceHeroSection from '../templates/seedance/SeedanceHeroSection.vue'
|
||||
import SeedanceModelsSection from '../templates/seedance/SeedanceModelsSection.vue'
|
||||
import SeedanceReviewsSection from '../templates/seedance/SeedanceReviewsSection.vue'
|
||||
import SeedanceRunOptionsSection from '../templates/seedance/SeedanceRunOptionsSection.vue'
|
||||
import SeedanceShowcaseSection from '../templates/seedance/SeedanceShowcaseSection.vue'
|
||||
import SeedanceStepsSection from '../templates/seedance/SeedanceStepsSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
import { seedanceFaqs } from '../data/seedance'
|
||||
import { t } from '../i18n/translations'
|
||||
import type { JsonLdNode } from '../utils/jsonLd'
|
||||
import { absoluteUrl, jsonLdId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const crumbs = [
|
||||
{ label: t('breadcrumb.home', locale), href: routes.home },
|
||||
{ label: t('models.breadcrumb.models', locale), href: routes.models },
|
||||
{ label: t('seedance.breadcrumb.model', locale) },
|
||||
]
|
||||
|
||||
// Derive the JSON-LD breadcrumb trail from the visible crumbs so the two
|
||||
// cannot drift apart.
|
||||
const breadcrumbs = crumbs.map((crumb) =>
|
||||
crumb.href
|
||||
? { name: crumb.label, url: absoluteUrl(Astro.site, crumb.href) }
|
||||
: { name: crumb.label },
|
||||
)
|
||||
|
||||
const faqPage: JsonLdNode = {
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: seedanceFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
|
||||
})),
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('seedance.meta.title', locale)}
|
||||
description={t('seedance.meta.description', locale)}
|
||||
breadcrumbs={breadcrumbs}
|
||||
extraJsonLd={[faqPage]}
|
||||
>
|
||||
<BreadcrumbBar
|
||||
crumbs={crumbs}
|
||||
updated={t('seedance.breadcrumb.updated', locale)}
|
||||
/>
|
||||
<SeedanceHeroSection client:load />
|
||||
<SeedanceShowcaseSection client:visible />
|
||||
<SeedanceStepsSection />
|
||||
<SeedanceModelsSection />
|
||||
<PriceSection client:load />
|
||||
<SeedanceFaqSection client:visible />
|
||||
<SeedanceCtaSection />
|
||||
<SeedanceRunOptionsSection />
|
||||
<SeedanceReviewsSection client:visible />
|
||||
</BaseLayout>
|
||||
70
apps/website/src/pages/zh-CN/seedance-2.5.astro
Normal file
70
apps/website/src/pages/zh-CN/seedance-2.5.astro
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import BreadcrumbBar from '../../components/common/BreadcrumbBar.vue'
|
||||
import PriceSection from '../../components/pricing/PriceSection.vue'
|
||||
import SeedanceCtaSection from '../../templates/seedance/SeedanceCtaSection.vue'
|
||||
import SeedanceFaqSection from '../../templates/seedance/SeedanceFaqSection.vue'
|
||||
import SeedanceHeroSection from '../../templates/seedance/SeedanceHeroSection.vue'
|
||||
import SeedanceModelsSection from '../../templates/seedance/SeedanceModelsSection.vue'
|
||||
import SeedanceReviewsSection from '../../templates/seedance/SeedanceReviewsSection.vue'
|
||||
import SeedanceRunOptionsSection from '../../templates/seedance/SeedanceRunOptionsSection.vue'
|
||||
import SeedanceShowcaseSection from '../../templates/seedance/SeedanceShowcaseSection.vue'
|
||||
import SeedanceStepsSection from '../../templates/seedance/SeedanceStepsSection.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { seedanceFaqs } from '../../data/seedance'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const crumbs = [
|
||||
{ label: t('breadcrumb.home', locale), href: routes.home },
|
||||
{ label: t('models.breadcrumb.models', locale), href: routes.models },
|
||||
{ label: t('seedance.breadcrumb.model', locale) },
|
||||
]
|
||||
|
||||
// Derive the JSON-LD breadcrumb trail from the visible crumbs so the two
|
||||
// cannot drift apart.
|
||||
const breadcrumbs = crumbs.map((crumb) =>
|
||||
crumb.href
|
||||
? { name: crumb.label, url: absoluteUrl(Astro.site, crumb.href) }
|
||||
: { name: crumb.label },
|
||||
)
|
||||
|
||||
const faqPage: JsonLdNode = {
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: seedanceFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
|
||||
})),
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('seedance.meta.title', locale)}
|
||||
description={t('seedance.meta.description', locale)}
|
||||
breadcrumbs={breadcrumbs}
|
||||
extraJsonLd={[faqPage]}
|
||||
>
|
||||
<BreadcrumbBar
|
||||
crumbs={crumbs}
|
||||
updated={t('seedance.breadcrumb.updated', locale)}
|
||||
/>
|
||||
<SeedanceHeroSection client:load locale="zh-CN" />
|
||||
<SeedanceShowcaseSection client:visible locale="zh-CN" />
|
||||
<SeedanceStepsSection locale="zh-CN" />
|
||||
<SeedanceModelsSection locale="zh-CN" />
|
||||
<PriceSection client:load locale="zh-CN" />
|
||||
<SeedanceFaqSection client:visible locale="zh-CN" />
|
||||
<SeedanceCtaSection locale="zh-CN" />
|
||||
<SeedanceRunOptionsSection locale="zh-CN" />
|
||||
<SeedanceReviewsSection client:visible locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
26
apps/website/src/templates/seedance/SeedanceCtaSection.vue
Normal file
26
apps/website/src/templates/seedance/SeedanceCtaSection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CtaCenter01
|
||||
:heading="t('seedance.cta.heading', locale)"
|
||||
:subtitle="t('seedance.cta.subtitle', locale)"
|
||||
:primary-cta="{
|
||||
label: t('seedance.cta.primaryCta', locale),
|
||||
href: externalLinks.workflows,
|
||||
target: '_blank'
|
||||
}"
|
||||
:secondary-cta="{
|
||||
label: t('seedance.cta.secondaryCta', locale),
|
||||
href: externalLinks.workflows,
|
||||
target: '_blank'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
19
apps/website/src/templates/seedance/SeedanceFaqSection.vue
Normal file
19
apps/website/src/templates/seedance/SeedanceFaqSection.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
|
||||
import { seedanceFaqs } from '../../data/seedance'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqs = seedanceFaqs.map((faq) => ({
|
||||
id: faq.id,
|
||||
question: faq.question[locale],
|
||||
answer: faq.answer[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FAQSplit01 :heading="t('seedance.faq.heading', locale)" :faqs="faqs" />
|
||||
</template>
|
||||
72
apps/website/src/templates/seedance/SeedanceHeroSection.vue
Normal file
72
apps/website/src/templates/seedance/SeedanceHeroSection.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import BrandButton from '../../components/common/BrandButton.vue'
|
||||
import NodeBadge from '../../components/common/NodeBadge.vue'
|
||||
import VideoPlayer from '../../components/common/VideoPlayer.vue'
|
||||
import { seedanceHeroVideo } from '../../data/seedance'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-12 lg:px-20 lg:py-16">
|
||||
<div class="relative">
|
||||
<VideoPlayer :locale :src="seedanceHeroVideo" autoplay loop />
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-0 bottom-24 flex flex-col items-center justify-center gap-6 px-6 text-center"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="[
|
||||
{ text: t('seedance.hero.badge', locale) },
|
||||
{ logoSrc: '/icons/logo.svg', logoAlt: 'Comfy' }
|
||||
]"
|
||||
text-class="text-lg uppercase lg:text-2xl"
|
||||
size-class="h-12"
|
||||
segment-class="px-4 lg:px-5"
|
||||
/>
|
||||
<h1
|
||||
class="text-5xl font-light text-white drop-shadow-[0_2px_12px_rgba(0,0,0,0.6)] md:text-6xl lg:text-7xl"
|
||||
>
|
||||
{{ t('seedance.hero.title', locale) }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto mt-10 flex max-w-2xl flex-col items-center text-center">
|
||||
<p
|
||||
class="text-base/relaxed font-light text-primary-comfy-canvas lg:text-lg/relaxed"
|
||||
>
|
||||
{{ t('seedance.hero.description', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-4 sm:flex-row">
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
target="_blank"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="text-center lg:min-w-52 lg:p-4"
|
||||
>
|
||||
{{ t('seedance.hero.primaryCta', locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
target="_blank"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="text-center lg:min-w-52 lg:p-4"
|
||||
>
|
||||
{{ t('seedance.hero.secondaryCta', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<p class="text-primary-warm-gray mt-6 text-xs">
|
||||
{{ t('seedance.hero.footnote', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
125
apps/website/src/templates/seedance/SeedanceModelsSection.vue
Normal file
125
apps/website/src/templates/seedance/SeedanceModelsSection.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import NodeTag from '../../components/common/NodeTag.vue'
|
||||
import IconButton from '../../components/ui/icon-button/IconButton.vue'
|
||||
import { seedanceModels } from '../../data/seedance'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
|
||||
<div class="mx-auto flex max-w-3xl flex-col items-center text-center">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('seedance.models.eyebrow', locale) }}
|
||||
</p>
|
||||
<h2
|
||||
class="mt-6 text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
>
|
||||
{{ t('seedance.models.heading', locale) }}
|
||||
</h2>
|
||||
<p class="mt-6 max-w-md text-sm font-light text-primary-comfy-canvas/70">
|
||||
{{ t('seedance.models.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mx-auto mt-16 grid max-w-7xl grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2"
|
||||
>
|
||||
<article v-for="model in seedanceModels" :key="model.id">
|
||||
<div
|
||||
class="group rounded-4.5xl relative block aspect-19/10 overflow-hidden bg-black/40"
|
||||
>
|
||||
<video
|
||||
v-if="model.imageSrc.endsWith('.webm')"
|
||||
:src="model.imageSrc"
|
||||
:aria-label="model.name"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="model.imageSrc"
|
||||
:alt="model.name"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-b from-black/25 to-transparent"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-x-8 top-8 flex items-start justify-between"
|
||||
>
|
||||
<h3
|
||||
class="text-primary-warm-white text-3xl/tight font-medium drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]"
|
||||
>
|
||||
{{ model.name }}
|
||||
</h3>
|
||||
<div
|
||||
class="group-hover:bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors group-hover:text-primary-comfy-ink"
|
||||
>
|
||||
<span
|
||||
class="inline-block size-6 bg-current"
|
||||
:style="{
|
||||
maskImage: `url(${model.logoSrc})`,
|
||||
maskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
maskPosition: 'center'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<NodeTag
|
||||
:class="
|
||||
model.tier === 'free'
|
||||
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
: 'bg-primary-comfy-plum text-primary-warm-white'
|
||||
"
|
||||
>
|
||||
{{
|
||||
model.tier === 'free'
|
||||
? t('seedance.models.tagFree', locale)
|
||||
: t('seedance.models.tagPremium', locale)
|
||||
}}
|
||||
</NodeTag>
|
||||
<span class="text-primary-warm-gray text-xs">
|
||||
{{ model.note[locale] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
as="a"
|
||||
:href="model.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:aria-label="model.name"
|
||||
size="sm"
|
||||
class="bg-primary-warm-gray hover:bg-primary-comfy-yellow rounded-xl text-primary-comfy-ink hover:text-primary-comfy-ink"
|
||||
>
|
||||
<ChevronRight class="size-5" :stroke-width="2" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-sm font-light text-primary-comfy-canvas">
|
||||
{{ model.description[locale] }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
145
apps/website/src/templates/seedance/SeedanceReviewsSection.vue
Normal file
145
apps/website/src/templates/seedance/SeedanceReviewsSection.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import BrandButton from '../../components/common/BrandButton.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { seedanceReviews } from '../../data/seedance'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const reviews = seedanceReviews.map((review) => ({
|
||||
id: review.id,
|
||||
title: review.title?.[locale],
|
||||
body: review.body[locale],
|
||||
name: review.name,
|
||||
role: review.role?.[locale]
|
||||
}))
|
||||
|
||||
const trackRef = ref<HTMLElement>()
|
||||
const { x } = useScroll(trackRef)
|
||||
|
||||
const progress = computed(() => {
|
||||
const el = trackRef.value
|
||||
if (!el) return 0
|
||||
const max = el.scrollWidth - el.clientWidth
|
||||
return max > 0 ? x.value / max : 0
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
|
||||
function scroll(direction: -1 | 1) {
|
||||
const el = trackRef.value
|
||||
if (!el) return
|
||||
el.scrollBy({ left: direction * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 pt-0 pb-16 lg:px-16 lg:pt-4 lg:pb-24">
|
||||
<!-- Comfy MCP highlight card -->
|
||||
<div
|
||||
class="rounded-5xl bg-primary-comfy-yellow flex flex-col gap-6 p-8 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between lg:gap-8"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3
|
||||
class="text-2xl font-medium text-primary-comfy-ink lg:text-3xl/tight"
|
||||
>
|
||||
{{ t('seedance.reviews.highlightTitle', locale) }}
|
||||
</h3>
|
||||
<p class="mt-4 text-base/relaxed font-light text-primary-comfy-ink">
|
||||
{{ t('seedance.reviews.highlightDescription', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BrandButton
|
||||
:href="routes.mcp"
|
||||
variant="inverse"
|
||||
size="sm"
|
||||
class="h-12 shrink-0 px-5 uppercase"
|
||||
>
|
||||
{{ t('seedance.reviews.highlightCta', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<!-- Reviews heading -->
|
||||
<h2
|
||||
class="mt-20 text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:mt-28 lg:text-5xl/tight"
|
||||
>
|
||||
{{ t('seedance.reviews.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<!-- Scrollable track -->
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="mt-12 flex snap-x snap-mandatory scrollbar-none gap-8 overflow-x-auto lg:mt-16"
|
||||
>
|
||||
<article
|
||||
v-for="review in reviews"
|
||||
:key="review.id"
|
||||
class="bg-transparency-white-t4 rounded-5xl flex w-full shrink-0 snap-start flex-col justify-between p-8 lg:w-2/3 lg:p-12"
|
||||
>
|
||||
<div>
|
||||
<template v-if="review.title">
|
||||
<p
|
||||
class="text-2xl font-medium text-primary-comfy-canvas lg:text-3xl/snug"
|
||||
>
|
||||
{{ review.title }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-6 text-lg/relaxed font-light text-primary-comfy-canvas lg:text-xl/relaxed"
|
||||
>
|
||||
{{ review.body }}
|
||||
</p>
|
||||
</template>
|
||||
<p
|
||||
v-else
|
||||
class="text-xl/relaxed font-light text-primary-comfy-canvas lg:text-2xl/relaxed"
|
||||
>
|
||||
"{{ review.body }}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-primary-comfy-yellow mt-10 text-base lg:mt-12">
|
||||
<span class="font-medium">{{ review.name }}</span
|
||||
><template v-if="review.role">,<br />{{ review.role }}</template>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="mt-10 flex items-center gap-4">
|
||||
<div class="h-1 flex-1 rounded-full bg-white/20">
|
||||
<div
|
||||
class="bg-primary-comfy-yellow h-full rounded-full"
|
||||
:style="{ width: progressPercent }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex size-10 items-center justify-center rounded-full border border-white/20 text-white/60 transition-colors hover:border-white/40"
|
||||
:aria-label="t('seedance.reviews.prev', locale)"
|
||||
@click="scroll(-1)"
|
||||
>
|
||||
<img
|
||||
src="/icons/arrow-right.svg"
|
||||
alt=""
|
||||
class="size-3 rotate-180 opacity-60 invert"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full transition-opacity hover:opacity-90"
|
||||
:aria-label="t('seedance.reviews.next', locale)"
|
||||
@click="scroll(1)"
|
||||
>
|
||||
<img src="/icons/arrow-right.svg" alt="" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import ProductCard from '../../components/common/ProductCard.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const cards = [
|
||||
{ product: 'local', href: routes.download, bg: 'bg-primary-warm-gray' },
|
||||
{ product: 'cloud', href: routes.cloud, bg: 'bg-secondary-mauve' },
|
||||
{ product: 'api', href: routes.api, bg: 'bg-primary-comfy-plum' },
|
||||
{
|
||||
product: 'enterprise',
|
||||
href: routes.cloudEnterprise,
|
||||
bg: 'bg-secondary-cool-gray'
|
||||
}
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24"
|
||||
>
|
||||
<div class="flex flex-col items-center px-4 text-center">
|
||||
<h2
|
||||
class="text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
>
|
||||
{{ t('seedance.runOptions.heading', locale) }}
|
||||
</h2>
|
||||
<p class="mt-6 max-w-xl text-sm font-light text-primary-comfy-canvas/70">
|
||||
{{ t('seedance.runOptions.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 mt-16 grid grid-cols-1 gap-4 p-4 lg:grid-cols-4 lg:p-2"
|
||||
>
|
||||
<ProductCard
|
||||
v-for="card in cards"
|
||||
:key="card.product"
|
||||
:title="t(`products.${card.product}.title`, locale)"
|
||||
:description="t(`products.${card.product}.description`, locale)"
|
||||
:cta="t(`products.${card.product}.cta`, locale)"
|
||||
:href="card.href"
|
||||
:bg="card.bg"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import BrandButton from '../../components/common/BrandButton.vue'
|
||||
import MediaCarousel from '../../components/common/MediaCarousel.vue'
|
||||
import { seedanceExamples } from '../../data/seedance'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-12 lg:px-20 lg:py-16">
|
||||
<h2
|
||||
class="text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
>
|
||||
{{ t('seedance.showcase.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<MediaCarousel
|
||||
class="mt-12"
|
||||
:prev-label="t('seedance.showcase.prev', locale)"
|
||||
:next-label="t('seedance.showcase.next', locale)"
|
||||
>
|
||||
<div
|
||||
v-for="example in seedanceExamples"
|
||||
:key="example.id"
|
||||
class="w-full shrink-0 snap-start"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-5">
|
||||
<div
|
||||
class="rounded-5xl border-primary-comfy-yellow relative flex w-full flex-col gap-10 self-start border-2 bg-primary-comfy-canvas/8 p-8 lg:w-[520px] lg:shrink-0"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xl font-medium text-primary-comfy-canvas">
|
||||
{{ t('seedance.showcase.promptLabel', locale) }}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs/relaxed font-semibold text-primary-comfy-canvas/90"
|
||||
>
|
||||
{{ example.prompt[locale] }}
|
||||
</p>
|
||||
</div>
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
target="_blank"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
class="self-start"
|
||||
>
|
||||
{{ t('seedance.showcase.cta', locale) }}
|
||||
</BrandButton>
|
||||
|
||||
<img
|
||||
src="/icons/node-link.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="absolute top-1/2 left-full z-10 hidden h-8 w-5 -translate-y-1/2 lg:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-5xl border-primary-warm-gray flex flex-1 items-center justify-center self-stretch border-2 bg-primary-comfy-ink p-2"
|
||||
>
|
||||
<video
|
||||
v-if="example.imageSrc.endsWith('.webm')"
|
||||
:src="example.imageSrc"
|
||||
:aria-label="example.imageAlt[locale]"
|
||||
class="aspect-video w-full rounded-4xl object-cover"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="example.imageSrc"
|
||||
:alt="example.imageAlt[locale]"
|
||||
class="aspect-video w-full rounded-4xl object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MediaCarousel>
|
||||
</section>
|
||||
</template>
|
||||
67
apps/website/src/templates/seedance/SeedanceStepsSection.vue
Normal file
67
apps/website/src/templates/seedance/SeedanceStepsSection.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { seedanceSteps } from '../../data/seedance'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const stepNumber = (index: number) => String(index + 1).padStart(2, '0')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 pt-10 pb-24 lg:px-20">
|
||||
<div class="mx-auto flex max-w-3xl flex-col items-center text-center">
|
||||
<h2
|
||||
class="text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
>
|
||||
{{ t('seedance.steps.heading', locale) }}
|
||||
</h2>
|
||||
<p class="mt-5 text-base/relaxed font-light text-primary-comfy-canvas/80">
|
||||
{{ t('seedance.steps.leadIn', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol class="mt-10 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<li
|
||||
v-for="(step, index) in seedanceSteps"
|
||||
:key="step.id"
|
||||
class="rounded-4.5xl bg-transparency-white-t4 flex flex-col gap-8 p-2"
|
||||
>
|
||||
<div class="aspect-4/3 overflow-hidden rounded-4xl">
|
||||
<video
|
||||
v-if="step.imageSrc.endsWith('.webm')"
|
||||
:src="step.imageSrc"
|
||||
class="size-full object-cover"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="step.imageSrc"
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 p-5">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-extrabold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('seedance.steps.step', locale) }} {{ stepNumber(index) }}
|
||||
</p>
|
||||
<p class="text-3xl/snug font-medium text-primary-comfy-canvas">
|
||||
{{ step.title[locale] }}
|
||||
</p>
|
||||
<p class="text-base/relaxed font-light text-primary-comfy-canvas">
|
||||
{{ step.description[locale] }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user