mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
1 Commits
drjkl/upda
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b1eabe1bc |
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
||||
"entry": ["src/main.ts", "index.html"],
|
||||
"duplicates": {
|
||||
"minOccurrences": 3,
|
||||
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
|
||||
},
|
||||
"rules": {}
|
||||
}
|
||||
2
.github/workflows/ci-perf-report.yaml
vendored
2
.github/workflows/ci-perf-report.yaml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
12
.github/workflows/ci-tests-e2e.yaml
vendored
12
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -73,8 +73,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shardTotal: [16]
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
# Run sharded tests (browsers pre-installed in container)
|
||||
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
id: playwright
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
COLLECT_COVERAGE: 'true'
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
# Run tests (browsers pre-installed in container)
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
id: playwright
|
||||
run: pnpm exec playwright test --project=${{ matrix.browser }}
|
||||
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
|
||||
|
||||
2
.github/workflows/ci-website-e2e.yaml
vendored
2
.github/workflows/ci-website-e2e.yaml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
update-screenshots:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -128,9 +128,6 @@ jobs:
|
||||
|
||||
- name: Remove label
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
# Label cleanup is best-effort; the restricted pull_request token may
|
||||
# lack permission to remove labels and must not fail the run.
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,7 +16,6 @@ yarn.lock
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
.stylelintcache
|
||||
.fallow/
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
@@ -88,11 +88,6 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/composables/useWorkspaceUI',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
|
||||
@@ -6,19 +6,15 @@
|
||||
"customSyntax": "postcss-html"
|
||||
}
|
||||
],
|
||||
"languageOptions": {
|
||||
"syntax": {
|
||||
"types": {
|
||||
"radial-gradient()": "| <any-value>"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"import-notation": "string",
|
||||
"font-family-no-missing-generic-family-keyword": true,
|
||||
"declaration-property-value-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"typesSyntax": {
|
||||
"radial-gradient()": "| <any-value>"
|
||||
},
|
||||
"ignoreProperties": {
|
||||
"speak": ["none"],
|
||||
"app-region": ["drag", "no-drag"],
|
||||
|
||||
@@ -10,13 +10,11 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
|
||||
...LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Brand portal @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/brand')
|
||||
})
|
||||
|
||||
test('renders each brand guideline section', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'One mark, many dimensions.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Every color earns its place.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Precise, never cute.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Trademark guidelines.' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows all four marks', async ({ page }) => {
|
||||
const logos = page.locator('#logos')
|
||||
for (const name of [
|
||||
'Core Logo',
|
||||
'Logomark',
|
||||
'Icon',
|
||||
'Amplified Logomark'
|
||||
]) {
|
||||
await expect(logos.getByText(name, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('the hero ctas open the gated guidelines and the logo bundle', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'View brand guidelines' })
|
||||
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Download logos' })
|
||||
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
|
||||
|
||||
test.describe('MCP page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/mcp')
|
||||
})
|
||||
|
||||
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
|
||||
page
|
||||
}) => {
|
||||
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
|
||||
await expect(installLinks).toHaveCount(2)
|
||||
for (const link of await installLinks.all()) {
|
||||
await expect(link).toHaveAttribute('href', '#setup')
|
||||
}
|
||||
})
|
||||
|
||||
test('setup section shows both install options', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('heading', {
|
||||
name: 'Ask your agent to install Comfy MCP'
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: 'Install manually' })
|
||||
).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('client tabs swap install instructions', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
|
||||
|
||||
// Claude Code is the default tab and carries the CLI command
|
||||
await expect(
|
||||
setup.getByRole('tab', { name: 'Claude Code' })
|
||||
).toHaveAttribute('data-state', 'active')
|
||||
await expect(activePanel).toContainText(
|
||||
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
|
||||
)
|
||||
|
||||
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
|
||||
await expect(activePanel).toContainText('Add custom connector')
|
||||
|
||||
await setup.getByRole('tab', { name: 'Cursor' }).click()
|
||||
await expect(activePanel).toContainText('X-API-Key')
|
||||
await expect(
|
||||
activePanel.getByRole('link', { name: 'platform.comfy.org' })
|
||||
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
|
||||
|
||||
await setup.getByRole('tab', { name: 'Codex' }).click()
|
||||
await expect(activePanel).toContainText(
|
||||
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
|
||||
)
|
||||
})
|
||||
|
||||
test('skills plugin link lives in the agent option card', async ({
|
||||
page
|
||||
}) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('link', { name: 'View on GitHub' })
|
||||
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
|
||||
})
|
||||
|
||||
test('capabilities section shows all six tool cards', async ({ page }) => {
|
||||
for (const title of [
|
||||
'Generate anything',
|
||||
'Search the ecosystem',
|
||||
'Run real workflows',
|
||||
'Direct any model',
|
||||
'Generate in batches',
|
||||
'Ship it as an app'
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: title, exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('FAQ lists nine questions and autolinks the server URL', async ({
|
||||
page
|
||||
}) => {
|
||||
const triggers = page.locator('[id^="faq-trigger-"]')
|
||||
await triggers.first().scrollIntoViewIfNeeded()
|
||||
await expect(triggers).toHaveCount(9)
|
||||
|
||||
await page.getByRole('button', { name: "What's the server URL?" }).click()
|
||||
await expect(
|
||||
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
|
||||
).toHaveAttribute('href', MCP_ENDPOINT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('MCP page zh-CN @smoke', () => {
|
||||
test('setup section renders localized options', async ({ page }) => {
|
||||
await page.goto('/zh-CN/mcp')
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(setup.getByText('方式一')).toBeVisible()
|
||||
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="275" height="275" viewBox="0 0 275 275" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="274.66" height="274.66" rx="63.5555" fill="#211927"/>
|
||||
<path d="M177.456 174.409C177.713 173.538 177.854 172.621 177.854 171.656C177.854 166.313 173.546 161.983 168.232 161.983H125.108C122.791 162.006 120.894 160.124 120.894 157.794C120.894 157.37 120.965 156.97 121.058 156.594L132.67 115.926C133.162 114.137 134.801 112.819 136.72 112.819L180.008 112.772C189.138 112.772 196.84 106.582 199.158 98.1335L205.666 75.4696C205.877 74.6695 205.994 73.7987 205.994 72.9279C205.994 67.6091 201.71 63.3022 196.419 63.3022H144.048C134.965 63.3022 127.286 69.4448 124.921 77.7996L120.52 93.2618C120.005 95.0269 118.389 96.3213 116.47 96.3213H103.898C94.8846 96.3213 87.276 102.346 84.8412 110.607L69.0152 166.172C68.7811 166.996 68.6641 167.89 68.6641 168.784C68.6641 174.127 72.9717 178.457 78.2861 178.457H90.6472C92.9649 178.457 94.8612 180.34 94.8612 182.693C94.8612 183.093 94.8144 183.494 94.6973 183.87L90.3194 199.191C90.1087 200.015 89.9683 200.862 89.9683 201.733C89.9683 207.052 94.2525 211.359 99.5434 211.359L151.938 211.312C161.045 211.312 168.724 205.145 171.065 196.744L177.432 174.433L177.456 174.409Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
@@ -9,31 +9,6 @@ const { faqs } = defineProps<{
|
||||
faqs: readonly Faq[]
|
||||
}>()
|
||||
|
||||
type AnswerPart = { type: 'text' | 'link'; value: string }
|
||||
|
||||
function parseAnswer(answer: string): AnswerPart[] {
|
||||
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
|
||||
const parts: AnswerPart[] = []
|
||||
let lastIndex = 0
|
||||
for (const match of answer.matchAll(urlPattern)) {
|
||||
const start = match.index ?? 0
|
||||
const url = match[0].replace(/[.,;:]+$/, '')
|
||||
if (start > lastIndex) {
|
||||
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
|
||||
}
|
||||
parts.push({ type: 'link', value: url })
|
||||
lastIndex = start + url.length
|
||||
}
|
||||
if (lastIndex < answer.length) {
|
||||
parts.push({ type: 'text', value: answer.slice(lastIndex) })
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
const parsedFaqs = computed(() =>
|
||||
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
|
||||
)
|
||||
|
||||
const expanded = reactive<boolean[]>(faqs.map(() => false))
|
||||
|
||||
watch(
|
||||
@@ -65,7 +40,7 @@ function toggle(index: number) {
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in parsedFaqs"
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
@@ -108,23 +83,8 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p
|
||||
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
|
||||
>
|
||||
<template
|
||||
v-for="(part, partIndex) in faq.answerParts"
|
||||
:key="partIndex"
|
||||
>
|
||||
<a
|
||||
v-if="part.type === 'link'"
|
||||
:href="part.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ part.value }}</a
|
||||
>
|
||||
<template v-else>{{ part.value }}</template>
|
||||
</template>
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
120
apps/website/src/components/blocks/FeatureGrid01.vue
Normal file
120
apps/website/src/components/blocks/FeatureGrid01.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
type CardAction =
|
||||
| {
|
||||
type: 'link'
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank'
|
||||
icon?: Component
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
export interface FeatureCard {
|
||||
id: string
|
||||
label?: string
|
||||
title: string
|
||||
description: string
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<ColumnCount, string> = {
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
4: 'lg:grid-cols-4'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<p
|
||||
v-if="card.label"
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ card.label }}
|
||||
</p>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
|
||||
card.label && 'mt-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="card.action" class="mt-6">
|
||||
<Button
|
||||
v-if="card.action.type === 'link'"
|
||||
as="a"
|
||||
:href="card.action.href"
|
||||
:target="card.action.target"
|
||||
:rel="
|
||||
card.action.target === '_blank'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
"
|
||||
:variant="card.action.variant ?? 'outline'"
|
||||
:append-icon="card.action.icon"
|
||||
>
|
||||
{{ card.action.label }}
|
||||
</Button>
|
||||
<CopyableField
|
||||
v-else
|
||||
:value="card.action.value"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@ import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import type { VideoTrack } from '../common/VideoPlayer.vue'
|
||||
|
||||
type RowMedia =
|
||||
| { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' }
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
@@ -20,7 +20,6 @@ type RowMedia =
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
@@ -59,7 +58,7 @@ const {
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12',
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
|
||||
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
|
||||
)
|
||||
"
|
||||
@@ -73,11 +72,10 @@ const {
|
||||
</div>
|
||||
|
||||
<!-- Media: image or video -->
|
||||
<!-- 620/364 and w-155 (620px) match the card media asset dimensions -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0',
|
||||
'order-1 flex lg:w-1/2',
|
||||
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
|
||||
)
|
||||
"
|
||||
@@ -88,12 +86,7 @@ const {
|
||||
:alt="row.media.alt ?? row.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full rounded-4xl',
|
||||
row.media.fit === 'contain' ? 'object-contain' : 'object-cover'
|
||||
)
|
||||
"
|
||||
class="aspect-4/3 w-full rounded-4xl object-cover"
|
||||
/>
|
||||
<VideoPlayer
|
||||
v-else
|
||||
@@ -106,13 +99,7 @@ const {
|
||||
:loop="row.media.loop"
|
||||
:minimal="row.media.minimal"
|
||||
:hide-controls="row.media.hideControls"
|
||||
:fit="row.media.fit"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full',
|
||||
row.media.fit === 'contain' && 'bg-transparent'
|
||||
)
|
||||
"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { resources } = defineProps<{
|
||||
resources: { label: string; href: string; display: string }[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
|
||||
<li v-for="resource in resources" :key="resource.href">
|
||||
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
|
||||
<span class="text-primary-warm-gray"> — </span>
|
||||
<a
|
||||
:href="resource.href"
|
||||
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{{ resource.display }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -85,7 +85,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
links: [
|
||||
{ label: t('footer.about', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.brand', locale), href: routes.brand },
|
||||
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
|
||||
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
|
||||
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
|
||||
@@ -176,7 +175,10 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="pointer-events-none size-52 opacity-80 lg:mt-28"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
whenever
|
||||
} from '@vueuse/core'
|
||||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
@@ -31,9 +30,7 @@ const {
|
||||
autoplay = false,
|
||||
loop = false,
|
||||
minimal = false,
|
||||
hideControls = false,
|
||||
fit = 'cover',
|
||||
class: className
|
||||
hideControls = false
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
src?: string
|
||||
@@ -43,8 +40,6 @@ const {
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
|
||||
@@ -194,12 +189,7 @@ function toggleFullscreen() {
|
||||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
:class="
|
||||
cn(
|
||||
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
|
||||
className
|
||||
)
|
||||
"
|
||||
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
|
||||
@pointermove="showControls"
|
||||
@pointerdown="showControls"
|
||||
@focusin="showControls"
|
||||
@@ -207,9 +197,7 @@ function toggleFullscreen() {
|
||||
<video
|
||||
v-if="src"
|
||||
ref="videoEl"
|
||||
:class="
|
||||
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
|
||||
"
|
||||
class="size-full object-cover"
|
||||
:src
|
||||
:poster
|
||||
:preload="autoplay ? 'auto' : 'metadata'"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { href, label } = defineProps<{
|
||||
href: string
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ label }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,25 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { title, class: className } = defineProps<{
|
||||
title: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
const { title } = defineProps<{ title: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
|
||||
>
|
||||
<h1
|
||||
:class="
|
||||
cn(
|
||||
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
@@ -20,8 +20,6 @@ export const buttonVariants = cva(
|
||||
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
|
||||
underlineLink:
|
||||
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
|
||||
inline:
|
||||
'text-primary-comfy-yellow inline h-auto rounded-none p-0 align-baseline text-sm font-normal tracking-normal whitespace-normal hover:opacity-90 [&>span]:top-0 [&>span]:underline',
|
||||
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
|
||||
navMuted:
|
||||
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
|
||||
|
||||
@@ -21,8 +21,7 @@ const baseRoutes = {
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models',
|
||||
mcp: '/mcp',
|
||||
brand: '/brand'
|
||||
mcp: '/mcp'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -88,7 +87,6 @@ export const externalLinks = {
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpEndpoint: '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',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
import { BRAND_ASSETS_ZIP } from './brandAssets'
|
||||
|
||||
interface AffiliateBrandAsset {
|
||||
id: string
|
||||
title: LocalizedText
|
||||
@@ -9,6 +7,9 @@ interface AffiliateBrandAsset {
|
||||
preview: string
|
||||
}
|
||||
|
||||
const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
|
||||
{
|
||||
id: 'core-logo',
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// Shared brand download URLs served from the media bucket, used by both the
|
||||
// affiliate page and the brand portal.
|
||||
export const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
// Brand guidelines live in Google Drive, shared to Comfy Org only, so Google
|
||||
// enforces the comfy.org sign-in. Opened in a new tab rather than downloaded.
|
||||
export const BRAND_GUIDELINES_PDF =
|
||||
'https://drive.google.com/file/d/1EDt03JTfF_nbbY_H2n67aaUj6k11v3bS/view'
|
||||
@@ -1,91 +0,0 @@
|
||||
interface BrandColor {
|
||||
name: string
|
||||
hex: string
|
||||
rgb: string
|
||||
hsl: string
|
||||
cmyk: string
|
||||
swatchClass: string
|
||||
textClass: string
|
||||
wide?: boolean
|
||||
border?: boolean
|
||||
}
|
||||
|
||||
export const brandColors: readonly BrandColor[] = [
|
||||
{
|
||||
name: 'Comfy Yellow',
|
||||
hex: '#F2FF59',
|
||||
rgb: '242, 255, 89',
|
||||
hsl: '65, 100, 67',
|
||||
cmyk: '5, 0, 65, 0',
|
||||
swatchClass: 'bg-primary-comfy-yellow',
|
||||
textClass: 'text-primary-comfy-ink',
|
||||
wide: true
|
||||
},
|
||||
{
|
||||
name: 'Comfy Ink',
|
||||
hex: '#211927',
|
||||
rgb: '33, 25, 39',
|
||||
hsl: '274, 22, 13',
|
||||
cmyk: '15, 36, 0, 85',
|
||||
swatchClass: 'bg-primary-comfy-ink',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Comfy Canvas',
|
||||
hex: '#C2BFB9',
|
||||
rgb: '194, 191, 185',
|
||||
hsl: '40, 7, 74',
|
||||
cmyk: '0, 2, 5, 24',
|
||||
swatchClass: 'bg-primary-comfy-canvas',
|
||||
textClass: 'text-primary-comfy-ink'
|
||||
},
|
||||
{
|
||||
name: 'Comfy Plum',
|
||||
hex: '#49378B',
|
||||
rgb: '73, 55, 139',
|
||||
hsl: '253, 43, 38',
|
||||
cmyk: '47, 60, 0, 45',
|
||||
swatchClass: 'bg-primary-comfy-plum',
|
||||
textClass: 'text-primary-comfy-canvas'
|
||||
},
|
||||
{
|
||||
name: 'Warm White',
|
||||
hex: '#F0EFED',
|
||||
rgb: '240, 239, 237',
|
||||
hsl: '40, 9, 94',
|
||||
cmyk: '0, 0, 1, 6',
|
||||
swatchClass: 'bg-primary-warm-white',
|
||||
textClass: 'text-primary-comfy-ink',
|
||||
wide: true
|
||||
},
|
||||
{
|
||||
name: 'Warm Gray',
|
||||
hex: '#7E7C78',
|
||||
rgb: '126, 124, 120',
|
||||
hsl: '40, 2, 48',
|
||||
cmyk: '0, 2, 5, 51',
|
||||
swatchClass: 'bg-primary-warm-gray',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Cool Gray',
|
||||
hex: '#3C3C3C',
|
||||
rgb: '60, 60, 60',
|
||||
hsl: '0, 0, 24',
|
||||
cmyk: '0, 0, 0, 76',
|
||||
swatchClass: 'bg-secondary-cool-gray',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Mauve',
|
||||
hex: '#4D3762',
|
||||
rgb: '77, 55, 98',
|
||||
hsl: '271, 28, 30',
|
||||
cmyk: '21, 44, 0, 62',
|
||||
swatchClass: 'bg-secondary-mauve',
|
||||
textClass: 'text-primary-warm-white'
|
||||
}
|
||||
] as const
|
||||
@@ -1864,26 +1864,10 @@ const translations = {
|
||||
'zh-CN':
|
||||
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
|
||||
},
|
||||
'mcp.hero.demoPromptMoodboard': {
|
||||
en: 'turn the brief in this email into a 6-up moodboard',
|
||||
'zh-CN': '把这封邮件里的需求做成六宫格情绪板'
|
||||
},
|
||||
'mcp.hero.demoPromptConcepts': {
|
||||
en: 'sketch three concept frames for the launch page',
|
||||
'zh-CN': '为发布页画三张概念稿'
|
||||
},
|
||||
'mcp.hero.demoPromptKeyart': {
|
||||
'mcp.hero.demoPrompt': {
|
||||
en: "match this frame's palette, make the hero key art",
|
||||
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
|
||||
},
|
||||
'mcp.hero.demoPromptPbr': {
|
||||
en: 'make a tileable asphalt PBR material, all 5 maps',
|
||||
'zh-CN': '生成可平铺的沥青 PBR 材质,共 5 张贴图'
|
||||
},
|
||||
'mcp.hero.demoPromptUpscale': {
|
||||
en: 'upscale the neon kaiju shot to 4K',
|
||||
'zh-CN': '把霓虹怪兽画面放大到 4K'
|
||||
},
|
||||
'mcp.hero.viewDocs': {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
@@ -1892,6 +1876,10 @@ const translations = {
|
||||
en: 'INSTALL MCP',
|
||||
'zh-CN': '安装 MCP'
|
||||
},
|
||||
'mcp.hero.runWorkflow': {
|
||||
en: 'RUN A WORKFLOW',
|
||||
'zh-CN': '运行工作流'
|
||||
},
|
||||
'mcp.hero.demoGenerate': {
|
||||
en: 'GENERATE',
|
||||
'zh-CN': '生成'
|
||||
@@ -1909,90 +1897,60 @@ const translations = {
|
||||
'zh-CN': '放大图像'
|
||||
},
|
||||
|
||||
// MCP – SetupSection
|
||||
// MCP – SetupStepsSection
|
||||
'mcp.setup.label': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'mcp.setup.heading': {
|
||||
en: 'Set up Comfy MCP',
|
||||
'zh-CN': '配置 Comfy MCP'
|
||||
en: 'Set up Comfy MCP in three steps',
|
||||
'zh-CN': '三步完成 Comfy MCP 配置'
|
||||
},
|
||||
'mcp.setup.subtitle': {
|
||||
en: 'Two ways to connect: ask your agent to install it, or add the server yourself. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
'zh-CN':
|
||||
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.option1.label': { en: 'OPTION 1', 'zh-CN': '方式一' },
|
||||
'mcp.setup.option1.title': {
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Ask your agent to install Comfy MCP',
|
||||
'zh-CN': '让你的智能体安装 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.option1.command': {
|
||||
'mcp.setup.step1.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
},
|
||||
'mcp.setup.option1.description': {
|
||||
'mcp.setup.step1.description': {
|
||||
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'zh-CN':
|
||||
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
},
|
||||
'mcp.setup.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
|
||||
'mcp.setup.option2.title': {
|
||||
en: 'Install manually',
|
||||
'zh-CN': '手动安装'
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Or add it by hand',
|
||||
'zh-CN': '或手动添加'
|
||||
},
|
||||
'mcp.setup.option2.description': {
|
||||
en: 'Prefer manual setup? Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
|
||||
'zh-CN':
|
||||
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
|
||||
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
|
||||
},
|
||||
'mcp.setup.option2.tabsLabel': {
|
||||
en: 'Pick your client',
|
||||
'zh-CN': '选择你的客户端'
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
'zh-CN': 'COMFY CLOUD MCP 文档'
|
||||
},
|
||||
'mcp.setup.clients.claudeCode.step': {
|
||||
en: 'Run this in your terminal, then use /mcp to pick comfy-cloud and authenticate.',
|
||||
'zh-CN': '在终端运行以下命令,然后通过 /mcp 选择 comfy-cloud 并完成认证。'
|
||||
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
|
||||
'mcp.setup.step3.title': {
|
||||
en: 'Connect and sign in',
|
||||
'zh-CN': '连接并登录'
|
||||
},
|
||||
'mcp.setup.clients.claudeDesktop.step': {
|
||||
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
|
||||
'zh-CN':
|
||||
'点击侧边栏的 Customize,进入 Connectors,选择添加自定义连接器,粘贴上方 URL 并登录。'
|
||||
'mcp.setup.step3.description': {
|
||||
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
|
||||
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
|
||||
},
|
||||
'mcp.setup.clients.cursor.step': {
|
||||
en: 'Add the URL above to ~/.cursor/mcp.json with an X-API-Key header. Create your key at ',
|
||||
'zh-CN':
|
||||
'将上方 URL 添加到 ~/.cursor/mcp.json,并附带 X-API-Key 请求头。在此创建密钥:'
|
||||
},
|
||||
'mcp.setup.clients.cursor.linkLabel': {
|
||||
en: 'platform.comfy.org',
|
||||
'zh-CN': 'platform.comfy.org'
|
||||
},
|
||||
'mcp.setup.clients.codex.step': {
|
||||
en: 'Run this in your terminal, then codex mcp login comfy-cloud to sign in.',
|
||||
'zh-CN': '在终端运行以下命令,然后执行 codex mcp login comfy-cloud 登录。'
|
||||
},
|
||||
'mcp.setup.clients.other.name': {
|
||||
en: 'Other clients',
|
||||
'zh-CN': '其他客户端'
|
||||
},
|
||||
'mcp.setup.clients.other.step': {
|
||||
en: 'Add the URL above as a remote MCP server. No OAuth in your client? Use an X-API-Key header instead. Full walkthroughs live in the ',
|
||||
'zh-CN':
|
||||
'将上方 URL 添加为远程 MCP 服务器。客户端不支持 OAuth?改用 X-API-Key 请求头。完整教程见'
|
||||
},
|
||||
'mcp.setup.clients.other.linkLabel': {
|
||||
en: 'setup docs',
|
||||
'zh-CN': '设置文档'
|
||||
},
|
||||
'mcp.setup.skillsNote': {
|
||||
en: 'Using Claude Code? The Comfy skills plugin adds ready-made slash commands. ',
|
||||
'zh-CN': '在用 Claude Code?Comfy 技能插件提供现成的斜杠命令。'
|
||||
},
|
||||
'mcp.setup.skillsLink': {
|
||||
en: 'View on GitHub',
|
||||
'zh-CN': '在 GitHub 上查看'
|
||||
'mcp.setup.step3.cta': {
|
||||
en: 'COMFY CLOUD SKILLS',
|
||||
'zh-CN': 'COMFY CLOUD 技能'
|
||||
},
|
||||
|
||||
// MCP – WhyBuildSection
|
||||
@@ -2013,9 +1971,9 @@ const translations = {
|
||||
'zh-CN': '开放协议,\n任意客户端。'
|
||||
},
|
||||
'mcp.why.1.description': {
|
||||
en: 'MCP is an open standard, so any MCP-compatible client can connect. Claude Code, Claude Desktop, and Codex sign in with OAuth; every other agent connects with an API key.',
|
||||
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
|
||||
'zh-CN':
|
||||
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。Claude Code、Claude Desktop 和 Codex 通过 OAuth 登录,其他智能体使用 API 密钥连接。'
|
||||
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop,更多客户端即将推出。'
|
||||
},
|
||||
'mcp.why.2.title': {
|
||||
en: 'The full engine,\nnot a sandbox.',
|
||||
@@ -2079,53 +2037,14 @@ const translations = {
|
||||
'zh-CN': '运行真实工作流'
|
||||
},
|
||||
'mcp.tools.3.description': {
|
||||
en: 'Submit graphs, track jobs, and pull outputs back. Save and share workflows, reuse a saved one, or open any run on the ComfyUI canvas — the full engine, driven by tool calls.',
|
||||
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
|
||||
'zh-CN':
|
||||
'提交计算图、跟踪任务并取回输出。保存和分享工作流,复用已保存的工作流,或在 ComfyUI 画布上打开任意运行——完整的引擎,由工具调用驱动。'
|
||||
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
|
||||
},
|
||||
'mcp.tools.3.alt': {
|
||||
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
|
||||
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
|
||||
},
|
||||
'mcp.tools.4.title': {
|
||||
en: 'Direct any model',
|
||||
'zh-CN': '直接调用任意模型'
|
||||
},
|
||||
'mcp.tools.4.description': {
|
||||
en: 'Kling, Veo, Seedance, Flux, GPT-Image, Nano Banana, and ElevenLabs. Closed partner APIs and open-source models, reached through one set of tools.',
|
||||
'zh-CN':
|
||||
'Kling、Veo、Seedance、Flux、GPT-Image、Nano Banana 和 ElevenLabs。封闭的合作伙伴 API 与开源模型,通过同一套工具即可调用。'
|
||||
},
|
||||
'mcp.tools.4.alt': {
|
||||
en: 'Comfy MCP directing closed partner APIs and open-source models through one set of tools',
|
||||
'zh-CN': 'Comfy MCP 通过同一套工具调用封闭合作伙伴 API 和开源模型'
|
||||
},
|
||||
'mcp.tools.5.title': {
|
||||
en: 'Generate in batches',
|
||||
'zh-CN': '批量生成'
|
||||
},
|
||||
'mcp.tools.5.description': {
|
||||
en: 'Stack a batch on the Queue, track it, and pull back every output. Dozens of runs from a single call.',
|
||||
'zh-CN':
|
||||
'将一批任务加入队列,跟踪进度,并取回每一个输出。一次调用即可完成数十次运行。'
|
||||
},
|
||||
'mcp.tools.5.alt': {
|
||||
en: 'Comfy MCP stacking a batch on the Queue and pulling back every output',
|
||||
'zh-CN': 'Comfy MCP 将一批任务加入队列并取回每个输出'
|
||||
},
|
||||
'mcp.tools.6.title': {
|
||||
en: 'Ship it as an app',
|
||||
'zh-CN': '作为应用发布'
|
||||
},
|
||||
'mcp.tools.6.description': {
|
||||
en: 'Turn any workflow into an app with a shareable URL. Collaborators run it in the browser — only the inputs you expose, nothing to install.',
|
||||
'zh-CN':
|
||||
'将任意工作流变成带可分享链接的应用。协作者在浏览器中运行——只暴露你开放的输入,无需安装任何东西。'
|
||||
},
|
||||
'mcp.tools.6.alt': {
|
||||
en: 'Comfy MCP turning a workflow into a shareable browser app',
|
||||
'zh-CN': 'Comfy MCP 将工作流变成可在浏览器中分享的应用'
|
||||
},
|
||||
|
||||
// MCP – HowItWorksSection
|
||||
'mcp.howItWorks.heading': {
|
||||
@@ -2172,81 +2091,71 @@ const translations = {
|
||||
'zh-CN': '支持哪些客户端?'
|
||||
},
|
||||
'mcp.faq.1.a': {
|
||||
en: "For Claude Code, Claude Desktop, or Codex, add https://cloud.comfy.org/mcp as a custom connector or remote MCP server in any client, then sign in when prompted.\nFor clients that don't support OAuth, connect with a Comfy API key. Send the docs https://docs.comfy.org/agent-tools/cloud to your agent and it will figure out the installation for you.",
|
||||
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
|
||||
'zh-CN':
|
||||
'对于 Claude Code、Claude Desktop 或 Codex,在任意客户端中将 https://cloud.comfy.org/mcp 添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。\n对于不支持 OAuth 的客户端,请使用 Comfy API 密钥连接。将文档 https://docs.comfy.org/agent-tools/cloud 发送给你的智能体,它会为你完成安装。'
|
||||
'目前支持 Claude Code 和 Claude Desktop,均通过 OAuth 登录。更多客户端的支持即将推出。'
|
||||
},
|
||||
'mcp.faq.2.q': {
|
||||
en: "What's the server URL?",
|
||||
'zh-CN': '服务器 URL 是什么?'
|
||||
},
|
||||
'mcp.faq.2.a': {
|
||||
en: 'https://cloud.comfy.org/mcp — add it as a custom connector or remote MCP server in any client, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'https://cloud.comfy.org/mcp——在任意客户端中将它添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。'
|
||||
},
|
||||
'mcp.faq.3.q': {
|
||||
en: 'Do I need an API key?',
|
||||
'zh-CN': '我需要 API 密钥吗?'
|
||||
},
|
||||
'mcp.faq.3.a': {
|
||||
en: 'Not for Claude Code, Claude Desktop, or Codex. You need a Comfy API key for Cursor, Hermes, and OpenClaw for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
|
||||
'mcp.faq.2.a': {
|
||||
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
|
||||
'zh-CN':
|
||||
'Claude Code、Claude Desktop 和 Codex 不需要。Cursor、Hermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud,你的智能体就会为你完成安装。'
|
||||
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
|
||||
},
|
||||
'mcp.faq.3.q': {
|
||||
en: 'Do the slash commands work in Claude Desktop?',
|
||||
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
|
||||
},
|
||||
'mcp.faq.3.a': {
|
||||
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
|
||||
'zh-CN':
|
||||
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
|
||||
},
|
||||
'mcp.faq.4.q': {
|
||||
en: 'Does it cost anything?',
|
||||
'zh-CN': '需要付费吗?'
|
||||
en: "The sign-in didn't open a browser.",
|
||||
'zh-CN': '登录时没有打开浏览器。'
|
||||
},
|
||||
'mcp.faq.4.a': {
|
||||
en: "Connecting is free with a Comfy account, and searching models, nodes, and templates doesn't cost credits. Running a generation uses Comfy Cloud credits and needs a subscription or credit balance. Your agent confirms with you before it spends.",
|
||||
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
|
||||
'zh-CN':
|
||||
'使用 Comfy 账户连接是免费的,搜索模型、节点和模板也不消耗积分。运行生成会使用 Comfy Cloud 积分,需要订阅或积分余额。智能体在消费前会先与你确认。'
|
||||
'在 Claude Code 中,运行 /mcp,选择 comfy-cloud,然后选择 Authenticate(授权)。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
|
||||
},
|
||||
'mcp.faq.5.q': {
|
||||
en: 'Can I use it with my local ComfyUI?',
|
||||
'zh-CN': '可以配合我的本地 ComfyUI 使用吗?'
|
||||
en: 'How do I connect in Claude Code?',
|
||||
'zh-CN': '如何在 Claude Code 中连接?'
|
||||
},
|
||||
'mcp.faq.5.a': {
|
||||
en: 'Coming soon. Today, to drive a local ComfyUI, you can use comfy-cli: https://github.com/Comfy-Org/comfy-cli',
|
||||
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
|
||||
'zh-CN':
|
||||
'即将推出。目前,若要操作本地 ComfyUI,你可以使用 comfy-cli:https://github.com/Comfy-Org/comfy-cli'
|
||||
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate(授权)。一步即可添加连接和斜杠命令。'
|
||||
},
|
||||
'mcp.faq.6.q': {
|
||||
en: "What's the server URL for Claude Desktop?",
|
||||
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
|
||||
},
|
||||
'mcp.faq.6.a': {
|
||||
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
|
||||
},
|
||||
'mcp.faq.7.q': {
|
||||
en: 'What can my agent do once connected?',
|
||||
'zh-CN': '连接后我的智能体能做什么?'
|
||||
},
|
||||
'mcp.faq.6.a': {
|
||||
en: "• Generate images, video, audio, and 3D — including all open-source workflows and partner models like Seedance, GPT-Image, Nano Banana, and Kling\n• Build, edit, and run workflows; save and re-run workflows\n• Run and read in large batches\n• Search models, nodes, and template workflows\n• Read and execute shared workflow URLs\n• Upload and download assets for you\n\nEverything is now in natural language. No nodes, no downloads, no GPU, no node graphs if you don't want them.",
|
||||
'zh-CN':
|
||||
'• 生成图像、视频、音频和 3D——包括所有开源工作流以及 Seedance、GPT-Image、Nano Banana 和 Kling 等合作伙伴模型\n• 构建、编辑和运行工作流;保存并重新运行工作流\n• 大批量运行和读取\n• 搜索模型、节点和模板工作流\n• 读取并执行分享的工作流链接\n• 为你上传和下载资产\n\n现在一切都用自然语言完成。如果你愿意,无需节点、无需下载、无需 GPU、无需节点图。'
|
||||
},
|
||||
'mcp.faq.7.q': {
|
||||
en: 'Where do my outputs go?',
|
||||
'zh-CN': '我的输出会保存到哪里?'
|
||||
},
|
||||
'mcp.faq.7.a': {
|
||||
en: 'Into your Comfy Cloud asset library, so you can reuse, remix, and share them — and open any run on the canvas to keep editing. You can also ask your agent to download the assets locally for you.',
|
||||
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
|
||||
'zh-CN':
|
||||
'保存到你的 Comfy Cloud 资产库,你可以复用、二次创作和分享——还能在画布上打开任意运行继续编辑。你也可以让智能体把资产下载到本地。'
|
||||
'生成图像、视频、音频和 3D;搜索模型、节点和模板;并运行 ComfyUI 工作流——全部在对话中完成。'
|
||||
},
|
||||
'mcp.faq.8.q': {
|
||||
en: 'Do slash commands work in Claude Desktop?',
|
||||
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
|
||||
},
|
||||
'mcp.faq.8.a': {
|
||||
en: 'No. They ship with the Claude Code comfy-cloud plugin. Desktop connects to the same MCP server, so every tool works; just ask in plain language.',
|
||||
'zh-CN':
|
||||
'不可以。斜杠命令随 Claude Code 的 comfy-cloud 插件一起提供。Claude Desktop 连接的是同一个 MCP 服务器,因此所有工具都能使用;直接用自然语言提问即可。'
|
||||
},
|
||||
'mcp.faq.9.q': {
|
||||
en: 'Is it generally available?',
|
||||
'zh-CN': '现已正式发布了吗?'
|
||||
},
|
||||
'mcp.faq.9.a': {
|
||||
en: 'Yes. Comfy Cloud MCP is in open beta and available to everyone with a Comfy account.',
|
||||
'zh-CN':
|
||||
'是的。Comfy Cloud MCP 目前处于公开测试阶段,任何拥有 Comfy 账户的人都可以使用。'
|
||||
'mcp.faq.8.a': {
|
||||
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
|
||||
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
|
||||
},
|
||||
|
||||
// SiteNav
|
||||
@@ -2272,7 +2181,6 @@ const translations = {
|
||||
'nav.youtube': { en: 'YouTube', 'zh-CN': 'YouTube' },
|
||||
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'nav.brand': { en: 'Brand', 'zh-CN': '品牌' },
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
@@ -4538,161 +4446,6 @@ const translations = {
|
||||
'launches.section.title': {
|
||||
en: 'Latest Launches',
|
||||
'zh-CN': '最新发布'
|
||||
},
|
||||
|
||||
// Brand Portal page (/brand)
|
||||
'brand.page.title': {
|
||||
en: 'Brand — Comfy',
|
||||
'zh-CN': '品牌 — Comfy'
|
||||
},
|
||||
'brand.page.description': {
|
||||
en: 'The Comfy brand portal: logos, color, typography, and voice. Everything you need to build something that looks and sounds like Comfy.',
|
||||
'zh-CN':
|
||||
'Comfy 品牌门户:标志、色彩、字体与语调。打造与 Comfy 观感一致、表达一致所需的一切。'
|
||||
},
|
||||
'brand.hero.label': {
|
||||
en: 'Brand Portal',
|
||||
'zh-CN': '品牌门户'
|
||||
},
|
||||
'brand.hero.heading': {
|
||||
en: 'Create with ComfyUI',
|
||||
'zh-CN': '用 ComfyUI 创作'
|
||||
},
|
||||
'brand.hero.subheading': {
|
||||
en: 'Logo, color, type, and voice. Everything you need to build something that looks and sounds like us.',
|
||||
'zh-CN': '标志、色彩、字体与语调。打造与我们观感一致、表达一致所需的一切。'
|
||||
},
|
||||
'brand.hero.viewGuidelines': {
|
||||
en: 'View brand guidelines',
|
||||
'zh-CN': '查看品牌规范'
|
||||
},
|
||||
'brand.hero.downloadLogos': {
|
||||
en: 'Download logos',
|
||||
'zh-CN': '下载标志'
|
||||
},
|
||||
'brand.logos.heading': {
|
||||
en: 'One mark, many dimensions.',
|
||||
'zh-CN': '一个标志,多种维度。'
|
||||
},
|
||||
'brand.logos.subheading': {
|
||||
en: 'Logos come in light and dark options. Use as provided. Do not distort, recolor, or outline. Make sure the logo is legible against its background.',
|
||||
'zh-CN':
|
||||
'标志提供浅色和深色两种版本。请按原样使用,不要变形、改色或描边。确保标志在其背景上清晰可辨。'
|
||||
},
|
||||
'brand.colors.heading': {
|
||||
en: 'Every color earns its place.',
|
||||
'zh-CN': '每种颜色都各得其所。'
|
||||
},
|
||||
'brand.colors.subheading': {
|
||||
en: 'Our color palette helps build brand recognition. When people think of Comfy, we want them to associate it with the following colors.',
|
||||
'zh-CN':
|
||||
'我们的调色板有助于建立品牌辨识度。当人们想到 Comfy 时,我们希望他们联想到以下这些颜色。'
|
||||
},
|
||||
'brand.colors.copy': {
|
||||
en: 'Copy',
|
||||
'zh-CN': '复制'
|
||||
},
|
||||
'brand.colors.copied': {
|
||||
en: 'Copied',
|
||||
'zh-CN': '已复制'
|
||||
},
|
||||
'brand.voice.heading': {
|
||||
en: 'Precise, never cute.',
|
||||
'zh-CN': '精准,绝不卖弄。'
|
||||
},
|
||||
'brand.voice.direct.title': {
|
||||
en: 'Direct',
|
||||
'zh-CN': '直接'
|
||||
},
|
||||
'brand.voice.direct.body': {
|
||||
en: 'We state things. We don’t hedge, qualify, or suggest. Short sentences. Active voice. One idea at a time.',
|
||||
'zh-CN':
|
||||
'我们直陈其事。不含糊、不设限、不暗示。短句。主动语态。一次只讲一个观点。'
|
||||
},
|
||||
'brand.voice.precise.title': {
|
||||
en: 'Precise',
|
||||
'zh-CN': '精准'
|
||||
},
|
||||
'brand.voice.precise.body': {
|
||||
en: 'We use the real names for things. Nodes, samplers, seeds, checkpoints. We don’t talk around the product or reach for metaphor when the technical term is already good.',
|
||||
'zh-CN':
|
||||
'我们直呼其名:nodes、samplers、seeds、checkpoints。当技术术语已经足够贴切时,我们不绕弯子,也不借用比喻。'
|
||||
},
|
||||
'brand.voice.human.title': {
|
||||
en: 'Human-first',
|
||||
'zh-CN': '以人为先'
|
||||
},
|
||||
'brand.voice.human.body': {
|
||||
en: 'The human creates. Comfy makes every step visible. We never write as though the AI is doing the work.',
|
||||
'zh-CN':
|
||||
'创作的是人。Comfy 让每一步都清晰可见。我们绝不把功劳写成是 AI 完成的。'
|
||||
},
|
||||
'brand.voice.antihype.title': {
|
||||
en: 'Anti-hype',
|
||||
'zh-CN': '拒绝浮夸'
|
||||
},
|
||||
'brand.voice.antihype.body': {
|
||||
en: 'We don’t write “stunning,” “revolutionary,” or “effortless.” We don’t promise magic. Our tagline says exactly what we mean: Method, not magic.',
|
||||
'zh-CN':
|
||||
'我们不写“惊艳”“革命性”或“毫不费力”。我们不承诺魔法。我们的口号恰如其分:方法,而非魔法。'
|
||||
},
|
||||
'brand.voice.doLabel': {
|
||||
en: 'Do',
|
||||
'zh-CN': '推荐'
|
||||
},
|
||||
'brand.voice.dontLabel': {
|
||||
en: 'Don’t',
|
||||
'zh-CN': '避免'
|
||||
},
|
||||
'brand.voice.do.0': {
|
||||
en: 'Route your prompt through a ControlNet. Wire the output to the VAE decode.',
|
||||
'zh-CN': '让你的 prompt 经过 ControlNet,再将输出连接到 VAE decode。'
|
||||
},
|
||||
'brand.voice.do.1': {
|
||||
en: 'Comfy runs on your hardware. Nothing leaves your machine.',
|
||||
'zh-CN': 'Comfy 在你自己的硬件上运行。任何数据都不会离开你的机器。'
|
||||
},
|
||||
'brand.voice.dont.0': {
|
||||
en: 'Simply connect your AI blocks and watch the magic happen!',
|
||||
'zh-CN': '只需连接你的 AI 模块,见证奇迹的发生!'
|
||||
},
|
||||
'brand.voice.dont.1': {
|
||||
en: 'Oops! Something went wrong. Please try again later.',
|
||||
'zh-CN': '哎呀!出了点问题,请稍后再试。'
|
||||
},
|
||||
'brand.trademark.heading': {
|
||||
en: 'Trademark guidelines.',
|
||||
'zh-CN': '商标使用规范。'
|
||||
},
|
||||
'brand.trademark.body1': {
|
||||
en: 'Comfy and ComfyUI are trademarks of Comfy Org. You’re welcome to reference them in content that accurately describes your work with our platform. Tutorials, reviews, integrations, and affiliate content all qualify.',
|
||||
'zh-CN':
|
||||
'Comfy 和 ComfyUI 是 Comfy Org 的商标。欢迎在准确描述你与我们平台相关工作的内容中引用它们。教程、评测、集成以及联盟内容均可。'
|
||||
},
|
||||
'brand.trademark.body2': {
|
||||
en: 'A few rules: don’t modify the logo, don’t use the Comfy name in your own product or company name, and don’t present your content in a way that implies official endorsement or partnership beyond what’s been agreed.',
|
||||
'zh-CN':
|
||||
'几条规则:不要修改标志,不要在你自己的产品或公司名称中使用 Comfy 这一名称,也不要以暗示官方认可或合作关系(超出双方已达成的约定)的方式呈现你的内容。'
|
||||
},
|
||||
'brand.trademark.body3': {
|
||||
en: 'For permissions outside these guidelines,',
|
||||
'zh-CN': '如需本规范之外的授权,请'
|
||||
},
|
||||
'brand.trademark.contact': {
|
||||
en: 'Contact Us',
|
||||
'zh-CN': '联系我们'
|
||||
},
|
||||
'brand.questions.heading': {
|
||||
en: 'Questions?',
|
||||
'zh-CN': '有疑问?'
|
||||
},
|
||||
'brand.questions.body': {
|
||||
en: 'For press, partnerships, or anything outside these guidelines,',
|
||||
'zh-CN': '如涉及媒体、合作,或本规范未涵盖的任何事宜,请'
|
||||
},
|
||||
'brand.questions.contact': {
|
||||
en: 'Contact Us',
|
||||
'zh-CN': '联系我们'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: 'Learning Center',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: 'Workflow templates',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: 'Customer stories',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="You're booked - Comfy"
|
||||
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="You're booked." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>Check your email for the calendar invite and meeting link!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
Resources while you wait
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import BrandBackground from '../templates/brand/BrandBackground.vue'
|
||||
import BrandHeroSection from '../templates/brand/BrandHeroSection.vue'
|
||||
import BrandLogosSection from '../templates/brand/BrandLogosSection.vue'
|
||||
import BrandColorSection from '../templates/brand/BrandColorSection.vue'
|
||||
import BrandVoiceSection from '../templates/brand/BrandVoiceSection.vue'
|
||||
import BrandTrademarkSection from '../templates/brand/BrandTrademarkSection.vue'
|
||||
import BrandQuestionsSection from '../templates/brand/BrandQuestionsSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('brand.page.title', locale)}
|
||||
description={t('brand.page.description', locale)}
|
||||
>
|
||||
<div class="relative">
|
||||
<BrandBackground client:idle />
|
||||
<BrandHeroSection />
|
||||
<BrandLogosSection />
|
||||
<BrandColorSection client:visible />
|
||||
<BrandVoiceSection />
|
||||
<BrandTrademarkSection />
|
||||
<BrandQuestionsSection />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
@@ -31,7 +30,7 @@ const { siteUrl, locale } = pageContext(
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Thanks for reaching out - Comfy"
|
||||
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="Thanks for reaching out." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
Based on what you shared, one of our self-serve plans is probably a
|
||||
better fit.
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>,
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>, and
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
> for individual creators, plus our new
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> plan for multiple users under shared billing.
|
||||
</p>
|
||||
|
||||
<PlansPricingCta
|
||||
href={routes.cloudPricing}
|
||||
label="See plans and pricing →"
|
||||
/>
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
Need help with something else? Email: <a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: '学习中心',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: '工作流模板',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: '客户案例',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: '文档',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="预约成功 - Comfy"
|
||||
description="您的会议已预约成功。请查收邮件中的日历邀请和会议链接。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="预约成功。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>请查收邮件中的日历邀请和会议链接!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
等待期间的资源
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import BrandBackground from '../../templates/brand/BrandBackground.vue'
|
||||
import BrandHeroSection from '../../templates/brand/BrandHeroSection.vue'
|
||||
import BrandLogosSection from '../../templates/brand/BrandLogosSection.vue'
|
||||
import BrandColorSection from '../../templates/brand/BrandColorSection.vue'
|
||||
import BrandVoiceSection from '../../templates/brand/BrandVoiceSection.vue'
|
||||
import BrandTrademarkSection from '../../templates/brand/BrandTrademarkSection.vue'
|
||||
import BrandQuestionsSection from '../../templates/brand/BrandQuestionsSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('brand.page.title', 'zh-CN')}
|
||||
description={t('brand.page.description', 'zh-CN')}
|
||||
>
|
||||
<div class="relative">
|
||||
<BrandBackground client:idle />
|
||||
<BrandHeroSection locale="zh-CN" />
|
||||
<BrandLogosSection locale="zh-CN" />
|
||||
<BrandColorSection client:visible locale="zh-CN" />
|
||||
<BrandVoiceSection locale="zh-CN" />
|
||||
<BrandTrademarkSection locale="zh-CN" />
|
||||
<BrandQuestionsSection locale="zh-CN" />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
@@ -34,7 +33,7 @@ const { siteUrl, locale } = pageContext(
|
||||
},
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="感谢您的联系 - Comfy"
|
||||
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="感谢您的联系。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>、
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>
|
||||
和
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
>,以及我们全新的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> 套餐,可让多位用户共享账单。
|
||||
</p>
|
||||
|
||||
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
需要其他方面的帮助?邮箱:<a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,112 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useEventListener,
|
||||
useIntersectionObserver,
|
||||
useRafFn
|
||||
} from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
|
||||
interface Node {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
}
|
||||
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
let dpr =
|
||||
typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1
|
||||
|
||||
const nodes: Node[] = Array.from({ length: 14 }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
vx: (Math.random() - 0.5) * 0.0005,
|
||||
vy: (Math.random() - 0.5) * 0.0005
|
||||
}))
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
|
||||
function draw() {
|
||||
const el = canvasEl.value
|
||||
if (!el || !ctx) return
|
||||
const w = el.width
|
||||
const h = el.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
for (const n of nodes) {
|
||||
n.x += n.vx
|
||||
n.y += n.vy
|
||||
if (n.x < 0 || n.x > 1) n.vx *= -1
|
||||
if (n.y < 0 || n.y > 1) n.vy *= -1
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(242, 255, 89, 0.18)'
|
||||
ctx.lineWidth = dpr
|
||||
const max = Math.min(w, h) * 0.22
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i]
|
||||
const b = nodes[j]
|
||||
const dx = (a.x - b.x) * w
|
||||
const dy = (a.y - b.y) * h
|
||||
const d = Math.hypot(dx, dy)
|
||||
if (d < max) {
|
||||
ctx.globalAlpha = 1 - d / max
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x * w, a.y * h)
|
||||
const mx = ((a.x + b.x) / 2) * w
|
||||
ctx.bezierCurveTo(mx, a.y * h, mx, b.y * h, b.x * w, b.y * h)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.fillStyle = 'rgba(242, 255, 89, 0.9)'
|
||||
for (const n of nodes) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(n.x * w, n.y * h, 2.5 * dpr, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// Resizing clears the canvas bitmap, so repaint immediately afterwards to keep
|
||||
// the field visible even when the RAF loop is paused (off screen or
|
||||
// reduced-motion). Also refresh dpr in case the window moved to another display.
|
||||
function resize() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
el.width = el.offsetWidth * dpr
|
||||
el.height = el.offsetHeight * dpr
|
||||
draw()
|
||||
}
|
||||
|
||||
const { pause, resume } = useRafFn(draw, { immediate: false })
|
||||
|
||||
// Only animate while the field is on screen, and honour reduced-motion by
|
||||
// painting a single static frame instead of looping.
|
||||
useIntersectionObserver(canvasEl, ([entry]) => {
|
||||
if (prefersReducedMotion()) return
|
||||
if (entry?.isIntersecting) resume()
|
||||
else pause()
|
||||
})
|
||||
|
||||
useEventListener('resize', resize)
|
||||
|
||||
onMounted(() => {
|
||||
ctx = canvasEl.value?.getContext('2d') ?? null
|
||||
resize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-screen w-full"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import { brandColors } from '../../data/brandColors'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const specRows = ['hex', 'rgb', 'hsl', 'cmyk'] as const
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
|
||||
const copiedHex = ref<string | null>(null)
|
||||
const copiedValue = ref('')
|
||||
|
||||
function copyValue(hex: string, value: string) {
|
||||
copiedHex.value = hex
|
||||
copiedValue.value = value
|
||||
void copy(value)
|
||||
}
|
||||
|
||||
function isCardCopied(hex: string) {
|
||||
return copied.value && copiedHex.value === hex
|
||||
}
|
||||
|
||||
const liveMessage = computed(() =>
|
||||
copied.value ? `${t('brand.colors.copied', locale)} ${copiedValue.value}` : ''
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.colors.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
|
||||
{{ t('brand.colors.subheading', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
|
||||
|
||||
<ul class="mt-10 grid grid-cols-2 gap-4 lg:grid-cols-5">
|
||||
<li
|
||||
v-for="color in brandColors"
|
||||
:key="color.hex"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-[123px] cursor-pointer flex-col rounded-[30px] p-6',
|
||||
color.swatchClass,
|
||||
color.textClass,
|
||||
color.wide && 'lg:col-span-2',
|
||||
color.border && 'border-primary-warm-gray border-[0.783px]'
|
||||
)
|
||||
"
|
||||
@click="copyValue(color.hex, color.hex)"
|
||||
>
|
||||
<div
|
||||
v-if="isCardCopied(color.hex)"
|
||||
class="flex flex-1 items-center justify-center text-center text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ t('brand.colors.copied', locale) }} {{ copiedValue }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs font-semibold">{{ color.name }}</span>
|
||||
<dl
|
||||
class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 text-xs leading-[1.4]"
|
||||
>
|
||||
<template v-for="row in specRows" :key="row">
|
||||
<dt class="uppercase opacity-50">{{ row }}</dt>
|
||||
<dd>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="`${t('brand.colors.copy', locale)} ${row} ${color[row]}`"
|
||||
class="cursor-pointer text-left hover:underline"
|
||||
@click.stop="copyValue(color.hex, color[row])"
|
||||
>
|
||||
{{ color[row] }}
|
||||
</button>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import Button from '../../components/ui/button/Button.vue'
|
||||
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../../data/brandAssets'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto px-6 pt-4 pb-10 text-center lg:px-20 lg:pb-12"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-extrabold tracking-[0.7px] uppercase"
|
||||
>
|
||||
{{ t('brand.hero.label', locale) }}
|
||||
</p>
|
||||
<h1
|
||||
class="lg:text-6.5xl mx-auto mt-6 max-w-4xl text-4xl leading-[1.3] font-light tracking-[-0.03em] text-primary-comfy-canvas md:text-5xl"
|
||||
>
|
||||
{{ t('brand.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
class="mx-auto mt-6 max-w-2xl text-[17px] leading-[1.6] font-light text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ t('brand.hero.subheading', locale) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row"
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
:href="BRAND_GUIDELINES_PDF"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
|
||||
>
|
||||
{{ t('brand.hero.viewGuidelines', locale) }}
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
:href="BRAND_ASSETS_ZIP"
|
||||
download
|
||||
variant="outline"
|
||||
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
|
||||
>
|
||||
{{ t('brand.hero.downloadLogos', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const assets = affiliateBrandAssets.map((asset) =>
|
||||
asset.id === 'icon' ? { ...asset, preview: '/icons/comfyicon.svg' } : asset
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="logos" class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.logos.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
|
||||
{{ t('brand.logos.subheading', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<ul class="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<li
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
class="flex min-h-60 flex-col rounded-[30px] border-[1.5px] border-white/8 lg:min-h-[285px]"
|
||||
>
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<img
|
||||
:src="asset.preview"
|
||||
:alt="asset.title[locale]"
|
||||
:class="
|
||||
cn(
|
||||
'object-contain',
|
||||
asset.id === 'icon'
|
||||
? 'size-24 rounded-[23%] border border-white/10'
|
||||
: 'max-h-24 max-w-[75%]'
|
||||
)
|
||||
"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="pb-8 text-center text-[21px] font-medium tracking-[1.05px] text-primary-comfy-canvas"
|
||||
>
|
||||
{{ asset.title[locale] }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import Button from '../../components/ui/button/Button.vue'
|
||||
import { externalLinks } from '../../config/routes.ts'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto px-6 pt-10 pb-24 lg:px-20 lg:pt-12 lg:pb-32"
|
||||
>
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.questions.heading', locale) }}
|
||||
</SectionHeader>
|
||||
|
||||
<p class="text-primary-warm-gray mt-6 max-w-2xl text-sm leading-[1.6]">
|
||||
{{ t('brand.questions.body', locale) }}
|
||||
<Button
|
||||
as="a"
|
||||
variant="inline"
|
||||
:href="externalLinks.support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('brand.questions.contact', locale) }}
|
||||
</Button>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import Button from '../../components/ui/button/Button.vue'
|
||||
import { externalLinks } from '../../config/routes.ts'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.trademark.heading', locale) }}
|
||||
</SectionHeader>
|
||||
|
||||
<div
|
||||
class="text-primary-warm-gray mt-6 flex max-w-4xl flex-col gap-4 text-sm leading-[1.6]"
|
||||
>
|
||||
<p>{{ t('brand.trademark.body1', locale) }}</p>
|
||||
<p>{{ t('brand.trademark.body2', locale) }}</p>
|
||||
<p>
|
||||
{{ t('brand.trademark.body3', locale) }}
|
||||
<Button
|
||||
as="a"
|
||||
variant="inline"
|
||||
:href="externalLinks.support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('brand.trademark.contact', locale) }}
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const principles = [
|
||||
{
|
||||
title: t('brand.voice.direct.title', locale),
|
||||
body: t('brand.voice.direct.body', locale)
|
||||
},
|
||||
{
|
||||
title: t('brand.voice.precise.title', locale),
|
||||
body: t('brand.voice.precise.body', locale)
|
||||
},
|
||||
{
|
||||
title: t('brand.voice.human.title', locale),
|
||||
body: t('brand.voice.human.body', locale)
|
||||
},
|
||||
{
|
||||
title: t('brand.voice.antihype.title', locale),
|
||||
body: t('brand.voice.antihype.body', locale)
|
||||
}
|
||||
]
|
||||
|
||||
const doExamples = [
|
||||
t('brand.voice.do.0', locale),
|
||||
t('brand.voice.do.1', locale)
|
||||
]
|
||||
|
||||
const dontExamples = [
|
||||
t('brand.voice.dont.0', locale),
|
||||
t('brand.voice.dont.1', locale)
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.voice.heading', locale) }}
|
||||
</SectionHeader>
|
||||
|
||||
<dl class="mt-10 flex max-w-4xl flex-col gap-3.5 text-sm leading-[1.6]">
|
||||
<div v-for="principle in principles" :key="principle.title">
|
||||
<dt class="text-primary-comfy-yellow">{{ principle.title }}</dt>
|
||||
<dd class="text-primary-warm-gray">{{ principle.body }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-12 grid gap-4 md:grid-cols-2">
|
||||
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow size-2.5 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-bold tracking-wider text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ t('brand.voice.doLabel', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="example in doExamples"
|
||||
:key="example"
|
||||
class="bg-transparency-ink-t80 rounded-2xl p-5"
|
||||
>
|
||||
<p class="text-base/[1.45] text-primary-comfy-canvas">
|
||||
{{ example }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="bg-primary-warm-gray size-2.5 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="text-primary-warm-gray text-sm font-bold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('brand.voice.dontLabel', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="example in dontExamples"
|
||||
:key="example"
|
||||
class="bg-transparency-ink-t80 rounded-2xl p-5"
|
||||
>
|
||||
<p class="text-primary-warm-gray text-base/[1.45] line-through">
|
||||
{{ example }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,40 +7,35 @@ import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const PROMPT = t('mcp.hero.demoPrompt', locale)
|
||||
const generateLabel = t('mcp.hero.demoGenerate', locale)
|
||||
|
||||
// Each cycle types the prompt that produces the card about to slide in.
|
||||
const cards = [
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptMoodboard',
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'moodboard_v1.png · 6-up',
|
||||
tag: 'Gmail',
|
||||
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptConcepts',
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'concepts_01–03.png',
|
||||
tag: 'Notion',
|
||||
thumb: '/images/mcp/mcp-thumb-concepts.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptKeyart',
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'hero_keyart.png',
|
||||
tag: 'Figma',
|
||||
thumb: '/images/mcp/mcp-thumb-keyart.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptPbr',
|
||||
actionKey: 'mcp.hero.demoActionGenerate3d',
|
||||
file: 'asphalt_pbr/ · 5 maps',
|
||||
tag: 'Blender',
|
||||
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptUpscale',
|
||||
actionKey: 'mcp.hero.demoActionUpscale',
|
||||
file: 'kaiju_neon_4k.png · 4096',
|
||||
tag: null,
|
||||
@@ -70,15 +65,15 @@ function schedule(fn: () => void, ms: number) {
|
||||
}, ms)
|
||||
}
|
||||
|
||||
function typePrompt(prompt: string, onDone: () => void) {
|
||||
function typePrompt(onDone: () => void) {
|
||||
displayedPrompt.value = ''
|
||||
promptDone.value = false
|
||||
let i = 0
|
||||
|
||||
function step() {
|
||||
i++
|
||||
displayedPrompt.value = prompt.slice(0, i)
|
||||
if (i < prompt.length) {
|
||||
displayedPrompt.value = PROMPT.slice(0, i)
|
||||
if (i < PROMPT.length) {
|
||||
schedule(step, 35)
|
||||
} else {
|
||||
promptDone.value = true
|
||||
@@ -99,8 +94,8 @@ function revealNextCard() {
|
||||
return
|
||||
}
|
||||
|
||||
// Type the next card's prompt, then slide that card in
|
||||
typePrompt(t(cards[visibleCount.value].promptKey, locale), () => {
|
||||
// Type the prompt, then slide in the next card
|
||||
typePrompt(() => {
|
||||
visibleCount.value++
|
||||
schedule(revealNextCard, 400)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const
|
||||
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
|
||||
|
||||
const faqs = faqNumbers.map((n) => ({
|
||||
id: String(n),
|
||||
|
||||
@@ -11,10 +11,9 @@ const ctas = mcpCtas(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 5rem/6.75rem = HeaderMain's rendered height (py-5 / lg:py-8) so the hero fills the viewport below the sticky nav -->
|
||||
<HeroSplit01
|
||||
:locale="locale"
|
||||
class="min-h-[calc(100svh-5rem)] lg:min-h-[calc(100svh-6.75rem)]"
|
||||
class="min-h-screen"
|
||||
badge-text="MCP"
|
||||
:title="t('mcp.hero.heading', locale)"
|
||||
:subtitle="t('mcp.hero.subtitle', locale)"
|
||||
|
||||
@@ -23,7 +23,7 @@ const steps: FeatureStep[] = stepNumbers.map((n) => ({
|
||||
<FeatureGrid02
|
||||
:heading="t('mcp.howItWorks.heading', locale)"
|
||||
:steps="steps"
|
||||
:primary-cta="ctas.installMcp"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:secondary-cta="ctas.docs"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,180 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import SectionLabel from '../../components/common/SectionLabel.vue'
|
||||
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
|
||||
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
|
||||
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const agentCommand = t('mcp.setup.option1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
|
||||
interface McpClient {
|
||||
id: string
|
||||
name: string
|
||||
step: string
|
||||
command?: string
|
||||
link?: { label: string; href: string }
|
||||
}
|
||||
|
||||
const clients: McpClient[] = [
|
||||
const cards: FeatureCard[] = [
|
||||
{
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code',
|
||||
step: t('mcp.setup.clients.claudeCode.step', locale),
|
||||
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
name: 'Claude Desktop',
|
||||
step: t('mcp.setup.clients.claudeDesktop.step', locale)
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
name: 'Cursor',
|
||||
step: t('mcp.setup.clients.cursor.step', locale),
|
||||
link: {
|
||||
label: t('mcp.setup.clients.cursor.linkLabel', locale),
|
||||
href: externalLinks.apiKeys
|
||||
id: 'step1',
|
||||
label: t('mcp.setup.step1.label', locale),
|
||||
title: t('mcp.setup.step1.title', locale),
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: t('mcp.setup.step1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
step: t('mcp.setup.clients.codex.step', locale),
|
||||
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
|
||||
id: 'step2',
|
||||
label: t('mcp.setup.step2.label', locale),
|
||||
title: t('mcp.setup.step2.title', locale),
|
||||
description: t('mcp.setup.step2.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step2.cta', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
name: t('mcp.setup.clients.other.name', locale),
|
||||
step: t('mcp.setup.clients.other.step', locale),
|
||||
link: {
|
||||
label: t('mcp.setup.clients.other.linkLabel', locale),
|
||||
href: externalLinks.docsMcp
|
||||
id: 'step3',
|
||||
label: t('mcp.setup.step3.label', locale),
|
||||
title: t('mcp.setup.step3.title', locale),
|
||||
description: t('mcp.setup.step3.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step3.cta', locale),
|
||||
href: externalLinks.mcpSkills,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const copyLabel = t('ui.copy', locale)
|
||||
const copiedLabel = t('ui.copied', locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
<FeatureGrid01
|
||||
id="setup"
|
||||
class="max-w-9xl mx-auto scroll-mt-24 px-6 py-16 lg:scroll-mt-36 lg:py-24"
|
||||
>
|
||||
<SectionHeader
|
||||
max-width="xl"
|
||||
:label="t('mcp.setup.label', locale)"
|
||||
align="start"
|
||||
>
|
||||
{{ t('mcp.setup.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ t('mcp.setup.subtitle', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div class="mt-16 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
|
||||
<h3
|
||||
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
>
|
||||
{{ t('mcp.setup.option1.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.option1.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="agentCommand"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-auto pt-6 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.skillsNote', locale)
|
||||
}}<a
|
||||
:href="externalLinks.mcpSkills"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ t('mcp.setup.skillsLink', locale) }}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<SectionLabel>{{ t('mcp.setup.option2.label', locale) }}</SectionLabel>
|
||||
<h3
|
||||
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
>
|
||||
{{ t('mcp.setup.option2.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.option2.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="externalLinks.mcpEndpoint"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsRoot default-value="claude-code" class="mt-6">
|
||||
<TabsList
|
||||
:aria-label="t('mcp.setup.option2.tabsLabel', locale)"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<TabsTrigger
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="bg-transparency-white-t4 focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow cursor-pointer rounded-full px-4 py-2 text-xs font-bold tracking-wider text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink"
|
||||
>
|
||||
{{ client.name }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="mt-4 flex min-h-24 flex-col gap-3"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ client.step
|
||||
}}<a
|
||||
v-if="client.link"
|
||||
:href="client.link.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ client.link.label }}</a
|
||||
>
|
||||
</p>
|
||||
<CopyableField
|
||||
v-if="client.command"
|
||||
:value="client.command"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
class="scroll-mt-24 lg:scroll-mt-36"
|
||||
:eyebrow="t('mcp.setup.label', locale)"
|
||||
:heading="t('mcp.setup.heading', locale)"
|
||||
:subtitle="t('mcp.setup.subtitle', locale)"
|
||||
:columns="3"
|
||||
:cards="cards"
|
||||
:copy-label="t('ui.copy', locale)"
|
||||
:copied-label="t('ui.copied', locale)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -7,21 +7,16 @@ import { t } from '../../i18n/translations'
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
type ToolMedia =
|
||||
| { type: 'image'; src: string; fit?: 'cover' | 'contain' }
|
||||
| { type: 'image'; src: string }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
}
|
||||
|
||||
const tools: {
|
||||
n: 1 | 2 | 3 | 4 | 5 | 6
|
||||
media: ToolMedia
|
||||
altKey?: TranslationKey
|
||||
}[] = [
|
||||
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
|
||||
{
|
||||
n: 1,
|
||||
media: {
|
||||
@@ -45,38 +40,9 @@ const tools: {
|
||||
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
hideControls: true,
|
||||
fit: 'contain'
|
||||
hideControls: true
|
||||
},
|
||||
altKey: 'mcp.tools.3.alt'
|
||||
},
|
||||
{
|
||||
n: 4,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/direct-any-model.png'
|
||||
},
|
||||
altKey: 'mcp.tools.4.alt'
|
||||
},
|
||||
{
|
||||
n: 5,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/generate-in-batches.png'
|
||||
},
|
||||
altKey: 'mcp.tools.5.alt'
|
||||
},
|
||||
{
|
||||
n: 6,
|
||||
media: {
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/homepage/showcase/ui-overview.webm',
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
hideControls: true,
|
||||
fit: 'contain'
|
||||
},
|
||||
altKey: 'mcp.tools.6.alt'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
@@ -9,13 +9,14 @@ export interface McpCta {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls-to-action for the MCP page: view the docs or jump to the on-page
|
||||
* setup options. Both the hero and the "how it works" section pair install
|
||||
* with docs.
|
||||
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
|
||||
* steps, or run a workflow in the cloud. The hero leads with install + docs;
|
||||
* the "how it works" section pairs run-a-workflow with docs.
|
||||
*/
|
||||
export function mcpCtas(locale: Locale): {
|
||||
docs: McpCta
|
||||
installMcp: McpCta
|
||||
runWorkflow: McpCta
|
||||
} {
|
||||
return {
|
||||
docs: {
|
||||
@@ -26,6 +27,10 @@ export function mcpCtas(locale: Locale): {
|
||||
installMcp: {
|
||||
label: t('mcp.hero.installMcp', locale),
|
||||
href: '#setup'
|
||||
},
|
||||
runWorkflow: {
|
||||
label: t('mcp.hero.runWorkflow', locale),
|
||||
href: getRoutes(locale).cloud
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,31 +171,6 @@ describe('comfyUiSourceCodeNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyUI entity links', () => {
|
||||
it('names the home page as the canonical page for the application', () => {
|
||||
const node = comfyUiApplicationNode(siteUrl)
|
||||
expect(node.mainEntityOfPage).toBe(`${siteUrl}/`)
|
||||
})
|
||||
|
||||
it('links the application back to the source code emitted alongside it', () => {
|
||||
const app = comfyUiApplicationNode(siteUrl)
|
||||
const source = comfyUiSourceCodeNode(siteUrl)
|
||||
expect(app.isBasedOn).toEqual({ '@id': source['@id'] })
|
||||
})
|
||||
|
||||
it('claims no canonical page or source code for third-party software', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.mainEntityOfPage).toBeUndefined()
|
||||
expect(node.isBasedOn).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPageGraph', () => {
|
||||
const url = 'https://comfy.org/cloud/pricing/'
|
||||
const graph = buildPageGraph(
|
||||
|
||||
@@ -194,8 +194,6 @@ export interface SoftwareAppInput {
|
||||
authorName?: string
|
||||
isFree?: boolean
|
||||
sameAs?: string[]
|
||||
mainEntityOfPage?: string
|
||||
isBasedOnId?: string
|
||||
}
|
||||
|
||||
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
@@ -221,8 +219,6 @@ export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
author,
|
||||
publisher: input.firstParty ? orgRef : undefined,
|
||||
sameAs: input.sameAs,
|
||||
mainEntityOfPage: input.mainEntityOfPage,
|
||||
isBasedOn: input.isBasedOnId ? { '@id': input.isBasedOnId } : undefined,
|
||||
offers: input.isFree
|
||||
? {
|
||||
'@type': 'Offer',
|
||||
@@ -261,10 +257,6 @@ export function comfyUiSoftwareId(siteUrl: string): string {
|
||||
return `${siteUrl}/#software`
|
||||
}
|
||||
|
||||
function comfyUiSourceCodeId(siteUrl: string): string {
|
||||
return `${siteUrl}/#sourcecode`
|
||||
}
|
||||
|
||||
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
return softwareApplicationNode({
|
||||
siteUrl,
|
||||
@@ -275,16 +267,14 @@ export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Windows, macOS, Linux',
|
||||
isFree: true,
|
||||
sameAs: comfyUiSameAs,
|
||||
mainEntityOfPage: `${siteUrl}/`,
|
||||
isBasedOnId: comfyUiSourceCodeId(siteUrl)
|
||||
sameAs: comfyUiSameAs
|
||||
})
|
||||
}
|
||||
|
||||
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
|
||||
return softwareSourceCodeNode({
|
||||
siteUrl,
|
||||
id: comfyUiSourceCodeId(siteUrl),
|
||||
id: `${siteUrl}/#sourcecode`,
|
||||
name: 'ComfyUI',
|
||||
codeRepository: externalLinks.github,
|
||||
programmingLanguage: 'Python',
|
||||
|
||||
@@ -54,11 +54,6 @@
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/login",
|
||||
"destination": "https://cloud.comfy.org/login",
|
||||
"permanent": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -322,9 +322,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Folder view ---
|
||||
public readonly backToAssetsButton: Locator
|
||||
|
||||
// --- Panel chrome ---
|
||||
public readonly panelHeader: Locator
|
||||
|
||||
// --- Loading ---
|
||||
public readonly skeletonLoaders: Locator
|
||||
|
||||
@@ -361,7 +358,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
|
||||
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.panelHeader = page.locator('.comfy-vue-side-bar-header')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
)
|
||||
|
||||
@@ -17,7 +17,9 @@ import { mockSystemStats } from '../fixtures/data/systemStats'
|
||||
|
||||
// Extend the base set with test-specific nodes
|
||||
const nodeDefs = createMockNodeDefinitions({
|
||||
MyCustomNode: {/* ... */}
|
||||
MyCustomNode: {
|
||||
/* ... */
|
||||
}
|
||||
})
|
||||
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
|
||||
@@ -28,11 +28,11 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
// billing_control_enabled routes personal workspaces to the unified pricing
|
||||
// table asserted here; without it they fall back to the legacy table.
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
billing_control_enabled: true
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Widget actions menu', { tag: '@ui' }, () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
})
|
||||
|
||||
test('menu opens when clicking the more button', async ({ comfyPage }) => {
|
||||
const moreButton = panel.root
|
||||
.getByTestId(TestIds.subgraphEditor.widgetActionsMenuButton)
|
||||
.first()
|
||||
await expect(moreButton).toBeVisible()
|
||||
await moreButton.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu.getByText('Rename')).toBeVisible()
|
||||
})
|
||||
|
||||
test('menu items are left-aligned', async ({ comfyPage }) => {
|
||||
const moreButton = panel.root
|
||||
.getByTestId(TestIds.subgraphEditor.widgetActionsMenuButton)
|
||||
.first()
|
||||
await moreButton.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const menuButtons = menu.getByRole('button')
|
||||
const count = await menuButtons.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const button = menuButtons.nth(i)
|
||||
await expect
|
||||
.poll(() =>
|
||||
button.evaluate((el) => {
|
||||
const style = getComputedStyle(el)
|
||||
return style.justifyContent
|
||||
})
|
||||
)
|
||||
.toBe('flex-start')
|
||||
}
|
||||
})
|
||||
|
||||
test('menu shows Rename and Favorite actions', async ({ comfyPage }) => {
|
||||
const moreButton = panel.root
|
||||
.getByTestId(TestIds.subgraphEditor.widgetActionsMenuButton)
|
||||
.first()
|
||||
await moreButton.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu.getByText('Rename')).toBeVisible()
|
||||
await expect(menu.getByText('Favorite')).toBeVisible()
|
||||
})
|
||||
|
||||
test('menu closes after clicking an action', async ({ comfyPage }) => {
|
||||
const moreButton = panel.root
|
||||
.getByTestId(TestIds.subgraphEditor.widgetActionsMenuButton)
|
||||
.first()
|
||||
await moreButton.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
await menu.getByText('Favorite').click()
|
||||
await expect(menu).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -276,255 +276,3 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('FE-910 marquee selection and select all', () => {
|
||||
test.beforeEach(async ({ jobsRoutes, page, comfyPage }) => {
|
||||
await jobsRoutes.mockJobsQueue([])
|
||||
await jobsRoutes.mockJobsHistory(generatedJobs)
|
||||
await mockInputFiles(page, ['imported.png'])
|
||||
await mockViewFiles(page, viewFiles)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.menu.assetsTab.open()
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd+A selects every asset while the panel is hovered', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
await tab.getAssetCardByName('alpha').hover()
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+a')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('a marquee that begins in the panel header selects the cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
|
||||
const header = await tab.panelHeader.boundingBox()
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!header || !beta) {
|
||||
throw new Error('panel header or asset card has no layout box')
|
||||
}
|
||||
|
||||
// Begin the rubber-band in the header (above the grid), then drag down
|
||||
// across both cards.
|
||||
await page.mouse.move(header.x + 24, header.y + 20)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + 8, beta.y + beta.height - 8, { steps: 14 })
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd+A leaves assets unselected while the canvas is hovered', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
if (!viewport) throw new Error('viewport size is unavailable')
|
||||
|
||||
// Hover the canvas (not the panel); Ctrl/Cmd+A must yield to the canvas.
|
||||
await page.mouse.move(viewport.width - 100, viewport.height / 2)
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a modifier-held marquee adds to the existing selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
await tab.getAssetCardByName('alpha').click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!beta) throw new Error('beta card has no layout box')
|
||||
|
||||
// Hold a modifier so the marquee is additive, then rubber-band over beta.
|
||||
await page.keyboard.down('Control')
|
||||
await page.mouse.move(beta.x + 12, beta.y + 12)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
|
||||
steps: 12
|
||||
})
|
||||
await page.mouse.up()
|
||||
await page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('a Ctrl/Cmd+Shift marquee removes the covered cards from the selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
await tab.getAssetCardByName('alpha').hover()
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!beta) throw new Error('beta card has no layout box')
|
||||
|
||||
// Ctrl+Shift makes the marquee subtractive: rubber-band over beta only.
|
||||
await page.keyboard.down('Control')
|
||||
await page.keyboard.down('Shift')
|
||||
await page.mouse.move(beta.x + 12, beta.y + 12)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
|
||||
steps: 12
|
||||
})
|
||||
await page.mouse.up()
|
||||
await page.keyboard.up('Shift')
|
||||
await page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName('alpha')).toHaveAttribute(
|
||||
'data-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd-dragging from an asset card starts a marquee selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
|
||||
const alpha = await tab.getAssetCardByName('alpha').boundingBox()
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!alpha || !beta) throw new Error('asset cards have no layout box')
|
||||
|
||||
// Ctrl bypasses card drag, so a press that begins on a card rubber-bands.
|
||||
await page.keyboard.down('Control')
|
||||
await page.mouse.move(alpha.x + alpha.width / 2, alpha.y + alpha.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + beta.width - 6, beta.y + beta.height - 6, {
|
||||
steps: 12
|
||||
})
|
||||
await page.mouse.up()
|
||||
await page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd-dragging within a single card selects only that card', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
const alpha = tab.getAssetCardByName('alpha')
|
||||
const box = await alpha.boundingBox()
|
||||
if (!box) throw new Error('alpha card has no layout box')
|
||||
|
||||
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
await page.keyboard.down('Control')
|
||||
await page.mouse.move(start.x, start.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(start.x + 12, start.y + 12, { steps: 4 })
|
||||
await page.mouse.up()
|
||||
await page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
await expect(alpha).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd+A in the focused search input does not select assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const query = 'alpha'
|
||||
|
||||
await tab.searchInput.fill(query)
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
|
||||
await tab.searchInput.focus()
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+a')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
tab.searchInput.evaluate((el: HTMLInputElement) => {
|
||||
return { start: el.selectionStart, end: el.selectionEnd }
|
||||
})
|
||||
)
|
||||
.toEqual({ start: 0, end: query.length })
|
||||
})
|
||||
|
||||
test('a drag starting in the search input does not marquee-select assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
const search = await tab.searchInput.boundingBox()
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!search || !beta)
|
||||
throw new Error('search box or card has no layout box')
|
||||
|
||||
await page.mouse.move(
|
||||
search.x + search.width / 2,
|
||||
search.y + search.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + beta.width / 2, beta.y + beta.height / 2, {
|
||||
steps: 12
|
||||
})
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd+A does not select assets while an aria-modal dialog is open', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const dialog = document.createElement('div')
|
||||
dialog.id = 'test-modal'
|
||||
dialog.setAttribute('role', 'dialog')
|
||||
dialog.setAttribute('aria-modal', 'true')
|
||||
document.body.appendChild(dialog)
|
||||
})
|
||||
|
||||
await tab.getAssetCardByName('alpha').hover()
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+a')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
document.getElementById('test-modal')?.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const ERROR_CLASS = /ring-destructive-background/
|
||||
const SLOT_ERROR_CLASS = /before:ring-error/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
@@ -70,25 +69,6 @@ async function selectLoadImageNodeForPaste(
|
||||
}, localLoadImageId)
|
||||
}
|
||||
|
||||
async function getInputSlotIndexByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
inputName: string
|
||||
): Promise<number> {
|
||||
return comfyPage.page.evaluate(
|
||||
({ inputName, nodeId }) => {
|
||||
const graph = window.app!.canvas.graph ?? window.app!.graph
|
||||
const node = graph.getNodeById(nodeId)
|
||||
const index = node?.findInputSlot(inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ inputName, nodeId: toNodeId(nodeId) }
|
||||
)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
@@ -159,10 +139,17 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
async ({ comfyPage }) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
|
||||
const modelInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
ksamplerId,
|
||||
KSAMPLER_MODEL_INPUT_NAME
|
||||
const modelInputIndex = await comfyPage.page.evaluate(
|
||||
({ nodeId, inputName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const index =
|
||||
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
)
|
||||
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
|
||||
ksamplerId,
|
||||
@@ -188,7 +175,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(modelInputSlotRow).toBeVisible()
|
||||
await expect(modelInputSlotRow).toBeInViewport()
|
||||
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
@@ -420,76 +407,5 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('boundary-linked validation error surfaces on the subgraph host', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphParentId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
const innerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
const hostInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
subgraphParentId,
|
||||
'positive'
|
||||
)
|
||||
const hostInputSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
subgraphParentId,
|
||||
hostInputIndex
|
||||
)
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host must mount before injecting validation errors'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host should start without an error ring'
|
||||
).not.toHaveClass(ERROR_CLASS)
|
||||
|
||||
await test.step('surface the boundary-linked error on the host', async () => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[INNER_EXECUTION_ID]: buildKSamplerError(
|
||||
'required_input_missing',
|
||||
'positive',
|
||||
'Required input is missing: positive'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('confirm the interior node does not show the surfaced ring', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
const interiorKSamplerId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const interiorPositiveInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
interiorKSamplerId,
|
||||
'positive'
|
||||
)
|
||||
const interiorPositiveSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
interiorKSamplerId,
|
||||
interiorPositiveInputIndex
|
||||
)
|
||||
const interiorInnerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
|
||||
|
||||
await expect(interiorInnerWrapper).toBeVisible()
|
||||
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
await expect(interiorPositiveSlotHighlight).toBeVisible()
|
||||
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
|
||||
SLOT_ERROR_CLASS
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,45 +7,6 @@ import {
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Vue Upload Widgets', { tag: '@vue-nodes' }, () => {
|
||||
test.describe('media selection', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
})
|
||||
|
||||
test('keeps a selected image loaded when it is selected again', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
expect(loadImageNodes, 'Workflow has one Load Image node').toHaveLength(1)
|
||||
const [loadImageNode] = loadImageNodes
|
||||
|
||||
const imageWidget = await loadImageNode.getWidgetByName('image')
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const imageLoadError = node.getByTestId(TestIds.errors.imageLoadError)
|
||||
const selectedImageButton = node.getByRole('button', {
|
||||
name: 'example.png',
|
||||
exact: true
|
||||
})
|
||||
await expect(selectedImageButton).toBeVisible()
|
||||
await expect(imageLoadError).toBeHidden()
|
||||
|
||||
await selectedImageButton.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByText('example.png', { exact: true }).click()
|
||||
await expect(menu).toBeHidden()
|
||||
|
||||
await expect(selectedImageButton).toBeFocused()
|
||||
await expect(selectedImageButton).toBeVisible()
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
|
||||
await expect(imageLoadError).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# 12. Cloud Release Notes Use the ComfyUI Version
|
||||
|
||||
Date: 2026-07-13
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
|
||||
|
||||
## Context
|
||||
|
||||
The release-note system (`releaseStore`) decides whether to surface new-release
|
||||
UI — the desktop toast/red-dot and the "what's new" popup — by comparing the
|
||||
version of the most recent entry in the `/releases` feed against the version the
|
||||
user is currently running.
|
||||
|
||||
`currentVersion` sourced that "current version" differently per platform:
|
||||
|
||||
- on Cloud, from `system_stats.system.cloud_version`,
|
||||
- everywhere else, from `system_stats.system.comfyui_version`.
|
||||
|
||||
The `/releases` feed, however, is authored in the docs repo and its entries are
|
||||
keyed by **ComfyUI** version for every project, including `project: 'cloud'`.
|
||||
There is no separate cloud-versioned feed, and the "learn more" link on every
|
||||
release note points at <https://docs.comfy.org/changelog>, which only lists
|
||||
ComfyUI versions.
|
||||
|
||||
This mismatch broke the feature on Cloud (tracked as **FE-1237**):
|
||||
|
||||
- Cloud runs a much higher `cloud_version` (e.g. `0.160.1`) than the ComfyUI
|
||||
version the feed entries carry (e.g. `0.27.1`).
|
||||
- The comparison therefore resolved as `0.27.1 < 0.160.1` → "already ahead of
|
||||
the latest release" → the popup's `isLatestVersion` gate never passed and the
|
||||
popup never showed.
|
||||
- Analytics confirmed the regression: `release_note` clicks fell from 13.4% of
|
||||
clicks over 90 days to 0% over 30 days, and `cloud_release_note` was
|
||||
effectively never clicked.
|
||||
|
||||
Two directions could fix the mismatch:
|
||||
|
||||
1. Give Cloud its own cloud-versioned release feed and a cloud changelog page.
|
||||
2. Compare against the ComfyUI version on Cloud too, so the running version and
|
||||
the feed entries share the same version namespace.
|
||||
|
||||
Option 1 requires infrastructure that does not exist: no cloud changelog page,
|
||||
and in current practice a single person maintains the changelog in the docs
|
||||
repo using ComfyUI versions, updated after each Cloud deploy completes. A
|
||||
cloud-versioned popup would deep-link users to a changelog page that has no
|
||||
matching entry, which is more confusing than the version label itself.
|
||||
|
||||
## Decision
|
||||
|
||||
`currentVersion` always uses `comfyui_version`, on Cloud as well as everywhere
|
||||
else. `cloud_version` is no longer consulted for release-note version
|
||||
comparisons.
|
||||
|
||||
The `/releases` request still sends `project: 'cloud'` on Cloud, so Cloud can
|
||||
receive a curated subset of release notes; only the version used for comparison
|
||||
changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Cloud shows a release note once the running ComfyUI version matches the latest
|
||||
published feed entry — consistent with the practice of updating the changelog
|
||||
after a Cloud deploy lands. Publishing a note for a version Cloud has not yet
|
||||
deployed correctly withholds the popup until the deploy catches up.
|
||||
- The popup and its "learn more" link now reference a ComfyUI version that
|
||||
actually exists on the changelog page.
|
||||
- `cloud_version` remains available in `system_stats` for other consumers; this
|
||||
decision scopes only to release-note version comparison.
|
||||
- If Cloud later wants release notes tied to its own versioning, it would need a
|
||||
cloud-versioned feed **and** a cloud changelog page, at which point this ADR
|
||||
should be revisited.
|
||||
@@ -21,7 +21,6 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
|
||||
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
|
||||
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
|
||||
| [0012](0012-cloud-release-notes-use-comfyui-version.md) | Cloud Release Notes Use the ComfyUI Version | Accepted | 2026-07-13 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {/* props */}
|
||||
args: {
|
||||
/* props */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -37,8 +37,10 @@ const config: KnipConfig = {
|
||||
entry: ['src/scripts/**/*.ts']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify-json/lucide',
|
||||
'@iconify/json',
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
@@ -55,9 +57,6 @@ const config: KnipConfig = {
|
||||
// Marketing media tooling — adopted by pages in a follow-up PR
|
||||
'apps/website/src/components/common/SiteVideo.vue',
|
||||
'apps/website/src/utils/marketingImage.ts',
|
||||
// Pending integration: consumed by the useWorkspaceInvoices seam once
|
||||
// #13591 (Plan & Credits tabs) lands — FE-1245
|
||||
'src/composables/billing/useNextInvoice.ts',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Devtools extensions, included dynamically
|
||||
|
||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.48.3",
|
||||
"version": "1.48.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,8 +29,6 @@
|
||||
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"fallow": "fallow",
|
||||
"fallow:audit": "fallow audit",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
@@ -97,7 +95,7 @@
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.3.0",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
@@ -111,7 +109,7 @@
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "7.0.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "catalog:",
|
||||
"jsonata": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
@@ -174,7 +172,6 @@
|
||||
"eslint-plugin-testing-library": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fallow": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "catalog:",
|
||||
@@ -219,5 +216,5 @@
|
||||
"node": ">=25 <26",
|
||||
"pnpm": ">=11.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
}
|
||||
|
||||
@@ -40,11 +40,6 @@ export type ComfyDesktop2TelemetryProperties = Record<
|
||||
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
|
||||
>
|
||||
|
||||
export type ComfyDesktop2FirebaseAuthState =
|
||||
| { status: 'pending' }
|
||||
| { status: 'signed_out' }
|
||||
| { status: 'signed_in'; userId: string }
|
||||
|
||||
export interface ComfyDesktop2TerminalBridge {
|
||||
subscribe(installationId?: string): Promise<TerminalRestore>
|
||||
unsubscribe(installationId?: string): Promise<void>
|
||||
@@ -65,7 +60,6 @@ export interface ComfyDesktop2LogsBridge {
|
||||
|
||||
export interface ComfyDesktop2TelemetryBridge {
|
||||
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
|
||||
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2Bridge {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-desktop-bridge-types",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
|
||||
"homepage": "https://comfy.org",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -78,9 +78,9 @@
|
||||
--color-interface-panel-job-progress-border: var(--base-foreground);
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b / 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b / 0.1);
|
||||
--color-modal-tag: rgb(from var(--color-smoke-400) r g b / 0.4);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
|
||||
--color-modal-tag: rgb(from var(--color-smoke-400) r g b/ 0.4);
|
||||
--color-alpha-charcoal-600-30: color-mix(
|
||||
in srgb,
|
||||
var(--color-charcoal-600) 30%,
|
||||
@@ -107,7 +107,7 @@
|
||||
--color-highlight: var(--p-primary-color);
|
||||
|
||||
/* Special Colors (temporary) */
|
||||
--color-dark-elevation-1-5: rgba(from white r g b / 0.015);
|
||||
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
|
||||
--color-dark-elevation-2: rgba(from white r g b / 0.03);
|
||||
}
|
||||
|
||||
@@ -1873,9 +1873,9 @@ audio.comfy-audio.empty-audio-widget {
|
||||
/* Shadow Cover TOP */
|
||||
linear-gradient(--value(--color-*) 30%, transparent) center top,
|
||||
/* Shadow Cover BOTTOM */
|
||||
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
|
||||
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
|
||||
/* Shadow TOP */
|
||||
radial-gradient(
|
||||
radial-gradient(
|
||||
farthest-side at 50% 0,
|
||||
color-mix(in oklab, --value(--color-*), #777777 35%),
|
||||
60%,
|
||||
@@ -1883,7 +1883,7 @@ audio.comfy-audio.empty-audio-widget {
|
||||
)
|
||||
center top,
|
||||
/* Shadow BOTTOM */
|
||||
radial-gradient(
|
||||
radial-gradient(
|
||||
farthest-side at 50% 100%,
|
||||
color-mix(in oklab, --value(--color-*), #777777 35%),
|
||||
60%,
|
||||
|
||||
@@ -16,7 +16,6 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
|
||||
}
|
||||
: {
|
||||
retries: process.env.CI ? 3 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
use: {
|
||||
trace: 'on-first-retry'
|
||||
}
|
||||
@@ -26,7 +25,7 @@ export default defineConfig({
|
||||
testDir: './browser_tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
reporter: process.env.PLAYWRIGHT_BLOB_OUTPUT_DIR ? 'blob' : 'html',
|
||||
reporter: 'html',
|
||||
...maybeLocalOptions,
|
||||
|
||||
globalSetup: './browser_tests/globalSetup.ts',
|
||||
|
||||
10311
pnpm-lock.yaml
generated
10311
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -24,26 +24,26 @@ catalog:
|
||||
'@iconify/tailwind4': ^1.2.3
|
||||
'@iconify/tools': ^5.0.3
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.5.1
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@lucide/vue': ^1.17.0
|
||||
'@pinia/testing': ^2.0.1
|
||||
'@playwright/test': ^1.61.1
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
'@primeuix/styled': 0.3.2
|
||||
'@primeuix/utils': ^0.3.2
|
||||
'@primevue/core': 4.2.5
|
||||
'@primevue/forms': 4.2.5
|
||||
'@primevue/core': ^4.2.5
|
||||
'@primevue/forms': ^4.2.5
|
||||
'@primevue/icons': 4.2.5
|
||||
'@primevue/themes': 4.2.5
|
||||
'@primevue/themes': ^4.2.5
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^2.1.0
|
||||
'@storybook/addon-docs': ^10.5.0
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.5.0
|
||||
'@storybook/vue3-vite': ^10.5.0
|
||||
'@tailwindcss/vite': ^4.3.2
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.3.0
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
@@ -59,16 +59,16 @@ catalog:
|
||||
'@total-typescript/shoehorn': ^0.1.2
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^25.9.5
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.184.1
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.8
|
||||
'@vitest/coverage-v8': ^4.1.10
|
||||
'@vitest/ui': ^4.1.10
|
||||
'@vueuse/core': ^14.3.0
|
||||
'@vueuse/integrations': ^14.3.0
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
'@vitest/ui': ^4.0.16
|
||||
'@vueuse/core': ^14.2.0
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
astro: ^6.4.2
|
||||
@@ -76,75 +76,74 @@ catalog:
|
||||
class-variance-authority: ^0.7.1
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dompurify: 3.4.7
|
||||
dompurify: ^3.4.5
|
||||
dotenv: ^16.4.5
|
||||
eslint: ^10.7.0
|
||||
eslint: ^10.4.0
|
||||
eslint-config-prettier: ^10.1.8
|
||||
eslint-import-resolver-typescript: ^4.4.5
|
||||
eslint-plugin-better-tailwindcss: ^4.6.1
|
||||
eslint-plugin-import-x: ^4.17.1
|
||||
eslint-plugin-oxlint: 1.73.0
|
||||
eslint-plugin-playwright: ^2.10.5
|
||||
eslint-plugin-storybook: ^10.5.0
|
||||
eslint-plugin-testing-library: ^7.16.2
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.2
|
||||
eslint-plugin-oxlint: 1.69.0
|
||||
eslint-plugin-playwright: ^2.10.1
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.4.1
|
||||
eslint-plugin-vue: ^10.9.2
|
||||
fallow: ^2.102.0
|
||||
eslint-plugin-vue: ^10.9.1
|
||||
fast-check: ^4.5.3
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
globals: ^17.7.0
|
||||
globals: ^16.5.0
|
||||
gsap: ^3.14.2
|
||||
happy-dom: 20.9.0
|
||||
happy-dom: ^20.8.9
|
||||
husky: ^9.1.7
|
||||
jiti: 2.7.0
|
||||
jsdom: ^29.1.1
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
knip: ^6.27.0
|
||||
knip: ^6.15.0
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
minisearch: ^7.2.0
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.59.0
|
||||
oxlint: ^1.74.0
|
||||
oxlint-tsgolint: ^0.24.0
|
||||
oxfmt: ^0.54.0
|
||||
oxlint: ^1.69.0
|
||||
oxlint-tsgolint: ^0.23.0
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^4.0.2
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
posthog-js: ^1.358.1
|
||||
pretty-bytes: ^7.1.0
|
||||
primeicons: ^7.0.0
|
||||
primevue: 4.2.5
|
||||
primevue: ^4.2.5
|
||||
reka-ui: 2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.5.0
|
||||
stylelint: ^17.14.0
|
||||
tailwindcss: ^4.3.2
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
three: ^0.184.0
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
typegpu: ^0.8.2
|
||||
typescript: ^6.0.3
|
||||
typescript-eslint: ^8.64.0
|
||||
typescript: ^5.9.3
|
||||
typescript-eslint: ^8.60.0
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
uuid: ^11.1.1
|
||||
vee-validate: ^4.15.1
|
||||
vite: ^8.1.4
|
||||
vite: ^8.0.13
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.1.5
|
||||
vitest: ^4.1.10
|
||||
vue: ^3.5.39
|
||||
vue-component-type-helpers: ^3.3.7
|
||||
vue-eslint-parser: ^10.4.1
|
||||
vue-i18n: ^11.4.6
|
||||
vue-router: ^5.2.0
|
||||
vue-tsc: ^3.3.7
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
vitest: ^4.1.0
|
||||
vue: ^3.5.34
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.4.0
|
||||
vue-i18n: ^9.14.5
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.2.5
|
||||
vuefire: ^3.2.1
|
||||
wwobjloader2: ^6.2.1
|
||||
yjs: ^13.6.27
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
computeTargetVersion,
|
||||
isValidSemver,
|
||||
parseRequirementsVersion,
|
||||
parseTargetBranchOverride
|
||||
} from './resolve-comfyui-release'
|
||||
|
||||
describe('parseRequirementsVersion', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-release-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeRequirements(content: string): string {
|
||||
const filePath = path.join(dir, 'requirements.txt')
|
||||
fs.writeFileSync(filePath, content)
|
||||
return filePath
|
||||
}
|
||||
|
||||
it('parses a pinned == version', () => {
|
||||
const file = writeRequirements(
|
||||
'torch\ncomfyui-frontend-package==1.45.20\nnumpy'
|
||||
)
|
||||
expect(parseRequirementsVersion(file)).toBe('1.45.20')
|
||||
})
|
||||
|
||||
it('parses a >= constraint', () => {
|
||||
const file = writeRequirements('comfyui-frontend-package>=2.0.3')
|
||||
expect(parseRequirementsVersion(file)).toBe('2.0.3')
|
||||
})
|
||||
|
||||
it('returns null when the package is absent', () => {
|
||||
const file = writeRequirements('torch\nnumpy')
|
||||
expect(parseRequirementsVersion(file)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the file is missing', () => {
|
||||
expect(parseRequirementsVersion(path.join(dir, 'nope.txt'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidSemver', () => {
|
||||
it('accepts a valid X.Y.Z', () => {
|
||||
expect(isValidSemver('1.45.20')).toBe(true)
|
||||
expect(isValidSemver('2.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-three-part versions', () => {
|
||||
expect(isValidSemver('1.45')).toBe(false)
|
||||
expect(isValidSemver('1.45.20.1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-numeric or leading-zero-padded parts', () => {
|
||||
expect(isValidSemver('1.x.0')).toBe(false)
|
||||
expect(isValidSemver('v1.45.20')).toBe(false)
|
||||
expect(isValidSemver('1.045.20')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty / non-string input', () => {
|
||||
expect(isValidSemver('')).toBe(false)
|
||||
// @ts-expect-error exercising runtime guard
|
||||
expect(isValidSemver(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTargetBranchOverride', () => {
|
||||
it('parses core/1.47', () => {
|
||||
expect(parseTargetBranchOverride('core/1.47')).toEqual({
|
||||
major: 1,
|
||||
minor: 47,
|
||||
branch: 'core/1.47'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a major bump core/2.0', () => {
|
||||
expect(parseTargetBranchOverride('core/2.0')).toEqual({
|
||||
major: 2,
|
||||
minor: 0,
|
||||
branch: 'core/2.0'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed overrides', () => {
|
||||
expect(parseTargetBranchOverride('core/1')).toBeNull()
|
||||
expect(parseTargetBranchOverride('1.47')).toBeNull()
|
||||
expect(parseTargetBranchOverride('core/1.47.0')).toBeNull()
|
||||
expect(parseTargetBranchOverride('release/1.47')).toBeNull()
|
||||
expect(parseTargetBranchOverride('core/v1.47')).toBeNull()
|
||||
expect(parseTargetBranchOverride('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTargetVersion', () => {
|
||||
it('bumps patch on a 2.x line when commits exist', () => {
|
||||
expect(computeTargetVersion(2, 0, 'v2.0.3', true)).toBe('2.0.4')
|
||||
})
|
||||
|
||||
it('keeps the tag version when no new commits exist', () => {
|
||||
expect(computeTargetVersion(2, 0, 'v2.0.3', false)).toBe('2.0.3')
|
||||
})
|
||||
|
||||
it('starts a fresh major.minor line at .0 when no tag exists', () => {
|
||||
expect(computeTargetVersion(2, 0, null, true)).toBe('2.0.0')
|
||||
expect(computeTargetVersion(1, 47, null, true)).toBe('1.47.0')
|
||||
})
|
||||
|
||||
it('returns null for a malformed tag', () => {
|
||||
expect(computeTargetVersion(1, 47, 'v1.47', true)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -81,72 +81,15 @@ function isValidSemver(version: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a target branch override of the form `core/<major>.<minor>`.
|
||||
* Returns the parsed major/minor and normalized branch, or null if malformed.
|
||||
* Get the latest patch tag for a given minor version
|
||||
*/
|
||||
function parseTargetBranchOverride(
|
||||
branch: string
|
||||
): { major: number; minor: number; branch: string } | null {
|
||||
const match = branch.match(/^core\/(\d+)\.(\d+)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
branch
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next release version for a target major.minor line.
|
||||
*
|
||||
* With no prior tag, the line starts at `.0`. With a prior tag, the patch is
|
||||
* bumped when there are pending commits, otherwise the tagged version stands.
|
||||
* Returns null if the tag is not valid semver.
|
||||
*/
|
||||
function computeTargetVersion(
|
||||
targetMajor: number,
|
||||
targetMinor: number,
|
||||
latestPatchTag: string | null,
|
||||
hasPendingCommits: boolean
|
||||
): string | null {
|
||||
if (!latestPatchTag) {
|
||||
return `${targetMajor}.${targetMinor}.0`
|
||||
}
|
||||
|
||||
const tagVersion = latestPatchTag.replace('v', '')
|
||||
if (!isValidSemver(tagVersion)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existingPatch = Number(tagVersion.split('.')[2])
|
||||
return hasPendingCommits
|
||||
? `${targetMajor}.${targetMinor}.${existingPatch + 1}`
|
||||
: tagVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a branch exists on origin in the given repo.
|
||||
*/
|
||||
function branchExists(branch: string, repoPath: string): boolean {
|
||||
return Boolean(exec(`git rev-parse --verify origin/${branch}`, repoPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest patch tag for a given major.minor version
|
||||
*/
|
||||
function getLatestPatchTag(
|
||||
repoPath: string,
|
||||
major: number,
|
||||
minor: number
|
||||
): string | null {
|
||||
function getLatestPatchTag(repoPath: string, minor: number): string | null {
|
||||
// Fetch all tags
|
||||
exec('git fetch --tags', repoPath)
|
||||
|
||||
// Use git's native version sorting to get the latest tag
|
||||
const latestTag = exec(
|
||||
`git tag -l 'v${major}.${minor}.*' --sort=-version:refname | head -n 1`,
|
||||
`git tag -l 'v1.${minor}.*' --sort=-version:refname | head -n 1`,
|
||||
repoPath
|
||||
)
|
||||
|
||||
@@ -158,7 +101,7 @@ function getLatestPatchTag(
|
||||
const validTagRegex = /^v\d+\.\d+\.\d+$/
|
||||
if (!validTagRegex.test(latestTag)) {
|
||||
console.error(
|
||||
`Latest tag for version ${major}.${minor} is not valid semver: ${latestTag}`
|
||||
`Latest tag for minor version ${minor} is not valid semver: ${latestTag}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -189,106 +132,85 @@ function resolveRelease(
|
||||
return null
|
||||
}
|
||||
|
||||
const [currentMajor, currentMinor] = currentVersion.split('.').map(Number)
|
||||
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
|
||||
|
||||
// Fetch all branches
|
||||
exec('git fetch origin', frontendRepoPath)
|
||||
|
||||
// Target major defaults to the current pin's major, but a TARGET_BRANCH
|
||||
// override (below) can retarget both major and minor.
|
||||
let targetMajor = currentMajor
|
||||
// Determine target branch based on release type:
|
||||
// 'patch' → target current minor (hotfix for production version)
|
||||
// 'minor' → try next minor, fall back to current minor (bi-weekly cadence)
|
||||
const releaseTypeInput =
|
||||
process.env.RELEASE_TYPE?.trim().toLowerCase() || 'minor'
|
||||
if (releaseTypeInput !== 'minor' && releaseTypeInput !== 'patch') {
|
||||
console.error(
|
||||
`Invalid RELEASE_TYPE: "${releaseTypeInput}". Expected "minor" or "patch"`
|
||||
)
|
||||
return null
|
||||
}
|
||||
const releaseType: 'minor' | 'patch' = releaseTypeInput
|
||||
let targetMinor: number
|
||||
let targetBranch: string
|
||||
|
||||
const targetBranchOverride = process.env.TARGET_BRANCH?.trim()
|
||||
if (releaseType === 'patch') {
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
if (targetBranchOverride) {
|
||||
// Manual override takes precedence over RELEASE_TYPE / pin-derived selection.
|
||||
const parsed = parseTargetBranchOverride(targetBranchOverride)
|
||||
if (!parsed) {
|
||||
const branchExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!branchExists) {
|
||||
console.error(
|
||||
`Invalid TARGET_BRANCH: "${targetBranchOverride}". Expected format: core/<major>.<minor> (e.g. core/1.47 or core/2.0)`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
targetMajor = parsed.major
|
||||
targetMinor = parsed.minor
|
||||
targetBranch = parsed.branch
|
||||
|
||||
if (!branchExists(targetBranch, frontendRepoPath)) {
|
||||
console.error(
|
||||
`Manual override branch ${targetBranch} does not exist in frontend repo`
|
||||
`Patch release requested but branch ${targetBranch} does not exist`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Manual override: targeting ${targetBranch} (ignoring release_type)`
|
||||
`Patch release: targeting current production branch ${targetBranch}`
|
||||
)
|
||||
} else {
|
||||
// Determine target branch based on release type:
|
||||
// 'patch' → target current minor (hotfix for production version)
|
||||
// 'minor' → try next minor, fall back to current minor (bi-weekly cadence)
|
||||
const releaseTypeInput =
|
||||
process.env.RELEASE_TYPE?.trim().toLowerCase() || 'minor'
|
||||
if (releaseTypeInput !== 'minor' && releaseTypeInput !== 'patch') {
|
||||
console.error(
|
||||
`Invalid RELEASE_TYPE: "${releaseTypeInput}". Expected "minor" or "patch"`
|
||||
)
|
||||
return null
|
||||
}
|
||||
const releaseType: 'minor' | 'patch' = releaseTypeInput
|
||||
// Try next minor first, fall back to current minor if not available
|
||||
targetMinor = currentMinor + 1
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
if (releaseType === 'patch') {
|
||||
const nextMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!nextMinorExists) {
|
||||
// Fall back to current minor for minor release
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/${targetMajor}.${targetMinor}`
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
if (!branchExists(targetBranch, frontendRepoPath)) {
|
||||
const currentMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!currentMinorExists) {
|
||||
console.error(
|
||||
`Patch release requested but branch ${targetBranch} does not exist`
|
||||
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Patch release: targeting current production branch ${targetBranch}`
|
||||
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for minor release`
|
||||
)
|
||||
} else {
|
||||
// Try next minor first, fall back to current minor if not available
|
||||
targetMinor = currentMinor + 1
|
||||
targetBranch = `core/${targetMajor}.${targetMinor}`
|
||||
|
||||
if (!branchExists(targetBranch, frontendRepoPath)) {
|
||||
// Fall back to current minor for minor release
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/${targetMajor}.${targetMinor}`
|
||||
|
||||
if (!branchExists(targetBranch, frontendRepoPath)) {
|
||||
console.error(
|
||||
`Neither core/${targetMajor}.${currentMinor + 1} nor core/${targetMajor}.${currentMinor} branches exist in frontend repo`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Next minor branch core/${targetMajor}.${currentMinor + 1} not found, falling back to core/${targetMajor}.${currentMinor} for minor release`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get latest patch tag for target major.minor
|
||||
const latestPatchTag = getLatestPatchTag(
|
||||
frontendRepoPath,
|
||||
targetMajor,
|
||||
targetMinor
|
||||
)
|
||||
// Get latest patch tag for target minor
|
||||
const latestPatchTag = getLatestPatchTag(frontendRepoPath, targetMinor)
|
||||
|
||||
let needsRelease: boolean
|
||||
let branchHeadSha: string | null
|
||||
let needsRelease = false
|
||||
let branchHeadSha: string | null = null
|
||||
let tagCommitSha: string | null = null
|
||||
let targetVersion: string
|
||||
let targetVersion = currentVersion
|
||||
|
||||
if (latestPatchTag) {
|
||||
// Get commit SHA for the tag
|
||||
@@ -309,23 +231,34 @@ function resolveRelease(
|
||||
const commitCount = parseInt(commitsBetween, 10)
|
||||
needsRelease = !isNaN(commitCount) && commitCount > 0
|
||||
|
||||
const nextVersion = computeTargetVersion(
|
||||
targetMajor,
|
||||
targetMinor,
|
||||
latestPatchTag,
|
||||
needsRelease
|
||||
)
|
||||
if (!nextVersion) {
|
||||
// Parse existing patch number and increment if needed
|
||||
const tagVersion = latestPatchTag.replace('v', '')
|
||||
|
||||
// Validate tag version format
|
||||
if (!isValidSemver(tagVersion)) {
|
||||
console.error(
|
||||
`Invalid tag version format: ${latestPatchTag}. Expected format: vX.Y.Z`
|
||||
`Invalid tag version format: ${tagVersion}. Expected format: X.Y.Z`
|
||||
)
|
||||
return null
|
||||
}
|
||||
targetVersion = nextVersion
|
||||
|
||||
const [, , existingPatch] = tagVersion.split('.').map(Number)
|
||||
|
||||
// Validate existingPatch is a valid number
|
||||
if (!Number.isFinite(existingPatch) || existingPatch < 0) {
|
||||
console.error(`Invalid patch number in tag: ${existingPatch}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (needsRelease) {
|
||||
targetVersion = `1.${targetMinor}.${existingPatch + 1}`
|
||||
} else {
|
||||
targetVersion = tagVersion
|
||||
}
|
||||
} else {
|
||||
// No tags exist for this major.minor version, need to create the .0 patch
|
||||
// No tags exist for this minor version, need to create v1.{targetMinor}.0
|
||||
needsRelease = true
|
||||
targetVersion = `${targetMajor}.${targetMinor}.0`
|
||||
targetVersion = `1.${targetMinor}.0`
|
||||
branchHeadSha = exec(
|
||||
`git rev-parse origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
@@ -348,41 +281,26 @@ function resolveRelease(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution: parse args, resolve, and print the JSON result.
|
||||
*/
|
||||
function main(): void {
|
||||
const comfyuiRepoPath = process.argv[2]
|
||||
const frontendRepoPath = process.argv[3] || process.cwd()
|
||||
// Main execution
|
||||
const comfyuiRepoPath = process.argv[2]
|
||||
const frontendRepoPath = process.argv[3] || process.cwd()
|
||||
|
||||
if (!comfyuiRepoPath) {
|
||||
console.error(
|
||||
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
|
||||
|
||||
if (!releaseInfo) {
|
||||
console.error('Failed to resolve release information')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Output as JSON for GitHub Actions
|
||||
// oxlint-disable-next-line no-console -- stdout is captured by the workflow
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
if (!comfyuiRepoPath) {
|
||||
console.error(
|
||||
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Only run when invoked directly, not when imported by tests.
|
||||
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
||||
main()
|
||||
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
|
||||
|
||||
if (!releaseInfo) {
|
||||
console.error('Failed to resolve release information')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export {
|
||||
computeTargetVersion,
|
||||
isValidSemver,
|
||||
parseRequirementsVersion,
|
||||
parseTargetBranchOverride,
|
||||
resolveRelease
|
||||
}
|
||||
// Output as JSON for GitHub Actions
|
||||
// oxlint-disable-next-line no-console -- stdout is captured by the workflow
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
|
||||
export { resolveRelease }
|
||||
|
||||
@@ -3,9 +3,8 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import type { WidgetLabels } from './nodeDefLocaleSerializer'
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
@@ -15,6 +14,10 @@ interface WidgetInfo {
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface WidgetLabels {
|
||||
[key: string]: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
@@ -41,6 +44,28 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
console.log(`Collected ${nodeDefs.length} node definitions`)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
const inputDataTypes = Object.values(nodeDef.inputs).map(
|
||||
(inputSpec) => inputSpec.type
|
||||
)
|
||||
const outputDataTypes = nodeDef.outputs.map(
|
||||
(outputSpec) => outputSpec.type
|
||||
)
|
||||
const allDataTypes = [...inputDataTypes, ...outputDataTypes].flatMap(
|
||||
(type: string) => type.split(',')
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
dataType
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
async function extractWidgetLabels() {
|
||||
const nodeLabels: WidgetLabels = {}
|
||||
|
||||
@@ -69,10 +94,11 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
[nodeDef.name, nodeDef.display_name, inputNames]
|
||||
)
|
||||
|
||||
// Format runtime widgets
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, name]) => [key, { name }])
|
||||
.map(([key, value]) => [normalizeI18nKey(key), { name: value }])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -91,8 +117,84 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
|
||||
const nodeDefLabels = await extractWidgetLabels()
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(nodeDefs, nodeDefLabels)
|
||||
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name = input.name
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(input.name),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function extractOutputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name = output.name in allDataTypesLocale ? undefined : output.name
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
i.toString(),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
const allNodeDefsLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...extractInputs(nodeDef),
|
||||
...(nodeDefLabels[nodeDef.name] ?? {})
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: nodeDef.display_name ?? nodeDef.name,
|
||||
description: nodeDef.description || undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const allNodeCategoriesLocale = Object.fromEntries(
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [normalizeI18nKey(category), category])
|
||||
)
|
||||
)
|
||||
|
||||
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
|
||||
fs.writeFileSync(
|
||||
@@ -100,13 +202,13 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
JSON.stringify(
|
||||
{
|
||||
...locale,
|
||||
dataTypes,
|
||||
nodeCategories
|
||||
dataTypes: allDataTypesLocale,
|
||||
nodeCategories: allNodeCategoriesLocale
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(nodeDefinitions, null, 2))
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(allNodeDefsLocale, null, 2))
|
||||
})
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
function render(message: string): string {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
describe('serializeNodeDefLocales', () => {
|
||||
it('escapes compiled fields and preserves raw tooltips', () => {
|
||||
const syntax = '@ $ {value} | 50%{done}'
|
||||
const inputName = `Input ${syntax}`
|
||||
const outputName = `Output ${syntax}`
|
||||
const dataType = `TYPE ${syntax}`
|
||||
const category = `Category ${syntax}`
|
||||
const nodeDef = {
|
||||
name: 'Test.Node',
|
||||
display_name: `Display ${syntax}`,
|
||||
description: `Description ${syntax}`,
|
||||
category,
|
||||
inputs: {
|
||||
input: {
|
||||
name: inputName,
|
||||
type: dataType,
|
||||
tooltip: `Input tooltip ${syntax}`
|
||||
}
|
||||
},
|
||||
outputs: [
|
||||
{
|
||||
name: outputName,
|
||||
type: 'OTHER',
|
||||
tooltip: `Output tooltip ${syntax}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales([nodeDef], {
|
||||
'Test.Node': {
|
||||
'Runtime.Widget': { name: `Widget ${syntax}` }
|
||||
}
|
||||
})
|
||||
const serializedNode = nodeDefinitions.Test_Node
|
||||
const serializedInput =
|
||||
serializedNode.inputs['Input @ $ {value} | 50%{done}']
|
||||
const serializedOutput = serializedNode.outputs['0']
|
||||
|
||||
expect(render(serializedNode.display_name)).toBe(nodeDef.display_name)
|
||||
expect(render(serializedNode.description)).toBe(nodeDef.description)
|
||||
expect(render(serializedInput.name)).toBe(inputName)
|
||||
expect(render(serializedOutput.name)).toBe(outputName)
|
||||
expect(render(serializedNode.inputs.Runtime_Widget.name)).toBe(
|
||||
`Widget ${syntax}`
|
||||
)
|
||||
expect(render(dataTypes[dataType])).toBe(dataType)
|
||||
expect(render(nodeCategories[category])).toBe(category)
|
||||
expect(serializedInput.tooltip).toBe(nodeDef.inputs.input.tooltip)
|
||||
expect(serializedOutput.tooltip).toBe(nodeDef.outputs[0].tooltip)
|
||||
})
|
||||
|
||||
it('preserves locale shapes and ordering', () => {
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(
|
||||
[
|
||||
{
|
||||
name: 'Z.Node',
|
||||
description: '',
|
||||
category: 'group/sub.group',
|
||||
inputs: {
|
||||
omitted: { type: 'Z.TYPE' },
|
||||
tooltipOnly: { type: 'A_TYPE', tooltip: 'raw @ tooltip' }
|
||||
},
|
||||
outputs: [
|
||||
{ name: 'A_TYPE', type: 'A_TYPE' },
|
||||
{ name: 'Custom.Output', type: 'Z.TYPE' },
|
||||
{ tooltip: 'raw output @ tooltip', type: 'Z.TYPE' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'A.Node',
|
||||
category: 'group',
|
||||
inputs: {},
|
||||
outputs: []
|
||||
}
|
||||
],
|
||||
{
|
||||
'Z.Node': {
|
||||
'Runtime.Widget': { name: 'Runtime.Label' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(dataTypes).toEqual({
|
||||
A_TYPE: 'A_TYPE',
|
||||
Z_TYPE: 'Z.TYPE'
|
||||
})
|
||||
expect(nodeCategories).toEqual({
|
||||
group: 'group',
|
||||
sub_group: 'sub.group'
|
||||
})
|
||||
expect(nodeDefinitions).toEqual({
|
||||
A_Node: {
|
||||
display_name: 'A.Node',
|
||||
description: undefined,
|
||||
inputs: undefined,
|
||||
outputs: undefined
|
||||
},
|
||||
Z_Node: {
|
||||
display_name: 'Z.Node',
|
||||
description: undefined,
|
||||
inputs: {
|
||||
'': { name: undefined, tooltip: 'raw @ tooltip' },
|
||||
Runtime_Widget: { name: 'Runtime.Label' }
|
||||
},
|
||||
outputs: {
|
||||
1: { name: 'Custom.Output', tooltip: undefined },
|
||||
2: { name: undefined, tooltip: 'raw output @ tooltip' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(Object.keys(dataTypes)).toEqual(['A_TYPE', 'Z_TYPE'])
|
||||
expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node'])
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
interface LocalizableInput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableOutput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableNodeDef {
|
||||
category: string
|
||||
inputs: Record<string, LocalizableInput>
|
||||
name: string
|
||||
outputs: LocalizableOutput[]
|
||||
description?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export type WidgetLabels = Record<
|
||||
string,
|
||||
Record<string, { name: string | undefined }>
|
||||
>
|
||||
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g
|
||||
|
||||
function escapeMessage(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
export function serializeNodeDefLocales(
|
||||
nodeDefs: readonly LocalizableNodeDef[],
|
||||
widgetLabels: WidgetLabels = {}
|
||||
) {
|
||||
const dataTypes = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => [
|
||||
...Object.values(nodeDef.inputs).map(({ type }) => type),
|
||||
...nodeDef.outputs.map(({ type }) => type)
|
||||
])
|
||||
.flatMap((type) => type.split(','))
|
||||
.map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)])
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
function serializeInputs(nodeDef: LocalizableNodeDef) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap(({ name, tooltip }) => {
|
||||
if (name === undefined && tooltip === undefined) return []
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(name ?? ''),
|
||||
{
|
||||
name: name === undefined ? undefined : escapeMessage(name),
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function serializeOutputs(nodeDef: LocalizableNodeDef) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap(({ name, tooltip }, index) => {
|
||||
const serializedName =
|
||||
name === undefined || name in dataTypes
|
||||
? undefined
|
||||
: escapeMessage(name)
|
||||
if (serializedName === undefined && tooltip === undefined) return []
|
||||
|
||||
return [[index.toString(), { name: serializedName, tooltip }]]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
function serializeWidgetLabels(nodeName: string) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [
|
||||
normalizeI18nKey(name),
|
||||
{
|
||||
name: label.name === undefined ? undefined : escapeMessage(label.name)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
const nodeDefinitions = Object.fromEntries(
|
||||
[...nodeDefs]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...serializeInputs(nodeDef),
|
||||
...serializeWidgetLabels(nodeDef.name)
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name),
|
||||
description: nodeDef.description
|
||||
? escapeMessage(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: serializeOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const nodeCategories = Object.fromEntries(
|
||||
nodeDefs.flatMap(({ category }) =>
|
||||
category
|
||||
.split('/')
|
||||
.map((part) => [normalizeI18nKey(part), escapeMessage(part)])
|
||||
)
|
||||
)
|
||||
|
||||
return { dataTypes, nodeCategories, nodeDefinitions }
|
||||
}
|
||||
@@ -32,11 +32,11 @@ let _runWhenIdle: (
|
||||
* Falls back to setTimeout-based scheduling in browsers without native support.
|
||||
*/
|
||||
export let runWhenGlobalIdle: (
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
;(function () {
|
||||
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
|
||||
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
|
||||
@@ -429,7 +429,7 @@ const commandsData = computed<ICommandData[]>(() => {
|
||||
id: command.id,
|
||||
label: t(
|
||||
`commands.${normalizeI18nKey(command.id)}.label`,
|
||||
command.label ?? command.id
|
||||
command.label ?? ''
|
||||
),
|
||||
keybindings: keybindingStore.getKeybindingsByCommandId(command.id),
|
||||
source: command.source,
|
||||
|
||||
@@ -39,10 +39,6 @@
|
||||
<NodePropertiesPanel v-else />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<div
|
||||
ref="canvasPanelBoundsRef"
|
||||
class="pointer-events-none absolute inset-0"
|
||||
/>
|
||||
<GraphCanvasMenu
|
||||
v-if="canvasMenuEnabled && !isBuilderMode"
|
||||
class="pointer-events-auto"
|
||||
@@ -93,10 +89,7 @@
|
||||
/>
|
||||
|
||||
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
|
||||
<SelectionRectangle
|
||||
v-if="comfyAppReady"
|
||||
:panel-el="canvasPanelBoundsRef ?? undefined"
|
||||
/>
|
||||
<SelectionRectangle v-if="comfyAppReady" />
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
@@ -123,7 +116,6 @@ import {
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
@@ -210,7 +202,6 @@ const emit = defineEmits<{
|
||||
ready: []
|
||||
}>()
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const canvasPanelBoundsRef = useTemplateRef('canvasPanelBoundsRef')
|
||||
const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
typeof NodeSearchboxPopover
|
||||
> | null>(null)
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
|
||||
const rafCallbacks: Array<() => void> = []
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useRafFn: (cb: () => void) => {
|
||||
rafCallbacks.push(cb)
|
||||
return { pause: vi.fn(), resume: vi.fn() }
|
||||
}
|
||||
}))
|
||||
|
||||
const mockCanvas = ref<unknown>(null)
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
get canvas() {
|
||||
return mockCanvas.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function createPanelEl() {
|
||||
const panel = document.createElement('div')
|
||||
vi.spyOn(panel, 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({ left: 300, top: 0, right: 1000, bottom: 800 })
|
||||
)
|
||||
return panel
|
||||
}
|
||||
|
||||
function dragRectangle(eDown: [number, number], eMove: [number, number]) {
|
||||
const canvasEl = document.createElement('canvas')
|
||||
vi.spyOn(canvasEl, 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 800 })
|
||||
)
|
||||
mockCanvas.value = {
|
||||
canvas: canvasEl,
|
||||
dragging_rectangle: true,
|
||||
pointer: {
|
||||
eDown: { safeOffsetX: eDown[0], safeOffsetY: eDown[1] },
|
||||
eMove: { safeOffsetX: eMove[0], safeOffsetY: eMove[1] }
|
||||
}
|
||||
}
|
||||
rafCallbacks[rafCallbacks.length - 1]()
|
||||
}
|
||||
|
||||
describe('SelectionRectangle', () => {
|
||||
afterEach(() => {
|
||||
rafCallbacks.length = 0
|
||||
mockCanvas.value = null
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('clips the rectangle to the canvas panel when dragged over the sidebar', async () => {
|
||||
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
|
||||
|
||||
dragRectangle([100, 100], [800, 400])
|
||||
await nextTick()
|
||||
|
||||
const rect = screen.getByTestId('selection-rectangle')
|
||||
expect(rect.style.left).toBe('300px')
|
||||
expect(rect.style.top).toBe('100px')
|
||||
expect(rect.style.width).toBe('500px')
|
||||
expect(rect.style.height).toBe('300px')
|
||||
})
|
||||
|
||||
it('leaves a rectangle within the panel unchanged', async () => {
|
||||
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
|
||||
|
||||
dragRectangle([400, 100], [600, 300])
|
||||
await nextTick()
|
||||
|
||||
const rect = screen.getByTestId('selection-rectangle')
|
||||
expect(rect.style.left).toBe('400px')
|
||||
expect(rect.style.top).toBe('100px')
|
||||
expect(rect.style.width).toBe('200px')
|
||||
expect(rect.style.height).toBe('200px')
|
||||
})
|
||||
|
||||
it('normalizes and clips a rectangle dragged up-and-left', async () => {
|
||||
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
|
||||
|
||||
dragRectangle([800, 400], [100, 100])
|
||||
await nextTick()
|
||||
|
||||
const rect = screen.getByTestId('selection-rectangle')
|
||||
expect(rect.style.left).toBe('300px')
|
||||
expect(rect.style.top).toBe('100px')
|
||||
expect(rect.style.width).toBe('500px')
|
||||
expect(rect.style.height).toBe('300px')
|
||||
})
|
||||
|
||||
it('renders unclamped edges when the canvas panel is absent', async () => {
|
||||
render(SelectionRectangle)
|
||||
|
||||
dragRectangle([100, 100], [800, 400])
|
||||
await nextTick()
|
||||
|
||||
const rect = screen.getByTestId('selection-rectangle')
|
||||
expect(rect.style.left).toBe('100px')
|
||||
expect(rect.style.width).toBe('700px')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="isVisible"
|
||||
data-testid="selection-rectangle"
|
||||
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
|
||||
:style="rectangleStyle"
|
||||
/>
|
||||
@@ -12,13 +11,6 @@ import { useRafFn } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { clipRectToBounds } from '@/utils/mathUtil'
|
||||
import type { RectEdges } from '@/utils/mathUtil'
|
||||
|
||||
const { panelEl } = defineProps<{
|
||||
/** Clip surface owned by the caller; the rectangle renders unclipped when absent. */
|
||||
panelEl?: HTMLElement
|
||||
}>()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -28,18 +20,17 @@ const selectionRect = ref<{
|
||||
w: number
|
||||
h: number
|
||||
} | null>(null)
|
||||
const panelBounds = ref<RectEdges>()
|
||||
|
||||
useRafFn(() => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) return
|
||||
if (!canvas) {
|
||||
selectionRect.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const { pointer, dragging_rectangle } = canvas
|
||||
|
||||
if (dragging_rectangle && pointer.eDown && pointer.eMove) {
|
||||
if (!selectionRect.value) {
|
||||
panelBounds.value = getCanvasPanelBounds(canvas.canvas)
|
||||
}
|
||||
const x = pointer.eDown.safeOffsetX
|
||||
const y = pointer.eDown.safeOffsetY
|
||||
const w = pointer.eMove.safeOffsetX - x
|
||||
@@ -48,47 +39,25 @@ useRafFn(() => {
|
||||
selectionRect.value = { x, y, w, h }
|
||||
} else {
|
||||
selectionRect.value = null
|
||||
panelBounds.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const isVisible = computed(() => selectionRect.value !== null)
|
||||
|
||||
function getCanvasPanelBounds(
|
||||
canvasEl: HTMLCanvasElement
|
||||
): RectEdges | undefined {
|
||||
if (!panelEl) return undefined
|
||||
|
||||
const panel = panelEl.getBoundingClientRect()
|
||||
const canvas = canvasEl.getBoundingClientRect()
|
||||
return {
|
||||
left: panel.left - canvas.left,
|
||||
top: panel.top - canvas.top,
|
||||
right: panel.right - canvas.left,
|
||||
bottom: panel.bottom - canvas.top
|
||||
}
|
||||
}
|
||||
|
||||
const rectangleStyle = computed(() => {
|
||||
const rect = selectionRect.value
|
||||
if (!rect) return {}
|
||||
|
||||
const edges: RectEdges = {
|
||||
left: rect.w >= 0 ? rect.x : rect.x + rect.w,
|
||||
top: rect.h >= 0 ? rect.y : rect.y + rect.h,
|
||||
right: rect.w >= 0 ? rect.x + rect.w : rect.x,
|
||||
bottom: rect.h >= 0 ? rect.y + rect.h : rect.y
|
||||
}
|
||||
const bounds = panelBounds.value
|
||||
const { left, top, right, bottom } = bounds
|
||||
? clipRectToBounds(edges, bounds)
|
||||
: edges
|
||||
const left = rect.w >= 0 ? rect.x : rect.x + rect.w
|
||||
const top = rect.h >= 0 ? rect.y : rect.y + rect.h
|
||||
const width = Math.abs(rect.w)
|
||||
const height = Math.abs(rect.h)
|
||||
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${right - left}px`,
|
||||
height: `${bottom - top}px`
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,11 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
isGraphReady: true,
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
@@ -129,8 +127,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
getExecutionIdByNode,
|
||||
getNodeByExecutionId
|
||||
@@ -497,47 +493,6 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups lifted boundary errors under the host node card', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const { rootGraph, host } = createBoundaryLinkedSubgraph({
|
||||
interiorType: 'InteriorClass'
|
||||
})
|
||||
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
|
||||
await vi.importActual<typeof GraphTraversalUtil>(
|
||||
'@/utils/graphTraversalUtil'
|
||||
)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
|
||||
})
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError(
|
||||
[
|
||||
validationError(
|
||||
'required_input_missing',
|
||||
'seed_input',
|
||||
{},
|
||||
'Required input is missing'
|
||||
)
|
||||
],
|
||||
'InteriorClass'
|
||||
)
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.type).toBe('execution')
|
||||
if (execGroup?.type !== 'execution') return
|
||||
|
||||
const card = execGroup.cards[0]
|
||||
expect(card.nodeId).toBe('12')
|
||||
expect(card.title).toBe(host.title)
|
||||
expect(card.errors[0].displayDetails).toBe(
|
||||
`${host.title} is missing a required input: seed`
|
||||
)
|
||||
})
|
||||
|
||||
it('groups node validation errors by catalog id across node types', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
|
||||
@@ -382,10 +382,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionErrorStore.surfacedNodeErrors) return
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.surfacedNodeErrors
|
||||
executionErrorStore.lastNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
|
||||
@@ -130,7 +130,7 @@ function handleResetToDefault() {
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
class="flex w-full items-center justify-start gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
@click="
|
||||
() => {
|
||||
handleRename()
|
||||
@@ -146,7 +146,7 @@ function handleResetToDefault() {
|
||||
v-if="canToggleVisibility"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
class="flex w-full items-center justify-start gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
@click="
|
||||
() => {
|
||||
if (isShownOnParents) handleHideInput()
|
||||
@@ -168,7 +168,7 @@ function handleResetToDefault() {
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
class="flex w-full items-center justify-start gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
@click="
|
||||
() => {
|
||||
handleToggleFavorite()
|
||||
@@ -190,7 +190,7 @@ function handleResetToDefault() {
|
||||
v-if="hasDefault"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
class="flex w-full items-center justify-start gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
:disabled="isCurrentValueDefault"
|
||||
@click="
|
||||
() => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
ref="panelRef"
|
||||
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
@@ -101,19 +100,18 @@
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<div v-else class="size-full">
|
||||
<AssetsSidebarGridView
|
||||
:assets="displayAssets"
|
||||
:is-selected
|
||||
:show-output-count
|
||||
:get-output-count
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
/>
|
||||
</div>
|
||||
<AssetsSidebarGridView
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
@@ -127,13 +125,6 @@
|
||||
/>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="marqueeStyle"
|
||||
class="pointer-events-none fixed z-9999 border border-primary-background bg-primary-background/20"
|
||||
:style="marqueeStyle"
|
||||
/>
|
||||
</Teleport>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
@@ -160,7 +151,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
unrefElement,
|
||||
useAsyncState,
|
||||
useDebounceFn,
|
||||
useStorage,
|
||||
@@ -174,7 +164,6 @@ import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -193,7 +182,6 @@ import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBa
|
||||
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetGridSelection } from '@/platform/assets/composables/useAssetGridSelection'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
@@ -251,7 +239,7 @@ const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const showOutputCount = (item: AssetItem): boolean => {
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
}
|
||||
@@ -271,10 +259,7 @@ const outputAssets = useAssetsApi('output')
|
||||
// Asset selection
|
||||
const {
|
||||
isSelected,
|
||||
selectedIds,
|
||||
handleAssetClick,
|
||||
selectAll,
|
||||
setSelectedIds,
|
||||
hasSelection,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
@@ -285,12 +270,6 @@ const {
|
||||
deactivate: deactivateSelection
|
||||
} = useAssetSelection()
|
||||
|
||||
const panelRef = useTemplateRef('panelRef')
|
||||
const marqueePanelRef = computed(() => {
|
||||
const el = unrefElement(panelRef)
|
||||
return el instanceof HTMLElement ? el : undefined
|
||||
})
|
||||
|
||||
const {
|
||||
downloadAssets,
|
||||
deleteAssets,
|
||||
@@ -358,16 +337,6 @@ const visibleAssets = computed(() => {
|
||||
return listViewSelectableAssets.value
|
||||
})
|
||||
|
||||
const { marqueeStyle } = useAssetGridSelection({
|
||||
marqueeContainerRef: marqueePanelRef,
|
||||
hoverTargetRef: marqueePanelRef,
|
||||
getAssets: () => visibleAssets.value,
|
||||
getSelectedIds: () => [...selectedIds.value],
|
||||
setSelectedIds,
|
||||
selectAll,
|
||||
isEnabled: () => !isListView.value
|
||||
})
|
||||
|
||||
const previewableVisibleAssets = computed(() =>
|
||||
visibleAssets.value.filter((asset) =>
|
||||
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
|
||||
@@ -606,7 +575,7 @@ const handleDeselectAll = () => {
|
||||
}
|
||||
|
||||
const handleEmptySpaceClick = () => {
|
||||
if (hasSelection.value) {
|
||||
if (hasSelection) {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
@@ -129,7 +129,7 @@ export function useChart(
|
||||
data: Ref<ChartData>,
|
||||
options?: Ref<ChartOptions | undefined>
|
||||
) {
|
||||
const chartInstance = shallowRef<Chart | null>(null)
|
||||
const chartInstance = ref<Chart | null>(null)
|
||||
|
||||
function createChart() {
|
||||
if (!canvasRef.value) return
|
||||
|
||||
@@ -112,12 +112,5 @@ export interface BillingContext extends BillingState, BillingActions {
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the subscription is a team plan of either generation. Unlike
|
||||
* `isLegacyTeamPlan` this does not require an active subscription: the spend
|
||||
* gate folds billing_status into is_active, so a paused or payment-failed team
|
||||
* plan reports is_active=false and must still read as a team plan.
|
||||
*/
|
||||
isTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockBillingControlEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -27,7 +27,7 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockBillingControlEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -59,11 +59,13 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
|
||||
Object.defineProperty(mockBillingControlEnabled, 'value', {
|
||||
get: () => billingControlEnabledRef.value,
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
billingControlEnabledRef.value = value
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
@@ -72,8 +74,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get billingControlEnabled() {
|
||||
return mockBillingControlEnabled.value
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -163,7 +165,7 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -175,27 +177,27 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when billing control is enabled', () => {
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team regardless of billing control', () => {
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -296,7 +298,7 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -305,16 +307,16 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
@@ -323,9 +325,9 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -553,110 +555,4 @@ describe('useBillingContext', () => {
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTeamPlan', () => {
|
||||
it('is false for a personal workspace', () => {
|
||||
const { isTeamPlan } = useBillingContext()
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
|
||||
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
|
||||
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
|
||||
// isTeamPlan reads the credit stop and the slug, never the tier — which is
|
||||
// what keeps it working despite that divergence.
|
||||
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
// The banner states that need isTeamPlan most — paused and payment_failed —
|
||||
// are exactly the ones the backend reports with is_active=false, because the
|
||||
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
|
||||
// subscription would blank the banner precisely when it is needed.
|
||||
it('stays true for a paused team plan, which the backend reports inactive', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'paused',
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stays true for a legacy team plan whose payment failed', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'payment_failed',
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a team workspace on a personal-tier plan', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
plan_slug: 'pro-monthly'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,8 +35,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once billing control is enabled;
|
||||
* personal workspaces otherwise stay on legacy billing
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -141,21 +141,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
false)
|
||||
)
|
||||
|
||||
// Plan identity, independent of subscription health: the per-credit Team plan
|
||||
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
|
||||
// isActiveSubscription on purpose — paused and payment_failed both force
|
||||
// is_active=false, which is exactly when callers still need to know this is a
|
||||
// team plan.
|
||||
const isTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
(currentTeamCreditStop.value !== null ||
|
||||
(currentPlanSlug.value
|
||||
?.toLowerCase()
|
||||
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
|
||||
false))
|
||||
)
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
@@ -206,9 +191,9 @@ function useBillingContextInternal(): BillingContext {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type flips when the team-workspaces or billing-control flag resolves from
|
||||
// authenticated config, swapping the active backend. Reset then reinit on
|
||||
// every workspace-id or type change.
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
@@ -314,7 +299,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
isLegacyTeamPlan,
|
||||
isTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
billingControlEnabled: false
|
||||
consolidatedBillingEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when billing control is enabled', () => {
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of billing control', () => {
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with billing control enabled', () => {
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { BillingType } from './types'
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `billingControlEnabled`; team workspaces are always
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
@@ -23,7 +23,7 @@ export function useBillingRouting() {
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SubscriptionInfo } from '@/composables/billing/types'
|
||||
import type {
|
||||
Plan,
|
||||
TeamCreditStops
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import type { NextInvoiceInputs } from './useNextInvoice'
|
||||
import { deriveNextInvoice } from './useNextInvoice'
|
||||
|
||||
function makeSubscription(
|
||||
overrides: Partial<SubscriptionInfo> = {}
|
||||
): SubscriptionInfo {
|
||||
return {
|
||||
isActive: true,
|
||||
tier: 'STANDARD',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: 'standard-monthly',
|
||||
renewalDate: '2026-08-01T00:00:00Z',
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makePlan(overrides: Partial<Plan> = {}): Plan {
|
||||
return {
|
||||
slug: 'standard-monthly',
|
||||
tier: 'STANDARD',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 2000,
|
||||
credits_cents: 2000,
|
||||
max_seats: 1,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 2000,
|
||||
total_credits_cents: 2000
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Both stop prices are per-month figures; price_cents is the discounted
|
||||
// figure, kept distinct from list_price_cents and credits so a regression to
|
||||
// either fails the amount assertions.
|
||||
const teamCreditStops: TeamCreditStops = {
|
||||
default_stop_index: 0,
|
||||
stops: [
|
||||
{
|
||||
id: 'stop-320',
|
||||
credits: 67520,
|
||||
monthly: { list_price_cents: 32000, price_cents: 30400 },
|
||||
yearly: { list_price_cents: 32000, price_cents: 28800 }
|
||||
},
|
||||
{
|
||||
id: 'stop-640',
|
||||
credits: 135040,
|
||||
monthly: { list_price_cents: 64000, price_cents: 60800 },
|
||||
yearly: { list_price_cents: 64000, price_cents: 57600 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function makeInputs(
|
||||
overrides: Partial<NextInvoiceInputs> = {}
|
||||
): NextInvoiceInputs {
|
||||
return {
|
||||
subscription: makeSubscription(),
|
||||
planSlug: 'standard-monthly',
|
||||
plans: [makePlan()],
|
||||
teamCreditStops: null,
|
||||
currentTeamCreditStop: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe(deriveNextInvoice, () => {
|
||||
it('resolves a monthly invoice from the current plan price by slug', () => {
|
||||
expect(deriveNextInvoice(makeInputs())).toEqual({
|
||||
amountCents: 2000,
|
||||
renewalDate: '2026-08-01T00:00:00Z',
|
||||
duration: 'MONTHLY'
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the subscribed team credit stop over the plan price', () => {
|
||||
const inputs = makeInputs({
|
||||
subscription: makeSubscription({ planSlug: 'team-pro-monthly' }),
|
||||
planSlug: 'team-pro-monthly',
|
||||
plans: [makePlan({ slug: 'team-pro-monthly', price_cents: 9999 })],
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop: {
|
||||
id: 'stop-320',
|
||||
credits_monthly: 32000,
|
||||
stop_usd: 320
|
||||
}
|
||||
})
|
||||
|
||||
expect(deriveNextInvoice(inputs)?.amountCents).toBe(30400)
|
||||
})
|
||||
|
||||
it('multiplies the per-month yearly stop price by 12 for annual subs', () => {
|
||||
const inputs = makeInputs({
|
||||
subscription: makeSubscription({ duration: 'ANNUAL' }),
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop: {
|
||||
id: 'stop-640',
|
||||
credits_monthly: 64000,
|
||||
stop_usd: 640
|
||||
}
|
||||
})
|
||||
|
||||
expect(deriveNextInvoice(inputs)).toEqual({
|
||||
amountCents: 57600 * 12,
|
||||
renewalDate: '2026-08-01T00:00:00Z',
|
||||
duration: 'ANNUAL'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the annual plan price_cents as the yearly total, unscaled', () => {
|
||||
const inputs = makeInputs({
|
||||
subscription: makeSubscription({
|
||||
duration: 'ANNUAL',
|
||||
planSlug: 'standard-yearly'
|
||||
}),
|
||||
planSlug: 'standard-yearly',
|
||||
plans: [
|
||||
makePlan({
|
||||
slug: 'standard-yearly',
|
||||
duration: 'ANNUAL',
|
||||
price_cents: 21600
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(deriveNextInvoice(inputs)).toEqual({
|
||||
amountCents: 21600,
|
||||
renewalDate: '2026-08-01T00:00:00Z',
|
||||
duration: 'ANNUAL'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes a null renewalDate through (scheduled-cancellation window)', () => {
|
||||
const inputs = makeInputs({
|
||||
subscription: makeSubscription({ renewalDate: null })
|
||||
})
|
||||
|
||||
expect(deriveNextInvoice(inputs)?.renewalDate).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the plan price when the stop is not in the ladder', () => {
|
||||
const inputs = makeInputs({
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop: {
|
||||
id: 'stop-unknown',
|
||||
credits_monthly: 1000,
|
||||
stop_usd: 10
|
||||
}
|
||||
})
|
||||
|
||||
expect(deriveNextInvoice(inputs)?.amountCents).toBe(2000)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['no subscription', { subscription: null }],
|
||||
[
|
||||
'inactive subscription',
|
||||
{ subscription: makeSubscription({ isActive: false }) }
|
||||
],
|
||||
[
|
||||
'cancelled subscription',
|
||||
{ subscription: makeSubscription({ isCancelled: true }) }
|
||||
],
|
||||
['unresolvable plan slug', { planSlug: 'unknown-plan' }],
|
||||
[
|
||||
'annual sub whose slug resolves only to a monthly plan',
|
||||
{ subscription: makeSubscription({ duration: 'ANNUAL' }) }
|
||||
],
|
||||
['empty plan list (legacy billing)', { plans: [] }],
|
||||
['zero-price plan (free tier)', { plans: [makePlan({ price_cents: 0 })] }]
|
||||
] satisfies [string, Partial<NextInvoiceInputs>][])(
|
||||
'returns null for %s',
|
||||
([, overrides]) => {
|
||||
expect(deriveNextInvoice(makeInputs(overrides))).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SubscriptionInfo } from '@/composables/billing/types'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type {
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
SubscriptionDuration,
|
||||
TeamCreditStops
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
export interface NextInvoiceInputs {
|
||||
subscription: SubscriptionInfo | null
|
||||
planSlug: string | null
|
||||
plans: Plan[]
|
||||
teamCreditStops: TeamCreditStops | null
|
||||
currentTeamCreditStop: CurrentTeamCreditStop | null
|
||||
}
|
||||
|
||||
export interface NextInvoice {
|
||||
amountCents: number
|
||||
renewalDate: string | null
|
||||
duration: SubscriptionDuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Next invoice for the Settings > Invoices banner; annual subscriptions show
|
||||
* their yearly total and renewal date. Unit semantics: credit-stop
|
||||
* `yearly.price_cents` is a per-month figure (x12 for the invoice total)
|
||||
* while an ANNUAL plan's `price_cents` is already the yearly total.
|
||||
* `renewalDate` is BE-computed and passed through untouched — backends own
|
||||
* period math including month-end bias — and goes null once a cancellation
|
||||
* is scheduled. Cancelled/inactive return null because the cancelled Toast
|
||||
* owns that state. A non-positive resolved amount also returns null (free
|
||||
* tier can look like an active subscription with no real invoice).
|
||||
* Intentionally limited to the subscription price: usage/overage pending
|
||||
* charges are excluded until the backend exposes an authoritative
|
||||
* upcoming-invoice amount.
|
||||
*/
|
||||
export function deriveNextInvoice({
|
||||
subscription,
|
||||
planSlug,
|
||||
plans,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop
|
||||
}: NextInvoiceInputs): NextInvoice | null {
|
||||
if (!subscription?.isActive || subscription.isCancelled) return null
|
||||
|
||||
const duration = subscription.duration === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
|
||||
const stop = teamCreditStops?.stops.find(
|
||||
({ id }) => id === currentTeamCreditStop?.id
|
||||
)
|
||||
const plan = plans.find(
|
||||
(candidate) =>
|
||||
candidate.slug === planSlug && candidate.duration === duration
|
||||
)
|
||||
const amountCents = stop
|
||||
? duration === 'ANNUAL'
|
||||
? stop.yearly.price_cents * 12
|
||||
: stop.monthly.price_cents
|
||||
: plan?.price_cents
|
||||
|
||||
if (!amountCents || amountCents <= 0) return null
|
||||
|
||||
return {
|
||||
amountCents,
|
||||
renewalDate: subscription.renewalDate,
|
||||
duration
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callers own billing-context initialization; a null invoice hides the
|
||||
* banner.
|
||||
*/
|
||||
export function useNextInvoice() {
|
||||
const {
|
||||
subscription,
|
||||
currentPlanSlug,
|
||||
plans,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop
|
||||
} = useBillingContext()
|
||||
|
||||
const nextInvoice = computed(() =>
|
||||
deriveNextInvoice({
|
||||
subscription: subscription.value,
|
||||
planSlug: currentPlanSlug.value,
|
||||
plans: plans.value,
|
||||
teamCreditStops: teamCreditStops.value,
|
||||
currentTeamCreditStop: currentTeamCreditStop.value
|
||||
})
|
||||
)
|
||||
|
||||
return { nextInvoice }
|
||||
}
|
||||
@@ -84,7 +84,7 @@ function reconcileNodeErrorFlags(
|
||||
}
|
||||
|
||||
export function useNodeErrorFlagSync(
|
||||
nodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>,
|
||||
missingMediaStore: ReturnType<typeof useMissingMediaStore>
|
||||
): () => void {
|
||||
@@ -95,7 +95,7 @@ export function useNodeErrorFlagSync(
|
||||
|
||||
const stop = watch(
|
||||
[
|
||||
nodeErrors,
|
||||
lastNodeErrors,
|
||||
() => missingModelStore.missingModelNodeIds,
|
||||
() => missingMediaStore.missingMediaNodeIds,
|
||||
showErrorsTab
|
||||
@@ -108,7 +108,7 @@ export function useNodeErrorFlagSync(
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
reconcileNodeErrorFlags(
|
||||
app.rootGraph,
|
||||
nodeErrors.value,
|
||||
lastNodeErrors.value,
|
||||
showErrorsTab.value
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set(),
|
||||
|
||||
@@ -207,8 +207,7 @@ export function useGPUResources() {
|
||||
async function initTypeGPU(): Promise<void> {
|
||||
if (store.tgpuRoot) {
|
||||
/* c8 ignore start */
|
||||
// typegpu vendors its own WebGPU types; align with @webgpu/types
|
||||
device = store.tgpuRoot.device as GPUDevice
|
||||
device = store.tgpuRoot.device
|
||||
return
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
@@ -216,7 +215,7 @@ export function useGPUResources() {
|
||||
/* c8 ignore start — requires functional WebGPU hardware */
|
||||
const root = await tgpu.init()
|
||||
store.tgpuRoot = root
|
||||
device = root.device as GPUDevice
|
||||
device = root.device
|
||||
console.warn('✅ TypeGPU initialized! Root:', root)
|
||||
console.warn('Device info:', root.device.limits)
|
||||
/* c8 ignore stop */
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockWriteText = vi.fn()
|
||||
const mockCopy = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useClipboard: vi.fn(() => ({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => true)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: mockToastAdd
|
||||
@@ -13,31 +22,34 @@ vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
describe('useCopyToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: mockWriteText }
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => true),
|
||||
text: ref('')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows success toast when modern clipboard succeeds', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
mockCopy.mockResolvedValue(undefined)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('hello')
|
||||
expect(mockCopy).toHaveBeenCalledWith('hello')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to legacy when modern clipboard fails', async () => {
|
||||
mockWriteText.mockRejectedValue(new Error('Not allowed'))
|
||||
mockCopy.mockRejectedValue(new Error('Not allowed'))
|
||||
document.execCommand = vi.fn(() => true)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -50,7 +62,7 @@ describe('useCopyToClipboard', () => {
|
||||
})
|
||||
|
||||
it('shows error toast when both modern and legacy fail', async () => {
|
||||
mockWriteText.mockRejectedValue(new Error('Not allowed'))
|
||||
mockCopy.mockRejectedValue(new Error('Not allowed'))
|
||||
document.execCommand = vi.fn(() => false)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -61,17 +73,19 @@ describe('useCopyToClipboard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('falls through to legacy when clipboard API is unavailable', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined
|
||||
it('falls through to legacy when isSupported is false', async () => {
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => false),
|
||||
text: ref('')
|
||||
})
|
||||
document.execCommand = vi.fn(() => true)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockWriteText).not.toHaveBeenCalled()
|
||||
expect(mockCopy).not.toHaveBeenCalled()
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
@@ -19,14 +20,15 @@ function legacyCopy(text: string): boolean {
|
||||
}
|
||||
|
||||
export function useCopyToClipboard() {
|
||||
const { copy, isSupported } = useClipboard()
|
||||
const toast = useToast()
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
let success = false
|
||||
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
if (isSupported.value) {
|
||||
await copy(text)
|
||||
success = true
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -260,7 +260,7 @@ describe('useCoreCommands', () => {
|
||||
function createMockSettingStore(
|
||||
getReturnValue: boolean
|
||||
): ReturnType<typeof useSettingStore> {
|
||||
return fromPartial<ReturnType<typeof useSettingStore>>({
|
||||
return {
|
||||
get: vi.fn().mockReturnValue(getReturnValue),
|
||||
addSetting: vi.fn(),
|
||||
load: vi.fn(),
|
||||
@@ -287,7 +287,7 @@ describe('useCoreCommands', () => {
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set()
|
||||
})
|
||||
} satisfies ReturnType<typeof useSettingStore>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -226,19 +226,19 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:billing_control_enabled', 'true')
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('billingControlEnabled is false off-cloud even without an override', () => {
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
@@ -257,36 +257,36 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedBillingControlEnabled.value = true
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false during the auth window when nothing is cached', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers authenticated remoteConfig over the server feature fallback', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {
|
||||
team_workspaces_enabled: true,
|
||||
billing_control_enabled: true
|
||||
consolidated_billing_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
@@ -295,14 +295,15 @@ describe('useFeatureFlags', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user