Compare commits
26 Commits
feat/vfx-p
...
jaeone/err
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac64dc0469 | ||
|
|
905bc50129 | ||
|
|
5bf41a41bd | ||
|
|
a7f14a0b3f | ||
|
|
e6d1a9d4a2 | ||
|
|
0b4a960467 | ||
|
|
060957d66c | ||
|
|
1a98362984 | ||
|
|
ab33746b3e | ||
|
|
55c4e807a1 | ||
|
|
74147d7ee2 | ||
|
|
16fdcab4be | ||
|
|
96c6dbcb69 | ||
|
|
ffb4ae34c6 | ||
|
|
59341a4b71 | ||
|
|
97fffb5394 | ||
|
|
4972e4d42f | ||
|
|
8d8ae7dfc0 | ||
|
|
38c034e1d6 | ||
|
|
b40fad0e75 | ||
|
|
01cbfa6a23 | ||
|
|
945a143626 | ||
|
|
193bbaba81 | ||
|
|
1eacb224a1 | ||
|
|
4ed2fe70f3 | ||
|
|
5da5ee5031 |
4
.github/workflows/cla.yml
vendored
@@ -38,9 +38,11 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
|
||||
# otherwise fall back to the raw git name.
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
75
.github/workflows/pr-backport.yaml
vendored
@@ -278,32 +278,49 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create backport branch
|
||||
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
|
||||
# Create backport branch. A failure here (e.g. dirty state left
|
||||
# by a prior target) must not abort the loop and skip remaining
|
||||
# targets, so fall back to a clean checkout and record the error.
|
||||
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
|
||||
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
|
||||
git checkout main || git checkout -f main
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Try cherry-pick
|
||||
if git cherry-pick "${MERGE_COMMIT}"; then
|
||||
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
|
||||
git push --force-with-lease origin "${BACKPORT_BRANCH}"
|
||||
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
|
||||
else
|
||||
git push origin "${BACKPORT_BRANCH}"
|
||||
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
|
||||
fi
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
|
||||
# A push failure for one target must not abort the loop and
|
||||
# prevent remaining targets from being attempted.
|
||||
if "${PUSH_CMD[@]}"; then
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
else
|
||||
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
|
||||
fi
|
||||
|
||||
# Return to main (keep the branch, we need it for PR)
|
||||
git checkout main
|
||||
git checkout main || git checkout -f main
|
||||
else
|
||||
# Get conflict info
|
||||
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
|
||||
git cherry-pick --abort
|
||||
git cherry-pick --abort || true
|
||||
|
||||
echo "::error::Cherry-pick failed due to conflicts"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
|
||||
|
||||
# Clean up the failed branch
|
||||
git checkout main
|
||||
git branch -D "${BACKPORT_BRANCH}"
|
||||
git checkout main || git checkout -f main
|
||||
git branch -D "${BACKPORT_BRANCH}" || true
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
@@ -384,6 +401,10 @@ jobs:
|
||||
|
||||
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
|
||||
|
||||
The auto-backport could not be completed automatically. Please backport
|
||||
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
|
||||
open a PR to `${target}`.
|
||||
|
||||
<details>
|
||||
<summary>📄 Conflicting files</summary>
|
||||
|
||||
@@ -416,19 +437,37 @@ jobs:
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
# Post a comment without letting a single failed `gh pr comment` (e.g.
|
||||
# a locked issue, as happened for PR #13359, or a transient API error)
|
||||
# abort the step under `set -e` and swallow the remaining failures.
|
||||
post_comment() {
|
||||
local body="$1"
|
||||
local context="$2"
|
||||
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
|
||||
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
IFS=':' read -r target reason conflicts <<< "${failure}"
|
||||
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
|
||||
if [ "${reason}" = "branch-missing" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
|
||||
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
|
||||
|
||||
elif [ "${reason}" = "already-exists" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
|
||||
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
|
||||
|
||||
elif [ "${reason}" = "branch-create-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
|
||||
|
||||
elif [ "${reason}" = "push-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
|
||||
|
||||
elif [ "${reason}" = "conflicts" ]; then
|
||||
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
|
||||
|
||||
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
|
||||
@@ -444,10 +483,10 @@ jobs:
|
||||
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
|
||||
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
|
||||
|
||||
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
|
||||
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
|
||||
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ on:
|
||||
required: false
|
||||
default: 'Comfy-Org/ComfyUI'
|
||||
type: string
|
||||
target_branch:
|
||||
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
check-release-week:
|
||||
@@ -49,12 +53,15 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
resolve-version:
|
||||
needs: check-release-week
|
||||
@@ -103,6 +110,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
env:
|
||||
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Careers — Comfy')
|
||||
await expect(page).toHaveTitle('Careers - Comfy')
|
||||
})
|
||||
|
||||
test('Roles section heading is visible', async ({ page }) => {
|
||||
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
test('renders localized heading and roles', async ({ page }) => {
|
||||
await page.goto('/zh-CN/careers')
|
||||
await expect(page).toHaveTitle('招聘 — Comfy')
|
||||
await expect(page).toHaveTitle('招聘 - Comfy')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '职位', level: 2 })
|
||||
).toBeVisible()
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Custom-node packs on Comfy Cloud — supported by default'
|
||||
'Custom-node packs on Comfy Cloud - supported by default'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud')
|
||||
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
|
||||
})
|
||||
|
||||
test('HeroSection heading and subtitle are visible', async ({ page }) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Download Comfy Desktop — Run AI on Your Hardware'
|
||||
'Download Comfy Desktop - Run AI on Your Hardware'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Comfy — Professional Control of Visual AI')
|
||||
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
|
||||
})
|
||||
|
||||
test('HeroSection heading is visible', async ({ page }) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe('Learning page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Learning — Comfy')
|
||||
await expect(page).toHaveTitle('Learning - Comfy')
|
||||
})
|
||||
|
||||
test('hero headline references ComfyUI', async ({ page }) => {
|
||||
@@ -137,7 +137,7 @@ test.describe('Learning page (zh-CN) @smoke', () => {
|
||||
test('renders localized title, headings, and tutorials', async ({ page }) => {
|
||||
await page.goto('/zh-CN/learning')
|
||||
|
||||
await expect(page).toHaveTitle('学习 — Comfy')
|
||||
await expect(page).toHaveTitle('学习 - Comfy')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
/[一-鿿]/
|
||||
)
|
||||
|
||||
@@ -31,6 +31,26 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('NEW badge shows on Products and Community only', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
|
||||
for (const label of ['Products', 'Community']) {
|
||||
await expect(
|
||||
desktopLinks
|
||||
.getByRole('button', { name: label })
|
||||
.getByText('NEW', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('CTA buttons are visible', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopCTA = nav.getByTestId('desktop-nav-cta')
|
||||
@@ -117,6 +137,27 @@ test.describe('Mobile menu @mobile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('NEW badge shows on Products and Community only', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.getByRole('dialog')
|
||||
|
||||
for (const label of ['Products', 'Community']) {
|
||||
await expect(
|
||||
menu.getByRole('button', { name: label }).getByText('NEW', {
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clicking section with subitems drills down and back works', async ({
|
||||
page
|
||||
}) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Successful — Comfy')
|
||||
await expect(page).toHaveTitle('Payment Successful - Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Failed — Comfy')
|
||||
await expect(page).toHaveTitle('Payment Failed - Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
|
||||
test.describe('Payment pages zh-CN @smoke', () => {
|
||||
test('zh-CN success page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/success')
|
||||
await expect(page).toHaveTitle('支付成功 — Comfy')
|
||||
await expect(page).toHaveTitle('支付成功 - Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '支付成功', level: 1 })
|
||||
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
|
||||
|
||||
test('zh-CN failed page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/failed')
|
||||
await expect(page).toHaveTitle('支付失败 — Comfy')
|
||||
await expect(page).toHaveTitle('支付失败 - Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '无法完成支付', level: 1 })
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
@@ -39,9 +39,3 @@ export const NoBackdrop: Story = {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const DarkTone: Story = {
|
||||
args: {
|
||||
tone: 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ const {
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
tone = 'light',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
@@ -31,19 +30,9 @@ const {
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
tone?: 'light' | 'dark'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Light tone assumes a light backdrop: dark ink text on desktop plus a
|
||||
// warm-white radial scrim for contrast. Dark tone assumes a dark backdrop
|
||||
// and uses white text at all breakpoints, with no scrim.
|
||||
const toneTextClass = computed(() =>
|
||||
tone === 'light'
|
||||
? 'text-primary-comfy-canvas lg:text-primary-comfy-ink'
|
||||
: 'text-white'
|
||||
)
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
@@ -162,7 +151,6 @@ const scrimStyle = {
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
v-if="tone === 'light'"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
:style="scrimStyle"
|
||||
@@ -177,24 +165,22 @@ const scrimStyle = {
|
||||
/>
|
||||
|
||||
<h1
|
||||
:class="
|
||||
cn(
|
||||
'mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line lg:text-6xl/tight',
|
||||
toneTextClass
|
||||
)
|
||||
"
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
:class="cn('mt-8 max-w-md text-base lg:text-lg', toneTextClass)"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p v-if="footnote" :class="cn('mt-10 text-sm', toneTextClass)">
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { clientLogos } from '../../data/clientLogos'
|
||||
import LogosAll01 from './LogosAll01.vue'
|
||||
|
||||
const meta: Meta<typeof LogosAll01> = {
|
||||
title: 'Website/Blocks/LogosAll01',
|
||||
component: LogosAll01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
logos: clientLogos
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
@@ -1,83 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface LogoItem {
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
const { logos, testId = 'logos-all' } = defineProps<{
|
||||
logos: readonly LogoItem[]
|
||||
testId?: string
|
||||
}>()
|
||||
|
||||
// On mobile the single row splits into two counter-scrolling halves.
|
||||
const mobileRow1Logos = computed(() =>
|
||||
logos.slice(0, Math.ceil(logos.length / 2))
|
||||
)
|
||||
const mobileRow2Logos = computed(() => logos.slice(Math.ceil(logos.length / 2)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div :data-testid="`${testId}-desktop`" class="hidden w-max gap-2 md:flex">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo.src"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="logo.src" :alt="logo.alt" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two rows on mobile -->
|
||||
<div
|
||||
:data-testid="`${testId}-mobile`"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo.src"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="logo.src" :alt="logo.alt" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo.src"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="logo.src" :alt="logo.alt" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-balance text-primary-comfy-canvas lg:text-5xl"
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { NavItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavColumn from './NavColumn.vue'
|
||||
import NavFeaturedCard from './NavFeaturedCard.vue'
|
||||
import NewBadge from './NewBadge.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const mainNavigation = getMainNavigation(locale)
|
||||
@@ -42,7 +43,10 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
<NavigationMenuTrigger
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
>
|
||||
{{ navItem.label }}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="ppformula-text-center">{{ navItem.label }}</span>
|
||||
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
|
||||
</span>
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
|
||||
<ul class="flex w-max gap-16">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
import NewBadge from './NewBadge.vue'
|
||||
import Sheet from '@/components/ui/sheet/Sheet.vue'
|
||||
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
@@ -96,7 +97,8 @@ onUnmounted(() => {
|
||||
:href="item.columns ? undefined : item.href"
|
||||
@click="item.columns && (activeSection = item.label)"
|
||||
>
|
||||
{{ item.label }}
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
|
||||
<template #append>
|
||||
<ChevronRight class="size-7" />
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
|
||||
import type { NavColumnItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import NewBadge from './NewBadge.vue'
|
||||
|
||||
defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
</script>
|
||||
@@ -12,9 +11,7 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
<template>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<Badge v-if="item.badge" size="xs" variant="accent">
|
||||
{{ t('nav.badgeNew', locale) }}
|
||||
</Badge>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
|
||||
<ArrowUpRight
|
||||
v-if="item.external"
|
||||
class="text-primary-comfy-yellow size-4"
|
||||
|
||||
15
apps/website/src/components/common/HeaderMain/NewBadge.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
|
||||
import type { BadgeVariants } from '@/components/ui/badge'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :size="size" variant="accent">
|
||||
{{ t('nav.badgeNew', locale) }}
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -1,8 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { clientLogos } from '../../data/clientLogos'
|
||||
import LogosAll01 from '../blocks/LogosAll01.vue'
|
||||
const logos = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
'Netflix',
|
||||
'Nike',
|
||||
'Pixomondo',
|
||||
'Tencent',
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
const mobileRow1Logos = logos.slice(0, 6)
|
||||
const mobileRow2Logos = logos.slice(6)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LogosAll01 :logos="clientLogos" test-id="social-proof" />
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two rows on mobile -->
|
||||
<div
|
||||
data-testid="social-proof-mobile"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { vfxTutorials } from '../../data/vfxTutorials'
|
||||
import TutorialCard from './TutorialCard.vue'
|
||||
|
||||
const meta: Meta<typeof TutorialCard> = {
|
||||
title: 'Website/Common/TutorialCard',
|
||||
component: TutorialCard,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
tutorial: vfxTutorials[0],
|
||||
titlePrefixKey: 'learning.tutorials.titlePrefix'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Overlay: Story = {
|
||||
args: {
|
||||
variant: 'overlay',
|
||||
titlePrefixKey: undefined,
|
||||
tutorial: {
|
||||
...vfxTutorials[0],
|
||||
author: { name: 'Author Name', avatarSrc: '/icons/clients/Apple.svg' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { VideoTrack } from './VideoPlayer.vue'
|
||||
import type {
|
||||
Locale,
|
||||
LocalizedText,
|
||||
TranslationKey
|
||||
} from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
import { ButtonMask } from '../ui/button-mask'
|
||||
|
||||
export interface Tutorial {
|
||||
id: string
|
||||
tags: readonly TranslationKey[]
|
||||
title: LocalizedText
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
caption?: readonly VideoTrack[]
|
||||
posterTime?: number
|
||||
author?: { name: string; avatarSrc?: string }
|
||||
}
|
||||
|
||||
const DEFAULT_POSTER_TIME_SECONDS = 1
|
||||
|
||||
const getTutorialPosterSrc = (tutorial: Tutorial): string =>
|
||||
tutorial.poster
|
||||
? tutorial.poster
|
||||
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
|
||||
|
||||
const {
|
||||
tutorial,
|
||||
locale = 'en',
|
||||
titlePrefixKey,
|
||||
variant = 'default'
|
||||
} = defineProps<{
|
||||
tutorial: Tutorial
|
||||
locale?: Locale
|
||||
titlePrefixKey?: TranslationKey
|
||||
variant?: 'default' | 'overlay'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ play: [] }>()
|
||||
|
||||
const accessibleTitle = computed(() =>
|
||||
titlePrefixKey
|
||||
? `${t(titlePrefixKey, locale)} ${tutorial.title[locale]}`
|
||||
: tutorial.title[locale]
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="accessibleTitle"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<!-- Dim the thumbnail so the overlaid title stays legible. -->
|
||||
<span
|
||||
v-if="variant === 'overlay'"
|
||||
class="absolute inset-0 bg-black/30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
v-if="variant === 'overlay'"
|
||||
class="absolute inset-x-0 top-0 h-1/2 bg-linear-to-b from-black/60 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
v-if="variant === 'overlay'"
|
||||
class="pointer-events-none absolute inset-x-0 top-0 p-6 text-left text-2xl font-light text-primary-comfy-canvas lg:p-8 lg:text-3xl"
|
||||
>
|
||||
{{ tutorial.title[locale] }}
|
||||
</span>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white lg:size-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<div
|
||||
v-if="variant === 'overlay'"
|
||||
class="flex items-center justify-between gap-4"
|
||||
>
|
||||
<span
|
||||
v-if="tutorial.author"
|
||||
class="flex min-w-0 items-center gap-3 text-sm text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
<img
|
||||
v-if="tutorial.author.avatarSrc"
|
||||
:src="tutorial.author.avatarSrc"
|
||||
alt=""
|
||||
class="size-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span class="truncate">{{ tutorial.author.name }}</span>
|
||||
</span>
|
||||
<ButtonMask
|
||||
v-if="tutorial.href"
|
||||
as="a"
|
||||
:href="tutorial.href"
|
||||
icon-position="left"
|
||||
:hide-label="false"
|
||||
class="ml-auto shrink-0 ps-12"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
</ButtonMask>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug">
|
||||
<template v-if="titlePrefixKey"
|
||||
>{{ t(titlePrefixKey, locale) }}<br
|
||||
/></template>
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<ButtonMask
|
||||
v-if="tutorial.href"
|
||||
as="a"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</ButtonMask>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,21 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
|
||||
|
||||
import type { Tutorial } from './TutorialCard.vue'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import type { LearningTutorial } from '../../data/learningTutorials'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { t } from '../../i18n/translations'
|
||||
import VideoPlayer from './VideoPlayer.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const {
|
||||
tutorial,
|
||||
locale = 'en',
|
||||
titlePrefixKey
|
||||
} = defineProps<{
|
||||
tutorial: Tutorial
|
||||
const { tutorial, locale = 'en' } = defineProps<{
|
||||
tutorial: LearningTutorial
|
||||
locale?: Locale
|
||||
titlePrefixKey?: TranslationKey
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
@@ -78,9 +73,7 @@ onUnmounted(() => {
|
||||
<h2
|
||||
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
|
||||
>
|
||||
<template v-if="titlePrefixKey"
|
||||
>{{ t(titlePrefixKey, locale) }}
|
||||
</template>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h2>
|
||||
</dialog>
|
||||
@@ -3,10 +3,14 @@ import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { learningTutorials } from '../../data/learningTutorials'
|
||||
import {
|
||||
getTutorialPosterSrc,
|
||||
learningTutorials
|
||||
} from '../../data/learningTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import TutorialCard from '../common/TutorialCard.vue'
|
||||
import TutorialDetailDialog from '../common/TutorialDetailDialog.vue'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
import { ButtonMask } from '../ui/button-mask'
|
||||
import TutorialDetailDialog from './TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
@@ -26,13 +30,84 @@ const activeTutorial = () =>
|
||||
<ul
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li v-for="tutorial in learningTutorials" :key="tutorial.id">
|
||||
<TutorialCard
|
||||
:tutorial="tutorial"
|
||||
:locale="locale"
|
||||
title-prefix-key="learning.tutorials.titlePrefix"
|
||||
@play="activeTutorialId = tutorial.id"
|
||||
/>
|
||||
<li
|
||||
v-for="tutorial in learningTutorials"
|
||||
:key="tutorial.id"
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
|
||||
@click="activeTutorialId = tutorial.id"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white lg:size-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<ButtonMask
|
||||
v-if="tutorial.href"
|
||||
as="a"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</ButtonMask>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -40,7 +115,6 @@ const activeTutorial = () =>
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
title-prefix-key="learning.tutorials.titlePrefix"
|
||||
@close="activeTutorialId = null"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -6,14 +6,15 @@ export const badgeVariants = cva({
|
||||
variants: {
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
xs: 'px-2 py-0.5 text-[9px]'
|
||||
xs: 'px-2 py-0.5 text-[9px]',
|
||||
xxs: 'px-1.5 py-px text-[8px]'
|
||||
},
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
|
||||
accent:
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import HeroBackdrop01 from '../blocks/HeroBackdrop01.vue'
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = computed(() => getRoutes(locale))
|
||||
|
||||
const backdrop = {
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/vfx/vfx_hero.mp4'
|
||||
} as const
|
||||
|
||||
const mobileBackdrop = {
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/vfx/vfx_hero-mobile.mp4'
|
||||
} as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroBackdrop01
|
||||
tone="dark"
|
||||
:backdrop="backdrop"
|
||||
:mobile-backdrop="mobileBackdrop"
|
||||
:title="t('vfx.hero.title', locale)"
|
||||
:subtitle="t('vfx.hero.subtitle', locale)"
|
||||
>
|
||||
<div class="mt-10 flex flex-wrap gap-4">
|
||||
<Button as="a" :href="routes.contact">
|
||||
{{ t('vfx.cta.contactSales', locale) }}
|
||||
</Button>
|
||||
<Button as="a" :href="externalLinks.cloud" variant="outline">
|
||||
{{ t('vfx.hero.tryNow', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</HeroBackdrop01>
|
||||
</template>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { vfxTutorials } from '../../data/vfxTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import TutorialCard from '../common/TutorialCard.vue'
|
||||
import TutorialDetailDialog from '../common/TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeTutorialId = ref<string | null>(null)
|
||||
const activeTutorial = () =>
|
||||
vfxTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-6 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t('vfx.tutorials.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="mx-auto mb-12 max-w-3xl text-center text-base text-primary-comfy-canvas lg:mb-16 lg:text-lg"
|
||||
>
|
||||
{{ t('vfx.tutorials.description', locale) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li v-for="tutorial in vfxTutorials" :key="tutorial.id">
|
||||
<TutorialCard
|
||||
:tutorial="tutorial"
|
||||
:locale="locale"
|
||||
variant="overlay"
|
||||
@play="activeTutorialId = tutorial.id"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<TutorialDetailDialog
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
@close="activeTutorialId = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -14,7 +14,6 @@ const baseRoutes = {
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
vfx: '/learning/vfx',
|
||||
termsOfService: '/terms-of-service',
|
||||
enterpriseMsa: '/enterprise-msa',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { LogoItem } from '../components/blocks/LogosAll01.vue'
|
||||
|
||||
const names = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
'Netflix',
|
||||
'Nike',
|
||||
'Pixomondo',
|
||||
'Tencent',
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
export const clientLogos: readonly LogoItem[] = names.map((name) => ({
|
||||
src: `/icons/clients/${name}.svg`,
|
||||
alt: name
|
||||
}))
|
||||
@@ -1,10 +1,28 @@
|
||||
import type { Tutorial } from '../components/common/TutorialCard.vue'
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
|
||||
import type { LocalizedText, TranslationKey } from '../i18n/translations'
|
||||
|
||||
export interface LearningTutorial {
|
||||
id: string
|
||||
tags: readonly TranslationKey[]
|
||||
title: LocalizedText
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
caption?: readonly VideoTrack[]
|
||||
posterTime?: number
|
||||
}
|
||||
|
||||
const DEFAULT_POSTER_TIME_SECONDS = 1
|
||||
|
||||
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
|
||||
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
|
||||
|
||||
export const learningTutorials: readonly Tutorial[] = [
|
||||
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
|
||||
tutorial.poster
|
||||
? tutorial.poster
|
||||
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
|
||||
|
||||
export const learningTutorials: readonly LearningTutorial[] = [
|
||||
{
|
||||
id: 'cleanplate_walkthrough_v03',
|
||||
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
|
||||
|
||||
@@ -30,15 +30,23 @@ export type NavItem =
|
||||
label: string
|
||||
columns: NavColumn[]
|
||||
featured?: NavFeatured
|
||||
badge?: 'new'
|
||||
href?: never
|
||||
}
|
||||
| { label: string; href: string; columns?: never; featured?: never }
|
||||
| {
|
||||
label: string
|
||||
href: string
|
||||
badge?: 'new'
|
||||
columns?: never
|
||||
featured?: never
|
||||
}
|
||||
|
||||
export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
const routes = getRoutes(locale)
|
||||
return [
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
badge: 'new',
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
@@ -95,6 +103,7 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
|
||||
{
|
||||
label: t('nav.community', locale),
|
||||
badge: 'new',
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
|
||||
imageAlt: t('nav.featuredCommunityAlt', locale),
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { Tutorial } from '../components/common/TutorialCard.vue'
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
|
||||
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
|
||||
|
||||
const dougHoman: Tutorial['author'] = {
|
||||
name: 'Doug Homan',
|
||||
avatarSrc: 'https://media.comfy.org/website/authors/doug-hogan.jpeg'
|
||||
}
|
||||
|
||||
export const vfxTutorials: readonly Tutorial[] = [
|
||||
{
|
||||
id: 'skyreplacement_smaller_v06',
|
||||
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag],
|
||||
author: dougHoman
|
||||
},
|
||||
{
|
||||
id: 'cleanplate_walkthrough_v03',
|
||||
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_thumbnail.jpg',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
|
||||
tags: [partnerNodesTag, imageToVideoTag],
|
||||
author: dougHoman
|
||||
},
|
||||
{
|
||||
id: 'deaging_workflow_v03',
|
||||
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
|
||||
videoSrc: 'https://media.comfy.org/website/vfx/deaging_workflow_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/deaging_workflow_v03_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/deaging_workflow_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag],
|
||||
author: dougHoman
|
||||
},
|
||||
{
|
||||
id: 'frame_adjustments_demo_v03',
|
||||
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag],
|
||||
author: dougHoman
|
||||
},
|
||||
{
|
||||
id: 'mattes_and_utilities_v03',
|
||||
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/mattes_and_utilities_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/mattes_and_utilities_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=be0889296f65',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/mattes_and_utilities_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag],
|
||||
author: dougHoman
|
||||
},
|
||||
{
|
||||
id: 'seedance_demo_comfyui_v03',
|
||||
title: {
|
||||
en: 'Seedance Demo (ComfyUI)',
|
||||
'zh-CN': 'Seedance 演示(ComfyUI)'
|
||||
},
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/vfx/seedance_demo_comfyui_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/vfx/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/vfx/seedance_demo_comfyui_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag],
|
||||
author: dougHoman
|
||||
}
|
||||
] as const
|
||||
@@ -823,7 +823,7 @@ const translations = {
|
||||
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
|
||||
},
|
||||
'cloudNodes.meta.title': {
|
||||
en: 'Custom-node packs on Comfy Cloud — supported by default',
|
||||
en: 'Custom-node packs on Comfy Cloud - supported by default',
|
||||
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
|
||||
},
|
||||
'cloudNodes.meta.description': {
|
||||
@@ -1546,42 +1546,6 @@ const translations = {
|
||||
},
|
||||
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
|
||||
|
||||
// VfxHeroSection
|
||||
'vfx.hero.title': {
|
||||
en: 'ComfyUI for\nVisual Effects',
|
||||
'zh-CN': '面向视觉特效的\nComfyUI'
|
||||
},
|
||||
'vfx.hero.subtitle': {
|
||||
en: 'From ideation to production, ComfyUI gives VFX teams faster workflows and more controlled output — so what ships looks exactly like what you envisioned.',
|
||||
'zh-CN':
|
||||
'从创意到成片,ComfyUI 为视觉特效团队带来更快的工作流与更可控的产出——让最终交付与你的构想分毫不差。'
|
||||
},
|
||||
'vfx.hero.tryNow': {
|
||||
en: 'Try Now',
|
||||
'zh-CN': '立即体验'
|
||||
},
|
||||
|
||||
// VfxTutorialsSection
|
||||
'vfx.tutorials.heading': {
|
||||
en: 'See What’s Possible',
|
||||
'zh-CN': '见证无限可能'
|
||||
},
|
||||
'vfx.tutorials.description': {
|
||||
en: 'From concept, storyboarding to texture generation and compositing references — discover workflows built for every stage of VFX production. New workflows added regularly.',
|
||||
'zh-CN':
|
||||
'从概念设计、分镜脚本到纹理生成与合成参考——探索覆盖视觉特效制作各个环节的工作流。新工作流持续上新。'
|
||||
},
|
||||
|
||||
// VfxCallToActionSection
|
||||
'vfx.cta.heading': {
|
||||
en: 'Bring your ideas to life with ComfyUI, exactly as intended.',
|
||||
'zh-CN': '用 ComfyUI 将创意变为现实,一切尽如所想。'
|
||||
},
|
||||
'vfx.cta.contactSales': {
|
||||
en: 'Contact Sales',
|
||||
'zh-CN': '联系销售'
|
||||
},
|
||||
|
||||
// GalleryHeroSection
|
||||
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
|
||||
'gallery.heroTitle.before': {
|
||||
@@ -1881,8 +1845,8 @@ const translations = {
|
||||
|
||||
// MCP – Meta
|
||||
'mcp.meta.title': {
|
||||
en: 'Comfy MCP — Drive ComfyUI from any AI agent',
|
||||
'zh-CN': 'Comfy MCP — 让任何 AI 智能体驱动 ComfyUI'
|
||||
en: 'Comfy MCP - Drive ComfyUI from any AI agent',
|
||||
'zh-CN': 'Comfy MCP - 让任何 AI 智能体驱动 ComfyUI'
|
||||
},
|
||||
'mcp.meta.description': {
|
||||
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
|
||||
@@ -3519,8 +3483,8 @@ const translations = {
|
||||
},
|
||||
|
||||
'affiliate-terms.page.title': {
|
||||
en: 'Affiliate Terms — Comfy',
|
||||
'zh-CN': 'Affiliate Terms — Comfy'
|
||||
en: 'Affiliate Terms - Comfy',
|
||||
'zh-CN': 'Affiliate Terms - Comfy'
|
||||
},
|
||||
'affiliate-terms.page.description': {
|
||||
en: 'Comfy.org Affiliate Program Terms and Conditions.',
|
||||
@@ -3932,8 +3896,8 @@ const translations = {
|
||||
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
|
||||
},
|
||||
'enterprise-msa.page.title': {
|
||||
en: 'Enterprise MSA — Comfy',
|
||||
'zh-CN': 'Enterprise MSA — Comfy'
|
||||
en: 'Enterprise MSA - Comfy',
|
||||
'zh-CN': 'Enterprise MSA - Comfy'
|
||||
},
|
||||
'enterprise-msa.page.description': {
|
||||
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
|
||||
@@ -4397,8 +4361,8 @@ const translations = {
|
||||
|
||||
// Affiliate page (/affiliates) — head metadata
|
||||
'affiliate.page.title': {
|
||||
en: 'Comfy.org Affiliate Program — Become a Partner',
|
||||
'zh-CN': 'Comfy.org 联盟计划 — 成为合作伙伴'
|
||||
en: 'Comfy.org Affiliate Program - Become a Partner',
|
||||
'zh-CN': 'Comfy.org 联盟计划 - 成为合作伙伴'
|
||||
},
|
||||
'affiliate.page.description': {
|
||||
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
|
||||
@@ -4423,8 +4387,8 @@ const translations = {
|
||||
// Launches page (/launches) — head metadata
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.page.title': {
|
||||
en: 'ComfyUI Live Demo & Q&A — June 29 Launch Livestream',
|
||||
'zh-CN': 'ComfyUI 直播演示与问答 — 6 月 29 日发布直播'
|
||||
en: 'ComfyUI Live Demo & Q&A - June 29 Launch Livestream',
|
||||
'zh-CN': 'ComfyUI 直播演示与问答 - 6 月 29 日发布直播'
|
||||
},
|
||||
'launches.page.description': {
|
||||
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See what’s new across desktop, cloud, and community, and get your questions answered.',
|
||||
|
||||
@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="404 — Page Not Found — Comfy"
|
||||
title="404 - Page Not Found - Comfy"
|
||||
noindex>
|
||||
<div
|
||||
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">
|
||||
|
||||
@@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About Us — Comfy"
|
||||
title="About Us - Comfy"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
|
||||
@@ -42,7 +42,7 @@ const roles = itemListNode(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Careers — Comfy"
|
||||
title="Careers - Comfy"
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
|
||||
@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../components/common/ComingSoon.astro'
|
||||
---
|
||||
|
||||
<BaseLayout title="Case Studies — Comfy">
|
||||
<BaseLayout title="Case Studies - Comfy">
|
||||
<ComingSoon />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy Cloud — AI in the Cloud"
|
||||
title="Comfy Cloud - AI in the Cloud"
|
||||
description={t('cloud.hero.subtitle', 'en')}
|
||||
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Pricing — Comfy Cloud"
|
||||
title="Pricing - Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
|
||||
@@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Contact — Comfy"
|
||||
title="Contact - Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
|
||||
@@ -11,7 +11,7 @@ import { loadStories } from '../utils/loadStories'
|
||||
const stories = (await loadStories('en')).map(toCardProps)
|
||||
---
|
||||
|
||||
<BaseLayout title="Customer Stories — Comfy">
|
||||
<BaseLayout title="Customer Stories - Comfy">
|
||||
<HeroSection client:load />
|
||||
<StorySection stories={stories} />
|
||||
<FeedbackSection client:load />
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function getStaticPaths() {
|
||||
const { entry, next } = Astro.props
|
||||
---
|
||||
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`}>
|
||||
<BaseLayout title={`${entry.data.title} - Comfy`}>
|
||||
<DetailHeroSection
|
||||
label={entry.data.category}
|
||||
title={entry.data.title}
|
||||
|
||||
@@ -58,7 +58,7 @@ const learningResource: JsonLdNode = {
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
title={`${title} - Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
|
||||
@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../../components/common/ComingSoon.astro'
|
||||
---
|
||||
|
||||
<BaseLayout title="Demos — Comfy" description="Interactive demos and tutorials for ComfyUI.">
|
||||
<BaseLayout title="Demos - Comfy" description="Interactive demos and tutorials for ComfyUI.">
|
||||
<ComingSoon />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
title="Download Comfy Desktop - Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
|
||||
@@ -5,7 +5,7 @@ import GallerySection from '../components/gallery/GallerySection.vue'
|
||||
import ContactSection from '../components/gallery/ContactSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Gallery — Comfy">
|
||||
<BaseLayout title="Gallery - Comfy">
|
||||
<HeroSection />
|
||||
<GallerySection client:load />
|
||||
<ContactSection />
|
||||
|
||||
@@ -24,7 +24,7 @@ const { siteUrl } = pageContext(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
title="Comfy - Professional Control of Visual AI"
|
||||
description={t("hero.subtitle", "en")}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[
|
||||
|
||||
27
apps/website/src/pages/learning.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/learning/HeroSection.vue'
|
||||
import FeaturedWorkflowSection from '../components/learning/FeaturedWorkflowSection.vue'
|
||||
import TutorialsSection from '../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../components/common/CallToActionSection.vue'
|
||||
// import EventsSection from '../components/common/EventsSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
import { externalLinks } from '../config/routes'
|
||||
// import { learningEvents } from '../data/events'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout title="Learning - Comfy">
|
||||
<HeroSection client:load />
|
||||
<FeaturedWorkflowSection client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<CallToActionSection
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/vfx/HeroSection.vue'
|
||||
import TutorialsSection from '../../components/vfx/TutorialsSection.vue'
|
||||
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
|
||||
import CallToActionSection from '../../components/common/CallToActionSection.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout title="VFX — Comfy">
|
||||
<HeroSection client:load />
|
||||
<SocialProofBarSection client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<CallToActionSection
|
||||
headingKey="vfx.cta.heading"
|
||||
primaryLabelKey="vfx.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Models — Comfy"
|
||||
title="Models - Comfy"
|
||||
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
|
||||
>
|
||||
<ModelsHeroSection
|
||||
|
||||
@@ -103,7 +103,7 @@ const faqPage: JsonLdNode = {
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} — Comfy`}
|
||||
title={`${pageTitle} - Comfy`}
|
||||
description={pageDescription}
|
||||
ogImage={model.thumbnailUrl}
|
||||
mainEntityId={softwareId}
|
||||
|
||||
@@ -43,7 +43,7 @@ const dirLabel: Record<string, string> = {
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
title={`${title} - Comfy`}
|
||||
description={subtitle}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
|
||||
@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Payment Failed — Comfy"
|
||||
title="Payment Failed - Comfy"
|
||||
description="Your payment was not completed."
|
||||
noindex
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Payment Successful — Comfy"
|
||||
title="Payment Successful - Comfy"
|
||||
description="Your payment was processed successfully."
|
||||
noindex
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import HeroSection from '../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Privacy Policy — Comfy"
|
||||
title="Privacy Policy - Comfy"
|
||||
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
|
||||
noindex
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop Privacy Policy — Comfy"
|
||||
title="Desktop Privacy Policy - Comfy"
|
||||
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
|
||||
>
|
||||
<HeroSection title="Desktop Privacy Policy" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Terms of Service — Comfy"
|
||||
title="Terms of Service - Comfy"
|
||||
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
|
||||
noindex
|
||||
>
|
||||
|
||||
@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../components/common/ComingSoon.astro'
|
||||
---
|
||||
|
||||
<BaseLayout title="Videos — Comfy">
|
||||
<BaseLayout title="Videos - Comfy">
|
||||
<ComingSoon />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="关于我们 — Comfy"
|
||||
title="关于我们 - Comfy"
|
||||
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
|
||||
@@ -42,7 +42,7 @@ const roles = itemListNode(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="招聘 — Comfy"
|
||||
title="招聘 - Comfy"
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
|
||||
@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../../components/common/ComingSoon.astro'
|
||||
---
|
||||
|
||||
<BaseLayout title="案例研究 — Comfy">
|
||||
<BaseLayout title="案例研究 - Comfy">
|
||||
<ComingSoon />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { t } from '../../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy Cloud — 云端 AI"
|
||||
title="Comfy Cloud - 云端 AI"
|
||||
description={t('cloud.hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="定价 — Comfy Cloud"
|
||||
title="定价 - Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="联系我们 — Comfy"
|
||||
title="联系我们 - Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
|
||||
@@ -11,7 +11,7 @@ import { loadStories } from '../../utils/loadStories'
|
||||
const stories = (await loadStories('zh-CN')).map(toCardProps)
|
||||
---
|
||||
|
||||
<BaseLayout title="客户故事 — Comfy">
|
||||
<BaseLayout title="客户故事 - Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection stories={stories} locale="zh-CN" />
|
||||
<FeedbackSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function getStaticPaths() {
|
||||
const { entry, next } = Astro.props
|
||||
---
|
||||
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`}>
|
||||
<BaseLayout title={`${entry.data.title} - Comfy`}>
|
||||
<DetailHeroSection
|
||||
label={entry.data.category}
|
||||
title={entry.data.title}
|
||||
|
||||
@@ -58,7 +58,7 @@ const learningResource: JsonLdNode = {
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
title={`${title} - Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
|
||||
@@ -3,7 +3,7 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="演示 — Comfy" description="ComfyUI 的互动演示和教程。">
|
||||
<BaseLayout title="演示 - Comfy" description="ComfyUI 的互动演示和教程。">
|
||||
<section class="flex min-h-[60vh] items-center justify-center px-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light">
|
||||
|
||||
@@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
title="下载 Comfy 桌面版 - 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
|
||||
@@ -5,7 +5,7 @@ import GallerySection from '../../components/gallery/GallerySection.vue'
|
||||
import ContactSection from '../../components/gallery/ContactSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="作品集 — Comfy">
|
||||
<BaseLayout title="作品集 - Comfy">
|
||||
<HeroSection locale="zh-CN" />
|
||||
<GallerySection locale="zh-CN" client:load />
|
||||
<ContactSection locale="zh-CN" />
|
||||
|
||||
@@ -24,7 +24,7 @@ const { siteUrl } = pageContext(
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — 视觉 AI 的最强可控性"
|
||||
title="Comfy - 视觉 AI 的最强可控性"
|
||||
description={t('hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
|
||||
@@ -4,19 +4,19 @@ import HeroSection from '../../components/learning/HeroSection.vue'
|
||||
import FeaturedWorkflowSection from '../../components/learning/FeaturedWorkflowSection.vue'
|
||||
import TutorialsSection from '../../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../../components/common/CallToActionSection.vue'
|
||||
// import EventsSection from '../../components/common/EventsSection.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
// import { learningEvents } from '../../data/events'
|
||||
import EventsSection from '../../components/common/EventsSection.vue'
|
||||
import { getRoutes, externalLinks } from '../../config/routes'
|
||||
import { learningEvents } from '../../data/events'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout title="Learning — Comfy">
|
||||
<HeroSection client:load />
|
||||
<FeaturedWorkflowSection client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<BaseLayout title="学习 - Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<FeaturedWorkflowSection locale="zh-CN" client:visible />
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../../components/learning/HeroSection.vue'
|
||||
import FeaturedWorkflowSection from '../../../components/learning/FeaturedWorkflowSection.vue'
|
||||
import TutorialsSection from '../../../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../../../components/common/CallToActionSection.vue'
|
||||
import EventsSection from '../../../components/common/EventsSection.vue'
|
||||
import { getRoutes, externalLinks } from '../../../config/routes'
|
||||
import { learningEvents } from '../../../data/events'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout title="学习 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<FeaturedWorkflowSection locale="zh-CN" client:visible />
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../../components/vfx/HeroSection.vue'
|
||||
import TutorialsSection from '../../../components/vfx/TutorialsSection.vue'
|
||||
import SocialProofBarSection from '../../../components/common/SocialProofBarSection.vue'
|
||||
import CallToActionSection from '../../../components/common/CallToActionSection.vue'
|
||||
import { getRoutes, externalLinks } from '../../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout title="视效 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection client:visible />
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="vfx.cta.heading"
|
||||
primaryLabelKey="vfx.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="模型 — Comfy"
|
||||
title="模型 - Comfy"
|
||||
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
|
||||
>
|
||||
<ModelsHeroSection
|
||||
|
||||
@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="支付失败 — Comfy" description="您的支付未能完成。" noindex>
|
||||
<BaseLayout title="支付失败 - Comfy" description="您的支付未能完成。" noindex>
|
||||
<PaymentStatusSection status="failed" locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="支付成功 — Comfy" description="您的支付已成功完成。" noindex>
|
||||
<BaseLayout title="支付成功 - Comfy" description="您的支付已成功完成。" noindex>
|
||||
<PaymentStatusSection status="success" locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="隐私政策 — Comfy"
|
||||
title="隐私政策 - Comfy"
|
||||
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
|
||||
noindex
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import HeroSection from '../../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop 隐私政策 — Comfy"
|
||||
title="Desktop 隐私政策 - Comfy"
|
||||
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
|
||||
>
|
||||
<HeroSection title="Desktop 隐私政策" />
|
||||
|
||||
@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../../components/common/ComingSoon.astro'
|
||||
---
|
||||
|
||||
<BaseLayout title="视频 — Comfy">
|
||||
<BaseLayout title="视频 - Comfy">
|
||||
<ComingSoon />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -61,7 +61,12 @@ const { drop, locale } = defineProps<{
|
||||
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
|
||||
<Badge
|
||||
v-if="drop.badge"
|
||||
size="xs"
|
||||
variant="accent"
|
||||
class="absolute top-6 left-8"
|
||||
>
|
||||
{{ drop.badge[locale] }}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ModelLibrarySidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
NodeLibrarySidebarTabV2,
|
||||
SidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
@@ -70,6 +71,7 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _appsTab: SidebarTab | null = null
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
@@ -104,6 +106,11 @@ class ComfyMenu {
|
||||
return this._nodeLibraryTabV2
|
||||
}
|
||||
|
||||
get appsTab() {
|
||||
this._appsTab ??= new SidebarTab(this.page, 'apps')
|
||||
return this._appsTab
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class SidebarTab {
|
||||
export class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
|
||||
|
||||
40
browser_tests/fixtures/components/WorkflowActionsDropdown.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* The graph/app view-mode toggle and its workflow actions dropdown.
|
||||
* A separate instance mounts in each host - the subgraph breadcrumb (graph
|
||||
* mode) and the app-mode center panel - and unmounts as the mode flips.
|
||||
*/
|
||||
export class WorkflowActionsDropdown {
|
||||
/** The segmented graph/app toggle hosting the workflow actions trigger. */
|
||||
public readonly viewModeToggle: Locator
|
||||
/** The active segment; opens the workflow actions menu. */
|
||||
public readonly trigger: Locator
|
||||
/** The inactive segment that switches into app mode. */
|
||||
public readonly enterAppModeSegment: Locator
|
||||
/** The inactive segment that switches back to the node graph. */
|
||||
public readonly enterGraphSegment: Locator
|
||||
/** The workflow actions dropdown menu. */
|
||||
public readonly menu: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.viewModeToggle = page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
this.trigger = this.triggerIn(this.viewModeToggle)
|
||||
this.enterAppModeSegment = this.viewModeToggle.getByRole('button', {
|
||||
name: 'Enter app mode'
|
||||
})
|
||||
this.enterGraphSegment = this.viewModeToggle.getByRole('button', {
|
||||
name: 'Enter node graph'
|
||||
})
|
||||
this.menu = page.getByRole('menu', { name: 'Workflow actions' })
|
||||
}
|
||||
|
||||
/** The trigger as rendered inside a specific mode's host. */
|
||||
triggerIn(host: Locator): Locator {
|
||||
return host.getByRole('button', { name: 'Workflow actions' })
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
|
||||
import { WorkflowActionsDropdown } from '@e2e/fixtures/components/WorkflowActionsDropdown'
|
||||
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
|
||||
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||
@@ -19,6 +20,7 @@ export class AppModeHelper {
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
readonly workflowActions: WorkflowActionsDropdown
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
public readonly connectOutputPopover: Locator
|
||||
@@ -77,6 +79,7 @@ export class AppModeHelper {
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
this.workflowActions = new WorkflowActionsDropdown(comfyPage.page)
|
||||
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputPopover
|
||||
@@ -185,10 +188,7 @@ export class AppModeHelper {
|
||||
.waitFor({ state: 'hidden', timeout: 5000 })
|
||||
.catch(() => {})
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
.first()
|
||||
.click()
|
||||
await this.workflowActions.trigger.click()
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: /Build app|Edit app/ })
|
||||
.click()
|
||||
|
||||
@@ -239,6 +239,9 @@ export const TestIds = {
|
||||
renameInput: 'subgraph-breadcrumb-rename-input',
|
||||
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
|
||||
},
|
||||
workflowActions: {
|
||||
viewModeToggle: 'view-mode-toggle'
|
||||
},
|
||||
templates: {
|
||||
content: 'template-workflows-content',
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
@@ -276,6 +279,7 @@ export const TestIds = {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
load3d: {
|
||||
categoryMenu: 'load3d-category-menu',
|
||||
recordingDuration: 'load3d-recording-duration'
|
||||
},
|
||||
load3dViewer: {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
|
||||
|
||||
test.describe('App mode usage', () => {
|
||||
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
|
||||
const { centerPanel } = comfyPage.appMode
|
||||
@@ -137,6 +142,117 @@ test.describe('App mode usage', () => {
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
|
||||
})
|
||||
|
||||
test('Shows a single side toolbar per mode, filtered to assets + apps in app mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
|
||||
|
||||
await test.step('Graph mode shows the full toolbar', async () => {
|
||||
await expect(sideToolbar).toHaveCount(1)
|
||||
await expect(nodeLibraryTab.tabButton).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('App mode shows only assets + apps', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
await expect(sideToolbar).toHaveCount(1)
|
||||
await expect(assetsTab.tabButton).toBeVisible()
|
||||
await expect(appsTab.tabButton).toBeVisible()
|
||||
await expect(nodeLibraryTab.tabButton).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Workflow actions menu keeps the same position across graph/app mode', async ({
|
||||
comfyPage,
|
||||
subgraphBreadcrumb
|
||||
}) => {
|
||||
const { workflowActions, centerPanel } = comfyPage.appMode
|
||||
|
||||
// Toggling graph<->app mode happens from this control, so it must not move
|
||||
// out from under the cursor as the mode flips.
|
||||
const graphActions = workflowActions.triggerIn(
|
||||
subgraphBreadcrumb.panel.root
|
||||
)
|
||||
await expect(graphActions).toBeVisible()
|
||||
const graphBox = await graphActions.boundingBox()
|
||||
|
||||
expect(graphBox).not.toBeNull()
|
||||
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(centerPanel).toBeVisible()
|
||||
|
||||
const appActions = workflowActions.triggerIn(centerPanel)
|
||||
await expect(appActions).toBeVisible()
|
||||
|
||||
// The toggle segments reorder (morph) as the mode flips, so poll until the
|
||||
// active control settles at the same x it occupied in graph mode.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await appActions.boundingBox()
|
||||
return box ? Math.abs(box.x - graphBox!.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { workflowActions } = comfyPage.appMode
|
||||
await expect(workflowActions.viewModeToggle).toBeVisible()
|
||||
|
||||
await workflowActions.enterAppModeSegment.click()
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// The inactive segment switches mode; it must not also open the actions menu.
|
||||
await expect(workflowActions.menu).toBeHidden()
|
||||
await expect(workflowActions.viewModeToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode via keyboard without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { workflowActions } = comfyPage.appMode
|
||||
await workflowActions.enterAppModeSegment.focus()
|
||||
await workflowActions.enterAppModeSegment.press('Enter')
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
await expect(workflowActions.menu).toBeHidden()
|
||||
await expect(workflowActions.trigger).toBeFocused()
|
||||
})
|
||||
|
||||
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await expect(toggle).toBeHidden()
|
||||
await expect(comfyPage.appMode.centerPanel).toBeHidden()
|
||||
|
||||
await comfyPage.appMode.footer.exitButton.click()
|
||||
// Exiting the builder lands in graph mode: the app-mode-only center panel
|
||||
// stays hidden while the graph-mode toggle host re-mounts and the toggle
|
||||
// re-appears.
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(comfyPage.appMode.centerPanel).toBeHidden()
|
||||
})
|
||||
|
||||
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
|
||||
await comfyPage.menu.assetsTab.tabButton.click()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -11,39 +11,47 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
get menuButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /show menu/i })
|
||||
return this.node.getByTestId(TestIds.load3d.categoryMenu)
|
||||
}
|
||||
|
||||
private get menuPanel(): Locator {
|
||||
return this.node.page().getByRole('dialog')
|
||||
}
|
||||
|
||||
get recordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /start recording/i })
|
||||
return this.node.getByRole('button', { name: 'Record', exact: true })
|
||||
}
|
||||
|
||||
get stopRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /stop recording/i })
|
||||
}
|
||||
|
||||
get exportRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /export recording/i })
|
||||
}
|
||||
|
||||
get clearRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /clear recording/i })
|
||||
}
|
||||
|
||||
get recordingDuration(): Locator {
|
||||
get recordingMenuButton(): Locator {
|
||||
return this.node.getByTestId(TestIds.load3d.recordingDuration)
|
||||
}
|
||||
|
||||
get downloadRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
|
||||
}
|
||||
|
||||
get startNewRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
|
||||
}
|
||||
|
||||
get deleteRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
|
||||
}
|
||||
|
||||
get gridToggleButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /show grid/i })
|
||||
}
|
||||
|
||||
get uploadBackgroundImageButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /upload background image/i })
|
||||
return this.node.getByRole('button', { name: 'BG Image' })
|
||||
}
|
||||
|
||||
get removeBackgroundImageButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /remove background image/i })
|
||||
return this.node.getByRole('button', { name: 'Remove BG' })
|
||||
}
|
||||
|
||||
get panoramaModeButton(): Locator {
|
||||
@@ -54,6 +62,10 @@ export class Load3DHelper {
|
||||
return this.node.locator('input[type="color"]')
|
||||
}
|
||||
|
||||
get exportButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Export', exact: true })
|
||||
}
|
||||
|
||||
get openViewerButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /open in 3d viewer/i })
|
||||
}
|
||||
@@ -63,11 +75,15 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
getMenuCategory(name: string): Locator {
|
||||
return this.node.getByText(name, { exact: true })
|
||||
return this.menuPanel.getByRole('button', { name, exact: true })
|
||||
}
|
||||
|
||||
get gizmoToggleButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Gizmo' })
|
||||
// The category chip is also named "Gizmo" once that category is active;
|
||||
// only the toggle carries aria-pressed.
|
||||
return this.node
|
||||
.getByRole('button', { name: 'Gizmo' })
|
||||
.and(this.node.locator('[aria-pressed]'))
|
||||
}
|
||||
|
||||
get gizmoTranslateButton(): Locator {
|
||||
@@ -83,13 +99,17 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
get gizmoResetButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Reset Transform' })
|
||||
return this.node.getByRole('button', { name: 'Reset', exact: true })
|
||||
}
|
||||
|
||||
async openMenu(): Promise<void> {
|
||||
await this.menuButton.click()
|
||||
}
|
||||
|
||||
async openRecordingMenu(): Promise<void> {
|
||||
await this.recordingMenuButton.click()
|
||||
}
|
||||
|
||||
async openGizmoCategory(): Promise<void> {
|
||||
await this.openMenu()
|
||||
await this.getMenuCategory('Gizmo').click()
|
||||
|
||||