Compare commits

...

8 Commits

Author SHA1 Message Date
Mobeen Abdullah
e0a3c1adfc refactor(website): source Seedance reviews carousel aria-labels from i18n
Replace the inline locale ternaries on the reviews carousel Prev/Next
buttons with t() and dedicated seedance.reviews.prev/next keys, matching
the site's translation convention. English values stay "Previous"/"Next"
so the existing e2e button lookup keeps working.
2026-07-14 02:20:17 +05:00
Mobeen Abdullah
53d3868a9d style(website): balance spacing above the Seedance reviews MCP card
Drop the reviews section top padding so the gap above the Comfy MCP
card matches the gap between the card and the reviews heading, instead
of stacking the run-options section bottom padding on top of it.
2026-07-14 01:45:12 +05:00
Mobeen Abdullah
b739bf065f feat(website): add customer reviews section to Seedance page
- Add "4+ million Comfy creators say" reviews carousel reusing the
  customers FeedbackSection mechanics; ships the curated Scott Belsky
  quote from the Figma plus placeholders pending Nav's curated G2 picks
- Move the Comfy MCP highlight card into the top of the reviews section
  to match the latest Figma; fix its mobile layout so the title no
  longer collapses to one word per line
- Breadcrumb label -> "Supported Models" via the canonical
  models.breadcrumb.models key; drop the redundant breadcrumb.models key
- e2e: reviews heading + first quote, MCP CTA target, carousel advance
2026-07-14 01:36:47 +05:00
Mobeen Abdullah
a2dbeb1ca7 fix(website): address CodeRabbit review on Seedance page
- e2e: move breadcrumb/CTA link-target assertions out of @smoke into a
  dedicated link-targets suite; assert against routes.models, not a literal
- BreadcrumbBar: use an index-composite :key (labels are not unique)
- routes: add regression tests for /seedance-2.5 and /zh-CN/seedance-2.5
- data: fix Kling 3.0 free/pay-as-you-go contradiction (note -> Included free)
- pages: derive the JSON-LD breadcrumbs from the visible crumbs so they cannot drift
2026-07-11 00:02:11 +05:00
Mobeen Abdullah
12f8d210f8 refactor(website): keep SeedanceModelTier type internal 2026-07-10 23:40:59 +05:00
Mobeen Abdullah
7969688302 docs(website): add Seedance page preview screenshots 2026-07-10 23:32:35 +05:00
Mobeen Abdullah
231cbb63eb test(website): add Seedance page e2e smoke spec 2026-07-10 23:32:17 +05:00
Mobeen Abdullah
d1f33b83e4 feat(website): add Seedance 2.5 SEO page
Add the /seedance-2.5 marketing landing page (en + zh-CN) built from June's
Figma: video hero, prompt/result showcase carousel, direct-your-shot steps,
model grid, reused pricing, FAQ, CTA, and run-options.

Introduces three reusable primitives (BreadcrumbBar, MediaCarousel, NodeTag),
an inverse BrandButton variant, and an optional subtitle on CtaCenter01.
Per-page content lives in data/seedance.ts + seedance.* i18n keys so the
layout can be reused for future model pages.
2026-07-10 23:31:27 +05:00
20 changed files with 1557 additions and 6 deletions

View 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
)
})
})

View File

@@ -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"

View 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>

View 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>

View 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>

View File

@@ -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',

View File

@@ -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')
})
})

View File

@@ -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

View 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

View File

@@ -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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>