Compare commits
1 Commits
jaeone/err
...
feat/home-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb48a8718 |
@@ -63,14 +63,3 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
|
||||
- path: 'src/lib/litegraph/**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
|
||||
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.
|
||||
|
||||
3
.github/workflows/ci-website-build.yaml
vendored
@@ -40,6 +40,3 @@ jobs:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Validate JSON-LD structured data
|
||||
run: pnpm --filter @comfyorg/website validate:jsonld
|
||||
|
||||
4
.github/workflows/cla.yml
vendored
@@ -38,11 +38,9 @@ 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 // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // 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,49 +278,32 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# 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
|
||||
# Create backport branch
|
||||
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
|
||||
|
||||
# Try cherry-pick
|
||||
if git cherry-pick "${MERGE_COMMIT}"; then
|
||||
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
|
||||
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
|
||||
git push --force-with-lease origin "${BACKPORT_BRANCH}"
|
||||
else
|
||||
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
|
||||
git push origin "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
# Return to main (keep the branch, we need it for PR)
|
||||
git checkout main || git checkout -f main
|
||||
git checkout main
|
||||
else
|
||||
# Get conflict info
|
||||
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
|
||||
git cherry-pick --abort || true
|
||||
git cherry-pick --abort
|
||||
|
||||
echo "::error::Cherry-pick failed due to conflicts"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
|
||||
|
||||
# Clean up the failed branch
|
||||
git checkout main || git checkout -f main
|
||||
git branch -D "${BACKPORT_BRANCH}" || true
|
||||
git checkout main
|
||||
git branch -D "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
@@ -401,10 +384,6 @@ 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>
|
||||
|
||||
@@ -437,37 +416,19 @@ 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
|
||||
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
|
||||
|
||||
elif [ "${reason}" = "already-exists" ]; then
|
||||
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."
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
|
||||
|
||||
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
|
||||
@@ -483,10 +444,10 @@ jobs:
|
||||
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
|
||||
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
|
||||
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -23,10 +23,6 @@ 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:
|
||||
@@ -53,15 +49,12 @@ 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
|
||||
@@ -110,7 +103,6 @@ jobs:
|
||||
working-directory: frontend
|
||||
env:
|
||||
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -76,14 +76,10 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
|
||||
'@type': string
|
||||
mainEntity?: unknown[]
|
||||
}[]
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
|
||||
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
|
||||
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { learningTutorials } from '../src/data/learningTutorials'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
|
||||
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
|
||||
|
||||
test.describe('Learning page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learning')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Learning - Comfy')
|
||||
})
|
||||
|
||||
test('hero headline references ComfyUI', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
|
||||
await expect(heading).toContainText('ComfyUI')
|
||||
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
|
||||
})
|
||||
|
||||
test('featured workflow section shows title and author', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.featured.title', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(t('learning.featured.author', 'en'))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders every tutorial from the data source', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
for (const tutorial of learningTutorials) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(tutorial.title.en, 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('tutorials with a workflow link expose an external Try Workflow link', async ({
|
||||
page
|
||||
}) => {
|
||||
const linkedTutorials = learningTutorials.filter(
|
||||
(tutorial) => tutorial.href
|
||||
)
|
||||
const workflowLinks = page.getByRole('link', {
|
||||
name: t('cta.tryWorkflow', 'en')
|
||||
})
|
||||
const hrefs = await workflowLinks.evaluateAll((links) =>
|
||||
links.map((link) => link.getAttribute('href'))
|
||||
)
|
||||
for (const tutorial of linkedTutorials) {
|
||||
expect(hrefs).toContain(tutorial.href)
|
||||
}
|
||||
})
|
||||
|
||||
test('call to action links to contact sales', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.cta.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
|
||||
).toHaveAttribute('href', '/contact')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning tutorial dialog', () => {
|
||||
test('opens a tutorial video and dismisses via the close button', async ({
|
||||
page
|
||||
}) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
// TutorialsSection is hydrated via `client:visible`; retry the click until
|
||||
// Vue responds by opening the dialog.
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: t('gallery.detail.close', 'en') })
|
||||
.click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('dismisses the dialog with the Escape key', async ({ page }) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
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.getByRole('heading', { level: 1 })).toContainText(
|
||||
/[一-鿿]/
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'zh-CN'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const [firstTutorial] = learningTutorials
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -31,26 +31,6 @@ 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')
|
||||
@@ -137,27 +117,6 @@ 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: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
@@ -17,8 +17,7 @@
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts",
|
||||
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
BIN
apps/website/public/images/hero/renders/render-01.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-02.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-03.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
apps/website/public/images/hero/renders/render-04.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/website/public/images/hero/renders/render-05.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/website/public/images/hero/renders/render-06.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-07.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/website/public/images/hero/renders/render-08.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-09.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/website/public/images/hero/renders/render-10.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/website/public/images/hero/renders/render-11.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/website/public/images/hero/renders/render-12.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-13.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/website/public/images/hero/renders/render-14.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
apps/website/public/images/hero/renders/render-15.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-16.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-17.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
apps/website/public/images/hero/renders/render-18.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-19.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/website/public/images/hero/renders/render-20.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-21.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-22.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
apps/website/public/images/hero/renders/render-23.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-24.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-25.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/website/public/images/hero/renders/render-26.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/website/public/images/hero/renders/render-27.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/website/public/images/hero/renders/render-28.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-29.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/website/public/images/hero/renders/render-30.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/website/public/images/hero/renders/render-31.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/website/public/images/hero/renders/render-32.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/website/public/images/hero/renders/render-33.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/website/public/images/hero/renders/render-34.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/website/public/images/hero/renders/render-35.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/website/public/images/hero/renders/render-36.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-37.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/website/public/images/hero/renders/render-38.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/website/public/images/hero/renders/render-39.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-40.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/website/public/images/hero/renders/render-41.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/website/public/images/hero/renders/render-42.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/website/public/images/hero/renders/render-43.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/website/public/images/hero/renders/render-44.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-45.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/website/public/images/hero/renders/render-46.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/website/public/images/hero/renders/render-47.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/website/public/images/hero/renders/render-48.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
apps/website/public/images/hero/renders/render-49.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-50.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
@@ -1,129 +0,0 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { collectGraphIds } from '../src/utils/jsonLd'
|
||||
|
||||
const DIST_DIR = join(process.cwd(), 'dist')
|
||||
const JSON_LD_BLOCK =
|
||||
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function htmlFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((entry) => entry.endsWith('.html'))
|
||||
.map((entry) => join(dir, entry))
|
||||
}
|
||||
|
||||
function typesOf(node: Record<string, unknown>): string[] {
|
||||
const type = node['@type']
|
||||
if (typeof type === 'string') return [type]
|
||||
if (Array.isArray(type)) {
|
||||
return type.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function hasValidPrice(node: Record<string, unknown>): boolean {
|
||||
const price = node.price
|
||||
const priceStr = price == null ? '' : String(price).trim()
|
||||
return priceStr !== '' && !Number.isNaN(Number(priceStr))
|
||||
}
|
||||
|
||||
function checkHonesty(
|
||||
value: unknown,
|
||||
file: string,
|
||||
violations: Violation[]
|
||||
): void {
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (!node || typeof node !== 'object') return
|
||||
const record = node as Record<string, unknown>
|
||||
const types = typesOf(record)
|
||||
if (types.includes('Review') || types.includes('AggregateRating')) {
|
||||
violations.push({
|
||||
file,
|
||||
message: `dishonest node type ${types.join('/')}`
|
||||
})
|
||||
}
|
||||
if ('aggregateRating' in record || 'review' in record) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'node carries a review/aggregateRating'
|
||||
})
|
||||
}
|
||||
if (
|
||||
types.includes('Offer') &&
|
||||
(!hasValidPrice(record) || !record.priceCurrency)
|
||||
) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'Offer missing priceCurrency or a concrete price'
|
||||
})
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
}
|
||||
|
||||
function validateFile(file: string): Violation[] {
|
||||
const html = readFileSync(file, 'utf8')
|
||||
const violations: Violation[] = []
|
||||
const definedIds = new Set<string>()
|
||||
const referencedIds: string[] = []
|
||||
|
||||
for (const match of html.matchAll(JSON_LD_BLOCK)) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(match[1])
|
||||
} catch (error) {
|
||||
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
checkHonesty(parsed, file, violations)
|
||||
const { defined, references } = collectGraphIds(parsed)
|
||||
defined.forEach((id) => definedIds.add(id))
|
||||
referencedIds.push(...references)
|
||||
}
|
||||
|
||||
for (const id of referencedIds) {
|
||||
if (!definedIds.has(id)) {
|
||||
violations.push({ file, message: `unresolved @id reference: ${id}` })
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = htmlFiles(DIST_DIR)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(
|
||||
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const violations = files.flatMap(validateFile)
|
||||
if (violations.length > 0) {
|
||||
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
|
||||
for (const { file, message } of violations) {
|
||||
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`JSON-LD validation passed across ${files.length} page(s).\n`
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import HeroBackdrop01 from './HeroBackdrop01.vue'
|
||||
|
||||
const sampleImage =
|
||||
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
|
||||
|
||||
const meta: Meta<typeof HeroBackdrop01> = {
|
||||
title: 'Website/Blocks/HeroBackdrop01',
|
||||
component: HeroBackdrop01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
|
||||
title: 'Build anything\nwith ComfyUI',
|
||||
subtitle:
|
||||
'A powerful, modular visual interface for building and running AI workflows.'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithBadge: Story = {
|
||||
args: {
|
||||
badgeText: 'New'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFootnote: Story = {
|
||||
args: {
|
||||
footnote: 'Available on Windows, macOS, and Linux.'
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBackdrop: Story = {
|
||||
args: {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
|
||||
type Backdrop =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
backdrop,
|
||||
mobileBackdrop,
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
mobileBackdrop?: Backdrop
|
||||
badgeText?: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// 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())
|
||||
|
||||
// Removing the reactive `autoplay` attribute only suppresses the *initial*
|
||||
// play; it can't pause a video the browser has already started. That is
|
||||
// exactly the SSR case: the server renders `autoplay` (it can't read the
|
||||
// client's motion preference), the browser begins playback on parse, and the
|
||||
// post-hydration attribute removal is too late. Pause on mount so
|
||||
// reduced-motion users get the poster frame instead of a looping video.
|
||||
const pauseIfReduced = (el: unknown) => {
|
||||
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
|
||||
}
|
||||
|
||||
// On mobile the backdrop is an in-flow rounded card above the content; on
|
||||
// desktop it is the full-bleed background behind it. A single element serves
|
||||
// both roles via responsive classes — mobileBackdrop only swaps the source.
|
||||
const sharedBackdropClass =
|
||||
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
|
||||
|
||||
// When both breakpoints use images, serve them from a single responsive <img>
|
||||
// so the browser fetches only the source matching the viewport. Two
|
||||
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
|
||||
// does not stop the fetch), doubling the high-priority load on an
|
||||
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
|
||||
// way and fall back to breakpoint-toggled layers below.
|
||||
const responsiveImage = computed(() => {
|
||||
if (backdrop?.type !== 'image') return null
|
||||
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
|
||||
const base = mobileBackdrop ?? backdrop
|
||||
return {
|
||||
src: base.src,
|
||||
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
|
||||
// Larger-viewport source; omitted when one image serves both breakpoints.
|
||||
desktopSrc: mobileBackdrop ? backdrop.src : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
|
||||
const backdropLayers = computed(() => {
|
||||
if (!backdrop) return []
|
||||
if (mobileBackdrop) {
|
||||
return [
|
||||
{
|
||||
backdrop: mobileBackdrop,
|
||||
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
|
||||
},
|
||||
{
|
||||
backdrop,
|
||||
class: 'absolute inset-0 hidden size-full object-cover lg:block'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [{ backdrop, class: sharedBackdropClass }]
|
||||
})
|
||||
|
||||
const scrimShape = 'farthest-side at 50% 50%'
|
||||
const scrimStyle = {
|
||||
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
|
||||
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
|
||||
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-3xl">
|
||||
<slot name="backdrop">
|
||||
<picture v-if="responsiveImage" class="contents">
|
||||
<source
|
||||
v-if="responsiveImage.desktopSrc"
|
||||
:srcset="responsiveImage.desktopSrc"
|
||||
media="(min-width: 1024px)"
|
||||
/>
|
||||
<img
|
||||
:src="responsiveImage.src"
|
||||
:alt="responsiveImage.alt"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="sharedBackdropClass"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<template v-else>
|
||||
<template v-for="(layer, i) in backdropLayers" :key="i">
|
||||
<video
|
||||
v-if="layer.backdrop.type === 'video'"
|
||||
:ref="pauseIfReduced"
|
||||
:src="layer.backdrop.src"
|
||||
:poster="layer.backdrop.poster"
|
||||
:aria-label="layer.backdrop.alt"
|
||||
:aria-hidden="layer.backdrop.alt ? undefined : true"
|
||||
:autoplay="!reduceMotion"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:class="layer.class"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="layer.backdrop.src"
|
||||
:alt="layer.backdrop.alt ?? ''"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="layer.class"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
:style="scrimStyle"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<ProductHeroBadge
|
||||
v-if="badgeText"
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -16,7 +16,6 @@ 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)
|
||||
@@ -43,10 +42,7 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
<NavigationMenuTrigger
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
>
|
||||
<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>
|
||||
{{ navItem.label }}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
|
||||
<ul class="flex w-max gap-16">
|
||||
|
||||
@@ -8,7 +8,6 @@ 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'
|
||||
@@ -97,8 +96,7 @@ onUnmounted(() => {
|
||||
:href="item.columns ? undefined : item.href"
|
||||
@click="item.columns && (activeSection = item.label)"
|
||||
>
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
|
||||
{{ item.label }}
|
||||
<template #append>
|
||||
<ChevronRight class="size-7" />
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<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 NewBadge from './NewBadge.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
</script>
|
||||
@@ -11,7 +12,9 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
<template>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
|
||||
<Badge v-if="item.badge" size="xs" variant="accent">
|
||||
{{ t('nav.badgeNew', locale) }}
|
||||
</Badge>
|
||||
<ArrowUpRight
|
||||
v-if="item.external"
|
||||
class="text-primary-comfy-yellow size-4"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<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,12 +0,0 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
@@ -86,7 +86,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
{ label: t('footer.about', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
|
||||
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
|
||||
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { localizeHref } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
@@ -16,7 +15,8 @@ const {
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
75
apps/website/src/components/home/HeroHeadline.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en', compact = false } = defineProps<{
|
||||
locale?: Locale
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const lines = computed(() => t('hero.title', locale).split('\n'))
|
||||
|
||||
const size = computed(() => (compact ? 'text-3xl sm:text-4xl' : 'text-5xl'))
|
||||
|
||||
const lineGap = computed(() => (compact ? '-mt-2' : 'mt-2'))
|
||||
|
||||
const pill =
|
||||
'inline-block rounded-2xl px-5 py-2 font-formula-narrow leading-none font-semibold uppercase'
|
||||
|
||||
// PP Formula Narrow sits high in its em box; nudge the glyphs down so they read
|
||||
// optically centered inside the highlighter block.
|
||||
const inner = 'relative top-[0.06em] inline-block'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h1 class="flex flex-col items-center">
|
||||
<span
|
||||
:class="
|
||||
cn(pill, size, 'bg-primary-comfy-yellow text-primary-comfy-ink')
|
||||
"
|
||||
>
|
||||
<span :class="inner">{{ lines[0] }}</span>
|
||||
</span>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
pill,
|
||||
size,
|
||||
'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
lineGap
|
||||
)
|
||||
"
|
||||
>
|
||||
<span :class="inner">{{ lines[1] }}</span>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'max-w-md text-primary-comfy-canvas',
|
||||
compact ? 'mt-5 text-sm/relaxed' : 'mt-8 text-base'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<BrandButton
|
||||
:href="externalLinks.cloud"
|
||||
target="_blank"
|
||||
variant="outline"
|
||||
size="nav"
|
||||
:class="cn('uppercase', compact ? 'mt-5' : 'mt-7')"
|
||||
>
|
||||
{{ t('hero.cta.cloud', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</template>
|
||||
38
apps/website/src/components/home/HeroNodeWidgets.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, Minus, Plus } from '@lucide/vue'
|
||||
|
||||
import type { NodeWidget } from './heroWorkflowGraph'
|
||||
|
||||
const { widgets } = defineProps<{ widgets: NodeWidget[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="widget in widgets"
|
||||
:key="widget.name"
|
||||
class="bg-hero-node-inset flex h-7 items-center justify-between gap-2 rounded-lg px-2.5 text-xs"
|
||||
>
|
||||
<template v-if="widget.kind === 'number'">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Minus class="size-3 shrink-0 text-white/30" />
|
||||
<span class="truncate text-white/40">{{ widget.name }}</span>
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center gap-2 text-white/80">
|
||||
<span class="tabular-nums">{{ widget.value }}</span>
|
||||
<Plus class="size-3 text-white/30" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate text-white/40">{{ widget.name }}</span>
|
||||
<span class="flex min-w-0 items-center gap-1 text-white/80">
|
||||
<span class="truncate">{{ widget.value }}</span>
|
||||
<ChevronDown
|
||||
v-if="widget.kind === 'combo'"
|
||||
class="size-3 shrink-0 text-white/35"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,55 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import HeroWorkflow from './HeroWorkflow.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { useHeroLogo } from '../../composables/useHeroLogo'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logoContainer = ref<HTMLElement>()
|
||||
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
>
|
||||
<div
|
||||
ref="logoContainer"
|
||||
class="relative flex aspect-square w-full flex-1 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-show="!logoLoaded"
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
|
||||
alt="Comfy logo"
|
||||
class="w-3/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 px-6 py-12 lg:px-16">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line lg:text-6xl"
|
||||
>
|
||||
{{ t('hero.title', locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-8 max-w-lg text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
|
||||
>
|
||||
{{ t('hero.runFirstWorkflow', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
<section class="hero-dot-grid relative overflow-hidden bg-primary-comfy-ink">
|
||||
<HeroWorkflow :locale />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
344
apps/website/src/components/home/HeroWorkflow.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import HeroHeadline from './HeroHeadline.vue'
|
||||
import HeroNodeWidgets from './HeroNodeWidgets.vue'
|
||||
import HeroWorkflowNode from './HeroWorkflowNode.vue'
|
||||
import HeroWorkflowOutput from './HeroWorkflowOutput.vue'
|
||||
import {
|
||||
NODE_TITLE_KEYS,
|
||||
NODE_W,
|
||||
STAGE_H,
|
||||
STAGE_W,
|
||||
clampNodePosition,
|
||||
computeWires,
|
||||
homePositions,
|
||||
nodeWidgets
|
||||
} from './heroWorkflowGraph'
|
||||
import type {
|
||||
NodeWidget,
|
||||
Point,
|
||||
Rect,
|
||||
WorkflowNodeId
|
||||
} from './heroWorkflowGraph'
|
||||
import { useHeroWorkflowRun } from './useHeroWorkflowRun'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const run = useHeroWorkflowRun()
|
||||
const { activeNode, nodeProgress, phase, seed, totalProgress } = run
|
||||
|
||||
const NODE_IDS: WorkflowNodeId[] = [
|
||||
'model',
|
||||
'clip',
|
||||
'vae',
|
||||
'lora',
|
||||
'seed',
|
||||
'output'
|
||||
]
|
||||
|
||||
const percent = computed(() => Math.round(totalProgress.value * 100))
|
||||
|
||||
function widgetsFor(id: WorkflowNodeId): NodeWidget[] {
|
||||
if (id === 'seed') {
|
||||
return [
|
||||
{ name: 'seed', value: String(seed.value), kind: 'number' },
|
||||
{ name: 'control_after_generate', value: 'randomize', kind: 'combo' }
|
||||
]
|
||||
}
|
||||
return nodeWidgets[id] ?? []
|
||||
}
|
||||
|
||||
// The desktop graph is authored in a fixed design coordinate space and scaled
|
||||
// as a single unit to fit the viewport width, so the whole composition stays on
|
||||
// screen at every size. Node positions are live state so they can be dragged;
|
||||
// widths are fixed per node and heights are measured once for wiring.
|
||||
const MAX_SCALE = 1.3
|
||||
|
||||
const positions = ref<Record<WorkflowNodeId, Point>>(
|
||||
structuredClone(homePositions)
|
||||
)
|
||||
|
||||
const frameRef = ref<HTMLElement>()
|
||||
const stageRef = ref<HTMLElement>()
|
||||
const scale = ref(1)
|
||||
const heights = ref<Record<string, number>>({})
|
||||
|
||||
// Heights are read from layout offsets (not getBoundingClientRect) so they stay
|
||||
// in unscaled design coordinates regardless of the stage's scale transform.
|
||||
function measureHeights() {
|
||||
const stage = stageRef.value
|
||||
if (!stage) return
|
||||
const next: Record<string, number> = {}
|
||||
stage.querySelectorAll<HTMLElement>('[data-node]').forEach((el) => {
|
||||
next[el.dataset.node ?? ''] = el.offsetHeight
|
||||
})
|
||||
heights.value = next
|
||||
}
|
||||
|
||||
function updateScale() {
|
||||
const width = frameRef.value?.clientWidth ?? STAGE_W
|
||||
scale.value = Math.min(width / STAGE_W, MAX_SCALE)
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
updateScale()
|
||||
measureHeights()
|
||||
}
|
||||
|
||||
useResizeObserver(frameRef, refresh)
|
||||
|
||||
const stageStyle = computed(() => ({
|
||||
width: `${STAGE_W}px`,
|
||||
height: `${STAGE_H}px`,
|
||||
transform: `translateX(-50%) scale(${scale.value})`
|
||||
}))
|
||||
|
||||
function nodeStyle(id: WorkflowNodeId) {
|
||||
const { x, y } = positions.value[id]
|
||||
return {
|
||||
transform: `translate3d(${x}px, ${y}px, 0)`,
|
||||
width: `${NODE_W[id]}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Wires recompute from live positions + measured heights, so they track the
|
||||
// nodes synchronously while dragging with no measure round-trip.
|
||||
const anchors = computed<Record<WorkflowNodeId, Rect>>(() => {
|
||||
const ids = Object.keys(positions.value) as WorkflowNodeId[]
|
||||
return Object.fromEntries(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
{ ...positions.value[id], w: NODE_W[id], h: heights.value[id] ?? 0 }
|
||||
])
|
||||
) as Record<WorkflowNodeId, Rect>
|
||||
})
|
||||
|
||||
const dragging = ref<WorkflowNodeId | null>(null)
|
||||
let drag = {
|
||||
id: '' as WorkflowNodeId,
|
||||
pointerId: -1,
|
||||
px: 0,
|
||||
py: 0,
|
||||
ox: 0,
|
||||
oy: 0
|
||||
}
|
||||
|
||||
function onPointerDown(id: WorkflowNodeId, e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
drag = {
|
||||
id,
|
||||
pointerId: e.pointerId,
|
||||
px: e.clientX,
|
||||
py: e.clientY,
|
||||
ox: positions.value[id].x,
|
||||
oy: positions.value[id].y
|
||||
}
|
||||
dragging.value = id
|
||||
}
|
||||
|
||||
// A small threshold keeps clicks on buttons from registering as drags.
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (dragging.value == null || e.pointerId !== drag.pointerId) return
|
||||
const dx = e.clientX - drag.px
|
||||
const dy = e.clientY - drag.py
|
||||
if (Math.hypot(dx, dy) < 4) return
|
||||
positions.value[drag.id] = clampNodePosition(
|
||||
drag.id,
|
||||
{ x: drag.ox + dx / scale.value, y: drag.oy + dy / scale.value },
|
||||
heights.value[drag.id] ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = null
|
||||
}
|
||||
|
||||
// Listeners live on window so a drag continues even when the pointer outruns
|
||||
// the node; registered in onMounted to keep window off the SSR path.
|
||||
onMounted(() => {
|
||||
void nextTick(refresh)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
|
||||
const wires = computed(() => computeWires(anchors.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<!-- Execution progress pinned to the top of the hero, like the real app. -->
|
||||
<div
|
||||
v-if="phase === 'running'"
|
||||
class="absolute inset-x-0 top-0 z-40"
|
||||
data-testid="hero-total-progress"
|
||||
>
|
||||
<div class="h-1 bg-white/10">
|
||||
<div
|
||||
class="bg-hero-exec h-full transition-[width] duration-100 ease-linear"
|
||||
:style="{ width: `${percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="absolute top-2.5 right-4 text-xs text-white/60 tabular-nums">
|
||||
{{ t('hero.totalProgress', locale) }}:
|
||||
<span class="font-semibold text-white">{{ percent }}%</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop / large screens: a fixed design stage scaled to fit the width -->
|
||||
<div
|
||||
ref="frameRef"
|
||||
class="relative hidden aspect-1600/780 max-h-[1000px] w-full lg:block"
|
||||
>
|
||||
<div
|
||||
ref="stageRef"
|
||||
data-testid="hero-stage"
|
||||
class="absolute top-0 left-1/2 origin-top"
|
||||
:style="stageStyle"
|
||||
>
|
||||
<svg
|
||||
class="pointer-events-none absolute inset-0 size-full overflow-visible"
|
||||
:viewBox="`0 0 ${STAGE_W} ${STAGE_H}`"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
v-for="(wire, i) in wires"
|
||||
:key="i"
|
||||
:d="wire.d"
|
||||
:stroke="wire.color"
|
||||
stroke-opacity="0.5"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<template v-for="(wire, i) in wires" :key="`d${i}`">
|
||||
<circle
|
||||
:cx="wire.from.x"
|
||||
:cy="wire.from.y"
|
||||
r="3.5"
|
||||
:fill="wire.color"
|
||||
/>
|
||||
<circle
|
||||
:cx="wire.to.x"
|
||||
:cy="wire.to.y"
|
||||
r="3.5"
|
||||
:fill="wire.color"
|
||||
/>
|
||||
</template>
|
||||
<!-- Energy pulses that flow along every wire while the workflow runs;
|
||||
idle-hidden via opacity, animated through CSS. -->
|
||||
<g :class="cn(phase === 'running' && 'hero-wire-active')">
|
||||
<path
|
||||
v-for="(wire, i) in wires"
|
||||
:key="`p${i}`"
|
||||
:d="wire.d"
|
||||
class="hero-wire-pulse"
|
||||
:stroke="wire.color"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
pathLength="1"
|
||||
stroke-dasharray="0.18 0.82"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- The headline stays beneath the nodes so a dragged node passes
|
||||
cleanly over it instead of flipping layers mid-drag. -->
|
||||
<div class="absolute top-[90px] left-[720px] -translate-x-1/2">
|
||||
<HeroHeadline :locale />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="id in NODE_IDS"
|
||||
:key="id"
|
||||
:data-node="id"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
|
||||
dragging === id && 'z-30 cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:style="nodeStyle(id)"
|
||||
@pointerdown="onPointerDown(id, $event)"
|
||||
>
|
||||
<HeroWorkflowNode
|
||||
:title="t(NODE_TITLE_KEYS[id], locale)"
|
||||
:state="run.nodeState(id)"
|
||||
:progress="activeNode === id ? nodeProgress : 0"
|
||||
>
|
||||
<HeroWorkflowOutput v-if="id === 'output'" :run :locale />
|
||||
<HeroNodeWidgets v-else :widgets="widgetsFor(id)" />
|
||||
</HeroWorkflowNode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile / tablet: the loaders condense into a compact grid feeding the
|
||||
Save Image node, so the whole workflow still fits one screen. -->
|
||||
<div class="flex flex-col items-center px-5 pt-6 pb-10 lg:hidden">
|
||||
<HeroHeadline :locale compact />
|
||||
|
||||
<div class="mt-6 w-full max-w-sm sm:max-w-md">
|
||||
<div class="grid grid-cols-2 items-start gap-2">
|
||||
<HeroWorkflowNode
|
||||
v-for="id in ['model', 'clip', 'vae', 'lora'] as const"
|
||||
:key="id"
|
||||
:title="t(NODE_TITLE_KEYS[id], locale)"
|
||||
:state="run.nodeState(id)"
|
||||
:progress="activeNode === id ? nodeProgress : 0"
|
||||
>
|
||||
<HeroNodeWidgets :widgets="widgetsFor(id)" />
|
||||
</HeroWorkflowNode>
|
||||
</div>
|
||||
|
||||
<HeroWorkflowNode
|
||||
class="mt-2"
|
||||
:title="t(NODE_TITLE_KEYS.seed, locale)"
|
||||
:state="run.nodeState('seed')"
|
||||
:progress="activeNode === 'seed' ? nodeProgress : 0"
|
||||
>
|
||||
<HeroNodeWidgets :widgets="widgetsFor('seed')" />
|
||||
</HeroWorkflowNode>
|
||||
|
||||
<div class="relative h-6 w-full" aria-hidden="true">
|
||||
<svg
|
||||
class="absolute inset-0 size-full"
|
||||
viewBox="0 0 100 36"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M50 3 C 50 18 50 18 50 33"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="absolute top-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
|
||||
/>
|
||||
<span
|
||||
class="bg-hero-exec absolute bottom-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HeroWorkflowNode
|
||||
:title="t(NODE_TITLE_KEYS.output, locale)"
|
||||
:state="run.nodeState('output')"
|
||||
:progress="activeNode === 'output' ? nodeProgress : 0"
|
||||
>
|
||||
<HeroWorkflowOutput :run :locale />
|
||||
</HeroWorkflowNode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
53
apps/website/src/components/home/HeroWorkflowNode.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { NodeRunState } from './useHeroWorkflowRun'
|
||||
|
||||
const {
|
||||
title,
|
||||
state = 'idle',
|
||||
progress = 0,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
title: string
|
||||
state?: NodeRunState
|
||||
progress?: number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-hero-node overflow-hidden rounded-xl border shadow-xl shadow-black/30 transition-colors duration-300',
|
||||
state === 'running' ? 'border-hero-exec' : 'border-white/10',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 px-3 py-2">
|
||||
<ChevronDown class="size-3.5 shrink-0 text-white/35" />
|
||||
<span class="truncate text-[13px] font-medium text-white/85">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-0.5 bg-white/5">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-hero-exec h-full',
|
||||
state === 'running' && 'transition-[width] duration-100 ease-linear'
|
||||
)
|
||||
"
|
||||
:style="{ width: `${state === 'running' ? progress * 100 : 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
107
apps/website/src/components/home/HeroWorkflowOutput.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpRight, ImagePlus, Loader2, Play, RefreshCw } from '@lucide/vue'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import HeroNodeWidgets from './HeroNodeWidgets.vue'
|
||||
import { NODE_TITLE_KEYS } from './heroWorkflowGraph'
|
||||
import type { HeroWorkflowRun } from './useHeroWorkflowRun'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { run, locale = 'en' } = defineProps<{
|
||||
run: HeroWorkflowRun
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const filenameWidget = [
|
||||
{ name: 'filename_prefix', value: 'Krea2_turbo', kind: 'text' as const }
|
||||
]
|
||||
|
||||
const percent = computed(() => Math.round(run.totalProgress.value * 100))
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
run.activeNode.value
|
||||
? t(NODE_TITLE_KEYS[run.activeNode.value], locale)
|
||||
: t('hero.node.output', locale)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroNodeWidgets :widgets="filenameWidget" />
|
||||
|
||||
<div
|
||||
class="bg-hero-node-inset relative mt-1 aspect-square overflow-hidden rounded-lg"
|
||||
>
|
||||
<Transition name="hero-render">
|
||||
<img
|
||||
v-if="run.outputSrc.value"
|
||||
:key="run.outputSrc.value"
|
||||
:src="run.outputSrc.value"
|
||||
:alt="t('hero.output.alt', locale)"
|
||||
draggable="false"
|
||||
class="absolute inset-0 size-full object-cover select-none"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="run.phase.value === 'idle'"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-4 p-6 text-center"
|
||||
>
|
||||
<span
|
||||
class="flex size-11 items-center justify-center rounded-full bg-white/5"
|
||||
>
|
||||
<ImagePlus class="size-5 text-white/40" />
|
||||
</span>
|
||||
<p class="max-w-52 text-sm text-white/50">
|
||||
{{ t('hero.output.hint', locale) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-hero-exec flex cursor-pointer items-center gap-2 rounded-lg px-7 py-2.5 text-sm font-semibold text-white transition-[filter] hover:brightness-110"
|
||||
@click="run.run()"
|
||||
>
|
||||
<Play class="size-4 fill-current" />
|
||||
{{ t('hero.run', locale) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="run.phase.value === 'running'"
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/60"
|
||||
>
|
||||
<Loader2 class="text-hero-exec size-6 animate-spin" />
|
||||
<p class="flex items-baseline gap-2 text-sm text-white/75">
|
||||
<span>{{ statusLabel }}</span>
|
||||
<span class="font-semibold text-white tabular-nums">
|
||||
{{ percent }}%
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="run.phase.value === 'done'">
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<span class="truncate font-mono text-[11px] text-white/40 tabular-nums">
|
||||
{{ t('hero.output.seed', locale) }} {{ run.seed.value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/10 px-3 py-1.5 text-xs font-medium text-white/85 transition-colors hover:bg-white/15"
|
||||
@click="run.run()"
|
||||
>
|
||||
<RefreshCw class="size-3" />
|
||||
{{ t('hero.runAgain', locale) }}
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
:href="externalLinks.cloud"
|
||||
target="_blank"
|
||||
class="bg-primary-comfy-yellow mt-2 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-bold tracking-wide text-primary-comfy-ink uppercase transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('hero.output.openCloud', locale) }}
|
||||
<ArrowUpRight class="size-3.5" />
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
70
apps/website/src/components/home/heroWorkflowGraph.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Rect, WorkflowNodeId } from './heroWorkflowGraph'
|
||||
import {
|
||||
NODE_W,
|
||||
STAGE_H,
|
||||
STAGE_W,
|
||||
clampNodePosition,
|
||||
computeWires,
|
||||
connections,
|
||||
homePositions,
|
||||
spline
|
||||
} from './heroWorkflowGraph'
|
||||
|
||||
// Cubic command shape: "M sx sy C c1x c1y c2x c2y ex ey"
|
||||
function controlPoints(d: string) {
|
||||
const [sx, sy, c1x, c1y, c2x, c2y, ex, ey] = d
|
||||
.replace(/[MC]/g, ' ')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(Number)
|
||||
return { sx, sy, c1x, c1y, c2x, c2y, ex, ey }
|
||||
}
|
||||
|
||||
describe('spline', () => {
|
||||
it('departs and arrives horizontally for side ports even when the vertical gap dominates', () => {
|
||||
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
|
||||
spline({ x: 0, y: 200 }, { x: 120, y: 0 }, 'h')
|
||||
)
|
||||
expect(c1y).toBe(sy)
|
||||
expect(c2y).toBe(ey)
|
||||
expect(c1x).toBeGreaterThan(sx)
|
||||
expect(c2x).toBeLessThan(ex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeWires', () => {
|
||||
const anchors = Object.fromEntries(
|
||||
(Object.keys(homePositions) as WorkflowNodeId[]).map((id) => [
|
||||
id,
|
||||
{ ...homePositions[id], w: NODE_W[id], h: 120 } satisfies Rect
|
||||
])
|
||||
) as Record<WorkflowNodeId, Rect>
|
||||
|
||||
it('produces one wire per connection with endpoints on the node edges', () => {
|
||||
const wires = computeWires(anchors)
|
||||
expect(wires).toHaveLength(connections.length)
|
||||
for (const [i, wire] of wires.entries()) {
|
||||
const from = anchors[connections[i].from]
|
||||
const to = anchors[connections[i].to]
|
||||
expect(wire.from.x).toBe(from.x + from.w)
|
||||
expect(wire.to.x).toBe(to.x)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips wires whose endpoints are not yet measured', () => {
|
||||
const { model, lora } = anchors
|
||||
expect(computeWires({ model, lora })).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampNodePosition', () => {
|
||||
it('keeps nodes fully inside the stage', () => {
|
||||
const clamped = clampNodePosition('output', { x: 5000, y: -50 }, 560)
|
||||
expect(clamped).toEqual({ x: STAGE_W - NODE_W.output, y: 0 })
|
||||
expect(clampNodePosition('seed', { x: 100, y: 9999 }, 120).y).toBe(
|
||||
STAGE_H - 120
|
||||
)
|
||||
})
|
||||
})
|
||||
190
apps/website/src/components/home/heroWorkflowGraph.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
export type WorkflowNodeId =
|
||||
| 'model'
|
||||
| 'clip'
|
||||
| 'vae'
|
||||
| 'lora'
|
||||
| 'seed'
|
||||
| 'output'
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Rect extends Point {
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface Wire {
|
||||
d: string
|
||||
from: Point
|
||||
to: Point
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface NodeWidget {
|
||||
name: string
|
||||
value: string
|
||||
kind: 'combo' | 'number' | 'text'
|
||||
}
|
||||
|
||||
export const STAGE_W = 1600
|
||||
export const STAGE_H = 780
|
||||
|
||||
export const NODE_W: Record<WorkflowNodeId, number> = {
|
||||
model: 300,
|
||||
clip: 300,
|
||||
vae: 300,
|
||||
lora: 320,
|
||||
seed: 280,
|
||||
output: 460
|
||||
}
|
||||
|
||||
// Loaders stack on the left, the LoRA + seed chain runs under the centred
|
||||
// headline, and the Save Image node sits fully inside the right edge so
|
||||
// nothing bleeds offscreen.
|
||||
export const homePositions: Record<WorkflowNodeId, Point> = {
|
||||
model: { x: 24, y: 70 },
|
||||
clip: { x: 24, y: 280 },
|
||||
vae: { x: 24, y: 490 },
|
||||
lora: { x: 420, y: 470 },
|
||||
seed: { x: 790, y: 520 },
|
||||
output: { x: 1090, y: 48 }
|
||||
}
|
||||
|
||||
export const NODE_TITLE_KEYS = {
|
||||
model: 'hero.node.model',
|
||||
clip: 'hero.node.clip',
|
||||
vae: 'hero.node.vae',
|
||||
lora: 'hero.node.lora',
|
||||
seed: 'hero.node.seed',
|
||||
output: 'hero.node.output'
|
||||
} as const satisfies Record<WorkflowNodeId, TranslationKey>
|
||||
|
||||
export const nodeWidgets: Partial<Record<WorkflowNodeId, NodeWidget[]>> = {
|
||||
model: [
|
||||
{ name: 'unet_name', value: 'krea2_turbo_fp8_scaled', kind: 'combo' }
|
||||
],
|
||||
clip: [{ name: 'clip_name', value: 'qwen3vl_4b_fp8_scaled', kind: 'combo' }],
|
||||
vae: [{ name: 'vae_name', value: 'qwen_image_vae', kind: 'combo' }],
|
||||
lora: [
|
||||
{ name: 'lora_name', value: 'krea2_darkbrush', kind: 'combo' },
|
||||
{ name: 'strength_model', value: '0.80', kind: 'number' }
|
||||
]
|
||||
}
|
||||
|
||||
// Litegraph slot colors, so the wiring reads as the real ComfyUI canvas.
|
||||
const WIRE_COLORS = {
|
||||
model: '#b39ddb',
|
||||
clip: '#ffd500',
|
||||
vae: '#ff6e6e',
|
||||
int: '#6a8bad'
|
||||
} as const
|
||||
|
||||
type Axis = 'h' | 'v'
|
||||
type Port = (r: Rect) => Point
|
||||
|
||||
const rightPort =
|
||||
(f = 0.5): Port =>
|
||||
(r) => ({ x: r.x + r.w, y: r.y + r.h * f })
|
||||
const leftPort =
|
||||
(f = 0.5): Port =>
|
||||
(r) => ({ x: r.x, y: r.y + r.h * f })
|
||||
|
||||
function clampOffset(d: number): number {
|
||||
return Math.min(Math.max(Math.abs(d) * 0.5, 55), 120)
|
||||
}
|
||||
|
||||
// Soft cubic whose tangents follow the connected ports, so a wire between side
|
||||
// ports departs horizontally even when the vertical gap dominates.
|
||||
export function spline(s: Point, e: Point, axis: Axis): string {
|
||||
if (axis === 'h') {
|
||||
const off = Math.sign(e.x - s.x || 1) * clampOffset(e.x - s.x)
|
||||
return `M ${s.x} ${s.y} C ${s.x + off} ${s.y} ${e.x - off} ${e.y} ${e.x} ${e.y}`
|
||||
}
|
||||
const off = Math.sign(e.y - s.y || 1) * clampOffset(e.y - s.y)
|
||||
return `M ${s.x} ${s.y} C ${s.x} ${s.y + off} ${e.x} ${e.y - off} ${e.x} ${e.y}`
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
from: WorkflowNodeId
|
||||
to: WorkflowNodeId
|
||||
fromPort: Port
|
||||
toPort: Port
|
||||
axis: Axis
|
||||
color: string
|
||||
}
|
||||
|
||||
export const connections: Connection[] = [
|
||||
{
|
||||
from: 'model',
|
||||
to: 'lora',
|
||||
fromPort: rightPort(0.7),
|
||||
toPort: leftPort(0.35),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.model
|
||||
},
|
||||
{
|
||||
from: 'clip',
|
||||
to: 'lora',
|
||||
fromPort: rightPort(0.7),
|
||||
toPort: leftPort(0.6),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.clip
|
||||
},
|
||||
{
|
||||
from: 'lora',
|
||||
to: 'output',
|
||||
fromPort: rightPort(0.4),
|
||||
toPort: leftPort(0.14),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.model
|
||||
},
|
||||
{
|
||||
from: 'seed',
|
||||
to: 'output',
|
||||
fromPort: rightPort(0.45),
|
||||
toPort: leftPort(0.19),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.int
|
||||
},
|
||||
{
|
||||
from: 'vae',
|
||||
to: 'output',
|
||||
fromPort: rightPort(0.7),
|
||||
toPort: leftPort(0.24),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.vae
|
||||
}
|
||||
]
|
||||
|
||||
export function computeWires(
|
||||
anchors: Partial<Record<WorkflowNodeId, Rect>>
|
||||
): Wire[] {
|
||||
return connections.flatMap((c) => {
|
||||
const fr = anchors[c.from]
|
||||
const to = anchors[c.to]
|
||||
if (!fr || !to) return []
|
||||
const from = c.fromPort(fr)
|
||||
const dest = c.toPort(to)
|
||||
return [{ from, to: dest, color: c.color, d: spline(from, dest, c.axis) }]
|
||||
})
|
||||
}
|
||||
|
||||
// Drags are confined to the stage rect so every node stops at the edge
|
||||
// instead of getting cut off.
|
||||
export function clampNodePosition(
|
||||
id: WorkflowNodeId,
|
||||
point: Point,
|
||||
height: number
|
||||
): Point {
|
||||
return {
|
||||
x: clamp(point.x, 0, STAGE_W - NODE_W[id]),
|
||||
y: clamp(point.y, 0, STAGE_H - height)
|
||||
}
|
||||
}
|
||||
27
apps/website/src/components/home/useHeroWorkflowRun.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { RENDER_COUNT, pickSeed, renderSrc } from './useHeroWorkflowRun'
|
||||
|
||||
describe('renderSrc', () => {
|
||||
it('maps indices to zero-padded webp paths', () => {
|
||||
expect(renderSrc(0)).toBe('/images/hero/renders/render-01.webp')
|
||||
expect(renderSrc(RENDER_COUNT - 1)).toBe(
|
||||
'/images/hero/renders/render-50.webp'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickSeed', () => {
|
||||
it('never lands on the previous render bucket two runs in a row', () => {
|
||||
const lastIndex = 7
|
||||
// Force a seed that collides with the last render bucket.
|
||||
const collidingRandom = () => (RENDER_COUNT + lastIndex) / 999_999_999
|
||||
const seed = pickSeed(collidingRandom, lastIndex)
|
||||
expect(seed % RENDER_COUNT).not.toBe(lastIndex)
|
||||
})
|
||||
|
||||
it('keeps the seed unchanged when there is no collision', () => {
|
||||
const seed = pickSeed(() => 0.5, null)
|
||||
expect(seed).toBe(Math.floor(0.5 * 999_999_999))
|
||||
})
|
||||
})
|
||||
124
apps/website/src/components/home/useHeroWorkflowRun.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { WorkflowNodeId } from './heroWorkflowGraph'
|
||||
|
||||
export const RENDER_COUNT = 50
|
||||
|
||||
export function renderSrc(index: number): string {
|
||||
return `/images/hero/renders/render-${String(index + 1).padStart(2, '0')}.webp`
|
||||
}
|
||||
|
||||
// The seed is what the user sees; the render shown is seed % RENDER_COUNT.
|
||||
// Nudging the seed when it lands on the previous bucket guarantees a fresh
|
||||
// image on every consecutive run.
|
||||
export function pickSeed(
|
||||
random: () => number,
|
||||
lastIndex: number | null
|
||||
): number {
|
||||
const seed = Math.floor(random() * 999_999_999)
|
||||
return seed % RENDER_COUNT === lastIndex ? seed + 1 : seed
|
||||
}
|
||||
|
||||
// Fake execution timeline: loaders warm up quickly, then the sampler carries
|
||||
// most of the run — mirroring how the real workflow feels in ComfyUI.
|
||||
const RUN_STEPS: { id: WorkflowNodeId; duration: number }[] = [
|
||||
{ id: 'model', duration: 600 },
|
||||
{ id: 'clip', duration: 450 },
|
||||
{ id: 'vae', duration: 400 },
|
||||
{ id: 'lora', duration: 550 },
|
||||
{ id: 'seed', duration: 300 },
|
||||
{ id: 'output', duration: 1700 }
|
||||
]
|
||||
|
||||
const TOTAL_DURATION = RUN_STEPS.reduce((sum, s) => sum + s.duration, 0)
|
||||
|
||||
export type RunPhase = 'idle' | 'running' | 'done'
|
||||
export type NodeRunState = 'idle' | 'running' | 'done'
|
||||
|
||||
export function useHeroWorkflowRun() {
|
||||
const phase = ref<RunPhase>('idle')
|
||||
const seed = ref(52)
|
||||
const activeNode = ref<WorkflowNodeId | null>(null)
|
||||
const nodeProgress = ref(0)
|
||||
const totalProgress = ref(0)
|
||||
const outputSrc = ref<string | null>(null)
|
||||
|
||||
let elapsed = 0
|
||||
let pendingIndex: number | null = null
|
||||
let imageReady = false
|
||||
|
||||
const { pause, resume } = useRafFn(({ delta }) => advance(delta), {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
function advance(delta: number) {
|
||||
// Cap long frames (background tab) so the run never skips visibly.
|
||||
elapsed += Math.min(delta, 100)
|
||||
let start = 0
|
||||
for (const step of RUN_STEPS) {
|
||||
if (elapsed < start + step.duration) {
|
||||
activeNode.value = step.id
|
||||
nodeProgress.value = (elapsed - start) / step.duration
|
||||
totalProgress.value = elapsed / TOTAL_DURATION
|
||||
return
|
||||
}
|
||||
start += step.duration
|
||||
}
|
||||
if (!imageReady) {
|
||||
// Hold just short of done until the render finishes downloading.
|
||||
activeNode.value = 'output'
|
||||
nodeProgress.value = 0.96
|
||||
totalProgress.value = 0.96
|
||||
return
|
||||
}
|
||||
pause()
|
||||
phase.value = 'done'
|
||||
activeNode.value = null
|
||||
nodeProgress.value = 0
|
||||
totalProgress.value = 1
|
||||
outputSrc.value = pendingIndex === null ? null : renderSrc(pendingIndex)
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (phase.value === 'running') return
|
||||
const nextSeed = pickSeed(Math.random, pendingIndex)
|
||||
seed.value = nextSeed
|
||||
pendingIndex = nextSeed % RENDER_COUNT
|
||||
imageReady = false
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
imageReady = true
|
||||
}
|
||||
image.onerror = () => {
|
||||
imageReady = true
|
||||
}
|
||||
image.src = renderSrc(pendingIndex)
|
||||
elapsed = 0
|
||||
totalProgress.value = 0
|
||||
phase.value = 'running'
|
||||
resume()
|
||||
}
|
||||
|
||||
function nodeState(id: WorkflowNodeId): NodeRunState {
|
||||
if (activeNode.value === id) return 'running'
|
||||
if (phase.value === 'done') return 'done'
|
||||
if (phase.value !== 'running') return 'idle'
|
||||
const activeIndex = RUN_STEPS.findIndex((s) => s.id === activeNode.value)
|
||||
const index = RUN_STEPS.findIndex((s) => s.id === id)
|
||||
return index < activeIndex ? 'done' : 'idle'
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
seed,
|
||||
activeNode,
|
||||
nodeProgress,
|
||||
totalProgress,
|
||||
outputSrc,
|
||||
run,
|
||||
nodeState
|
||||
}
|
||||
}
|
||||
|
||||
export type HeroWorkflowRun = ReturnType<typeof useHeroWorkflowRun>
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { hasKey, translationKeys } from '../../i18n/translations'
|
||||
|
||||
const PREFIX = 'enterprise-msa'
|
||||
|
||||
function deriveMsaSectionIds(): string[] {
|
||||
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
|
||||
const ids: string[] = []
|
||||
for (const key of translationKeys) {
|
||||
const match = key.match(labelRegex)
|
||||
if (match && !ids.includes(match[1])) ids.push(match[1])
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
describe('enterprise MSA i18n', () => {
|
||||
it('every derived section has a title and at least one block', () => {
|
||||
const sectionIds = deriveMsaSectionIds()
|
||||
expect(sectionIds.length).toBeGreaterThan(0)
|
||||
for (const id of sectionIds) {
|
||||
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the page-chrome keys the .astro file references', () => {
|
||||
for (const suffix of [
|
||||
'effective-date',
|
||||
'page.title',
|
||||
'page.description',
|
||||
'page.heading',
|
||||
'page.tocLabel',
|
||||
'page.effectiveDateLabel',
|
||||
'page.parties'
|
||||
]) {
|
||||
expect(hasKey(`${PREFIX}.${suffix}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the enterprise MSA at the canonical /enterprise-msa path regardless of locale', () => {
|
||||
expect(getRoutes('en').enterpriseMsa).toBe('/enterprise-msa')
|
||||
expect(getRoutes('zh-CN').enterpriseMsa).toBe('/enterprise-msa')
|
||||
})
|
||||
})
|
||||
@@ -6,15 +6,14 @@ export const badgeVariants = cva({
|
||||
variants: {
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
xs: 'px-2 py-0.5 text-[9px]',
|
||||
xxs: 'px-1.5 py-px text-[8px]'
|
||||
xs: 'px-2 py-0.5 text-[9px]'
|
||||
},
|
||||
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 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 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'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
// Interactive: the copy button is inert until its host island is hydrated.
|
||||
// Render under a `client:*` directive (e.g. `client:visible`) when the page
|
||||
// needs it to work.
|
||||
@@ -14,8 +11,6 @@ const {
|
||||
copiedLabel = 'Copied'
|
||||
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
|
||||
|
||||
const multiline = computed(() => value.includes('\n'))
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function handleCopy() {
|
||||
@@ -25,32 +20,15 @@ function handleCopy() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
|
||||
multiline ? 'items-start' : 'items-center'
|
||||
)
|
||||
"
|
||||
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 font-mono text-xs text-primary-comfy-canvas',
|
||||
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
|
||||
{{ value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? copiedLabel : copyLabel"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
|
||||
multiline && 'mt-0.5'
|
||||
)
|
||||
"
|
||||
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" class="size-4" />
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isHrefActive } from './useCurrentPath'
|
||||
|
||||
describe('isHrefActive', () => {
|
||||
it('matches the current page', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match other pages', () => {
|
||||
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches regardless of a trailing slash', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores query and hash on the href', () => {
|
||||
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('never matches an external href', () => {
|
||||
expect(
|
||||
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never matches an empty href', () => {
|
||||
expect(isHrefActive('', '/mcp')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
@@ -74,7 +73,7 @@ export function getBannerData(
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: localizeHref(link.href, locale),
|
||||
href: link.href,
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { t } from '../i18n/translations'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
import { externalLinks } from './routes'
|
||||
|
||||
interface PricingTier {
|
||||
slug: string
|
||||
labelKey: TranslationKey
|
||||
priceKey: TranslationKey
|
||||
}
|
||||
|
||||
const tiers: PricingTier[] = [
|
||||
{
|
||||
slug: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
priceKey: 'pricing.plan.standard.price'
|
||||
},
|
||||
{
|
||||
slug: 'creator',
|
||||
labelKey: 'pricing.plan.creator.label',
|
||||
priceKey: 'pricing.plan.creator.price'
|
||||
},
|
||||
{
|
||||
slug: 'pro',
|
||||
labelKey: 'pricing.plan.pro.label',
|
||||
priceKey: 'pricing.plan.pro.price'
|
||||
}
|
||||
]
|
||||
|
||||
export interface PricingOffer {
|
||||
name: string
|
||||
price: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function pricingOffers(locale: Locale): PricingOffer[] {
|
||||
return tiers.flatMap((tier) => {
|
||||
const display = t(tier.priceKey, locale).trim()
|
||||
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
|
||||
if (!match) {
|
||||
console.warn(
|
||||
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
|
||||
)
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: t(tier.labelKey, locale),
|
||||
price: match[1],
|
||||
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
describe('localizeHref', () => {
|
||||
it('prefixes an internal path for a non-default locale', () => {
|
||||
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
|
||||
})
|
||||
|
||||
it('leaves the default locale unprefixed', () => {
|
||||
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
|
||||
})
|
||||
|
||||
it('passes external URLs through unchanged', () => {
|
||||
expect(
|
||||
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
|
||||
).toBe('https://docs.comfy.org/agent-tools/cloud')
|
||||
})
|
||||
|
||||
it('never prefixes locale-invariant routes', () => {
|
||||
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,6 @@ const baseRoutes = {
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
termsOfService: '/terms-of-service',
|
||||
enterpriseMsa: '/enterprise-msa',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
affiliates: '/affiliates',
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
@@ -36,37 +35,19 @@ type Routes = typeof baseRoutes
|
||||
// block in src/i18n/translations.ts for the reasoning.
|
||||
//
|
||||
// termsOfService: legal-reviewed English-only document, same reasoning.
|
||||
//
|
||||
// enterpriseMsa: legal-reviewed English-only document (Comfy Enterprise
|
||||
// Customer Agreement template), same reasoning. See the comment header
|
||||
// in src/pages/enterprise-msa.astro.
|
||||
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
|
||||
'affiliates',
|
||||
'affiliateTerms',
|
||||
'termsOfService',
|
||||
'enterpriseMsa'
|
||||
'termsOfService'
|
||||
])
|
||||
|
||||
const LOCALE_INVARIANT_PATHS = new Set<string>(
|
||||
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
|
||||
)
|
||||
|
||||
/**
|
||||
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
|
||||
* URLs and locale-invariant routes pass through unchanged.
|
||||
*/
|
||||
export function localizeHref(href: string, locale: Locale = 'en'): string {
|
||||
if (locale === 'en' || !href.startsWith('/')) return href
|
||||
if (LOCALE_INVARIANT_PATHS.has(href)) return href
|
||||
return `/${locale}${href}`
|
||||
}
|
||||
|
||||
export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
if (locale === 'en') return baseRoutes
|
||||
const prefix = `/${locale}`
|
||||
return Object.fromEntries(
|
||||
Object.entries(baseRoutes).map(([key, path]) => [
|
||||
key,
|
||||
localizeHref(path, locale)
|
||||
Object.entries(baseRoutes).map(([k, v]) => [
|
||||
k,
|
||||
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
|
||||
])
|
||||
) as unknown as Routes
|
||||
}
|
||||
@@ -79,22 +60,18 @@ export const externalLinks = {
|
||||
cloudStatus: 'https://status.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
g2ComfyUi: 'https://www.g2.com/products/comfyui',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
|
||||
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
|
||||
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -72,24 +72,6 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'app-mode',
|
||||
badge: NEW_BADGE,
|
||||
@@ -130,6 +112,24 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'community-workflows',
|
||||
category: COMMUNITY,
|
||||
|
||||