From 96c6dbcb69d8e47d63a8828a66d94c7659ea97ee Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Tue, 14 Jul 2026 03:24:52 +0500 Subject: [PATCH 01/21] fix(website): replace em dash meta title separators with hyphens (#13628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Switch the meta title separator sitewide from an em dash (—) to a spaced hyphen ( - ), the follow-up to #13480 tracked as FE-1233, based on the Screaming Frog crawl that flagged the em dash titles. ## Changes - **What**: Replaced the em dash separator with a hyphen in every page meta title, in both `en` and `zh-CN`. Covers ~44 inline page titles, the shared title keys in `i18n/translations.ts` (`cloudNodes.meta.title`, `mcp.meta.title`, `affiliate-terms.page.title`, `enterprise-msa.page.title`, `affiliate.page.title`, `launches.page.title`), and the dynamic `customers`, `demos`, and `supported-models` `... - Comfy` suffixes. `BaseLayout` reuses each page title for `og:title`, `twitter:title`, and the JSON-LD `name`, so those inherit the change from a single source per page. - Updated the e2e title assertions to match. `launches` asserts via `t()` so it follows the translation change automatically. ## Review Focus - **Scope is titles only.** The crawl and this ticket are about meta titles. Description / body copy em dashes are left untouched, they are marketing copy, which we keep em-dashed per the comms-only styling decision. A full JSON-LD audit across all 57 routes confirms every `name`/title field is now em-dash-free. - **zh-CN cloud-nodes title keeps its Chinese double dash `——`** (`自定义节点包合集——开箱即用`). That is correct CJK punctuation, a dash break, not a brand/Latin separator, so the Latin-script separator concern does not apply. Its e2e assertion is left as `——` to match. - No visual change: titles are `` metadata, not rendered UI, so there is nothing to screenshot on the page. Rendered `` output was verified on the dev server across inline, `t()`-based, zh-CN, dynamic, and 404 routes, and `og:`/`twitter:` inheritance was confirmed. Ref: FE-1233 --- apps/website/e2e/careers.spec.ts | 4 ++-- apps/website/e2e/cloud-nodes.spec.ts | 2 +- apps/website/e2e/cloud.spec.ts | 2 +- apps/website/e2e/download.spec.ts | 2 +- apps/website/e2e/homepage.spec.ts | 2 +- apps/website/e2e/learning.spec.ts | 4 ++-- apps/website/e2e/payment.spec.ts | 8 +++---- apps/website/src/i18n/translations.ts | 22 +++++++++---------- apps/website/src/pages/404.astro | 2 +- apps/website/src/pages/about.astro | 2 +- apps/website/src/pages/careers.astro | 2 +- apps/website/src/pages/case-studies.astro | 2 +- apps/website/src/pages/cloud/index.astro | 2 +- apps/website/src/pages/cloud/pricing.astro | 2 +- apps/website/src/pages/contact.astro | 2 +- apps/website/src/pages/customers.astro | 2 +- apps/website/src/pages/customers/[slug].astro | 2 +- apps/website/src/pages/demos/[slug].astro | 2 +- apps/website/src/pages/demos/index.astro | 2 +- apps/website/src/pages/download.astro | 2 +- apps/website/src/pages/gallery.astro | 2 +- apps/website/src/pages/index.astro | 2 +- apps/website/src/pages/learning.astro | 2 +- apps/website/src/pages/models.astro | 2 +- .../src/pages/p/supported-models/[slug].astro | 2 +- .../src/pages/p/supported-models/index.astro | 2 +- apps/website/src/pages/payment/failed.astro | 2 +- apps/website/src/pages/payment/success.astro | 2 +- apps/website/src/pages/privacy-policy.astro | 2 +- apps/website/src/pages/privacy/desktop.astro | 2 +- apps/website/src/pages/terms-of-service.astro | 2 +- apps/website/src/pages/videos.astro | 2 +- apps/website/src/pages/zh-CN/about.astro | 2 +- apps/website/src/pages/zh-CN/careers.astro | 2 +- .../src/pages/zh-CN/case-studies.astro | 2 +- .../website/src/pages/zh-CN/cloud/index.astro | 2 +- .../src/pages/zh-CN/cloud/pricing.astro | 2 +- apps/website/src/pages/zh-CN/contact.astro | 2 +- apps/website/src/pages/zh-CN/customers.astro | 2 +- .../src/pages/zh-CN/customers/[slug].astro | 2 +- .../src/pages/zh-CN/demos/[slug].astro | 2 +- .../website/src/pages/zh-CN/demos/index.astro | 2 +- apps/website/src/pages/zh-CN/download.astro | 2 +- apps/website/src/pages/zh-CN/gallery.astro | 2 +- apps/website/src/pages/zh-CN/index.astro | 2 +- apps/website/src/pages/zh-CN/learning.astro | 2 +- apps/website/src/pages/zh-CN/models.astro | 2 +- .../src/pages/zh-CN/payment/failed.astro | 2 +- .../src/pages/zh-CN/payment/success.astro | 2 +- .../src/pages/zh-CN/privacy-policy.astro | 2 +- .../src/pages/zh-CN/privacy/desktop.astro | 2 +- apps/website/src/pages/zh-CN/videos.astro | 2 +- 52 files changed, 67 insertions(+), 67 deletions(-) diff --git a/apps/website/e2e/careers.spec.ts b/apps/website/e2e/careers.spec.ts index 22263c1bfc..95ac837792 100644 --- a/apps/website/e2e/careers.spec.ts +++ b/apps/website/e2e/careers.spec.ts @@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => { }) test('has correct title', async ({ page }) => { - await expect(page).toHaveTitle('Careers — Comfy') + await expect(page).toHaveTitle('Careers - Comfy') }) test('Roles section heading is visible', async ({ page }) => { @@ -72,7 +72,7 @@ test.describe('Careers page role links', () => { test.describe('Careers page (zh-CN) @smoke', () => { test('renders localized heading and roles', async ({ page }) => { await page.goto('/zh-CN/careers') - await expect(page).toHaveTitle('招聘 — Comfy') + await expect(page).toHaveTitle('招聘 - Comfy') await expect( page.getByRole('heading', { name: '职位', level: 2 }) ).toBeVisible() diff --git a/apps/website/e2e/cloud-nodes.spec.ts b/apps/website/e2e/cloud-nodes.spec.ts index 9f99bf453b..7ecf9224fb 100644 --- a/apps/website/e2e/cloud-nodes.spec.ts +++ b/apps/website/e2e/cloud-nodes.spec.ts @@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => { test('has correct title', async ({ page }) => { await expect(page).toHaveTitle( - 'Custom-node packs on Comfy Cloud — supported by default' + 'Custom-node packs on Comfy Cloud - supported by default' ) }) diff --git a/apps/website/e2e/cloud.spec.ts b/apps/website/e2e/cloud.spec.ts index 603c8de974..d704dec30e 100644 --- a/apps/website/e2e/cloud.spec.ts +++ b/apps/website/e2e/cloud.spec.ts @@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => { }) test('has correct title', async ({ page }) => { - await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud') + await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud') }) test('HeroSection heading and subtitle are visible', async ({ page }) => { diff --git a/apps/website/e2e/download.spec.ts b/apps/website/e2e/download.spec.ts index 3eb896f34b..ccc7515a90 100644 --- a/apps/website/e2e/download.spec.ts +++ b/apps/website/e2e/download.spec.ts @@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => { test('has correct title', async ({ page }) => { await expect(page).toHaveTitle( - 'Download Comfy Desktop — Run AI on Your Hardware' + 'Download Comfy Desktop - Run AI on Your Hardware' ) }) diff --git a/apps/website/e2e/homepage.spec.ts b/apps/website/e2e/homepage.spec.ts index 787027f615..7504a47fe3 100644 --- a/apps/website/e2e/homepage.spec.ts +++ b/apps/website/e2e/homepage.spec.ts @@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => { }) test('has correct title', async ({ page }) => { - await expect(page).toHaveTitle('Comfy — Professional Control of Visual AI') + await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI') }) test('HeroSection heading is visible', async ({ page }) => { diff --git a/apps/website/e2e/learning.spec.ts b/apps/website/e2e/learning.spec.ts index 7a23ee884e..6165a56bca 100644 --- a/apps/website/e2e/learning.spec.ts +++ b/apps/website/e2e/learning.spec.ts @@ -13,7 +13,7 @@ test.describe('Learning page @smoke', () => { }) test('has correct title', async ({ page }) => { - await expect(page).toHaveTitle('Learning — Comfy') + await expect(page).toHaveTitle('Learning - Comfy') }) test('hero headline references ComfyUI', async ({ page }) => { @@ -137,7 +137,7 @@ test.describe('Learning page (zh-CN) @smoke', () => { test('renders localized title, headings, and tutorials', async ({ page }) => { await page.goto('/zh-CN/learning') - await expect(page).toHaveTitle('学习 — Comfy') + await expect(page).toHaveTitle('学习 - Comfy') await expect(page.getByRole('heading', { level: 1 })).toContainText( /[一-鿿]/ ) diff --git a/apps/website/e2e/payment.spec.ts b/apps/website/e2e/payment.spec.ts index 2a2e8f535c..528b7f839b 100644 --- a/apps/website/e2e/payment.spec.ts +++ b/apps/website/e2e/payment.spec.ts @@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => { }) test('has correct title and is noindex', async ({ page }) => { - await expect(page).toHaveTitle('Payment Successful — Comfy') + await expect(page).toHaveTitle('Payment Successful - Comfy') await expectNoIndex(page) }) @@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => { }) test('has correct title and is noindex', async ({ page }) => { - await expect(page).toHaveTitle('Payment Failed — Comfy') + await expect(page).toHaveTitle('Payment Failed - Comfy') await expectNoIndex(page) }) @@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => { test.describe('Payment pages zh-CN @smoke', () => { test('zh-CN success page renders and links correctly', async ({ page }) => { await page.goto('/zh-CN/payment/success') - await expect(page).toHaveTitle('支付成功 — Comfy') + await expect(page).toHaveTitle('支付成功 - Comfy') await expectNoIndex(page) await expect( page.getByRole('heading', { name: '支付成功', level: 1 }) @@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => { test('zh-CN failed page renders and links correctly', async ({ page }) => { await page.goto('/zh-CN/payment/failed') - await expect(page).toHaveTitle('支付失败 — Comfy') + await expect(page).toHaveTitle('支付失败 - Comfy') await expectNoIndex(page) await expect( page.getByRole('heading', { name: '无法完成支付', level: 1 }) diff --git a/apps/website/src/i18n/translations.ts b/apps/website/src/i18n/translations.ts index 67aecfce44..3996efaecf 100644 --- a/apps/website/src/i18n/translations.ts +++ b/apps/website/src/i18n/translations.ts @@ -823,7 +823,7 @@ const translations = { 'zh-CN': 'Comfy Cloud 支持的自定义节点包' }, 'cloudNodes.meta.title': { - en: 'Custom-node packs on Comfy Cloud — supported by default', + en: 'Custom-node packs on Comfy Cloud - supported by default', 'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用' }, 'cloudNodes.meta.description': { @@ -1845,8 +1845,8 @@ const translations = { // MCP – Meta 'mcp.meta.title': { - en: 'Comfy MCP — Drive ComfyUI from any AI agent', - 'zh-CN': 'Comfy MCP — 让任何 AI 智能体驱动 ComfyUI' + en: 'Comfy MCP - Drive ComfyUI from any AI agent', + 'zh-CN': 'Comfy MCP - 让任何 AI 智能体驱动 ComfyUI' }, 'mcp.meta.description': { en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.', @@ -3483,8 +3483,8 @@ const translations = { }, 'affiliate-terms.page.title': { - en: 'Affiliate Terms — Comfy', - 'zh-CN': 'Affiliate Terms — Comfy' + en: 'Affiliate Terms - Comfy', + 'zh-CN': 'Affiliate Terms - Comfy' }, 'affiliate-terms.page.description': { en: 'Comfy.org Affiliate Program Terms and Conditions.', @@ -3896,8 +3896,8 @@ const translations = { 'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.' }, 'enterprise-msa.page.title': { - en: 'Enterprise MSA — Comfy', - 'zh-CN': 'Enterprise MSA — Comfy' + en: 'Enterprise MSA - Comfy', + 'zh-CN': 'Enterprise MSA - Comfy' }, 'enterprise-msa.page.description': { en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.', @@ -4361,8 +4361,8 @@ const translations = { // Affiliate page (/affiliates) — head metadata 'affiliate.page.title': { - en: 'Comfy.org Affiliate Program — Become a Partner', - 'zh-CN': 'Comfy.org 联盟计划 — 成为合作伙伴' + en: 'Comfy.org Affiliate Program - Become a Partner', + 'zh-CN': 'Comfy.org 联盟计划 - 成为合作伙伴' }, 'affiliate.page.description': { en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.', @@ -4387,8 +4387,8 @@ const translations = { // Launches page (/launches) — head metadata // zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md) 'launches.page.title': { - en: 'ComfyUI Live Demo & Q&A — June 29 Launch Livestream', - 'zh-CN': 'ComfyUI 直播演示与问答 — 6 月 29 日发布直播' + en: 'ComfyUI Live Demo & Q&A - June 29 Launch Livestream', + 'zh-CN': 'ComfyUI 直播演示与问答 - 6 月 29 日发布直播' }, 'launches.page.description': { en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See what’s new across desktop, cloud, and community, and get your questions answered.', diff --git a/apps/website/src/pages/404.astro b/apps/website/src/pages/404.astro index e0964a8d81..9fff35c63c 100644 --- a/apps/website/src/pages/404.astro +++ b/apps/website/src/pages/404.astro @@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro' --- <BaseLayout - title="404 — Page Not Found — Comfy" + title="404 - Page Not Found - Comfy" noindex> <div class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]"> diff --git a/apps/website/src/pages/about.astro b/apps/website/src/pages/about.astro index d8b17d87a4..1ef20c36b2 100644 --- a/apps/website/src/pages/about.astro +++ b/apps/website/src/pages/about.astro @@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext( --- <BaseLayout - title="About Us — Comfy" + title="About Us - Comfy" pageType="AboutPage" mainEntityId={organizationId(siteUrl)} breadcrumbs={[ diff --git a/apps/website/src/pages/careers.astro b/apps/website/src/pages/careers.astro index a2618f8374..719c6f389e 100644 --- a/apps/website/src/pages/careers.astro +++ b/apps/website/src/pages/careers.astro @@ -42,7 +42,7 @@ const roles = itemListNode( --- <BaseLayout - title="Careers — Comfy" + title="Careers - Comfy" description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more." pageType="CollectionPage" mainEntityId={jsonLdId(url, 'itemlist')} diff --git a/apps/website/src/pages/case-studies.astro b/apps/website/src/pages/case-studies.astro index f71e6eb1ec..9a52b6459e 100644 --- a/apps/website/src/pages/case-studies.astro +++ b/apps/website/src/pages/case-studies.astro @@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro' import ComingSoon from '../components/common/ComingSoon.astro' --- -<BaseLayout title="Case Studies — Comfy"> +<BaseLayout title="Case Studies - Comfy"> <ComingSoon /> </BaseLayout> diff --git a/apps/website/src/pages/cloud/index.astro b/apps/website/src/pages/cloud/index.astro index 889a98adb8..eb2abbb39c 100644 --- a/apps/website/src/pages/cloud/index.astro +++ b/apps/website/src/pages/cloud/index.astro @@ -11,7 +11,7 @@ import { t } from '../../i18n/translations' --- <BaseLayout - title="Comfy Cloud — AI in the Cloud" + title="Comfy Cloud - AI in the Cloud" description={t('cloud.hero.subtitle', 'en')} keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']} > diff --git a/apps/website/src/pages/cloud/pricing.astro b/apps/website/src/pages/cloud/pricing.astro index 22692e831f..6717e93405 100644 --- a/apps/website/src/pages/cloud/pricing.astro +++ b/apps/website/src/pages/cloud/pricing.astro @@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product') --- <BaseLayout - title="Pricing — Comfy Cloud" + title="Pricing - Comfy Cloud" mainEntityId={productId} breadcrumbs={[ { name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') }, diff --git a/apps/website/src/pages/contact.astro b/apps/website/src/pages/contact.astro index 9dbd9bdd89..d729002070 100644 --- a/apps/website/src/pages/contact.astro +++ b/apps/website/src/pages/contact.astro @@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext( --- <BaseLayout - title="Contact — Comfy" + title="Contact - Comfy" pageType="ContactPage" mainEntityId={organizationId(siteUrl)} breadcrumbs={[ diff --git a/apps/website/src/pages/customers.astro b/apps/website/src/pages/customers.astro index 308123a952..3e4ac84c2d 100644 --- a/apps/website/src/pages/customers.astro +++ b/apps/website/src/pages/customers.astro @@ -11,7 +11,7 @@ import { loadStories } from '../utils/loadStories' const stories = (await loadStories('en')).map(toCardProps) --- -<BaseLayout title="Customer Stories — Comfy"> +<BaseLayout title="Customer Stories - Comfy"> <HeroSection client:load /> <StorySection stories={stories} /> <FeedbackSection client:load /> diff --git a/apps/website/src/pages/customers/[slug].astro b/apps/website/src/pages/customers/[slug].astro index c1017aec50..3f40c17741 100644 --- a/apps/website/src/pages/customers/[slug].astro +++ b/apps/website/src/pages/customers/[slug].astro @@ -17,7 +17,7 @@ export async function getStaticPaths() { const { entry, next } = Astro.props --- -<BaseLayout title={`${entry.data.title} — Comfy`}> +<BaseLayout title={`${entry.data.title} - Comfy`}> <DetailHeroSection label={entry.data.category} title={entry.data.title} diff --git a/apps/website/src/pages/demos/[slug].astro b/apps/website/src/pages/demos/[slug].astro index 670ba27e5a..4924fcaae2 100644 --- a/apps/website/src/pages/demos/[slug].astro +++ b/apps/website/src/pages/demos/[slug].astro @@ -58,7 +58,7 @@ const learningResource: JsonLdNode = { --- <BaseLayout - title={`${title} — Comfy`} + title={`${title} - Comfy`} description={description} ogImage={demo.ogImage} mainEntityId={learningId} diff --git a/apps/website/src/pages/demos/index.astro b/apps/website/src/pages/demos/index.astro index 05d3caa106..78767a876e 100644 --- a/apps/website/src/pages/demos/index.astro +++ b/apps/website/src/pages/demos/index.astro @@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro' import ComingSoon from '../../components/common/ComingSoon.astro' --- -<BaseLayout title="Demos — Comfy" description="Interactive demos and tutorials for ComfyUI."> +<BaseLayout title="Demos - Comfy" description="Interactive demos and tutorials for ComfyUI."> <ComingSoon /> </BaseLayout> diff --git a/apps/website/src/pages/download.astro b/apps/website/src/pages/download.astro index 3e06fc3751..7f633803b8 100644 --- a/apps/website/src/pages/download.astro +++ b/apps/website/src/pages/download.astro @@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext( --- <BaseLayout - title="Download Comfy Desktop — Run AI on Your Hardware" + title="Download Comfy Desktop - Run AI on Your Hardware" description={t('download.hero.subtitle', 'en')} mainEntityId={comfyUiSoftwareId(siteUrl)} breadcrumbs={[ diff --git a/apps/website/src/pages/gallery.astro b/apps/website/src/pages/gallery.astro index f3b7b9a63e..1d39a0d358 100644 --- a/apps/website/src/pages/gallery.astro +++ b/apps/website/src/pages/gallery.astro @@ -5,7 +5,7 @@ import GallerySection from '../components/gallery/GallerySection.vue' import ContactSection from '../components/gallery/ContactSection.vue' --- -<BaseLayout title="Gallery — Comfy"> +<BaseLayout title="Gallery - Comfy"> <HeroSection /> <GallerySection client:load /> <ContactSection /> diff --git a/apps/website/src/pages/index.astro b/apps/website/src/pages/index.astro index 11a77d60f0..079089234b 100644 --- a/apps/website/src/pages/index.astro +++ b/apps/website/src/pages/index.astro @@ -24,7 +24,7 @@ const { siteUrl } = pageContext( --- <BaseLayout - title="Comfy — Professional Control of Visual AI" + title="Comfy - Professional Control of Visual AI" description={t("hero.subtitle", "en")} mainEntityId={comfyUiSoftwareId(siteUrl)} extraJsonLd={[ diff --git a/apps/website/src/pages/learning.astro b/apps/website/src/pages/learning.astro index ee9013979d..25e9f651f4 100644 --- a/apps/website/src/pages/learning.astro +++ b/apps/website/src/pages/learning.astro @@ -12,7 +12,7 @@ import { externalLinks } from '../config/routes' const routes = getRoutes('en') --- -<BaseLayout title="Learning — Comfy"> +<BaseLayout title="Learning - Comfy"> <HeroSection client:load /> <FeaturedWorkflowSection client:visible /> <TutorialsSection client:visible /> diff --git a/apps/website/src/pages/models.astro b/apps/website/src/pages/models.astro index 981b7f8791..9c92fff08c 100644 --- a/apps/website/src/pages/models.astro +++ b/apps/website/src/pages/models.astro @@ -7,7 +7,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu --- <BaseLayout - title="Models — Comfy" + title="Models - Comfy" description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run." > <ModelsHeroSection diff --git a/apps/website/src/pages/p/supported-models/[slug].astro b/apps/website/src/pages/p/supported-models/[slug].astro index 2acbf8313c..cfe4de19c4 100644 --- a/apps/website/src/pages/p/supported-models/[slug].astro +++ b/apps/website/src/pages/p/supported-models/[slug].astro @@ -103,7 +103,7 @@ const faqPage: JsonLdNode = { --- <BaseLayout - title={`${pageTitle} — Comfy`} + title={`${pageTitle} - Comfy`} description={pageDescription} ogImage={model.thumbnailUrl} mainEntityId={softwareId} diff --git a/apps/website/src/pages/p/supported-models/index.astro b/apps/website/src/pages/p/supported-models/index.astro index da602492dd..7529dfce21 100644 --- a/apps/website/src/pages/p/supported-models/index.astro +++ b/apps/website/src/pages/p/supported-models/index.astro @@ -43,7 +43,7 @@ const dirLabel: Record<string, string> = { --- <BaseLayout - title={`${title} — Comfy`} + title={`${title} - Comfy`} description={subtitle} pageType="CollectionPage" mainEntityId={jsonLdId(url, 'itemlist')} diff --git a/apps/website/src/pages/payment/failed.astro b/apps/website/src/pages/payment/failed.astro index 2acbf18cfe..293d3ce0b3 100644 --- a/apps/website/src/pages/payment/failed.astro +++ b/apps/website/src/pages/payment/failed.astro @@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection. --- <BaseLayout - title="Payment Failed — Comfy" + title="Payment Failed - Comfy" description="Your payment was not completed." noindex > diff --git a/apps/website/src/pages/payment/success.astro b/apps/website/src/pages/payment/success.astro index 07a3c7f725..b76ce8f94d 100644 --- a/apps/website/src/pages/payment/success.astro +++ b/apps/website/src/pages/payment/success.astro @@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection. --- <BaseLayout - title="Payment Successful — Comfy" + title="Payment Successful - Comfy" description="Your payment was processed successfully." noindex > diff --git a/apps/website/src/pages/privacy-policy.astro b/apps/website/src/pages/privacy-policy.astro index 24decb03d3..25d5a43c11 100644 --- a/apps/website/src/pages/privacy-policy.astro +++ b/apps/website/src/pages/privacy-policy.astro @@ -5,7 +5,7 @@ import HeroSection from '../components/legal/HeroSection.vue' --- <BaseLayout - title="Privacy Policy — Comfy" + title="Privacy Policy - Comfy" description="Comfy privacy policy. Learn how we collect, use, and protect your personal information." noindex > diff --git a/apps/website/src/pages/privacy/desktop.astro b/apps/website/src/pages/privacy/desktop.astro index 06b214a3d8..9edadde49f 100644 --- a/apps/website/src/pages/privacy/desktop.astro +++ b/apps/website/src/pages/privacy/desktop.astro @@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue' --- <BaseLayout - title="Desktop Privacy Policy — Comfy" + title="Desktop Privacy Policy - Comfy" description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights." > <HeroSection title="Desktop Privacy Policy" /> diff --git a/apps/website/src/pages/terms-of-service.astro b/apps/website/src/pages/terms-of-service.astro index 26d5ab9b70..a2bd703cfa 100644 --- a/apps/website/src/pages/terms-of-service.astro +++ b/apps/website/src/pages/terms-of-service.astro @@ -6,7 +6,7 @@ import { t } from '../i18n/translations' --- <BaseLayout - title="Terms of Service — Comfy" + title="Terms of Service - Comfy" description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise." noindex > diff --git a/apps/website/src/pages/videos.astro b/apps/website/src/pages/videos.astro index 45c6b61922..3afd829ca6 100644 --- a/apps/website/src/pages/videos.astro +++ b/apps/website/src/pages/videos.astro @@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro' import ComingSoon from '../components/common/ComingSoon.astro' --- -<BaseLayout title="Videos — Comfy"> +<BaseLayout title="Videos - Comfy"> <ComingSoon /> </BaseLayout> diff --git a/apps/website/src/pages/zh-CN/about.astro b/apps/website/src/pages/zh-CN/about.astro index 1702d180f8..c19c02ec42 100644 --- a/apps/website/src/pages/zh-CN/about.astro +++ b/apps/website/src/pages/zh-CN/about.astro @@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext( --- <BaseLayout - title="关于我们 — Comfy" + title="关于我们 - Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。" pageType="AboutPage" mainEntityId={organizationId(siteUrl)} diff --git a/apps/website/src/pages/zh-CN/careers.astro b/apps/website/src/pages/zh-CN/careers.astro index 134fb70abe..a39b6e7604 100644 --- a/apps/website/src/pages/zh-CN/careers.astro +++ b/apps/website/src/pages/zh-CN/careers.astro @@ -42,7 +42,7 @@ const roles = itemListNode( --- <BaseLayout - title="招聘 — Comfy" + title="招聘 - Comfy" description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。" pageType="CollectionPage" mainEntityId={jsonLdId(url, 'itemlist')} diff --git a/apps/website/src/pages/zh-CN/case-studies.astro b/apps/website/src/pages/zh-CN/case-studies.astro index 62061880a7..a8e144a519 100644 --- a/apps/website/src/pages/zh-CN/case-studies.astro +++ b/apps/website/src/pages/zh-CN/case-studies.astro @@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro' import ComingSoon from '../../components/common/ComingSoon.astro' --- -<BaseLayout title="案例研究 — Comfy"> +<BaseLayout title="案例研究 - Comfy"> <ComingSoon /> </BaseLayout> diff --git a/apps/website/src/pages/zh-CN/cloud/index.astro b/apps/website/src/pages/zh-CN/cloud/index.astro index 0f3ee8b065..a1d4f27241 100644 --- a/apps/website/src/pages/zh-CN/cloud/index.astro +++ b/apps/website/src/pages/zh-CN/cloud/index.astro @@ -11,7 +11,7 @@ import { t } from '../../../i18n/translations' --- <BaseLayout - title="Comfy Cloud — 云端 AI" + title="Comfy Cloud - 云端 AI" description={t('cloud.hero.subtitle', 'zh-CN')} keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']} > diff --git a/apps/website/src/pages/zh-CN/cloud/pricing.astro b/apps/website/src/pages/zh-CN/cloud/pricing.astro index c541173ace..e9c5282e0a 100644 --- a/apps/website/src/pages/zh-CN/cloud/pricing.astro +++ b/apps/website/src/pages/zh-CN/cloud/pricing.astro @@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product') --- <BaseLayout - title="定价 — Comfy Cloud" + title="定价 - Comfy Cloud" mainEntityId={productId} breadcrumbs={[ { diff --git a/apps/website/src/pages/zh-CN/contact.astro b/apps/website/src/pages/zh-CN/contact.astro index 0b9eebe652..ae89576aab 100644 --- a/apps/website/src/pages/zh-CN/contact.astro +++ b/apps/website/src/pages/zh-CN/contact.astro @@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext( --- <BaseLayout - title="联系我们 — Comfy" + title="联系我们 - Comfy" pageType="ContactPage" mainEntityId={organizationId(siteUrl)} breadcrumbs={[ diff --git a/apps/website/src/pages/zh-CN/customers.astro b/apps/website/src/pages/zh-CN/customers.astro index 6e7ace0558..e9688811f4 100644 --- a/apps/website/src/pages/zh-CN/customers.astro +++ b/apps/website/src/pages/zh-CN/customers.astro @@ -11,7 +11,7 @@ import { loadStories } from '../../utils/loadStories' const stories = (await loadStories('zh-CN')).map(toCardProps) --- -<BaseLayout title="客户故事 — Comfy"> +<BaseLayout title="客户故事 - Comfy"> <HeroSection locale="zh-CN" client:load /> <StorySection stories={stories} locale="zh-CN" /> <FeedbackSection locale="zh-CN" client:load /> diff --git a/apps/website/src/pages/zh-CN/customers/[slug].astro b/apps/website/src/pages/zh-CN/customers/[slug].astro index 9cf56bca59..e47dc9bc9e 100644 --- a/apps/website/src/pages/zh-CN/customers/[slug].astro +++ b/apps/website/src/pages/zh-CN/customers/[slug].astro @@ -17,7 +17,7 @@ export async function getStaticPaths() { const { entry, next } = Astro.props --- -<BaseLayout title={`${entry.data.title} — Comfy`}> +<BaseLayout title={`${entry.data.title} - Comfy`}> <DetailHeroSection label={entry.data.category} title={entry.data.title} diff --git a/apps/website/src/pages/zh-CN/demos/[slug].astro b/apps/website/src/pages/zh-CN/demos/[slug].astro index 095dccf979..acf5bd4215 100644 --- a/apps/website/src/pages/zh-CN/demos/[slug].astro +++ b/apps/website/src/pages/zh-CN/demos/[slug].astro @@ -58,7 +58,7 @@ const learningResource: JsonLdNode = { --- <BaseLayout - title={`${title} — Comfy`} + title={`${title} - Comfy`} description={description} ogImage={demo.ogImage} mainEntityId={learningId} diff --git a/apps/website/src/pages/zh-CN/demos/index.astro b/apps/website/src/pages/zh-CN/demos/index.astro index 0bf35df318..40445ed061 100644 --- a/apps/website/src/pages/zh-CN/demos/index.astro +++ b/apps/website/src/pages/zh-CN/demos/index.astro @@ -3,7 +3,7 @@ import BaseLayout from '../../../layouts/BaseLayout.astro' import { t } from '../../../i18n/translations' --- -<BaseLayout title="演示 — Comfy" description="ComfyUI 的互动演示和教程。"> +<BaseLayout title="演示 - Comfy" description="ComfyUI 的互动演示和教程。"> <section class="flex min-h-[60vh] items-center justify-center px-6"> <div class="text-center"> <h1 class="text-primary-comfy-canvas text-4xl font-light"> diff --git a/apps/website/src/pages/zh-CN/download.astro b/apps/website/src/pages/zh-CN/download.astro index 6dfff8d5e1..b226c8cbfb 100644 --- a/apps/website/src/pages/zh-CN/download.astro +++ b/apps/website/src/pages/zh-CN/download.astro @@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext( --- <BaseLayout - title="下载 Comfy 桌面版 — 在您的硬件上运行 AI" + title="下载 Comfy 桌面版 - 在您的硬件上运行 AI" description={t('download.hero.subtitle', 'zh-CN')} mainEntityId={comfyUiSoftwareId(siteUrl)} breadcrumbs={[ diff --git a/apps/website/src/pages/zh-CN/gallery.astro b/apps/website/src/pages/zh-CN/gallery.astro index f181d554fc..24479b6aa6 100644 --- a/apps/website/src/pages/zh-CN/gallery.astro +++ b/apps/website/src/pages/zh-CN/gallery.astro @@ -5,7 +5,7 @@ import GallerySection from '../../components/gallery/GallerySection.vue' import ContactSection from '../../components/gallery/ContactSection.vue' --- -<BaseLayout title="作品集 — Comfy"> +<BaseLayout title="作品集 - Comfy"> <HeroSection locale="zh-CN" /> <GallerySection locale="zh-CN" client:load /> <ContactSection locale="zh-CN" /> diff --git a/apps/website/src/pages/zh-CN/index.astro b/apps/website/src/pages/zh-CN/index.astro index 2da2ce08a3..030006addc 100644 --- a/apps/website/src/pages/zh-CN/index.astro +++ b/apps/website/src/pages/zh-CN/index.astro @@ -24,7 +24,7 @@ const { siteUrl } = pageContext( --- <BaseLayout - title="Comfy — 视觉 AI 的最强可控性" + title="Comfy - 视觉 AI 的最强可控性" description={t('hero.subtitle', 'zh-CN')} mainEntityId={comfyUiSoftwareId(siteUrl)} extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]} diff --git a/apps/website/src/pages/zh-CN/learning.astro b/apps/website/src/pages/zh-CN/learning.astro index 919592e8f0..9d7c8875a2 100644 --- a/apps/website/src/pages/zh-CN/learning.astro +++ b/apps/website/src/pages/zh-CN/learning.astro @@ -11,7 +11,7 @@ import { learningEvents } from '../../data/events' const routes = getRoutes('zh-CN') --- -<BaseLayout title="学习 — Comfy"> +<BaseLayout title="学习 - Comfy"> <HeroSection locale="zh-CN" client:load /> <FeaturedWorkflowSection locale="zh-CN" client:visible /> <TutorialsSection locale="zh-CN" client:visible /> diff --git a/apps/website/src/pages/zh-CN/models.astro b/apps/website/src/pages/zh-CN/models.astro index d748cbb795..85ddf8cd8d 100644 --- a/apps/website/src/pages/zh-CN/models.astro +++ b/apps/website/src/pages/zh-CN/models.astro @@ -7,7 +7,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection --- <BaseLayout - title="模型 — Comfy" + title="模型 - Comfy" description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。" > <ModelsHeroSection diff --git a/apps/website/src/pages/zh-CN/payment/failed.astro b/apps/website/src/pages/zh-CN/payment/failed.astro index 85a07ae04b..bfb0a37000 100644 --- a/apps/website/src/pages/zh-CN/payment/failed.astro +++ b/apps/website/src/pages/zh-CN/payment/failed.astro @@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro' import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue' --- -<BaseLayout title="支付失败 — Comfy" description="您的支付未能完成。" noindex> +<BaseLayout title="支付失败 - Comfy" description="您的支付未能完成。" noindex> <PaymentStatusSection status="failed" locale="zh-CN" /> </BaseLayout> diff --git a/apps/website/src/pages/zh-CN/payment/success.astro b/apps/website/src/pages/zh-CN/payment/success.astro index 1e48c69e71..ff6b48767d 100644 --- a/apps/website/src/pages/zh-CN/payment/success.astro +++ b/apps/website/src/pages/zh-CN/payment/success.astro @@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro' import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue' --- -<BaseLayout title="支付成功 — Comfy" description="您的支付已成功完成。" noindex> +<BaseLayout title="支付成功 - Comfy" description="您的支付已成功完成。" noindex> <PaymentStatusSection status="success" locale="zh-CN" /> </BaseLayout> diff --git a/apps/website/src/pages/zh-CN/privacy-policy.astro b/apps/website/src/pages/zh-CN/privacy-policy.astro index 75655acd76..2d70464e39 100644 --- a/apps/website/src/pages/zh-CN/privacy-policy.astro +++ b/apps/website/src/pages/zh-CN/privacy-policy.astro @@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue' --- <BaseLayout - title="隐私政策 — Comfy" + title="隐私政策 - Comfy" description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。" noindex > diff --git a/apps/website/src/pages/zh-CN/privacy/desktop.astro b/apps/website/src/pages/zh-CN/privacy/desktop.astro index 3e0143a5fc..085d046add 100644 --- a/apps/website/src/pages/zh-CN/privacy/desktop.astro +++ b/apps/website/src/pages/zh-CN/privacy/desktop.astro @@ -5,7 +5,7 @@ import HeroSection from '../../../components/legal/HeroSection.vue' --- <BaseLayout - title="Desktop 隐私政策 — Comfy" + title="Desktop 隐私政策 - Comfy" description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。" > <HeroSection title="Desktop 隐私政策" /> diff --git a/apps/website/src/pages/zh-CN/videos.astro b/apps/website/src/pages/zh-CN/videos.astro index 69162f45d9..b25ad65d6b 100644 --- a/apps/website/src/pages/zh-CN/videos.astro +++ b/apps/website/src/pages/zh-CN/videos.astro @@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro' import ComingSoon from '../../components/common/ComingSoon.astro' --- -<BaseLayout title="视频 — Comfy"> +<BaseLayout title="视频 - Comfy"> <ComingSoon /> </BaseLayout> From 16fdcab4bef5ccc6f815eb0dd1653f08db698215 Mon Sep 17 00:00:00 2001 From: Benjamin Lu <benjaminlu1107@gmail.com> Date: Mon, 13 Jul 2026 16:04:58 -0700 Subject: [PATCH 02/21] feat(telemetry): track signup/login flow errors in PostHog (#13504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Auth error toasts in the signup/login flow (e.g. "No account found with this email…") were never tracked, leaving no analytics visibility into signup dropoff. This adds a PostHog event for them. ## Changes - **What**: New `app:user_auth_failed` PostHog event with `{ error_code, auth_action }`, fired from a `reportAuthFlowError` wrapper in `useAuthActions` before the existing error toast. Wired to the seven auth-flow actions (`email_sign_in`, `email_sign_up`, `google_sign_in`, `google_sign_up`, `github_sign_in`, `github_sign_up`, `password_reset`); covers both the cloud login/signup pages and the in-app sign-in dialog since all funnel through the same handler. Non-auth actions (logout, billing) intentionally stay untracked. ## Review Focus - PostHog provider only — not added to Mixpanel/GTM/CustomerIo/etc. - No PII: only the Firebase error code is sent (`'unknown'` for non-Firebase errors), never the message or email. - The `useAuthActions` test mock of `wrapWithErrorHandlingAsync` previously rethrew errors; the real implementation swallows them after handling. The mock now matches the real contract, and one pre-existing logout assertion that depended on the rethrow was updated. Fixes [MAR-451](https://linear.app/comfyorg/issue/MAR-451/add-tracking-for-signup-flow-errors) --------- Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com> --- src/composables/auth/useAuthActions.test.ts | 112 +++++++++++++++++- src/composables/auth/useAuthActions.ts | 42 ++++--- .../telemetry/TelemetryRegistry.test.ts | 17 +++ src/platform/telemetry/TelemetryRegistry.ts | 5 + .../cloud/PostHogTelemetryProvider.test.ts | 18 +++ .../cloud/PostHogTelemetryProvider.ts | 5 + src/platform/telemetry/types.ts | 25 +++- 7 files changed, 202 insertions(+), 22 deletions(-) diff --git a/src/composables/auth/useAuthActions.test.ts b/src/composables/auth/useAuthActions.test.ts index c41bdec9fd..002dc94b30 100644 --- a/src/composables/auth/useAuthActions.test.ts +++ b/src/composables/auth/useAuthActions.test.ts @@ -8,6 +8,10 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'> const mockAuthStore = vi.hoisted(() => ({ + login: vi.fn().mockResolvedValue(undefined), + loginWithGoogle: vi.fn().mockResolvedValue(undefined), + loginWithGithub: vi.fn().mockResolvedValue(undefined), + register: vi.fn().mockResolvedValue(undefined), logout: vi.fn().mockResolvedValue(undefined) })) @@ -28,10 +32,12 @@ const mockDialogService = vi.hoisted(() => ({ })) const mockToastErrorHandler = vi.hoisted(() => vi.fn()) +const mockTrackAuthFailed = vi.hoisted(() => vi.fn()) const knownAuthErrorCodes = new Set([ 'auth/invalid-credential', - 'auth/email-already-in-use' + 'auth/email-already-in-use', + 'auth/user-not-found' ]) vi.mock('@/i18n', () => ({ @@ -48,7 +54,9 @@ vi.mock('@/platform/distribution/types', () => ({ })) vi.mock('@/platform/telemetry', () => ({ - useTelemetry: vi.fn(() => undefined) + useTelemetry: vi.fn(() => ({ + trackAuthFailed: mockTrackAuthFailed + })) })) vi.mock('@/platform/updates/common/toastStore', () => ({ @@ -81,9 +89,19 @@ vi.mock('@/composables/billing/useBillingContext', () => ({ vi.mock('@/composables/useErrorHandling', () => ({ useErrorHandling: () => ({ - wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>( - action: (...args: TArgs) => Promise<TReturn> | TReturn - ) => action, + wrapWithErrorHandlingAsync: + <TArgs extends unknown[], TReturn>( + action: (...args: TArgs) => Promise<TReturn> | TReturn, + errorHandler?: (error: unknown) => void + ) => + async (...args: TArgs) => { + try { + return await action(...args) + } catch (error) { + ;(errorHandler ?? mockToastErrorHandler)(error) + return undefined + } + }, toastErrorHandler: mockToastErrorHandler }) })) @@ -156,10 +174,13 @@ describe('useAuthActions.logout', () => { ) const { logout } = useAuthActions() - await expect(logout()).rejects.toThrow('auth.signOut.saveFailed:a.json') + await logout() expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1) expect(mockAuthStore.logout).not.toHaveBeenCalled() + expect(mockToastErrorHandler).toHaveBeenCalledExactlyOnceWith( + new Error('auth.signOut.saveFailed:a.json') + ) }) it('saves every modified workflow before signing out when user picks Save (true)', async () => { @@ -206,6 +227,85 @@ describe('useAuthActions.logout', () => { }) }) +describe('useAuthActions auth flow error telemetry', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + mockWorkflowStore.modifiedWorkflows = [] + }) + + it('tracks email sign-in Firebase failures and still shows the error toast', async () => { + const error = new FirebaseError('auth/user-not-found', 'msg') + mockAuthStore.login.mockRejectedValueOnce(error) + const { signInWithEmail } = useAuthActions() + + await expect( + signInWithEmail('user@example.com', 'password') + ).resolves.toBeUndefined() + + expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({ + error_code: 'auth/user-not-found', + auth_action: 'email_sign_in' + }) + expect(mockToastStore.add).toHaveBeenCalledWith({ + severity: 'error', + summary: 'g.error', + detail: 'auth.errors.auth/user-not-found' + }) + }) + + it('tracks unknown errors for email sign-up failures', async () => { + const error = new Error('network failed') + mockAuthStore.register.mockRejectedValueOnce(error) + const { signUpWithEmail } = useAuthActions() + + await expect( + signUpWithEmail('user@example.com', 'password') + ).resolves.toBeUndefined() + + expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({ + error_code: 'unknown', + auth_action: 'email_sign_up' + }) + }) + + it('tracks Google sign-up failures separately from sign-in failures', async () => { + const error = new FirebaseError('auth/popup-closed-by-user', 'msg') + mockAuthStore.loginWithGoogle.mockRejectedValueOnce(error) + const { signInWithGoogle } = useAuthActions() + + await expect(signInWithGoogle({ isNewUser: true })).resolves.toBeUndefined() + + expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({ + error_code: 'auth/popup-closed-by-user', + auth_action: 'google_sign_up' + }) + }) + + it('tracks GitHub sign-up failures separately from sign-in failures', async () => { + const error = new FirebaseError('auth/popup-closed-by-user', 'msg') + mockAuthStore.loginWithGithub.mockRejectedValueOnce(error) + const { signInWithGithub } = useAuthActions() + + await expect(signInWithGithub({ isNewUser: true })).resolves.toBeUndefined() + + expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({ + error_code: 'auth/popup-closed-by-user', + auth_action: 'github_sign_up' + }) + }) + + it('does not track auth failures for logout failures', async () => { + const error = new FirebaseError('auth/network-request-failed', 'msg') + mockAuthStore.logout.mockRejectedValueOnce(error) + const { logout } = useAuthActions() + + await logout() + + expect(mockTrackAuthFailed).not.toHaveBeenCalled() + }) +}) + describe('useAuthActions.reportError', () => { beforeEach(() => { setActivePinia(createPinia()) diff --git a/src/composables/auth/useAuthActions.ts b/src/composables/auth/useAuthActions.ts index 68401eead0..d2480e4b56 100644 --- a/src/composables/auth/useAuthActions.ts +++ b/src/composables/auth/useAuthActions.ts @@ -8,6 +8,7 @@ import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling' import { st, t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' +import type { AuthFlowAction } from '@/platform/telemetry/types' import { useToastStore } from '@/platform/updates/common/toastStore' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' @@ -28,6 +29,15 @@ export const useAuthActions = () => { const accessError = ref(false) + const reportAuthFlowError = + (authAction: AuthFlowAction) => (error: unknown) => { + useTelemetry()?.trackAuthFailed({ + error_code: error instanceof FirebaseError ? error.code : 'unknown', + auth_action: authAction + }) + reportError(error) + } + const reportError = (error: unknown) => { // Ref: https://firebase.google.com/docs/auth/admin/errors if ( @@ -127,7 +137,7 @@ export const useAuthActions = () => { life: 5000 }) }, - reportError + reportAuthFlowError('password_reset') ) const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => { @@ -177,32 +187,34 @@ export const useAuthActions = () => { return result }, reportError) - const signInWithGoogle = wrapWithErrorHandlingAsync( - async (options?: { isNewUser?: boolean }) => { - return await authStore.loginWithGoogle(options) - }, - reportError - ) + const signInWithGoogle = async (options?: { isNewUser?: boolean }) => + await wrapWithErrorHandlingAsync( + async () => await authStore.loginWithGoogle(options), + reportAuthFlowError( + options?.isNewUser ? 'google_sign_up' : 'google_sign_in' + ) + )() - const signInWithGithub = wrapWithErrorHandlingAsync( - async (options?: { isNewUser?: boolean }) => { - return await authStore.loginWithGithub(options) - }, - reportError - ) + const signInWithGithub = async (options?: { isNewUser?: boolean }) => + await wrapWithErrorHandlingAsync( + async () => await authStore.loginWithGithub(options), + reportAuthFlowError( + options?.isNewUser ? 'github_sign_up' : 'github_sign_in' + ) + )() const signInWithEmail = wrapWithErrorHandlingAsync( async (email: string, password: string) => { return await authStore.login(email, password) }, - reportError + reportAuthFlowError('email_sign_in') ) const signUpWithEmail = wrapWithErrorHandlingAsync( async (email: string, password: string, turnstileToken?: string) => { return await authStore.register(email, password, turnstileToken) }, - reportError + reportAuthFlowError('email_sign_up') ) /** diff --git a/src/platform/telemetry/TelemetryRegistry.test.ts b/src/platform/telemetry/TelemetryRegistry.test.ts index 15dd256c6e..7803125c16 100644 --- a/src/platform/telemetry/TelemetryRegistry.test.ts +++ b/src/platform/telemetry/TelemetryRegistry.test.ts @@ -49,6 +49,23 @@ describe('TelemetryRegistry', () => { expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata) }) + it('dispatches trackAuthFailed to every registered provider', () => { + const a: TelemetryProvider = { trackAuthFailed: vi.fn() } + const b: TelemetryProvider = { trackAuthFailed: vi.fn() } + const registry = new TelemetryRegistry() + registry.registerProvider(a) + registry.registerProvider(b) + + const payload = { + error_code: 'auth/user-not-found', + auth_action: 'email_sign_in' as const + } + registry.trackAuthFailed(payload) + + expect(a.trackAuthFailed).toHaveBeenCalledExactlyOnceWith(payload) + expect(b.trackAuthFailed).toHaveBeenCalledExactlyOnceWith(payload) + }) + it('dispatches trackAddApiCreditButtonClicked with its source', () => { const provider: TelemetryProvider = { trackAddApiCreditButtonClicked: vi.fn() diff --git a/src/platform/telemetry/TelemetryRegistry.ts b/src/platform/telemetry/TelemetryRegistry.ts index db5d82feab..b4150e749a 100644 --- a/src/platform/telemetry/TelemetryRegistry.ts +++ b/src/platform/telemetry/TelemetryRegistry.ts @@ -2,6 +2,7 @@ import type { AuditLog } from '@/services/customerEventsService' import type { AddCreditsClickMetadata, + AuthErrorMetadata, AuthMetadata, BeginCheckoutMetadata, DefaultViewSetMetadata, @@ -75,6 +76,10 @@ export class TelemetryRegistry implements TelemetryDispatcher { this.dispatch((provider) => provider.trackAuth?.(metadata)) } + trackAuthFailed(metadata: AuthErrorMetadata): void { + this.dispatch((provider) => provider.trackAuthFailed?.(metadata)) + } + trackUserLoggedIn(): void { this.dispatch((provider) => provider.trackUserLoggedIn?.()) } diff --git a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts index c38f8c5bcb..278cb03559 100644 --- a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts @@ -361,6 +361,24 @@ describe('PostHogTelemetryProvider', () => { ) }) + it('captures auth failure events with metadata', async () => { + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackAuthFailed({ + error_code: 'auth/user-not-found', + auth_action: 'email_sign_in' + }) + + expect(hoisted.mockCapture).toHaveBeenCalledWith( + TelemetryEvents.USER_AUTH_FAILED, + { + error_code: 'auth/user-not-found', + auth_action: 'email_sign_in' + } + ) + }) + it.for([ ['flow_opened', TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED, {}], ['confirmed', TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED, {}], diff --git a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts index 0c70cddbbc..9535f572be 100644 --- a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts @@ -11,6 +11,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types' import type { AddCreditsClickMetadata, + AuthErrorMetadata, AuthMetadata, BeginCheckoutMetadata, DefaultViewSetMetadata, @@ -353,6 +354,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider { this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata) } + trackAuthFailed(metadata: AuthErrorMetadata): void { + this.trackEvent(TelemetryEvents.USER_AUTH_FAILED, metadata) + } + trackUserLoggedIn(): void { this.trackEvent(TelemetryEvents.USER_LOGGED_IN) } diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 369f3827ce..17f9cfeff3 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -52,7 +52,27 @@ export interface AuthMetadata { utm_campaign?: string } -/** Survey field ids → answers. Fields are backend-overridable, so all optional. */ +export type AuthFlowAction = + | 'email_sign_in' + | 'email_sign_up' + | 'google_sign_in' + | 'google_sign_up' + | 'github_sign_in' + | 'github_sign_up' + | 'password_reset' + +/** + * Metadata for failed authentication attempts + */ +export interface AuthErrorMetadata { + error_code: string + auth_action: AuthFlowAction +} + +/** + * Survey field ids mapped to answers. Fields are backend-overridable, so all + * are optional. + */ export interface SurveyResponses { // Current default schema (see defaultSurveySchema.ts) intent?: string | string[] @@ -531,6 +551,7 @@ export interface TelemetryProvider { // Authentication flow events trackSignupOpened?(): void trackAuth?(metadata: AuthMetadata): void + trackAuthFailed?(metadata: AuthErrorMetadata): void trackUserLoggedIn?(): void // Subscription flow events @@ -643,6 +664,7 @@ export const TelemetryEvents = { // Authentication Flow USER_SIGN_UP_OPENED: 'app:user_sign_up_opened', USER_AUTH_COMPLETED: 'app:user_auth_completed', + USER_AUTH_FAILED: 'app:user_auth_failed', USER_LOGGED_IN: 'app:user_logged_in', // Subscription Flow @@ -749,6 +771,7 @@ export type ExecutionTriggerSource = */ export type TelemetryEventProperties = | AuthMetadata + | AuthErrorMetadata | SurveyResponses | TemplateMetadata | ExecutionContext From 74147d7ee2ba67eef7a35d7ee330261a54503d46 Mon Sep 17 00:00:00 2001 From: jaeone94 <89377375+jaeone94@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:39:20 +0900 Subject: [PATCH 03/21] feat: lift boundary-exposed validation errors to the subgraph host (#13542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Backend validation errors (`node_errors`) are reported against the **flattened** prompt, so an error whose real fix lives on a subgraph host node gets attached to an interior node the user may never open — and, after ADR 0009, often *cannot* meaningfully fix there. This PR re-surfaces a validation error onto the subgraph host node **when — and only when — the error's subject (the specific input/widget named by `extra_info.input_name`) is exposed through the subgraph boundary**. ## Why this is needed Two concrete situations motivated this, both observed in real workflows: 1. **Broken link at the host.** Root node A should feed subgraph host B, whose boundary input is linked to interior node C. If the A→B link is missing, the backend flattens the prompt, sees C with no resolved input, and raises `required_input_missing` **on C** (`"B:C"`). The actual fix — connect B's input — is one level up, on a node the error never points at. 2. **Host-owned widget values.** Per ADR 0009 (subgraph promoted widgets use linked inputs), a promoted widget's value is owned by the host `SubgraphNode`; the interior widget only supplies schema/defaults. When the backend raises `value_not_in_list` (or min/max violations) for that value, attributing it to the interior node is factually wrong — the value that failed validation *is the host's value*. This continues the direction of #13059, which moved **missing-model** detection identity to `{hostExecutionId, hostWidgetName}` with the interior path kept as diagnostics. That was possible in the FE pre-scan; this PR applies the same ownership principle to **backend-received** errors via a receive-side mapping, since the backend cannot know about subgraph boundaries in a flattened prompt. ## The rule (design) > Lift an error from interior node N to host H **iff** N's input slot named by the error is linked to the containing subgraph's boundary (`SubgraphInput`). Apply the same test again at H (boundary-by-boundary, matching ADR 0009's chaining principle) and stop at the first level where the subject is no longer boundary-linked — that node is where the user can actually fix it. The predicate is **structural (boundary exposure), not data-flow**. ### In scope — examples - `required_input_missing` on interior `"12:5"` whose input is fed by the boundary → surfaces on host `12`'s input slot (red slot ring on the host, errors-tab card titled/located at the host, message names the host's `SubgraphInput.name`). - `value_not_in_list` / `value_smaller_than_min` / `value_bigger_than_max` on a promoted interior widget → surfaces on the host's promoted widget. Nested hosts chain: `"1:2:3"` lifts to `"1:2"`, and further to `"1"` only if `1:2`'s own slot is boundary-linked too. - Clearing follows the surface: connecting the highlighted host input or fixing the host widget clears the underlying interior (raw) error — range-guarded per target, so a still-out-of-range host value does **not** clear. ### Out of scope — examples - **No value-flow ancestry.** All in the root graph: A's widget links to B, B's to C, and C rejects the value that originated at A → the error **stays on C**. Following same-graph links to a "root cause" node is explicitly not this feature. - Errors without an `input_name` subject, node-level types (`exception_during_validation`, `dependency_cycle`, image-not-loaded), and unknown validation types — never lifted. Unknown types stay node-scoped to match how the error catalog renders them (the shared `isNodeLevelValidationError` in `executionErrorUtil` encodes this, and the catalog derives its node-level rules from the same set). - Runtime execution errors (exceptions during a run) — validation responses only. - Interior errors whose input is fed by another interior node — fixable in place, stay in place. - Fan-out display dedupe: when one boundary input feeds multiple interior nodes and the host slot is unconnected, each interior error lifts to the same host slot as a separate panel line. A single fix (connecting the host input) clears all of them — the clearing translation already fans out — so the duplication is cosmetic. Display-level dedupe is a follow-up; deduping inside the lift would break the one-source-per-error clearing contract. - Reactive re-lifting on graph topology edits while errors are displayed (invariant documented on the computed; follow-up), and deriving the error catalog's full validation rule table from the shared classification (follow-up; the node-level type set and the image-not-loaded predicate are already single-sourced in `executionErrorUtil` and consumed by both the lift and the catalog). ## Changes - **What**: New pure module `core/graph/subgraph/liftNodeErrorsToBoundary.ts` — per-error, fail-open record transform (unresolvable ids/slots/links leave the error where the backend put it; raw payload is never mutated). `executionErrorStore` derives `surfacedNodeErrors` from it and display consumers switch over (errors tab grouping, canvas node/slot flags, Vue node badges, app-mode/linear hints); raw `lastNodeErrors` remains the source of truth for mutation. Host-side clearing translates through the lift's diagnostics fields (`source_execution_id` / `source_input_name`) with a per-target range guard. - **Breaking**: None. No persistence/serialization changes; interior identity survives as diagnostics metadata only (ADR 0009 language). ## Review Focus - The lift predicate lives entirely on link topology (`LLink.originIsIoNode` → `SubgraphInput`) — no `proxyWidgets`/promotion-store style source authority is reintroduced. - `clearSlotErrorsWithRangeCheck` now resolves clear targets first and range-checks each target's raw errors; the lifted-path twin of the existing range-retention test pins this. - `useProcessedWidgets` deliberately stays on the raw record: host promoted widgets already map to interior errors via `widget.sourceExecutionId`, so an interior widget keeps its local red hint when the user opens the subgraph (hint layer vs surface layer). - Unit coverage is carried by the pure module (real litegraph subgraph fixtures, incl. nested recursion, promoted widgets via `promoteValueWidgetViaSubgraphInput`, ordering, fail-open/no-mutation); one e2e pins the user-visible contract (host ring + host slot dot + interior clean). ## Screenshots ### Before https://github.com/user-attachments/assets/81e5c4db-515d-4f1f-8f8a-e07ac490510f ### After https://github.com/user-attachments/assets/2949da06-a049-41c1-a480-98ee28333bf2 --- .../tests/vueNodes/nodeStates/error.spec.ts | 108 ++++++- src/components/builder/AppModeWidgetList.vue | 2 +- .../errors/useErrorGroups.test.ts | 45 +++ .../rightSidePanel/errors/useErrorGroups.ts | 4 +- src/composables/graph/useNodeErrorFlagSync.ts | 6 +- .../subgraph/liftNodeErrorsToBoundary.test.ts | 299 ++++++++++++++++++ .../subgraph/liftNodeErrorsToBoundary.ts | 193 +++++++++++ .../subgraph/__fixtures__/subgraphHelpers.ts | 44 +++ src/platform/errorCatalog/types.ts | 9 +- .../errorCatalog/validationErrorResolver.ts | 94 ++---- .../extensions/linearMode/MobileError.vue | 4 +- .../composables/useProcessedWidgets.test.ts | 65 ++++ .../composables/useProcessedWidgets.ts | 6 +- src/stores/executionErrorStore.test.ts | 247 ++++++++++++++- src/stores/executionErrorStore.ts | 228 +++++++++++-- .../__tests__/executionErrorTestUtils.ts | 22 +- src/utils/__tests__/nodeErrorHelpers.ts | 30 ++ src/utils/executionErrorUtil.ts | 55 ++++ 18 files changed, 1315 insertions(+), 146 deletions(-) create mode 100644 src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts create mode 100644 src/core/graph/subgraph/liftNodeErrorsToBoundary.ts create mode 100644 src/utils/__tests__/nodeErrorHelpers.ts diff --git a/browser_tests/tests/vueNodes/nodeStates/error.spec.ts b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts index 80dc4147d4..83a4b10ee8 100644 --- a/browser_tests/tests/vueNodes/nodeStates/error.spec.ts +++ b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts @@ -23,6 +23,7 @@ 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' @@ -69,6 +70,25 @@ 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 = ( @@ -139,17 +159,10 @@ 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 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 modelInputIndex = await getInputSlotIndexByName( + comfyPage, + ksamplerId, + KSAMPLER_MODEL_INPUT_NAME ) const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow( ksamplerId, @@ -175,7 +188,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => { await expect(modelInputSlotRow).toBeVisible() await expect(modelInputSlotRow).toBeInViewport() - await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/) + await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS) await expect( comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId) ).toHaveClass(ERROR_CLASS) @@ -407,5 +420,76 @@ 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 + ) + }) + }) }) }) diff --git a/src/components/builder/AppModeWidgetList.vue b/src/components/builder/AppModeWidgetList.vue index bb6583f33b..e06fffc342 100644 --- a/src/components/builder/AppModeWidgetList.vue +++ b/src/components/builder/AppModeWidgetList.vue @@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) { return { ...nodeData, - hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id], + hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id], dropIndicator, onDragDrop: node.onDragDrop, onDragOver: node.onDragOver diff --git a/src/components/rightSidePanel/errors/useErrorGroups.test.ts b/src/components/rightSidePanel/errors/useErrorGroups.test.ts index 114af3294a..e8146a5b59 100644 --- a/src/components/rightSidePanel/errors/useErrorGroups.test.ts +++ b/src/components/rightSidePanel/errors/useErrorGroups.test.ts @@ -5,9 +5,11 @@ 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() @@ -127,6 +129,8 @@ 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 @@ -493,6 +497,47 @@ 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 = { diff --git a/src/components/rightSidePanel/errors/useErrorGroups.ts b/src/components/rightSidePanel/errors/useErrorGroups.ts index 79b2efa07b..687970ba6a 100644 --- a/src/components/rightSidePanel/errors/useErrorGroups.ts +++ b/src/components/rightSidePanel/errors/useErrorGroups.ts @@ -382,10 +382,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) { groupsMap: Map<string, GroupEntry>, filterBySelection = false ) { - if (!executionErrorStore.lastNodeErrors) return + if (!executionErrorStore.surfacedNodeErrors) return for (const [rawNodeId, nodeError] of Object.entries( - executionErrorStore.lastNodeErrors + executionErrorStore.surfacedNodeErrors )) { const nodeId = tryNormalizeNodeExecutionId(rawNodeId) if (!nodeId) continue diff --git a/src/composables/graph/useNodeErrorFlagSync.ts b/src/composables/graph/useNodeErrorFlagSync.ts index 361b892161..fc55f52068 100644 --- a/src/composables/graph/useNodeErrorFlagSync.ts +++ b/src/composables/graph/useNodeErrorFlagSync.ts @@ -84,7 +84,7 @@ function reconcileNodeErrorFlags( } export function useNodeErrorFlagSync( - lastNodeErrors: Ref<Record<string, NodeError> | null>, + nodeErrors: Ref<Record<string, NodeError> | null>, missingModelStore: ReturnType<typeof useMissingModelStore>, missingMediaStore: ReturnType<typeof useMissingMediaStore> ): () => void { @@ -95,7 +95,7 @@ export function useNodeErrorFlagSync( const stop = watch( [ - lastNodeErrors, + nodeErrors, () => missingModelStore.missingModelNodeIds, () => missingMediaStore.missingMediaNodeIds, showErrorsTab @@ -108,7 +108,7 @@ export function useNodeErrorFlagSync( // Vue nodes compute hasAnyError independently and are unaffected. reconcileNodeErrorFlags( app.rootGraph, - lastNodeErrors.value, + nodeErrors.value, showErrorsTab.value ? missingModelStore.missingModelAncestorExecutionIds : new Set(), diff --git a/src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts b/src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts new file mode 100644 index 0000000000..62a9d56dff --- /dev/null +++ b/src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts @@ -0,0 +1,299 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils' +import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers' +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { + createBoundaryLinkedSubgraph, + createTestRootGraph, + createTestSubgraph, + createTestSubgraphNode +} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import { toNodeId } from '@/types/nodeId' + +import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary' + +beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) +}) + +describe('liftNodeErrorsToBoundary', () => { + it('lifts a boundary-linked slot error to the host', () => { + const { host, rootGraph } = createBoundaryLinkedSubgraph() + const errors = { + '12:5': nodeError([ + validationError('required_input_missing', 'seed_input') + ]) + } + + const result = liftNodeErrorsToBoundary(rootGraph, errors) + + expect(result).toEqual({ + '12': { + class_type: host.title, + dependent_outputs: [], + errors: [ + expect.objectContaining({ + type: 'required_input_missing', + extra_info: expect.objectContaining({ + input_name: 'seed', + source_execution_id: '12:5', + source_input_name: 'seed_input' + }) + }) + ] + } + }) + }) + + it('lifts a promoted-widget value error to the host input', () => { + const rootGraph = createTestRootGraph() + const subgraph = createTestSubgraph({ rootGraph }) + const host = createTestSubgraphNode(subgraph, { id: 12 }) + rootGraph.add(host) + + const interior = new LGraphNode('CheckpointLoaderSimple') + interior.id = toNodeId(5) + const input = interior.addInput('ckpt_name', 'COMBO') + const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, { + values: ['present.safetensors'] + }) + input.widget = { name: widget.name } + subgraph.add(interior) + + expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe( + true + ) + + const result = liftNodeErrorsToBoundary(rootGraph, { + '12:5': nodeError([ + validationError('value_not_in_list', 'ckpt_name', { + received_value: 'missing.safetensors', + input_config: ['COMBO', { values: ['present.safetensors'] }] + }) + ]) + }) + + expect(result['12'].errors[0].extra_info).toMatchObject({ + input_name: 'ckpt_name', + source_execution_id: '12:5', + source_input_name: 'ckpt_name', + received_value: 'missing.safetensors', + input_config: ['COMBO', { values: ['present.safetensors'] }] + }) + }) + + it('recurses through nested boundary-linked hosts', () => { + const rootGraph = createTestRootGraph() + const outerSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'seed', type: '*' }] + }) + const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 }) + outerHost.title = 'Outer Host' + rootGraph.add(outerHost) + + const middleSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'seed', type: '*' }] + }) + const middleHost = createTestSubgraphNode(middleSubgraph, { + id: 2, + parentGraph: outerSubgraph + }) + outerSubgraph.add(middleHost) + outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost) + + const leaf = new LGraphNode('LeafNode') + leaf.id = toNodeId(3) + const leafInput = leaf.addInput('seed_input', '*') + middleSubgraph.add(leaf) + middleSubgraph.inputNode.slots[0].connect(leafInput, leaf) + + const result = liftNodeErrorsToBoundary(rootGraph, { + '1:2:3': nodeError([ + validationError('required_input_missing', 'seed_input') + ]) + }) + + expect(Object.keys(result)).toEqual(['1']) + expect(result['1'].class_type).toBe(outerHost.title) + expect(result['1'].errors[0].extra_info).toMatchObject({ + input_name: 'seed', + source_execution_id: '1:2:3', + source_input_name: 'seed_input' + }) + }) + + it('keeps errors on ordinary interior data-flow links', () => { + const rootGraph = createTestRootGraph() + const subgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'seed', type: '*' }] + }) + const host = createTestSubgraphNode(subgraph, { id: 12 }) + rootGraph.add(host) + + const source = new LGraphNode('SourceNode') + source.id = toNodeId(4) + source.addOutput('seed', '*') + subgraph.add(source) + + const target = new LGraphNode('TargetNode') + target.id = toNodeId(5) + target.addInput('seed_input', '*') + subgraph.add(target) + source.connect(0, target, 0) + + const errors = { + '12:5': nodeError([ + validationError('required_input_missing', 'seed_input') + ]) + } + + expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors) + }) + + it('keeps errors without a liftable subject on the interior node', () => { + const { rootGraph } = createBoundaryLinkedSubgraph() + const errors = { + '12:5': nodeError([ + validationError('required_input_missing'), + validationError('exception_during_validation', 'seed_input'), + validationError('dependency_cycle', 'seed_input'), + validationError( + 'custom_validation_failed', + 'seed_input', + { received_value: 'image.png' }, + 'Invalid image file' + ) + ]) + } + + expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors) + }) + + it('keeps unknown typed validation errors on the interior node', () => { + const { rootGraph } = createBoundaryLinkedSubgraph() + const errors = { + '12:5': nodeError([ + validationError('future_backend_validation_type', 'seed_input') + ]) + } + + expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors) + }) + + it('splits liftable and non-liftable errors from the same node entry', () => { + const { rootGraph } = createBoundaryLinkedSubgraph() + + const result = liftNodeErrorsToBoundary(rootGraph, { + '12:5': nodeError([ + validationError('required_input_missing', 'seed_input'), + validationError('exception_during_validation', 'seed_input') + ]) + }) + + expect(result['12'].errors).toHaveLength(1) + expect(result['12'].errors[0].type).toBe('required_input_missing') + expect(result['12:5'].errors).toHaveLength(1) + expect(result['12:5'].errors[0].type).toBe('exception_during_validation') + }) + + it('merges a lifted error into an existing host entry', () => { + const { rootGraph } = createBoundaryLinkedSubgraph() + const errors = { + '12': { + class_type: 'ExistingHostClass', + dependent_outputs: ['existing-output'], + errors: [validationError('value_smaller_than_min', 'other')] + }, + '12:5': nodeError([ + validationError('required_input_missing', 'seed_input') + ]) + } + + const result = liftNodeErrorsToBoundary(rootGraph, errors) + + expect(result['12']).toMatchObject({ + class_type: 'ExistingHostClass', + dependent_outputs: ['existing-output'] + }) + expect(result['12'].errors.map((error) => error.type)).toEqual([ + 'value_smaller_than_min', + 'required_input_missing' + ]) + }) + + it('keeps own errors before lifted errors for nested host keys', () => { + const rootGraph = createTestRootGraph() + const outerSubgraph = createTestSubgraph({ rootGraph }) + const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 }) + rootGraph.add(outerHost) + + const middleSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'seed', type: '*' }] + }) + const middleHost = createTestSubgraphNode(middleSubgraph, { + id: 2, + parentGraph: outerSubgraph + }) + outerSubgraph.add(middleHost) + + const leaf = new LGraphNode('LeafNode') + leaf.id = toNodeId(3) + const leafInput = leaf.addInput('seed_input', '*') + middleSubgraph.add(leaf) + middleSubgraph.inputNode.slots[0].connect(leafInput, leaf) + + const result = liftNodeErrorsToBoundary(rootGraph, { + '1:2:3': nodeError([ + validationError('required_input_missing', 'seed_input') + ]), + '1:2': nodeError([validationError('value_smaller_than_min', 'seed')]) + }) + + expect(result['1:2'].errors.map((error) => error.type)).toEqual([ + 'value_smaller_than_min', + 'required_input_missing' + ]) + }) + + it('preserves empty error entries unchanged', () => { + const rootGraph = createTestRootGraph() + const errors = { + '12': nodeError([], 'ExtraRootNode') + } + + expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors) + }) + + it('fails open without mutating the input record', () => { + const rootGraph = createTestRootGraph() + const subgraph = createTestSubgraph({ rootGraph }) + const host = createTestSubgraphNode(subgraph, { id: 12 }) + rootGraph.add(host) + const interior = new LGraphNode('InteriorNode') + interior.id = toNodeId(5) + interior.addInput('unlinked', '*') + subgraph.add(interior) + + const errors = { + '99:5': nodeError([validationError('required_input_missing', 'x')]), + '12:5': nodeError([ + validationError('required_input_missing', 'missing'), + validationError('value_not_in_list', 'unlinked') + ]) + } + const original = structuredClone(errors) + + const result = liftNodeErrorsToBoundary(rootGraph, errors) + + expect(result).toEqual(original) + expect(errors).toEqual(original) + expect(result).not.toBe(errors) + }) +}) diff --git a/src/core/graph/subgraph/liftNodeErrorsToBoundary.ts b/src/core/graph/subgraph/liftNodeErrorsToBoundary.ts new file mode 100644 index 0000000000..52d41acf51 --- /dev/null +++ b/src/core/graph/subgraph/liftNodeErrorsToBoundary.ts @@ -0,0 +1,193 @@ +import { groupBy, partition } from 'es-toolkit' + +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import type { NodeError } from '@/schemas/apiSchema' +import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification' +import type { NodeExecutionId } from '@/types/nodeIdentification' +import { isNodeLevelValidationError } from '@/utils/executionErrorUtil' +import type { NodeValidationError } from '@/utils/executionErrorUtil' +import { getNodeByExecutionId } from '@/utils/graphTraversalUtil' +import { isSubgraph } from '@/utils/typeGuardUtil' + +export interface LiftedErrorExtraInfo { + input_name: string + source_execution_id: string + source_input_name: string +} + +export interface LiftedSurface { + hostExecId: NodeExecutionId + hostInputName: string +} + +interface ErrorPlacement { + kind: 'own' | 'lifted' + targetExecId: string + error: NodeValidationError +} + +export function getLiftedErrorSource( + error: NodeValidationError +): LiftedErrorExtraInfo | null { + const extraInfo = error.extra_info + if (!extraInfo) return null + + const { input_name, source_execution_id, source_input_name } = extraInfo + if ( + typeof input_name !== 'string' || + typeof source_execution_id !== 'string' || + typeof source_input_name !== 'string' + ) { + return null + } + + return { input_name, source_execution_id, source_input_name } +} + +function getHostExecutionId(executionId: string): NodeExecutionId | null { + const separatorIndex = executionId.lastIndexOf(':') + if (separatorIndex <= 0) return null + return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex)) +} + +/** + * Boundary surfaces that expose `(executionId, inputName)`, innermost first. + * Walks one host per level and stops at the last resolvable surface, so an + * unresolvable deeper host falls back to the shallower one (fail-open). + */ +export function resolveLiftChain( + rootGraph: LGraph, + executionId: string, + inputName: string +): LiftedSurface[] { + const chain: LiftedSurface[] = [] + let currentExecId = executionId + let currentInputName = inputName + + for (;;) { + const node = getNodeByExecutionId(rootGraph, currentExecId) + const graph = node?.graph + if (!node || !graph || !isSubgraph(graph)) break + + const slot = node.inputs?.find((input) => input.name === currentInputName) + if (slot?.link == null) break + + const subgraphInput = graph + .getLink(slot.link) + ?.resolve(graph)?.subgraphInput + if (!subgraphInput) break + + const hostExecId = getHostExecutionId(currentExecId) + if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break + + chain.push({ hostExecId, hostInputName: subgraphInput.name }) + currentExecId = hostExecId + currentInputName = subgraphInput.name + } + + return chain +} + +function createEmptyNodeError(nodeError: NodeError): NodeError { + return { + ...nodeError, + errors: [] + } +} + +// Lifted host entries use the host title for display; SubgraphNode.type is a UUID. +function createLiftedHostEntry( + rootGraph: LGraph, + hostExecId: string +): NodeError { + return { + class_type: + getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId, + dependent_outputs: [], + errors: [] + } +} + +function toErrorPlacement( + rootGraph: LGraph, + executionId: string, + error: NodeValidationError +): ErrorPlacement { + const inputName = error.extra_info?.input_name + const surface = + inputName && !isNodeLevelValidationError(error) + ? resolveLiftChain(rootGraph, executionId, inputName).at(-1) + : undefined + + if (!inputName || !surface) { + return { + kind: 'own', + targetExecId: executionId, + error + } + } + + const liftedExtraInfo: LiftedErrorExtraInfo = { + input_name: surface.hostInputName, + source_execution_id: executionId, + source_input_name: inputName + } + + return { + kind: 'lifted', + targetExecId: surface.hostExecId, + error: { + ...error, + extra_info: { + ...error.extra_info, + ...liftedExtraInfo + } + } + } +} + +export function liftNodeErrorsToBoundary( + rootGraph: LGraph, + nodeErrors: Record<string, NodeError> +): Record<string, NodeError> { + const output: Record<string, NodeError> = {} + const placements = Object.entries(nodeErrors).flatMap( + ([executionId, nodeError]) => + nodeError.errors.map((error) => + toErrorPlacement(rootGraph, executionId, error) + ) + ) + + for (const [executionId, nodeError] of Object.entries(nodeErrors)) { + if (nodeError.errors.length === 0) { + output[executionId] = createEmptyNodeError(nodeError) + } + } + + const placementsByTarget = groupBy( + placements, + (placement) => placement.targetExecId + ) + + for (const [targetExecId, targetPlacements] of Object.entries( + placementsByTarget + )) { + const baseEntry = nodeErrors[targetExecId] + ? createEmptyNodeError(nodeErrors[targetExecId]) + : createLiftedHostEntry(rootGraph, targetExecId) + + const [ownErrors, liftedErrors] = partition( + targetPlacements, + (placement) => placement.kind === 'own' + ) + + output[targetExecId] = { + ...baseEntry, + errors: [...ownErrors, ...liftedErrors].map( + (placement) => placement.error + ) + } + } + + return output +} diff --git a/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.ts b/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.ts index d9a5fc0d2f..27a0aeeafc 100644 --- a/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.ts +++ b/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.ts @@ -18,6 +18,7 @@ import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants' import type { SerializedNodeId } from '@/types/nodeId' +import { toNodeId } from '@/types/nodeId' import { LGraph, LGraphNode, @@ -86,6 +87,23 @@ interface TestSubgraphNodeOptions { size?: [number, number] } +interface BoundaryLinkedSubgraphOptions { + rootGraph?: LGraph + hostId?: SerializedNodeId + interiorId?: SerializedNodeId + boundaryName?: string + inputName?: string + hostTitle?: string + interiorType?: string +} + +export interface BoundaryLinkedSubgraphFixture { + rootGraph: LGraph + subgraph: Subgraph + host: SubgraphNode + interior: LGraphNode +} + interface NestedSubgraphOptions { depth?: number nodesPerLevel?: number @@ -269,6 +287,32 @@ export function createTestSubgraphNode( return new SubgraphNode(parentGraph, subgraph, instanceData) } +export function createBoundaryLinkedSubgraph({ + rootGraph = createTestRootGraph(), + hostId = 12, + interiorId = 5, + boundaryName = 'seed', + inputName = 'seed_input', + hostTitle = 'Host Subgraph', + interiorType = 'InteriorNode' +}: BoundaryLinkedSubgraphOptions = {}): BoundaryLinkedSubgraphFixture { + const subgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: boundaryName, type: '*' }] + }) + const host = createTestSubgraphNode(subgraph, { id: hostId }) + host.title = hostTitle + rootGraph.add(host) + + const interior = new LGraphNode(interiorType) + interior.id = toNodeId(interiorId) + const input = interior.addInput(inputName, '*') + subgraph.add(interior) + subgraph.inputNode.slots[0].connect(input, interior) + + return { rootGraph, subgraph, host, interior } +} + export function setupComplexPromotionFixture(): { graph: LGraph subgraph: Subgraph diff --git a/src/platform/errorCatalog/types.ts b/src/platform/errorCatalog/types.ts index efac1376eb..dedc30c0d0 100644 --- a/src/platform/errorCatalog/types.ts +++ b/src/platform/errorCatalog/types.ts @@ -1,13 +1,10 @@ -import type { - ExecutionErrorWsMessage, - NodeError, - PromptError -} from '@/schemas/apiSchema' +import type { ExecutionErrorWsMessage, PromptError } from '@/schemas/apiSchema' import type { MissingMediaGroup } from '@/platform/missingMedia/types' import type { MissingModelGroup } from '@/platform/missingModel/types' import type { MissingNodeType } from '@/types/comfy' +import type { NodeValidationError } from '@/utils/executionErrorUtil' -export type NodeValidationError = NodeError['errors'][number] +export type { NodeValidationError } export interface ResolvedErrorMessage { catalogId?: string diff --git a/src/platform/errorCatalog/validationErrorResolver.ts b/src/platform/errorCatalog/validationErrorResolver.ts index 74ca28b949..246e347a01 100644 --- a/src/platform/errorCatalog/validationErrorResolver.ts +++ b/src/platform/errorCatalog/validationErrorResolver.ts @@ -11,6 +11,12 @@ import { translateOptionalCatalogMessage } from './catalogI18n' import type { CatalogParams, ErrorResolveContext } from './catalogI18n' +import { + INPUT_LEVEL_VALIDATION_ERROR_TYPES, + NODE_LEVEL_VALIDATION_ERROR_TYPES, + getInputConfigBounds, + isImageNotLoadedValidationError +} from '@/utils/executionErrorUtil' const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing' @@ -62,51 +68,31 @@ const VALUE_SPECIFIC_COPY_RULES: Record< } } +const NODE_LEVEL_VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = + Object.fromEntries( + Array.from(NODE_LEVEL_VALIDATION_ERROR_TYPES, (type) => [ + type, + { catalogId: type, itemLabel: 'node' } satisfies ValidationCatalogRule + ]) + ) + +const INPUT_LEVEL_VALIDATION_ERROR_RULES: Record< + string, + ValidationCatalogRule +> = Object.fromEntries( + Array.from(INPUT_LEVEL_VALIDATION_ERROR_TYPES, (type) => [ + type, + { catalogId: type, itemLabel: 'nodeInput' } satisfies ValidationCatalogRule + ]) +) + const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = { + ...INPUT_LEVEL_VALIDATION_ERROR_RULES, [REQUIRED_INPUT_MISSING_TYPE]: { catalogId: MISSING_CONNECTION_CATALOG_ID, itemLabel: 'nodeInput' }, - bad_linked_input: { - catalogId: 'bad_linked_input', - itemLabel: 'nodeInput' - }, - return_type_mismatch: { - catalogId: 'return_type_mismatch', - itemLabel: 'nodeInput' - }, - invalid_input_type: { - catalogId: 'invalid_input_type', - itemLabel: 'nodeInput' - }, - value_smaller_than_min: { - catalogId: 'value_smaller_than_min', - itemLabel: 'nodeInput' - }, - value_bigger_than_max: { - catalogId: 'value_bigger_than_max', - itemLabel: 'nodeInput' - }, - value_not_in_list: { - catalogId: 'value_not_in_list', - itemLabel: 'nodeInput' - }, - custom_validation_failed: { - catalogId: 'custom_validation_failed', - itemLabel: 'nodeInput' - }, - exception_during_inner_validation: { - catalogId: 'exception_during_inner_validation', - itemLabel: 'nodeInput' - }, - exception_during_validation: { - catalogId: 'exception_during_validation', - itemLabel: 'node' - }, - dependency_cycle: { - catalogId: 'dependency_cycle', - itemLabel: 'node' - } + ...NODE_LEVEL_VALIDATION_ERROR_RULES } // Image-not-loaded shares the custom_validation_failed type, so type-keyed @@ -131,26 +117,6 @@ function getInputName(error: NodeValidationError): string { ) } -function getErrorText(error: NodeValidationError) { - return [ - 'message' in error ? error.message : undefined, - 'details' in error ? error.details : undefined - ] - .filter(Boolean) - .join('\n') -} - -function isImageNotLoadedText(text: string): boolean { - return /invalid image file|\[errno 21\].*is a directory/i.test(text) -} - -function isImageNotLoadedValidationError(error: NodeValidationError): boolean { - return ( - error.type === 'custom_validation_failed' && - isImageNotLoadedText(getErrorText(error)) - ) -} - function nodeInputItemLabel(nodeName: string, inputName: string): string { return `${nodeName} - ${inputName}` } @@ -179,13 +145,7 @@ function getInputConfigValue( error: NodeValidationError, key: 'min' | 'max' ): string | undefined { - const inputConfig = error.extra_info?.input_config - if (!Array.isArray(inputConfig)) return undefined - - const config = inputConfig[1] - if (!config || typeof config !== 'object') return undefined - - return formatCatalogValue((config as Record<string, unknown>)[key]) + return formatCatalogValue(getInputConfigBounds(error)[key]) } function getInputConfigType(error: NodeValidationError): string | undefined { diff --git a/src/renderer/extensions/linearMode/MobileError.vue b/src/renderer/extensions/linearMode/MobileError.vue index 4718d452a9..a1b06e979e 100644 --- a/src/renderer/extensions/linearMode/MobileError.vue +++ b/src/renderer/extensions/linearMode/MobileError.vue @@ -35,13 +35,13 @@ const inputNodeIds = computed(() => { }) const accessibleNodeErrors = computed(() => - Object.keys(executionErrorStore.lastNodeErrors ?? {}).filter((k) => + Object.keys(executionErrorStore.surfacedNodeErrors ?? {}).filter((k) => inputNodeIds.value.has(k) ) ) const accessibleErrors = computed(() => accessibleNodeErrors.value.flatMap((k) => { - const nodeError = executionErrorStore.lastNodeErrors?.[k] + const nodeError = executionErrorStore.surfacedNodeErrors?.[k] if (!nodeError) return [] return nodeError.errors.flatMap((error) => { diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts index dd4496ae90..795e6812b8 100644 --- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts @@ -252,6 +252,41 @@ describe('hasWidgetError', () => { ).toBe(true) expect(spy).toHaveBeenCalledWith('1', 'display_slot') }) + + it('matches raw interior errors by the source widget name for promoted widgets', () => { + const sourceExecutionId = createNodeExecutionId([ + toNodeId(65), + toNodeId(18) + ]) + const widget = createMockWidget({ + name: 'display_slot', + sourceExecutionId, + sourceWidgetName: 'ckpt_name' + }) + executionErrorStore.lastNodeErrors = { + [sourceExecutionId]: { + errors: [ + { + type: 'value_not_in_list', + message: 'Invalid model', + details: '', + extra_info: { input_name: 'ckpt_name' } + } + ], + class_type: 'CheckpointLoaderSimple', + dependent_outputs: [] + } + } + expect( + hasWidgetError( + widget, + createNodeExecutionId([toNodeId(1)]), + undefined, + executionErrorStore, + missingModelStore + ) + ).toBe(true) + }) }) const noopUi = { @@ -667,6 +702,36 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => { ) }) + it('clears raw interior errors through widget.sourceExecutionId, which boundary lift relies on', () => { + const sourceExecutionId = createNodeExecutionId([65, 18]) + const widget = createMockWidget({ + name: 'display_slot', + nodeId: NODE_ID, + sourceExecutionId, + sourceWidgetName: 'ckpt_name' + }) + const executionErrorStore = useExecutionErrorStore() + executionErrorStore.lastNodeErrors = { + [sourceExecutionId]: { + errors: [ + { + type: 'value_not_in_list', + message: 'Invalid model', + details: '', + extra_info: { input_name: 'ckpt_name' } + } + ], + class_type: 'CheckpointLoaderSimple', + dependent_outputs: [] + } + } + + const [processed] = processWidgets([widget]) + processed.updateHandler('real_model.safetensors') + + expect(executionErrorStore.lastNodeErrors).toBeNull() + }) + it('clears execution errors on update', () => { const widget = createMockWidget({ name: 'seed', diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts index 82d26791e4..304073e460 100644 --- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts +++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts @@ -130,8 +130,12 @@ export function hasWidgetError( const errors = widget.sourceExecutionId ? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors : nodeErrors?.errors + // Raw interior errors name the source widget, not the boundary name + const errorInputName = widget.sourceExecutionId + ? (widget.sourceWidgetName ?? widget.name) + : widget.name return ( - !!errors?.some((e) => e.extra_info?.input_name === widget.name) || + !!errors?.some((e) => e.extra_info?.input_name === errorInputName) || missingModelStore.isWidgetMissingModel(nodeExecId, widget.name) ) } diff --git a/src/stores/executionErrorStore.test.ts b/src/stores/executionErrorStore.test.ts index dbc0a01242..ef19d2a767 100644 --- a/src/stores/executionErrorStore.test.ts +++ b/src/stores/executionErrorStore.test.ts @@ -1,9 +1,21 @@ import { fromAny } from '@total-typescript/shoehorn' import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers' import type { MissingNodeType } from '@/types/comfy' -import { createNodeExecutionId } from '@/types/nodeIdentification' +import { + createBoundaryLinkedSubgraph, + createTestRootGraph, + createTestSubgraph, + createTestSubgraphNode +} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { app } from '@/scripts/app' +import { + createNodeExecutionId, + createNodeLocatorId +} from '@/types/nodeIdentification' // Mock dependencies vi.mock('@/i18n', () => ({ @@ -39,11 +51,20 @@ import { useExecutionErrorStore } from './executionErrorStore' import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore' import { toNodeId } from '@/types/nodeId' +function mockGraphReady(rootGraph: typeof app.rootGraph) { + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph) + vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true) +} + describe('executionErrorStore — node error operations', () => { beforeEach(() => { setActivePinia(createPinia()) }) + afterEach(() => { + vi.restoreAllMocks() + }) + describe('clearSimpleNodeErrors', () => { it('does nothing if lastNodeErrors is null', () => { const store = useExecutionErrorStore() @@ -296,6 +317,97 @@ describe('executionErrorStore — node error operations', () => { // Error should remain expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1) }) + + it('clears a lifted host slot error from the raw interior record', () => { + const { rootGraph } = createBoundaryLinkedSubgraph() + mockGraphReady(rootGraph) + + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '12:5': nodeError([ + validationError('required_input_missing', 'seed_input') + ]) + } + + expect(store.surfacedNodeErrors).toHaveProperty('12') + + store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed') + + expect(store.lastNodeErrors).toBeNull() + expect(store.surfacedNodeErrors).toBeNull() + }) + + it('does not clear lifted host slot errors when the raw error is not simple', () => { + const { rootGraph } = createBoundaryLinkedSubgraph() + mockGraphReady(rootGraph) + + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '12:5': nodeError([ + validationError( + 'custom_validation_failed', + 'seed_input', + {}, + 'Custom validation failed' + ) + ]) + } + + expect(store.surfacedNodeErrors).toHaveProperty('12') + + store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed') + + expect(store.lastNodeErrors).toHaveProperty('12:5') + expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1) + }) + + it('clears a nested lifted error fixed at an intermediate host level', () => { + const rootGraph = createTestRootGraph() + const outerSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'seed', type: '*' }] + }) + const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 }) + rootGraph.add(outerHost) + + const middleSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'seed', type: '*' }] + }) + const middleHost = createTestSubgraphNode(middleSubgraph, { + id: 2, + parentGraph: outerSubgraph + }) + outerSubgraph.add(middleHost) + outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost) + + const leaf = new LGraphNode('LeafNode') + leaf.id = toNodeId(3) + const leafInput = leaf.addInput('seed_input', '*') + middleSubgraph.add(leaf) + middleSubgraph.inputNode.slots[0].connect(leafInput, leaf) + mockGraphReady(rootGraph) + + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '1:2:3': nodeError([ + validationError('required_input_missing', 'seed_input') + ]) + } + + expect(store.surfacedNodeErrors).toHaveProperty('1') + + store.clearSimpleNodeErrors( + createNodeExecutionId([toNodeId(1), toNodeId(2)]), + 'seed' + ) + + expect( + store.lastNodeErrors, + 'a fix at the intermediate host clears the raw interior error' + ).toBeNull() + expect(store.surfacedNodeErrors).toBeNull() + }) }) describe('clearWidgetRelatedErrors', () => { @@ -388,6 +500,137 @@ describe('executionErrorStore — node error operations', () => { expect(store.lastNodeErrors).not.toBeNull() expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1) }) + + it('validates the base target against live widget bounds, not recorded ones', () => { + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '123': nodeError([ + validationError('value_bigger_than_max', 'testWidget', { + input_config: ['INT', { max: 100 }] + }) + ]) + } + + store.clearWidgetRelatedErrors( + createNodeExecutionId([toNodeId(123)]), + 'testWidget', + 'testWidget', + 150, + { max: 200 } + ) + + expect( + store.lastNodeErrors, + 'a value within the refreshed widget bounds clears despite stale recorded bounds' + ).toBeNull() + }) + + it('does not clear lifted range errors until the host value is in range', () => { + const { rootGraph } = createBoundaryLinkedSubgraph() + mockGraphReady(rootGraph) + + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '12:5': nodeError([ + validationError('value_bigger_than_max', 'seed_input', {}, 'Too high') + ]) + } + + expect(store.surfacedNodeErrors).toHaveProperty('12') + + store.clearWidgetRelatedErrors( + createNodeExecutionId([toNodeId(12)]), + 'seed', + 'seed', + 200, + { max: 100 } + ) + + expect(store.lastNodeErrors).toHaveProperty('12:5') + expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1) + + store.clearWidgetRelatedErrors( + createNodeExecutionId([toNodeId(12)]), + 'seed', + 'seed', + 50, + { max: 100 } + ) + + expect(store.lastNodeErrors).toBeNull() + }) + + it('clears fan-out lifted targets per their own recorded bounds', () => { + const { rootGraph, subgraph } = createBoundaryLinkedSubgraph() + const second = new LGraphNode('SecondInterior') + second.id = toNodeId(7) + const secondInput = second.addInput('other_input', '*') + subgraph.add(second) + subgraph.inputNode.slots[0].connect(secondInput, second) + mockGraphReady(rootGraph) + + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '12:5': nodeError([ + validationError('value_bigger_than_max', 'seed_input', { + input_config: ['INT', { max: 100 }] + }) + ]), + '12:7': nodeError([ + validationError('value_bigger_than_max', 'other_input', { + input_config: ['INT', { max: 50 }] + }) + ]) + } + + expect(store.surfacedNodeErrors?.['12'].errors).toHaveLength(2) + + store.clearWidgetRelatedErrors( + createNodeExecutionId([toNodeId(12)]), + 'seed', + 'seed', + 75, + { max: 100 } + ) + + expect( + store.lastNodeErrors?.['12:5'], + 'the target whose max=100 is satisfied by 75 clears' + ).toBeUndefined() + expect( + store.lastNodeErrors?.['12:7'].errors, + 'the target whose max=50 is still violated by 75 stays' + ).toHaveLength(1) + }) + }) + + describe('surfacedNodeErrors', () => { + it('derives boundary-lifted errors while preserving the raw record', () => { + const { rootGraph, host } = createBoundaryLinkedSubgraph() + mockGraphReady(rootGraph) + + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '12:5': nodeError([ + validationError('required_input_missing', 'seed_input') + ]) + } + + const hostLocatorId = createNodeLocatorId(null, toNodeId(12)) + + expect(store.lastNodeErrors).toHaveProperty('12:5') + expect(store.surfacedNodeErrors).toHaveProperty('12') + expect( + store.surfacedNodeErrors?.['12'].errors[0].extra_info + ).toMatchObject({ + input_name: 'seed', + source_execution_id: '12:5', + source_input_name: 'seed_input' + }) + expect(store.getNodeErrors(hostLocatorId)?.class_type).toBe(host.title) + expect(store.allErrorExecutionIds).toEqual(['12']) + expect(store.activeGraphErrorNodeIds).toEqual(new Set(['12'])) + }) }) }) diff --git a/src/stores/executionErrorStore.ts b/src/stores/executionErrorStore.ts index 82ee709500..398eb7a358 100644 --- a/src/stores/executionErrorStore.ts +++ b/src/stores/executionErrorStore.ts @@ -2,6 +2,11 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync' +import { + getLiftedErrorSource, + liftNodeErrorsToBoundary, + resolveLiftChain +} from '@/core/graph/subgraph/liftNodeErrorsToBoundary' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' @@ -16,7 +21,10 @@ import type { NodeError, PromptError } from '@/schemas/apiSchema' -import { getAncestorExecutionIds } from '@/types/nodeIdentification' +import { + getAncestorExecutionIds, + tryNormalizeNodeExecutionId +} from '@/types/nodeIdentification' import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification' import { executionIdToNodeLocatorId, @@ -25,10 +33,18 @@ import { } from '@/utils/graphTraversalUtil' import { SIMPLE_ERROR_TYPES, + getInputConfigBounds, isValueStillOutOfRange } from '@/utils/executionErrorUtil' import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore' +interface SlotNodeErrorClearTarget { + executionId: NodeExecutionId + slotName: string + /** Interior targets validate against the bounds recorded on their errors. */ + useRecordedBounds?: boolean +} + /** Execution error state: node errors, runtime errors, prompt errors, and missing assets. */ export const useExecutionErrorStore = defineStore('executionError', () => { const workflowStore = useWorkflowStore() @@ -79,31 +95,27 @@ export const useExecutionErrorStore = defineStore('executionError', () => { lastPromptError.value = null } - /** - * Removes a node's errors if they consist entirely of simple, auto-resolvable - * types. When `slotName` is provided, only errors for that slot are checked. - */ - function clearSimpleNodeErrors( + function clearSimpleNodeErrorsFromRecord( + nodeErrors: Record<string, NodeError>, executionId: NodeExecutionId, slotName?: string - ): void { - if (!lastNodeErrors.value) return - const nodeError = lastNodeErrors.value[executionId] - if (!nodeError) return + ): Record<string, NodeError> | null { + const nodeError = nodeErrors[executionId] + if (!nodeError) return null const isSlotScoped = slotName !== undefined - const relevantErrors = isSlotScoped ? nodeError.errors.filter((e) => e.extra_info?.input_name === slotName) : nodeError.errors - if (relevantErrors.length === 0) return - if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) return + if (relevantErrors.length === 0) return null + if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) { + return null + } - const updated = { ...lastNodeErrors.value } + const updated = { ...nodeErrors } if (isSlotScoped) { - // Remove only the target slot's errors if they were all simple const remainingErrors = nodeError.errors.filter( (e) => e.extra_info?.input_name !== slotName ) @@ -116,16 +128,150 @@ export const useExecutionErrorStore = defineStore('executionError', () => { } } } else { - // If no slot specified and all errors were simple, clear the whole node delete updated[executionId] } + return updated + } + + /** + * Raw interior sources of lifted errors whose boundary chain passes through + * `(executionId, slotName)`, so a fix at any host level — final surface or + * intermediate — clears the error at its raw key. + */ + function getLiftedErrorSourceTargets( + executionId: NodeExecutionId, + slotName: string + ): SlotNodeErrorClearTarget[] { + const surfaced = surfacedNodeErrors.value + if (!surfaced || !app.isGraphReady) return [] + + return Object.values(surfaced).flatMap((surface) => + surface.errors.flatMap((error): SlotNodeErrorClearTarget[] => { + const source = getLiftedErrorSource(error) + if (!source) return [] + + const sourceExecutionId = tryNormalizeNodeExecutionId( + source.source_execution_id + ) + if (!sourceExecutionId) return [] + + const clearsThisError = resolveLiftChain( + app.rootGraph, + sourceExecutionId, + source.source_input_name + ).some( + (level) => + level.hostExecId === executionId && level.hostInputName === slotName + ) + return clearsThisError + ? [ + { + executionId: sourceExecutionId, + slotName: source.source_input_name, + useRecordedBounds: true + } + ] + : [] + }) + ) + } + + /** Raw targets are keys into lastNodeErrors, not surfacedNodeErrors. */ + function getRawClearTargets( + executionId: NodeExecutionId, + slotName: string + ): SlotNodeErrorClearTarget[] { + return [ + { executionId, slotName }, + ...getLiftedErrorSourceTargets(executionId, slotName) + ] + } + + /** + * Bounds recorded on the error win only for interior lifted targets, where + * the caller's options describe the host widget rather than the interior + * input. The base target keeps the caller's live widget bounds, which stay + * authoritative when node definitions change after validation. + */ + function getTargetRangeOptions( + errors: NodeError['errors'], + fallback: { min?: number; max?: number } + ): { min?: number; max?: number } { + for (const error of errors) { + const { min, max } = getInputConfigBounds(error) + if (min === undefined && max === undefined) continue + return { + min: typeof min === 'number' ? min : fallback.min, + max: typeof max === 'number' ? max : fallback.max + } + } + return fallback + } + + function isTargetStillOutOfRange( + nodeErrors: Record<string, NodeError>, + target: SlotNodeErrorClearTarget, + value: number, + callerOptions: { min?: number; max?: number } + ): boolean { + const nodeError = nodeErrors[target.executionId] + if (!nodeError) return false + + const errors = nodeError.errors.filter( + (error) => error.extra_info?.input_name === target.slotName + ) + const options = target.useRecordedBounds + ? getTargetRangeOptions(errors, callerOptions) + : callerOptions + + return isValueStillOutOfRange(value, errors, options) + } + + function clearTargets( + targets: { executionId: NodeExecutionId; slotName?: string }[] + ): void { + if (!lastNodeErrors.value) return + + let updated = lastNodeErrors.value + for (const target of targets) { + updated = + clearSimpleNodeErrorsFromRecord( + updated, + target.executionId, + target.slotName + ) ?? updated + } + + if (updated === lastNodeErrors.value) return lastNodeErrors.value = Object.keys(updated).length > 0 ? updated : null } + /** + * Removes a node's errors if they consist entirely of simple, auto-resolvable + * types. When `slotName` is provided, only errors for that slot are checked + * and boundary-lifted errors are also cleared through their raw interior + * source. Node-scoped calls (no `slotName`) operate on the raw record key + * only. + */ + function clearSimpleNodeErrors( + executionId: NodeExecutionId, + slotName?: string + ): void { + clearTargets( + slotName === undefined + ? [{ executionId }] + : getRawClearTargets(executionId, slotName) + ) + } + /** * Attempts to clear an error for a given widget, but avoids clearing it if * the error is a range violation and the new value is still out of bounds. + * The base target validates against the caller's live widget bounds; each + * interior lifted target validates against its own recorded bounds, so a + * boundary input fanning out to inputs with different constraints clears + * only the targets the new value satisfies. * * Note: `value_not_in_list` errors are optimistically cleared without * list-membership validation because combo widgets constrain choices to @@ -138,16 +284,24 @@ export const useExecutionErrorStore = defineStore('executionError', () => { newValue: unknown, options?: { min?: number; max?: number } ): void { - if (typeof newValue === 'number' && lastNodeErrors.value) { - const nodeErrors = lastNodeErrors.value[executionId] - if (nodeErrors) { - const errs = nodeErrors.errors.filter( - (e) => e.extra_info?.input_name === widgetName - ) - if (isValueStillOutOfRange(newValue, errs, options || {})) return - } - } - clearSimpleNodeErrors(executionId, widgetName) + const nodeErrors = lastNodeErrors.value + if (!nodeErrors) return + + const targets = getRawClearTargets(executionId, widgetName) + const clearableTargets = + typeof newValue === 'number' + ? targets.filter( + (target) => + !isTargetStillOutOfRange( + nodeErrors, + target, + newValue, + options ?? {} + ) + ) + : targets + + clearTargets(clearableTargets) } /** @@ -224,6 +378,13 @@ export const useExecutionErrorStore = defineStore('executionError', () => { () => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0 ) + // Re-lifts only when the record changes; topology is assumed stable while errors are displayed. + const surfacedNodeErrors = computed(() => + lastNodeErrors.value && app.isGraphReady + ? liftNodeErrorsToBoundary(app.rootGraph, lastNodeErrors.value) + : lastNodeErrors.value + ) + const hasAnyError = computed( () => hasExecutionError.value || @@ -236,8 +397,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => { const allErrorExecutionIds = computed<string[]>(() => { const ids: string[] = [] - if (lastNodeErrors.value) { - ids.push(...Object.keys(lastNodeErrors.value)) + if (surfacedNodeErrors.value) { + ids.push(...Object.keys(surfacedNodeErrors.value)) } if (lastExecutionError.value) { const nodeId = lastExecutionError.value.node_id @@ -279,8 +440,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => { // Fall back to rootGraph when currentGraph hasn't been initialized yet const activeGraph = canvasStore.currentGraph ?? app.rootGraph - if (lastNodeErrors.value) { - for (const executionId of Object.keys(lastNodeErrors.value)) { + if (surfacedNodeErrors.value) { + for (const executionId of Object.keys(surfacedNodeErrors.value)) { const graphNode = getNodeByExecutionId(app.rootGraph, executionId) if (graphNode?.graph === activeGraph) { ids.add(String(graphNode.id)) @@ -302,12 +463,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => { /** Map of node errors indexed by locator ID. */ const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>( () => { - if (!lastNodeErrors.value) return {} + if (!surfacedNodeErrors.value) return {} const map: Record<NodeLocatorId, NodeError> = {} for (const [executionId, nodeError] of Object.entries( - lastNodeErrors.value + surfacedNodeErrors.value )) { const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId) if (locatorId) { @@ -361,7 +522,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => { return errorAncestorExecutionIds.value.has(execId) } - useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore) + useNodeErrorFlagSync(surfacedNodeErrors, missingModelStore, missingMediaStore) return { // Raw state @@ -380,6 +541,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => { dismissErrorOverlay, // Derived state + surfacedNodeErrors, hasExecutionError, hasPromptError, hasNodeError, diff --git a/src/utils/__tests__/executionErrorTestUtils.ts b/src/utils/__tests__/executionErrorTestUtils.ts index 8ec051a6d8..40864a211a 100644 --- a/src/utils/__tests__/executionErrorTestUtils.ts +++ b/src/utils/__tests__/executionErrorTestUtils.ts @@ -1,30 +1,18 @@ -import type { NodeError } from '@/schemas/apiSchema' import type { useExecutionErrorStore } from '@/stores/executionErrorStore' import type { NodeExecutionId } from '@/types/nodeIdentification' +import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers' type ExecutionErrorStore = ReturnType<typeof useExecutionErrorStore> -function createRequiredInputMissingNodeError(inputName: string): NodeError { - return { - errors: [ - { - type: 'required_input_missing', - message: 'Missing', - details: '', - extra_info: { input_name: inputName } - } - ], - dependent_outputs: [], - class_type: 'TestNode' - } -} - export function seedRequiredInputMissingNodeError( store: ExecutionErrorStore, executionId: NodeExecutionId, inputName: string ): void { store.lastNodeErrors = { - [executionId]: createRequiredInputMissingNodeError(inputName) + [executionId]: nodeError( + [validationError('required_input_missing', inputName, {}, 'Missing', '')], + 'TestNode' + ) } } diff --git a/src/utils/__tests__/nodeErrorHelpers.ts b/src/utils/__tests__/nodeErrorHelpers.ts new file mode 100644 index 0000000000..4751bee3ba --- /dev/null +++ b/src/utils/__tests__/nodeErrorHelpers.ts @@ -0,0 +1,30 @@ +import type { NodeError } from '@/schemas/apiSchema' +import type { NodeValidationError } from '@/utils/executionErrorUtil' + +export function validationError( + type: string, + inputName?: string, + extraInfo: Record<string, unknown> = {}, + message = `${type} message`, + details = `${type} details` +): NodeValidationError { + return { + type, + message, + details, + ...(inputName + ? { extra_info: { ...extraInfo, input_name: inputName } } + : {}) + } +} + +export function nodeError( + errors: NodeValidationError[], + classType = 'InteriorNode' +): NodeError { + return { + class_type: classType, + dependent_outputs: [], + errors + } +} diff --git a/src/utils/executionErrorUtil.ts b/src/utils/executionErrorUtil.ts index cf5ee303db..8bd6006939 100644 --- a/src/utils/executionErrorUtil.ts +++ b/src/utils/executionErrorUtil.ts @@ -105,6 +105,61 @@ export const SIMPLE_ERROR_TYPES = new Set([ 'required_input_missing' ]) +export type NodeValidationError = NodeError['errors'][number] + +export const INPUT_LEVEL_VALIDATION_ERROR_TYPES = new Set([ + 'required_input_missing', + 'bad_linked_input', + 'return_type_mismatch', + 'invalid_input_type', + 'value_smaller_than_min', + 'value_bigger_than_max', + 'value_not_in_list', + 'custom_validation_failed', + 'exception_during_inner_validation' +]) + +export const NODE_LEVEL_VALIDATION_ERROR_TYPES = new Set([ + 'exception_during_validation', + 'dependency_cycle' +]) + +/** Decodes the `[type, { min, max, ... }]` tuple the backend attaches to range errors. */ +export function getInputConfigBounds(error: NodeValidationError): { + min?: unknown + max?: unknown +} { + const config = error.extra_info?.input_config + if (!Array.isArray(config)) return {} + + const bounds = config[1] + if (!bounds || typeof bounds !== 'object') return {} + + const { min, max } = bounds as { min?: unknown; max?: unknown } + return { min, max } +} + +export function isImageNotLoadedValidationError( + error: NodeValidationError +): boolean { + return ( + error.type === 'custom_validation_failed' && + /invalid image file|\[errno 21\].*is a directory/i.test( + [error.message, error.details].filter(Boolean).join('\n') + ) + ) +} + +// Anything not input-level (including unknown types) is node-level. +export function isNodeLevelValidationError( + error: NodeValidationError +): boolean { + return ( + !INPUT_LEVEL_VALIDATION_ERROR_TYPES.has(error.type) || + isImageNotLoadedValidationError(error) + ) +} + /** * Returns true if `value` still violates a recorded range constraint. * Pass errors already filtered to the target widget (by `input_name`). From 55c4e807a1c60b8ed4a900ab36b7efba4bc75fa9 Mon Sep 17 00:00:00 2001 From: Benjamin Lu <benjaminlu1107@gmail.com> Date: Tue, 14 Jul 2026 09:58:25 -0700 Subject: [PATCH 04/21] fix: use system hour cycle for queue times (#12297) ## Summary Use the selected app locale for queue timestamp language and punctuation while honoring the browser/system 12-hour or 24-hour clock preference. ## Changes - **What**: `formatClockTime` resolves the hour cycle from the browser/runtime default locale, then applies it while formatting with the app locale. - **What**: Added behavioral unit coverage for the production two-argument path and explicit 12-hour and 24-hour preference locales. - **Breaking**: None. - **Dependencies**: None. ## Review Focus Queue timestamps already use the browser's local timezone. This PR fixes only the remaining clock-preference mismatch: app language stays in control of the rendered time string, while the browser/system locale controls 12-hour versus 24-hour display. No Playwright regression test was added because browser/system hour-cycle preference is environment-level Intl state, not deterministic UI state that the E2E suite can set reliably. Unit tests pin both hour cycles through BCP-47 locale preferences and assert the rendered behavior directly. Fixes [FE-252](https://linear.app/comfyorg/issue/FE-252/bug-queue-progress-times-ignore-browsersystem-1224-hour-preference) ## Screenshots (if applicable) Not applicable. --------- Co-authored-by: Christian Byrne <cbyrne@comfy.org> --- src/utils/dateTimeUtil.test.ts | 13 +++++++++---- src/utils/dateTimeUtil.ts | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/utils/dateTimeUtil.test.ts b/src/utils/dateTimeUtil.test.ts index 8b45301061..2f1d60a338 100644 --- a/src/utils/dateTimeUtil.test.ts +++ b/src/utils/dateTimeUtil.test.ts @@ -210,10 +210,15 @@ describe('formatShortMonthDay', () => { }) describe('formatClockTime', () => { - it('formats time with hours, minutes, and seconds', () => { + it('uses app locale with explicit 12-hour preference', () => { const ts = new Date(2024, 5, 15, 14, 5, 6).getTime() - const result = formatClockTime(ts, 'en-GB') - // en-GB uses 24-hour format - expect(result).toBe('14:05:06') + + expect(formatClockTime(ts, 'en-US', 'en-u-hc-h12')).toBe('2:05:06 PM') + }) + + it('uses app locale with explicit 24-hour preference', () => { + const ts = new Date(2024, 5, 15, 14, 5, 6).getTime() + + expect(formatClockTime(ts, 'en-US', 'en-u-hc-h23')).toBe('14:05:06') }) }) diff --git a/src/utils/dateTimeUtil.ts b/src/utils/dateTimeUtil.ts index 0ceac4b940..0b76375b2a 100644 --- a/src/utils/dateTimeUtil.ts +++ b/src/utils/dateTimeUtil.ts @@ -84,17 +84,27 @@ export const formatShortMonthDay = (ts: number, locale: string): string => { } /** - * Localized clock time, e.g. "10:05:06" with locale defaults for 12/24 hour. + * Localized clock time, e.g. "10:05:06" with the app locale for language and + * the browser/system locale preference for 12/24-hour formatting. * * @param ts Unix timestamp in milliseconds * @param locale BCP-47 locale string + * @param clockPreferenceLocale Optional locale source for hour-cycle preference * @returns Localized time string */ -export const formatClockTime = (ts: number, locale: string): string => { +export const formatClockTime = ( + ts: number, + locale: string, + clockPreferenceLocale?: string +): string => { const d = new Date(ts) + const { hourCycle } = new Intl.DateTimeFormat(clockPreferenceLocale, { + hour: 'numeric' + }).resolvedOptions() return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', - second: '2-digit' + second: '2-digit', + hourCycle }).format(d) } From ab33746b3e4ac7abd3d14cc5e78b366b3170a0ac Mon Sep 17 00:00:00 2001 From: Terry Jia <terryjia88@gmail.com> Date: Tue, 14 Jul 2026 13:39:03 -0400 Subject: [PATCH 05/21] fix(load3d): letterbox-aware pointer NDC for the transform gizmo (#13550) ## Summary The viewport letterboxes the scene to the width/height target aspect, but TransformControls maps pointers over the full canvas rect, so its handle raycasts skew toward the center, increasingly wrong toward the edges whenever the canvas aspect differs from the target aspect. Adds clientPointToLetterboxNdc() (pure, unit-tested) + a Viewport3d.clientPointToNdc() wrapper, and routes TransformControls' pointer math through it via GizmoManager.setPointerNdcSource(). Points on the letterbox bars resolve to nothing instead of phantom handles. ## Screenshots (if applicable) before <img width="813" height="703" alt="image" src="https://github.com/user-attachments/assets/cf5714c3-6b60-4ced-84ce-039f49c7e999" /> <img width="673" height="696" alt="image" src="https://github.com/user-attachments/assets/31f898d2-a820-4748-b960-4f3cc5752095" /> after <img width="1391" height="981" alt="image" src="https://github.com/user-attachments/assets/18890d89-ff50-4298-bac5-472e00c79e24" /> <img width="955" height="712" alt="image" src="https://github.com/user-attachments/assets/255f3c7d-dbb5-40a8-8865-c023caa1384e" /> --- .../core/load3d/GizmoManager.test.ts | 151 +++++++++++++++++- src/extensions/core/load3d/GizmoManager.ts | 37 +++++ src/extensions/core/load3d/Load3d.test.ts | 100 ++++++++++++ src/extensions/core/load3d/Load3d.ts | 3 + src/extensions/core/load3d/Viewport3d.test.ts | 61 +++++++ src/extensions/core/load3d/Viewport3d.ts | 19 ++- .../core/load3d/load3dViewport.test.ts | 62 ++++++- src/extensions/core/load3d/load3dViewport.ts | 35 ++++ 8 files changed, 458 insertions(+), 10 deletions(-) diff --git a/src/extensions/core/load3d/GizmoManager.test.ts b/src/extensions/core/load3d/GizmoManager.test.ts index d9c5a6c8b1..b266a7e350 100644 --- a/src/extensions/core/load3d/GizmoManager.test.ts +++ b/src/extensions/core/load3d/GizmoManager.test.ts @@ -3,23 +3,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { GizmoManager } from './GizmoManager' -const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } = - vi.hoisted(() => ({ - mockSetMode: vi.fn(), - mockAttach: vi.fn(), - mockDetach: vi.fn(), - mockGetHelper: vi.fn(), - mockDispose: vi.fn() - })) +const { + mockSetMode, + mockAttach, + mockDetach, + mockGetHelper, + mockDispose, + transformControlsInstances, + omitGetPointer +} = vi.hoisted(() => ({ + mockSetMode: vi.fn(), + mockAttach: vi.fn(), + mockDetach: vi.fn(), + mockGetHelper: vi.fn(), + mockDispose: vi.fn(), + transformControlsInstances: [] as unknown[], + omitGetPointer: { value: false } +})) vi.mock('three/examples/jsm/controls/TransformControls', () => { class TransformControls { enabled = true + dragging = false camera: THREE.Camera + _getPointer?: (event: PointerEvent) => { + x: number + y: number + button: number + } private listeners = new Map<string, ((e: unknown) => void)[]>() constructor(camera: THREE.Camera) { this.camera = camera + if (!omitGetPointer.value) { + this._getPointer = (event) => ({ x: 0, y: 0, button: event.button }) + } + transformControlsInstances.push(this) } addEventListener(event: string, cb: (e: unknown) => void) { @@ -64,6 +83,8 @@ describe('GizmoManager', () => { beforeEach(() => { vi.clearAllMocks() + transformControlsInstances.length = 0 + omitGetPointer.value = false scene = new THREE.Scene() interactionElement = document.createElement('div') @@ -89,6 +110,120 @@ describe('GizmoManager', () => { vi.restoreAllMocks() }) + describe('setPointerNdcSource', () => { + type PointerNdc = { x: number; y: number; button: number } + function lastControls() { + return transformControlsInstances.at(-1) as { + dragging: boolean + _getPointer?: (event: PointerEvent) => PointerNdc + } + } + function getPointerOverride() { + return lastControls()._getPointer + } + + it('routes TransformControls pointer NDC through the injected source', () => { + manager.init() + manager.setPointerNdcSource((clientX, clientY) => ({ + x: clientX / 100, + y: clientY / 100, + inside: true + })) + + const pointer = getPointerOverride()!({ + clientX: 50, + clientY: -25, + button: 2 + } as PointerEvent) + + expect(pointer).toEqual({ x: 0.5, y: -0.25, button: 2 }) + }) + + it('maps unmappable points to an off-screen pointer', () => { + manager.init() + manager.setPointerNdcSource(() => null) + + const pointer = getPointerOverride()!({ + clientX: 0, + clientY: 0, + button: 0 + } as PointerEvent) + + expect(pointer).toEqual({ x: 10, y: 10, button: 0 }) + }) + + it('maps points outside the viewport to an off-screen pointer while not dragging', () => { + manager.init() + manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false })) + + const pointer = getPointerOverride()!({ + clientX: 0, + clientY: 0, + button: 0 + } as PointerEvent) + + expect(pointer).toEqual({ x: 10, y: 10, button: 0 }) + }) + + it('keeps the unclamped NDC for points outside the viewport mid-drag', () => { + manager.init() + manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false })) + lastControls().dragging = true + + const pointer = getPointerOverride()!({ + clientX: 0, + clientY: 0, + button: -1 + } as PointerEvent) + + expect(pointer).toEqual({ x: -1.2, y: 0.4, button: -1 }) + }) + + it('applies a source registered before init once init runs', () => { + manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true })) + manager.init() + + const pointer = getPointerOverride()!({ + clientX: 0, + clientY: 0, + button: 1 + } as PointerEvent) + + expect(pointer).toEqual({ x: 0.5, y: 0.5, button: 1 }) + }) + + it('delegates to the stock mapping until a source is registered', () => { + manager.init() + + const stock = getPointerOverride()!({ + clientX: 40, + clientY: 60, + button: 2 + } as PointerEvent) + expect(stock).toEqual({ x: 0, y: 0, button: 2 }) + + manager.setPointerNdcSource(() => ({ x: 0.5, y: -0.25, inside: true })) + + const mapped = getPointerOverride()!({ + clientX: 40, + clientY: 60, + button: 2 + } as PointerEvent) + expect(mapped).toEqual({ x: 0.5, y: -0.25, button: 2 }) + }) + + it('warns and skips the override when _getPointer is missing at init', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + omitGetPointer.value = true + manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true })) + + manager.init() + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('_getPointer')) + expect(lastControls()._getPointer).toBeUndefined() + }) + }) + describe('init', () => { it('adds helper to scene with correct name and render order', () => { manager.init() diff --git a/src/extensions/core/load3d/GizmoManager.ts b/src/extensions/core/load3d/GizmoManager.ts index 42e9c4cdcc..3c7edf56ca 100644 --- a/src/extensions/core/load3d/GizmoManager.ts +++ b/src/extensions/core/load3d/GizmoManager.ts @@ -4,6 +4,9 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import type { GizmoMode, Model3DTransform } from './interfaces' +import type { PointerNdcSource } from './load3dViewport' + +const OFF_SCREEN_POINTER_NDC = { x: 10, y: 10 } export class GizmoManager { private transformControls: TransformControls | null = null @@ -18,6 +21,7 @@ export class GizmoManager { private interactionElement: HTMLElement private orbitControls: OrbitControls private onTransformChange?: () => void + private getPointerNdc?: PointerNdcSource constructor( scene: THREE.Scene, @@ -46,12 +50,45 @@ export class GizmoManager { } }) + this.installPointerNdcOverride() + const helper = this.transformControls.getHelper() helper.name = 'GizmoTransformControls' helper.renderOrder = 999 this.scene.add(helper) } + setPointerNdcSource(getPointerNdc: PointerNdcSource): void { + this.getPointerNdc = getPointerNdc + } + + private installPointerNdcOverride(): void { + if (!this.transformControls) return + const transformControls = this.transformControls + const controls = transformControls as unknown as { + _getPointer?: (event: PointerEvent) => { + x: number + y: number + button: number + } + } + const original = controls._getPointer + if (typeof original !== 'function') { + console.warn( + 'TransformControls no longer exposes _getPointer; letterbox-aware gizmo pointer mapping is disabled.' + ) + return + } + controls._getPointer = (event: PointerEvent) => { + if (!this.getPointerNdc) return original.call(transformControls, event) + const ndc = this.getPointerNdc(event.clientX, event.clientY) + if (!ndc || (!ndc.inside && !transformControls.dragging)) { + return { ...OFF_SCREEN_POINTER_NDC, button: event.button } + } + return { x: ndc.x, y: ndc.y, button: event.button } + } + } + setupForModel(model: THREE.Object3D): void { if (!this.transformControls) return diff --git a/src/extensions/core/load3d/Load3d.test.ts b/src/extensions/core/load3d/Load3d.test.ts index 5f5908428c..4ea2c9e2f5 100644 --- a/src/extensions/core/load3d/Load3d.test.ts +++ b/src/extensions/core/load3d/Load3d.test.ts @@ -1,11 +1,13 @@ import * as THREE from 'three' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Load3dDeps } from '@/extensions/core/load3d/Load3d' import Load3d from '@/extensions/core/load3d/Load3d' import type { CameraState, GizmoMode } from '@/extensions/core/load3d/interfaces' +import type { PointerNdcSource } from '@/extensions/core/load3d/load3dViewport' const { cloneSkinnedMock, @@ -1260,4 +1262,102 @@ describe('Load3d', () => { ) }) }) + + describe('constructor wiring', () => { + function makeConstructorDeps() { + const container = document.createElement('div') + const canvas = document.createElement('canvas') + container.appendChild(canvas) + + const view = { + canvas, + renderer: { + setViewport: vi.fn(), + setScissor: vi.fn(), + setScissorTest: vi.fn(), + setClearColor: vi.fn(), + clear: vi.fn(), + render: vi.fn() + }, + width: 800, + height: 600, + state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 }, + observeResize: vi.fn(), + beginRender: vi.fn(), + blit: vi.fn(), + setSize: vi.fn(), + dispose: vi.fn() + } + const gizmoManager = { + setPointerNdcSource: vi.fn(), + init: vi.fn(), + dispose: vi.fn() + } + const deps = { + view, + eventManager: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + }, + sceneManager: { + init: vi.fn(), + scene: new THREE.Scene(), + renderBackground: vi.fn(), + handleResize: vi.fn(), + dispose: vi.fn() + }, + cameraManager: { + init: vi.fn(), + activeCamera: new THREE.PerspectiveCamera(), + handleResize: vi.fn(), + dispose: vi.fn() + }, + controlsManager: { init: vi.fn(), update: vi.fn(), dispose: vi.fn() }, + lightingManager: { init: vi.fn(), dispose: vi.fn() }, + viewHelperManager: { + createViewHelper: vi.fn(), + init: vi.fn(), + update: vi.fn(), + render: vi.fn(), + dispose: vi.fn() + }, + hdriManager: { dispose: vi.fn() }, + loaderManager: { init: vi.fn(), dispose: vi.fn() }, + modelManager: { dispose: vi.fn() }, + recordingManager: { + getIsRecording: vi.fn(() => false), + dispose: vi.fn() + }, + animationManager: { + init: vi.fn(), + update: vi.fn(), + isAnimationPlaying: false, + dispose: vi.fn() + }, + gizmoManager, + adapterRef: { current: null, capabilities: null } + } + return { container, deps: deps as unknown as Load3dDeps, gizmoManager } + } + + it('wires the gizmo pointer NDC source to clientPointToNdc on every construction path', () => { + const { container, deps, gizmoManager } = makeConstructorDeps() + const load3d = new Load3d(container, deps) + + expect(gizmoManager.setPointerNdcSource).toHaveBeenCalledOnce() + + const ndc = { x: 0.25, y: -0.5, inside: true } + const clientPointToNdc = vi + .spyOn(load3d, 'clientPointToNdc') + .mockReturnValue(ndc) + const source = gizmoManager.setPointerNdcSource.mock + .calls[0][0] as PointerNdcSource + + expect(source(12, 34)).toBe(ndc) + expect(clientPointToNdc).toHaveBeenCalledWith(12, 34) + + load3d.remove() + }) + }) }) diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index cf48f03ba9..b484eb6702 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -83,6 +83,9 @@ class Load3d extends Viewport3d { this.loaderManager.init() this.animationManager.init() + this.gizmoManager.setPointerNdcSource((clientX, clientY) => + this.clientPointToNdc(clientX, clientY) + ) this.gizmoManager.init() this.eventManager.addEventListener('modelReady', () => { diff --git a/src/extensions/core/load3d/Viewport3d.test.ts b/src/extensions/core/load3d/Viewport3d.test.ts index cbf1bbb058..b985f90c31 100644 --- a/src/extensions/core/load3d/Viewport3d.test.ts +++ b/src/extensions/core/load3d/Viewport3d.test.ts @@ -386,6 +386,67 @@ describe('Viewport3d', () => { }) }) + describe('clientPointToNdc', () => { + function installCanvas(rect: { + left: number + top: number + width: number + height: number + }) { + const canvas = document.createElement('canvas') + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + ...rect, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON: () => ({}) + } as DOMRect) + Object.assign(ctx.viewport, { view: { canvas } }) + } + + beforeEach(() => { + Object.assign(ctx.viewport, { + targetWidth: 100, + targetHeight: 100, + targetAspectRatio: 1, + isViewerMode: false + }) + }) + + it('normalizes client coordinates against the canvas rect before letterbox mapping', () => { + installCanvas({ left: 100, top: 50, width: 400, height: 200 }) + + expect(ctx.viewport.clientPointToNdc(300, 150)).toEqual({ + x: expect.closeTo(0), + y: expect.closeTo(0), + inside: true + }) + expect(ctx.viewport.clientPointToNdc(150, 150)).toEqual({ + x: expect.closeTo(-1.5), + y: expect.closeTo(0), + inside: false + }) + }) + + it('returns null when the canvas has no layout size', () => { + installCanvas({ left: 0, top: 0, width: 0, height: 0 }) + + expect(ctx.viewport.clientPointToNdc(10, 10)).toBeNull() + }) + + it('maps the full canvas when no aspect ratio is maintained', () => { + installCanvas({ left: 100, top: 50, width: 400, height: 200 }) + Object.assign(ctx.viewport, { targetWidth: 0, targetHeight: 0 }) + + expect(ctx.viewport.clientPointToNdc(100, 50)).toEqual({ + x: expect.closeTo(-1), + y: expect.closeTo(1), + inside: true + }) + }) + }) + describe('start / remove lifecycle', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/extensions/core/load3d/Viewport3d.ts b/src/extensions/core/load3d/Viewport3d.ts index 50d8884cc6..3d2dd8c6d5 100644 --- a/src/extensions/core/load3d/Viewport3d.ts +++ b/src/extensions/core/load3d/Viewport3d.ts @@ -1,6 +1,7 @@ import * as THREE from 'three' import type { RendererView } from '@/renderer/three/RendererView' +import { normalize } from '@/utils/mathUtil' import type { CameraManager } from './CameraManager' import type { ControlsManager } from './ControlsManager' @@ -17,7 +18,12 @@ import type { import { attachContextMenuGuard } from './load3dContextMenuGuard' import type { RenderLoopHandle } from './load3dRenderLoop' import { startRenderLoop } from './load3dRenderLoop' -import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport' +import type { LetterboxNdc } from './load3dViewport' +import { + clientPointToLetterboxNdc, + computeLetterboxedViewport, + isLoad3dActive +} from './load3dViewport' const VIEW_HELPER_SIZE = 128 @@ -276,6 +282,17 @@ export class Viewport3d { this.renderer.render(this.sceneManager.scene, this.getRenderCamera()) } + clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null { + const rect = this.domElement.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return null + return clientPointToLetterboxNdc( + normalize(clientX, rect.left, rect.right), + normalize(clientY, rect.top, rect.bottom), + { width: rect.width, height: rect.height }, + this.shouldMaintainAspectRatio() ? this.targetAspectRatio : null + ) + } + protected startAnimation(): void { this.renderLoop = startRenderLoop({ tick: () => { diff --git a/src/extensions/core/load3d/load3dViewport.test.ts b/src/extensions/core/load3d/load3dViewport.test.ts index 6dc55e622e..576da1cde1 100644 --- a/src/extensions/core/load3d/load3dViewport.test.ts +++ b/src/extensions/core/load3d/load3dViewport.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' -import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport' +import { + clientPointToLetterboxNdc, + computeLetterboxedViewport, + isLoad3dActive +} from './load3dViewport' import type { Load3dActivityFlags } from './load3dViewport' describe('computeLetterboxedViewport', () => { @@ -106,3 +110,59 @@ describe('isLoad3dActive', () => { expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true) }) }) + +describe('clientPointToLetterboxNdc', () => { + function ndc(x: number, y: number, inside = true) { + return { x: expect.closeTo(x), y: expect.closeTo(y), inside } + } + + it('maps the full canvas when no target aspect is set', () => { + expect( + clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 300 }, null) + ).toEqual(ndc(0, 0)) + expect( + clientPointToLetterboxNdc(0, 1, { width: 400, height: 300 }, null) + ).toEqual(ndc(-1, -1)) + }) + + it('maps pillarboxed content edges to -1/1', () => { + const container = { width: 400, height: 200 } + expect(clientPointToLetterboxNdc(0.25, 0.5, container, 1)).toEqual( + ndc(-1, 0) + ) + expect(clientPointToLetterboxNdc(0.75, 0, container, 1)).toEqual(ndc(1, 1)) + expect(clientPointToLetterboxNdc(0.5, 0.5, container, 1)).toEqual(ndc(0, 0)) + }) + + it('extrapolates unclamped NDC marked outside on the letterbox bars', () => { + const container = { width: 400, height: 200 } + expect(clientPointToLetterboxNdc(0.1, 0.5, container, 1)).toEqual( + ndc(-1.6, 0, false) + ) + expect(clientPointToLetterboxNdc(0.9, 0.5, container, 1)).toEqual( + ndc(1.6, 0, false) + ) + }) + + it('handles letterbox bars above/below wide content', () => { + const container = { width: 200, height: 400 } + expect(clientPointToLetterboxNdc(0.5, 0.375, container, 2)).toEqual( + ndc(0, 1) + ) + expect(clientPointToLetterboxNdc(0.5, 0.1, container, 2)).toEqual( + ndc(0, 3.2, false) + ) + }) + + it('returns null instead of NaN for zero-size containers', () => { + expect( + clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 0 }, 1) + ).toBeNull() + expect( + clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 200 }, 1) + ).toBeNull() + expect( + clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 0 }, 1) + ).toBeNull() + }) +}) diff --git a/src/extensions/core/load3d/load3dViewport.ts b/src/extensions/core/load3d/load3dViewport.ts index 8e3d33731e..539b27b903 100644 --- a/src/extensions/core/load3d/load3dViewport.ts +++ b/src/extensions/core/load3d/load3dViewport.ts @@ -1,3 +1,5 @@ +import { denormalize, normalize } from '@/utils/mathUtil' + type Size = { width: number; height: number } type LetterboxedViewport = { @@ -34,6 +36,39 @@ export function computeLetterboxedViewport( } } +export type LetterboxNdc = { x: number; y: number; inside: boolean } + +export type PointerNdcSource = ( + clientX: number, + clientY: number +) => LetterboxNdc | null + +export function clientPointToLetterboxNdc( + normalizedX: number, + normalizedY: number, + container: Size, + targetAspectRatio: number | null +): LetterboxNdc | null { + const toNdc = (localX: number, localY: number): LetterboxNdc => ({ + x: denormalize(localX, -1, 1), + y: -denormalize(localY, -1, 1), + inside: localX >= 0 && localX <= 1 && localY >= 0 && localY <= 1 + }) + + if (targetAspectRatio === null) { + return toNdc(normalizedX, normalizedY) + } + const { offsetX, offsetY, width, height } = computeLetterboxedViewport( + container, + targetAspectRatio + ) + if (width <= 0 || height <= 0) return null + return toNdc( + normalize(normalizedX * container.width, offsetX, offsetX + width), + normalize(normalizedY * container.height, offsetY, offsetY + height) + ) +} + export type Load3dActivityFlags = { mouseOnNode: boolean mouseOnScene: boolean From 1a983629846c6cd8b7074a688c4dc1472ff6e444 Mon Sep 17 00:00:00 2001 From: Christian Byrne <cbyrne@comfy.org> Date: Tue, 14 Jul 2026 11:14:47 -0700 Subject: [PATCH 06/21] fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Defense-in-depth fix for the `Invalid linked format` crash: escape vue-i18n message-syntax characters at node-def generation so the locale bundle is always valid, **and** harden the runtime so any compile error (custom nodes, already-shipped bundles) degrades to the raw message instead of crashing the app. This folds in the runtime guard from #13603 (@xmarre) — see credit below — so the two layers land together. ## Changes **Layer 1 — generation-time escaping (source data)** - Add `escapeVueI18nMessageSyntax()` to `packages/shared-frontend-utils/src/formatUtil.ts`. It escapes every character vue-i18n's message compiler treats as syntax in text — `@ { } | %` (verified against `@intlify/message-compiler`'s `readText` tokenizer): `@` and unbalanced `{`/`}` throw, `|` mis-renders as plural branches, `%` matters immediately before `{`. Each becomes a literal interpolation `{'x'}` (the only escape vue-i18n supports); the set isn't exported by the library so it's hardcoded with source/doc references. Single-pass, applied once. - Apply it in `scripts/collect-i18n-node-defs.ts` to the fields **compiled** via `t()`/`st()`: `display_name`, `description`, input/output `name`, widget labels, `dataTypes`, `nodeCategories`. Tooltip fields go through `tm()`/`stRaw()` (no compile step) and are intentionally left unescaped. - Regression test (`src/locales/escapeNodeDefI18n.test.ts`) compiles the escaped output for all five characters through real vue-i18n and asserts it round-trips verbatim. **Layer 2 — runtime fallback (consumer), folded in from #13603 by @xmarre** - `st()` now wraps `t(key)` in try/catch: a vue-i18n `SyntaxError` falls back to the raw locale message (`tm()`) instead of crashing bootstrap. Shared `rawTranslationOrFallback` helper reused by `stRaw()`. Tests in `src/i18n.safeTranslation.test.ts` (isolated per-test, and covering the non-`SyntaxError` rethrow branch). **Tooling** - Lint the collection scripts instead of ignoring them: import the shared helper via the `@/utils/formatUtil` tsconfig alias (fixes `import-x/no-relative-packages`; verified it resolves under Playwright) and drop a stray progress `console.log`. No lint config changes and no rules relaxed — the scripts are now genuinely linted (they were previously excluded). - **Breaking**: none. ## Review Focus - **Root cause**: values read via `t()`/`st()` are compiled by vue-i18n, which parses `@ { } | %` as message syntax; a malformed `@` throws `Invalid linked format`. `app.ts` translates every node description at startup, so one bad string aborts router load. Surfaced by the 1.47.7 locale sync (#13246), whose `ByteDanceSeedAudio` description contains `@Audio1-3`. - **The two layers compose**: escaped output compiles cleanly, so the runtime catch never fires for generated strings; the catch is the safety net for strings that bypass generation (custom/third-party nodes via `mergeCustomNodesI18n`, and locale bundles already shipped in 1.47.x/1.48.x). - **Compiled vs. raw boundary**: escaping is applied only to compiler-consumed fields; leaving tooltips raw matches the `stRaw()`/`tm()` work in #12469. - **Translation propagation**: lobe-i18n's config prompt already keeps `{...}` placeholders intact, so the escaped English propagates to all 13 locales. - **Follow-up (out of scope)**: `scripts/collect-i18n-general.ts` (commands/settings/menus) shares the same generation-time hazard and could reuse the same helper later; Layer 2 already covers it at runtime. ## Credit The runtime fallback (Layer 2) is @xmarre's work from #13603, folded in here with authorship preserved on the original commits. Closing #13603 in favor of this combined PR. Fixes #13545 Fixes #13575 --------- Co-authored-by: Connor Byrne <c.byrne@comfy.org> Co-authored-by: xmarre <54859656+xmarre@users.noreply.github.com> --- .../src/formatUtil.test.ts | 46 +++++++++++ .../shared-frontend-utils/src/formatUtil.ts | 34 ++++++++ scripts/collect-i18n-node-defs.ts | 37 ++++++--- src/i18n.safeTranslation.test.ts | 80 +++++++++++++++++++ src/i18n.ts | 20 ++++- src/locales/escapeNodeDefI18n.test.ts | 35 ++++++++ 6 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 src/i18n.safeTranslation.test.ts create mode 100644 src/locales/escapeNodeDefI18n.test.ts diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index 4ee57be4cf..fa72cd3a40 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { appendWorkflowJsonExt, ensureWorkflowSuffix, + escapeVueI18nMessageSyntax, formatLocalizedMediumDate, formatLocalizedNumber, getFilePathSeparatorVariants, @@ -477,4 +478,49 @@ describe('formatUtil', () => { expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—') }) }) + + describe('escapeVueI18nMessageSyntax', () => { + it('escapes a literal @ that would break linked-message compilation', () => { + expect( + escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)') + ).toBe("clips (tagged {'@'}Audio1-3 in the prompt)") + }) + + it('escapes @ in an email address', () => { + expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe( + "support{'@'}comfy.org" + ) + }) + + it('escapes interpolation braces', () => { + expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe( + "size {'{'}w{'}'}x{'{'}h{'}'}" + ) + }) + + it('escapes the plural pipe separator', () => { + expect(escapeVueI18nMessageSyntax('foreground | background')).toBe( + "foreground {'|'} background" + ) + }) + + it('escapes the modulo percent so it cannot re-form %{', () => { + expect(escapeVueI18nMessageSyntax('50%{done}')).toBe( + "50{'%'}{'{'}done{'}'}" + ) + }) + + it('escapes every occurrence in a single pass', () => { + expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe( + "{'@'}a {'@'}b {'@'}c" + ) + }) + + it('leaves strings without syntax characters unchanged', () => { + expect(escapeVueI18nMessageSyntax('no special chars here')).toBe( + 'no special chars here' + ) + expect(escapeVueI18nMessageSyntax('')).toBe('') + }) + }) }) diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index e3cb7e1261..95aa50692d 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -178,6 +178,40 @@ export function normalizeI18nKey(key: string) { return typeof key === 'string' ? key.replace(/\./g, '_') : '' } +/** + * Characters that vue-i18n's message compiler treats as syntax in message text, + * so plain text has to escape them to render verbatim through `t()`/`st()`: + * + * - `@` starts a linked-message reference (`@:key`); malformed usage throws + * `Invalid linked format`. + * - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced + * brace throws `Unterminated/Unbalanced closing brace`. + * - `|` separates plural branches, so `a | b` silently renders as one branch. + * - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`); + * it must be escaped too, otherwise escaping a following `{` re-forms `%{`. + * + * The set is a build-inlined `const enum` (`TokenChars`) in + * `@intlify/message-compiler` and is not exported, so it is hardcoded here. + * + * @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation) + * @see https://vue-i18n.intlify.dev/guide/essentials/pluralization + */ +const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g + +/** + * Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`) + * so arbitrary text renders verbatim instead of being parsed as syntax. This is + * the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}. + * + * Only apply to values read through the compiler (`t()`/`st()`). Values read raw + * via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the + * literal `{'x'}` would surface to users. Apply exactly once to raw text: the + * escape output itself contains `{`/`}`, so it is not idempotent. + */ +export function escapeVueI18nMessageSyntax(text: string): string { + return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`) +} + /** * Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments. * @param input The dynamic prompt to process diff --git a/scripts/collect-i18n-node-defs.ts b/scripts/collect-i18n-node-defs.ts index 46d85ad734..e18604e11b 100644 --- a/scripts/collect-i18n-node-defs.ts +++ b/scripts/collect-i18n-node-defs.ts @@ -3,7 +3,10 @@ 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 { + escapeVueI18nMessageSyntax, + normalizeI18nKey +} from '@/utils/formatUtil' import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore' const localePath = './src/locales/en/main.json' @@ -44,8 +47,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { } ) - console.log(`Collected ${nodeDefs.length} node definitions`) - const allDataTypesLocale = Object.fromEntries( nodeDefs .flatMap((nodeDef) => { @@ -60,7 +61,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { ) return allDataTypes.map((dataType) => [ normalizeI18nKey(dataType), - dataType + escapeVueI18nMessageSyntax(dataType) ]) }) .sort((a, b) => a[0].localeCompare(b[0])) @@ -98,7 +99,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { const runtimeWidgets = Object.fromEntries( Object.entries(widgetsMappings) .sort((a, b) => a[0].localeCompare(b[0])) - .map(([key, value]) => [normalizeI18nKey(key), { name: value }]) + .map(([key, value]) => [ + normalizeI18nKey(key), + { name: value ? escapeVueI18nMessageSyntax(value) : value } + ]) ) if (Object.keys(runtimeWidgets).length > 0) { @@ -121,7 +125,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { function extractInputs(nodeDef: ComfyNodeDefImpl) { const inputs = Object.fromEntries( Object.values(nodeDef.inputs).flatMap((input) => { - const name = input.name + const name = + input.name === undefined + ? undefined + : escapeVueI18nMessageSyntax(input.name) const tooltip = input.tooltip if (name === undefined && tooltip === undefined) { @@ -146,7 +153,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { 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 name = + output.name === undefined || output.name in allDataTypesLocale + ? undefined + : escapeVueI18nMessageSyntax(output.name) const tooltip = output.tooltip if (name === undefined && tooltip === undefined) { @@ -179,8 +189,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { return [ normalizeI18nKey(nodeDef.name), { - display_name: nodeDef.display_name ?? nodeDef.name, - description: nodeDef.description || undefined, + display_name: escapeVueI18nMessageSyntax( + nodeDef.display_name ?? nodeDef.name + ), + description: nodeDef.description + ? escapeVueI18nMessageSyntax(nodeDef.description) + : undefined, inputs: Object.keys(inputs).length > 0 ? inputs : undefined, outputs: extractOutputs(nodeDef) } @@ -192,7 +206,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { nodeDefs.flatMap((nodeDef) => nodeDef.category .split('/') - .map((category) => [normalizeI18nKey(category), category]) + .map((category) => [ + normalizeI18nKey(category), + escapeVueI18nMessageSyntax(category) + ]) ) ) diff --git a/src/i18n.safeTranslation.test.ts b/src/i18n.safeTranslation.test.ts new file mode 100644 index 0000000000..5b29bbc13c --- /dev/null +++ b/src/i18n.safeTranslation.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { i18n, st, stRaw } from './i18n' + +const TEST_NAMESPACE = 'safeTranslationTest' + +beforeEach(() => { + i18n.global.locale.value = 'en' + const messages = i18n.global.getLocaleMessage('en') + delete (messages as Record<string, unknown>)[TEST_NAMESPACE] + i18n.global.setLocaleMessage('en', messages) +}) + +describe('st', () => { + it('returns the fallback when the key is not found', () => { + expect(st('safeTranslationTest.missing', 'Fallback value')).toBe( + 'Fallback value' + ) + }) + + it('uses compiled translations for valid locale messages', () => { + i18n.global.mergeLocaleMessage('en', { + safeTranslationTest: { + valid: 'Translated value' + } + }) + + expect(st('safeTranslationTest.valid', 'Fallback value')).toBe( + 'Translated value' + ) + }) + + it('returns raw locale messages when vue-i18n compilation fails', () => { + const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}' + + i18n.global.mergeLocaleMessage('en', { + safeTranslationTest: { + invalidLinkedFormat: message + } + }) + + expect( + st('safeTranslationTest.invalidLinkedFormat', 'Fallback value') + ).toBe(message) + }) +}) + +describe('stRaw', () => { + it('returns raw locale messages for valid keys', () => { + i18n.global.mergeLocaleMessage('en', { + safeTranslationTest: { + rawValue: 'Raw value' + } + }) + + expect(stRaw('safeTranslationTest.rawValue', 'Fallback value')).toBe( + 'Raw value' + ) + }) + + it('returns raw messages containing vue-i18n syntax', () => { + const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}' + + i18n.global.mergeLocaleMessage('en', { + safeTranslationTest: { + rawSyntax: message + } + }) + + expect(stRaw('safeTranslationTest.rawSyntax', 'Fallback value')).toBe( + message + ) + }) + + it('returns the fallback when the key is not found', () => { + expect(stRaw('safeTranslationTest.rawMissing', 'Fallback value')).toBe( + 'Fallback value' + ) + }) +}) diff --git a/src/i18n.ts b/src/i18n.ts index b26ee7cdb8..7d1154667e 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -159,15 +159,28 @@ export const te: (typeof i18n.global)['te'] = i18n.global.te export const d: (typeof i18n.global)['d'] = i18n.global.d const tm = i18n.global.tm +function rawTranslationOrFallback(key: string, fallbackMessage: string) { + const message = tm(key) + return typeof message === 'string' ? message : fallbackMessage +} + /** * Safe translation function that returns the fallback message if the key is not found. + * Invalid message syntax falls back to the raw locale message instead of crashing. * * @param key - The key to translate. * @param fallbackMessage - The fallback message to use if the key is not found. */ export function st(key: string, fallbackMessage: string) { - // The normal defaultMsg overload fails in some cases for custom nodes - return te(key) ? t(key) : fallbackMessage + if (!te(key)) return fallbackMessage + + try { + // The normal defaultMsg overload fails in some cases for custom nodes + return t(key) + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + return rawTranslationOrFallback(key, fallbackMessage) + } } /** @@ -180,6 +193,5 @@ export function st(key: string, fallbackMessage: string) { export function stRaw(key: string, fallbackMessage: string) { if (!te(key)) return fallbackMessage - const message = tm(key) - return typeof message === 'string' ? message : fallbackMessage + return rawTranslationOrFallback(key, fallbackMessage) } diff --git a/src/locales/escapeNodeDefI18n.test.ts b/src/locales/escapeNodeDefI18n.test.ts new file mode 100644 index 0000000000..1edeccbeb2 --- /dev/null +++ b/src/locales/escapeNodeDefI18n.test.ts @@ -0,0 +1,35 @@ +import { createI18n } from 'vue-i18n' +import { describe, expect, it } from 'vitest' + +import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil' + +/** + * Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses + * `@ { } | %` as message syntax — a literal `@` even crashes the compiler with + * `Invalid linked format` (this broke the whole app after the 1.47.7 locale + * sync). `collect-i18n-node-defs.ts` escapes such values with + * `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped + * output actually compiles and renders the original literal text. + */ +describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => { + const compile = (message: string) => { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { value: message } } + }) + return i18n.global.t('value') + } + + it.for([ + 'clips (tagged @Audio1-3 in the prompt)', + 'support@comfy.org', + 'resolution {width}x{height}', + 'foreground | background', + '50%{done}', + 'all of @ { } | % together', + 'no special chars here' + ])('renders %s as the original literal text', (raw) => { + expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw) + }) +}) From 060957d66c5c144a737740453dbe83667a393e0d Mon Sep 17 00:00:00 2001 From: Christian Byrne <cbyrne@comfy.org> Date: Tue, 14 Jul 2026 16:03:58 -0700 Subject: [PATCH 07/21] fix: use ComfyUI version for Cloud release notes (#13632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary On Cloud, `releaseStore.currentVersion` sourced `cloud_version` (e.g. `0.160.1`) while the `/releases` feed keys its entries by **ComfyUI** version (e.g. `0.27.1`). The what's-new popup compares the latest feed entry against the running version, so `0.27.1 < 0.160.1` read as "already ahead of the latest release" and the popup never showed. - Analytics confirmed the regression: `release_note` clicks fell from **13.4% (90d) → 0% (30d)**; `cloud_release_note` was effectively never clicked. ## Change - `currentVersion` always uses `comfyui_version` (drops the `isCloud → cloud_version` branch). The feed request keeps `project: 'cloud'`. - Rationale: the changelog page and each note's "learn more" link are ComfyUI-versioned, and Cloud has no separate versioned feed or changelog page. The changelog is maintained with ComfyUI versions, updated after each Cloud deploy lands. ## Tests - Adds a regression test (`isCloud environment (FE-1237)`) pinning that Cloud uses `comfyui_version`, not `cloud_version`. Verified via negative control: reverting the fix fails it with `0.160.1` vs expected `0.27.1`. - `test:unit` (releaseStore): 49 passed · `typecheck`, `lint`, `format:check`, `knip`: clean. ## ADR Adds `docs/adr/0012-cloud-release-notes-use-comfyui-version.md` (Accepted) recording the rationale and history, and updates the ADR index. Fixes FE-1237 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- ...cloud-release-notes-use-comfyui-version.md | 74 +++++++++++++++++++ docs/adr/README.md | 1 + .../updates/common/releaseStore.test.ts | 34 ++++++++- src/platform/updates/common/releaseStore.ts | 9 +-- 4 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 docs/adr/0012-cloud-release-notes-use-comfyui-version.md diff --git a/docs/adr/0012-cloud-release-notes-use-comfyui-version.md b/docs/adr/0012-cloud-release-notes-use-comfyui-version.md new file mode 100644 index 0000000000..edc3b46bdc --- /dev/null +++ b/docs/adr/0012-cloud-release-notes-use-comfyui-version.md @@ -0,0 +1,74 @@ +# 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index b6b2a899e4..29f1278e1d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,6 +21,7 @@ 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 diff --git a/src/platform/updates/common/releaseStore.test.ts b/src/platform/updates/common/releaseStore.test.ts index a542f8b6a0..41bb393353 100644 --- a/src/platform/updates/common/releaseStore.test.ts +++ b/src/platform/updates/common/releaseStore.test.ts @@ -18,13 +18,15 @@ vi.mock('semver', () => ({ valid: vi.fn(() => '1.0.0') })) -const mockData = vi.hoisted(() => ({ isDesktop: true })) +const mockData = vi.hoisted(() => ({ isDesktop: true, isCloud: false })) vi.mock('@/platform/distribution/types', () => ({ get isDesktop() { return mockData.isDesktop }, - isCloud: false + get isCloud() { + return mockData.isCloud + } })) vi.mock('@/platform/updates/common/releaseService', () => { @@ -115,6 +117,7 @@ describe('useReleaseStore', () => { vi.resetAllMocks() mockSystemStatsState.reset() + mockData.isCloud = false }) describe('initial state', () => { @@ -681,6 +684,33 @@ describe('useReleaseStore', () => { }) }) + describe('isCloud environment (FE-1237)', () => { + beforeEach(() => { + mockData.isCloud = true + }) + + it('should use comfyui_version, not cloud_version, as the current version', async () => { + const store = useReleaseStore() + const releaseService = useReleaseService() + const systemStatsStore = useSystemStatsStore() + systemStatsStore.systemStats!.system.comfyui_version = '0.27.1' + systemStatsStore.systemStats!.system.cloud_version = '0.160.1' + vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease]) + + await store.initialize() + + expect(releaseService.getReleases).toHaveBeenCalledWith( + { + project: 'cloud', + current_version: '0.27.1', + form_factor: 'git-windows', + locale: 'en' + }, + { deployEnvironment: undefined } + ) + }) + }) + describe('isDesktop environment checks', () => { describe('when running on desktop', () => { beforeEach(() => { diff --git a/src/platform/updates/common/releaseStore.ts b/src/platform/updates/common/releaseStore.ts index 0fc9e9afc1..1326d70674 100644 --- a/src/platform/updates/common/releaseStore.ts +++ b/src/platform/updates/common/releaseStore.ts @@ -23,12 +23,9 @@ export const useReleaseStore = defineStore('release', () => { const systemStatsStore = useSystemStatsStore() const settingStore = useSettingStore() - const currentVersion = computed(() => { - if (isCloud) { - return systemStatsStore?.systemStats?.system?.cloud_version ?? '' - } - return systemStatsStore?.systemStats?.system?.comfyui_version ?? '' - }) + const currentVersion = computed( + () => systemStatsStore?.systemStats?.system?.comfyui_version ?? '' + ) // Release data from settings const locale = computed(() => settingStore.get('Comfy.Locale')) From 0b4a9604678ab68474477ba41fb7de0a5bdb7107 Mon Sep 17 00:00:00 2001 From: Dante <bunggl@naver.com> Date: Wed, 15 Jul 2026 08:21:11 +0900 Subject: [PATCH 08/21] feat(billing): derive next-invoice amount, date, and cadence from billing state (FE-1245) (#13599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the data seam for the Settings > Plan & Credits Invoices tab (FE-1245): derives the next-invoice amount, date, and billing cadence from billing state already fetched by the billing context, replacing the prototype's hardcoded mock. Updated for the 2026-07-13 design decisions (Slack thread + Willie's Figma updates): the banner surfaces the BE-provided next-invoice date, and annual subscriptions now show their yearly total and real renewal date instead of hiding the banner. ## Changes - **What**: `useNextInvoice` composable + pure `deriveNextInvoice` in `src/composables/billing/` — returns `{ nextInvoice: ComputedRef<NextInvoice | null> }`, `NextInvoice = { amountCents, renewalDate, duration }` - Monthly: subscribed team credit stop `monthly.price_cents` (status `team_credit_stop.id` matched against the ladder) with plan `price_cents` fallback — unchanged precedence - Annual: stop `yearly.price_cents * 12` (stop yearly prices are per-month figures, per `useWorkspacePlanPricing`) or the ANNUAL plan's `price_cents` as-is (already the yearly total, per `UnifiedPricingTable`) - `renewalDate`: BE-computed `renewal_date` passed through untouched — backends own period math including month-end bias. It goes null the moment a cancellation is scheduled (mutually exclusive with `end_date`/`cancel_at` on both billing backends) - null (banner hidden) when inactive, cancelled, the amount is unresolvable/non-positive (covers legacy billing's empty plan list and free tier), or the plan resolved by slug disagrees with the subscription's cadence - 13 unit tests cover all branches, including x12-regression fixtures with discriminated list/discount prices Intentionally excludes usage/overage pending charges until the backend exposes an authoritative upcoming-invoice amount (see FE-1245). Follow-ups (not this PR): - Cancelled-state toast (Figma 4617:29298 month-remaining / 4617:29992 terminal): separate seam UI work. Data is already available — `subscription.endDate` is populated by both billing backends exactly during the cancelled-but-paid window, and `useResubscribe()` covers the Renew plan action - "/year" amount framing is shown with placeholder copy; no annual variant exists in the Figma file yet (design follow-up) ## Review Focus - Annual unit semantics: stop `yearly.price_cents` is a per-month figure (`useWorkspacePlanPricing.ts` `teamMonthlyCostCents`), while `Plan.price_cents` for ANNUAL plans is the yearly total (`UnifiedPricingTable.vue` `getAnnualTotal`) — hence x12 in one branch and as-is in the other - Consumed by the `useWorkspaceInvoices` seam once #13591 lands; the swap now also requires the seam template to render `renewalDate`/`duration`, so it is no longer a one-line body change - FE-1245 / DES-497 ## Screenshots Live captures through the real `WorkspaceInvoicesContent` + this composable with `/api/billing/*` mocked per state (isolated preview harness). Date format follows the existing billing convention; "/ year" framing is placeholder pending the annual design variant. Active monthly subscription, team credit stop `team_320` — $320 USD with next-invoice date: ![Invoices — team credit stop $320 with date](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-team-stop.png) Active monthly subscription without a credit stop — plan-price fallback, $20 USD: ![Invoices — plan price fallback $20 with date](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-plan-price.png) Annual subscription — banner now shown: yearly total from the per-month stop figure (288 x 12 = $3,456) and the real yearly renewal date: ![Invoices — annual shown](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-annual.png) Paused subscription — unchanged: next-invoice banner hidden, the paused banner hosts the Full invoice history action (capture from the earlier full-app harness): ![Invoices — paused](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-paused.png) --- knip.config.ts | 3 + .../billing/useNextInvoice.test.ts | 190 ++++++++++++++++++ src/composables/billing/useNextInvoice.ts | 96 +++++++++ 3 files changed, 289 insertions(+) create mode 100644 src/composables/billing/useNextInvoice.test.ts create mode 100644 src/composables/billing/useNextInvoice.ts diff --git a/knip.config.ts b/knip.config.ts index 0e2ab04336..5d1ce26bae 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -57,6 +57,9 @@ 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 diff --git a/src/composables/billing/useNextInvoice.test.ts b/src/composables/billing/useNextInvoice.test.ts new file mode 100644 index 0000000000..4594be88e9 --- /dev/null +++ b/src/composables/billing/useNextInvoice.test.ts @@ -0,0 +1,190 @@ +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() + } + ) +}) diff --git a/src/composables/billing/useNextInvoice.ts b/src/composables/billing/useNextInvoice.ts new file mode 100644 index 0000000000..0f83377c7d --- /dev/null +++ b/src/composables/billing/useNextInvoice.ts @@ -0,0 +1,96 @@ +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 } +} From e6d1a9d4a29ad27c1eef8cbb4fe0d611f61851de Mon Sep 17 00:00:00 2001 From: Christian Byrne <cbyrne@comfy.org> Date: Tue, 14 Jul 2026 16:41:35 -0700 Subject: [PATCH 09/21] fix(release): support manual target-branch override + major versions in resolve-comfyui-release (#13660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `scripts/cicd/resolve-comfyui-release.ts` derived the release target purely from ComfyUI's `requirements.txt` pin and **hardcoded major version `1`** (`core/1.${minor}`, `v1.${minor}.*`, `1.${minor}.${patch}`) even though it parsed `major` and never used it. Consequences: - Could not release an out-of-cadence branch (e.g. skip a dead 1.46 to ship 1.47 directly). - Could not do a major bump (2.x). ## Changes 1. **Resolver (`resolve-comfyui-release.ts`)** — uses the parsed `targetMajor` (defaults to the current pin's major) for every branch/tag/version string instead of literal `1`. `getLatestPatchTag` now takes a `major` param and globs `v${major}.${minor}.*`. 2. **`TARGET_BRANCH` env override (highest precedence)** — when set, validates `^core/(\d+)\.(\d+)$`, verifies `origin/<branch>` exists, and skips the `RELEASE_TYPE`/pin-derived selection entirely. If both `TARGET_BRANCH` and `RELEASE_TYPE` are set, the override wins. `current_version` still comes from the pin (for `diff_url` and the ComfyUI PR "from" version), so the requirements bump jumps straight from the pin to `target_version` (e.g. 1.45.20 → 1.47.8, skipping 1.46). 3. **Workflow (`release-biweekly-comfyui.yaml`)** — new optional `target_branch` `workflow_dispatch` input, wired into the resolve step's `env` as `TARGET_BRANCH`, and surfaced in the run summary. Downstream jobs consume `target_branch`/`target_version` outputs unchanged. The output JSON shape is identical. Extracted pure helpers (`parseTargetBranchOverride`, `computeTargetVersion`) and guarded the main block so the module is importable by tests. `release-version-bump.yaml` and `release-branch-create.yaml` were **already** major-aware and are left untouched. ## Tests New `scripts/cicd/resolve-comfyui-release.test.ts` (15 cases) covering `parseRequirementsVersion` (==, >=, missing/absent), `isValidSemver`, `parseTargetBranchOverride` (valid `core/1.47` and `core/2.0`, malformed rejected), and `computeTargetVersion` including a non-1 major (`v2.0.3` + commits → `2.0.4`). ## Usage ``` gh workflow run release-biweekly-comfyui.yaml --field target_branch=core/1.47 ``` releases 1.47.8 directly (skipping a dead 1.46), or `--field target_branch=core/2.0` for a major bump. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../workflows/release-biweekly-comfyui.yaml | 8 + scripts/cicd/resolve-comfyui-release.test.ts | 120 ++++++++ scripts/cicd/resolve-comfyui-release.ts | 268 ++++++++++++------ 3 files changed, 303 insertions(+), 93 deletions(-) create mode 100644 scripts/cicd/resolve-comfyui-release.test.ts diff --git a/.github/workflows/release-biweekly-comfyui.yaml b/.github/workflows/release-biweekly-comfyui.yaml index 8c3f181fdb..2edad02ac2 100644 --- a/.github/workflows/release-biweekly-comfyui.yaml +++ b/.github/workflows/release-biweekly-comfyui.yaml @@ -23,6 +23,10 @@ on: required: false default: 'Comfy-Org/ComfyUI' type: string + target_branch: + description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.' + required: false + type: string jobs: check-release-week: @@ -49,12 +53,15 @@ jobs: fi - name: Summary + env: + TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }} run: | echo "## Release Check" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY + echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY resolve-version: needs: check-release-week @@ -103,6 +110,7 @@ jobs: working-directory: frontend env: RELEASE_TYPE: ${{ inputs.release_type || 'minor' }} + TARGET_BRANCH: ${{ inputs.target_branch }} run: | set -euo pipefail diff --git a/scripts/cicd/resolve-comfyui-release.test.ts b/scripts/cicd/resolve-comfyui-release.test.ts new file mode 100644 index 0000000000..54bb032c00 --- /dev/null +++ b/scripts/cicd/resolve-comfyui-release.test.ts @@ -0,0 +1,120 @@ +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() + }) +}) diff --git a/scripts/cicd/resolve-comfyui-release.ts b/scripts/cicd/resolve-comfyui-release.ts index afee134cfe..8c7c156f7f 100755 --- a/scripts/cicd/resolve-comfyui-release.ts +++ b/scripts/cicd/resolve-comfyui-release.ts @@ -81,15 +81,72 @@ function isValidSemver(version: string): boolean { } /** - * Get the latest patch tag for a given minor version + * Parse a target branch override of the form `core/<major>.<minor>`. + * Returns the parsed major/minor and normalized branch, or null if malformed. */ -function getLatestPatchTag(repoPath: string, minor: number): string | null { +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 { // 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 'v1.${minor}.*' --sort=-version:refname | head -n 1`, + `git tag -l 'v${major}.${minor}.*' --sort=-version:refname | head -n 1`, repoPath ) @@ -101,7 +158,7 @@ function getLatestPatchTag(repoPath: string, minor: number): string | null { const validTagRegex = /^v\d+\.\d+\.\d+$/ if (!validTagRegex.test(latestTag)) { console.error( - `Latest tag for minor version ${minor} is not valid semver: ${latestTag}` + `Latest tag for version ${major}.${minor} is not valid semver: ${latestTag}` ) return null } @@ -132,85 +189,106 @@ function resolveRelease( return null } - const [major, currentMinor, patch] = currentVersion.split('.').map(Number) + const [currentMajor, currentMinor] = currentVersion.split('.').map(Number) // Fetch all branches exec('git fetch origin', frontendRepoPath) - // 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 + // Target major defaults to the current pin's major, but a TARGET_BRANCH + // override (below) can retarget both major and minor. + let targetMajor = currentMajor let targetMinor: number let targetBranch: string - if (releaseType === 'patch') { - targetMinor = currentMinor - targetBranch = `core/1.${targetMinor}` + const targetBranchOverride = process.env.TARGET_BRANCH?.trim() - const branchExists = exec( - `git rev-parse --verify origin/${targetBranch}`, - frontendRepoPath - ) - - if (!branchExists) { + if (targetBranchOverride) { + // Manual override takes precedence over RELEASE_TYPE / pin-derived selection. + const parsed = parseTargetBranchOverride(targetBranchOverride) + if (!parsed) { console.error( - `Patch release requested but branch ${targetBranch} does not exist` + `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` ) return null } console.error( - `Patch release: targeting current production branch ${targetBranch}` + `Manual override: targeting ${targetBranch} (ignoring release_type)` ) } else { - // Try next minor first, fall back to current minor if not available - targetMinor = currentMinor + 1 - targetBranch = `core/1.${targetMinor}` - - const nextMinorExists = exec( - `git rev-parse --verify origin/${targetBranch}`, - frontendRepoPath - ) - - if (!nextMinorExists) { - // Fall back to current minor for minor release - targetMinor = currentMinor - targetBranch = `core/1.${targetMinor}` - - const currentMinorExists = exec( - `git rev-parse --verify origin/${targetBranch}`, - frontendRepoPath + // 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 - if (!currentMinorExists) { + if (releaseType === 'patch') { + targetMinor = currentMinor + targetBranch = `core/${targetMajor}.${targetMinor}` + + if (!branchExists(targetBranch, frontendRepoPath)) { console.error( - `Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo` + `Patch release requested but branch ${targetBranch} does not exist` ) return null } console.error( - `Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for minor release` + `Patch release: targeting current production branch ${targetBranch}` ) + } 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 minor - const latestPatchTag = getLatestPatchTag(frontendRepoPath, targetMinor) + // Get latest patch tag for target major.minor + const latestPatchTag = getLatestPatchTag( + frontendRepoPath, + targetMajor, + targetMinor + ) - let needsRelease = false - let branchHeadSha: string | null = null + let needsRelease: boolean + let branchHeadSha: string | null let tagCommitSha: string | null = null - let targetVersion = currentVersion + let targetVersion: string if (latestPatchTag) { // Get commit SHA for the tag @@ -231,34 +309,23 @@ function resolveRelease( const commitCount = parseInt(commitsBetween, 10) needsRelease = !isNaN(commitCount) && commitCount > 0 - // Parse existing patch number and increment if needed - const tagVersion = latestPatchTag.replace('v', '') - - // Validate tag version format - if (!isValidSemver(tagVersion)) { + const nextVersion = computeTargetVersion( + targetMajor, + targetMinor, + latestPatchTag, + needsRelease + ) + if (!nextVersion) { console.error( - `Invalid tag version format: ${tagVersion}. Expected format: X.Y.Z` + `Invalid tag version format: ${latestPatchTag}. Expected format: vX.Y.Z` ) return null } - - 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 - } + targetVersion = nextVersion } else { - // No tags exist for this minor version, need to create v1.{targetMinor}.0 + // No tags exist for this major.minor version, need to create the .0 patch needsRelease = true - targetVersion = `1.${targetMinor}.0` + targetVersion = `${targetMajor}.${targetMinor}.0` branchHeadSha = exec( `git rev-parse origin/${targetBranch}`, frontendRepoPath @@ -281,26 +348,41 @@ function resolveRelease( } } -// Main execution -const comfyuiRepoPath = process.argv[2] -const frontendRepoPath = process.argv[3] || process.cwd() +/** + * 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() -if (!comfyuiRepoPath) { - console.error( - 'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]' - ) - process.exit(1) + 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)) } -const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath) - -if (!releaseInfo) { - console.error('Failed to resolve release information') - 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() } -// 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 } +export { + computeTargetVersion, + isValidSemver, + parseRequirementsVersion, + parseTargetBranchOverride, + resolveRelease +} From a7f14a0b3fdf7e99cbc6879cdff0b4b2f87ff145 Mon Sep 17 00:00:00 2001 From: CodeJuggernaut <81205671+CodeJuggernaut@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:35:09 -0700 Subject: [PATCH 10/21] fix(subscription): size pricing dialogs with Reka props (#13633) ## Summary Fixes the legacy personal and legacy workspace pricing dialogs so Reka owns the dialog width and the pricing table no longer overflows the default 576px frame. ## Changes - **What**: Replace the shared PrimeVue-only `style` and `pt` dialog props with Reka `renderer`, `size`, and `contentClass` props for both legacy pricing paths. - **What**: Preserve `modal: false` for the legacy workspace path so its teleported PrimeVue plan-details popover remains interactive. - **What**: Add unit coverage for both routes and assert that ignored PrimeVue shell props are no longer passed. ## Review Focus - **Regression origin**: [#12593](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12593) made Reka the default dialog renderer. [#12666](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12666) then added shared PrimeVue `style` and `pt` props for these pricing dialogs. Reka ignored those props and fell back to `size="md"` (`max-w-xl`, 576px). [#13092](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13092) fixed the unified pricing path only and explicitly left the two legacy paths for follow-up. - **Sizing ownership**: The fix puts width on the Reka dialog frame with `size: 'full'` and `sm:max-w-7xl`. Pricing content no longer has to compensate for a narrow shell. - **Legacy workspace behavior**: `modal: false` remains intentional because the legacy table opens a PrimeVue popover teleported to `body`. - **Scope**: This PR contains only global subscription-dialog sizing. Agent side-panel behavior remains in [#13472](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13472). - **Validation**: Focused tests pass (41 tests), `pnpm typecheck` passes, and targeted ESLint passes. Chrome validation at 1352x705 rendered a 1280px dialog with no horizontal overflow in both the isolated PR preview and the combined agent-panel preview. The docked agent panel remained mounted behind the modal. ## Screenshots (if applicable) - Original report and screenshots: [Slack thread](https://comfy-organization.slack.com/archives/C0A8Z4U7Y1K/p1783967281397449?thread_ts=1783966866.227439&cid=C0A8Z4U7Y1K) --- .../composables/useSubscriptionDialog.test.ts | 34 ++++++++++++++++--- .../composables/useSubscriptionDialog.ts | 28 +++++++-------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts index 91902df3ab..9c5e99cde7 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts @@ -79,6 +79,17 @@ vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({ }) })) +function expectRekaPricingDialogProps( + dialogComponentProps: Record<string, unknown> +) { + expect(dialogComponentProps).toMatchObject({ + renderer: 'reka', + size: 'full' + }) + expect(dialogComponentProps).not.toHaveProperty('style') + expect(dialogComponentProps).not.toHaveProperty('pt') +} + describe('useSubscriptionDialog', () => { beforeEach(() => { vi.clearAllMocks() @@ -136,11 +147,7 @@ describe('useSubscriptionDialog', () => { showPricingTable() const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0] - // Reka (the default renderer) sizes via size/contentClass; a PrimeVue - // `style` width is silently ignored and collapses the wide table to the - // default md (576px) frame. - expect(dialogComponentProps).toHaveProperty('contentClass') - expect(dialogComponentProps).not.toHaveProperty('style') + expectRekaPricingDialogProps(dialogComponentProps) }) it('defaults to the personal tab in a personal workspace', () => { @@ -174,6 +181,8 @@ describe('useSubscriptionDialog', () => { const props = mockShowLayoutDialog.mock.calls[0][0].props expect(props).toHaveProperty('onChooseTeam') + const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0] + expectRekaPricingDialogProps(dialogComponentProps) }) it('routes an existing per-member (legacy) team subscriber to the old team table', () => { @@ -194,6 +203,21 @@ describe('useSubscriptionDialog', () => { expect(props).not.toHaveProperty('onChooseTeam') }) + it('sizes the legacy workspace pricing dialog via Reka contentClass', () => { + mockShouldUseWorkspaceBilling.value = true + mockIsInPersonalWorkspace.value = false + mockIsLegacyTeamPlan.value = true + const { showPricingTable } = useSubscriptionDialog() + + showPricingTable() + + const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0] + expect(dialogComponentProps).toMatchObject({ + modal: false + }) + expectRekaPricingDialogProps(dialogComponentProps) + }) + it('keeps a non-legacy (credit-slider) team subscriber on the unified table', () => { mockShouldUseWorkspaceBilling.value = true mockIsInPersonalWorkspace.value = false diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts index 17f1fafc4a..afd941073b 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts @@ -80,19 +80,12 @@ export const useSubscriptionDialog = () => { trackModalOpened(options?.reason) - // Shared dialog shell styling for both variants. - const dialogComponentProps = { - style: 'width: min(1328px, 95vw); max-height: 958px;', - pt: { - root: { - class: 'rounded-2xl bg-transparent h-full' - }, - content: { - class: - '!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full' - } - } - } + const legacyPricingDialogProps = { + renderer: 'reka', + size: 'full', + contentClass: + 'sm:max-w-7xl max-h-[90vh] rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]' + } as const // Jun-5 model: a single unified pricing table (personal/team plan toggle on // one workspace) for workspaces on the consolidated billing flow. Replaces @@ -120,7 +113,10 @@ export const useSubscriptionDialog = () => { // The legacy table hosts a PrimeVue Popover teleported to body; Reka // modal mode traps focus and disables body pointer-events, making it // unclickable. The unified table has no such overlay. - dialogComponentProps: { ...dialogComponentProps, modal: false } + dialogComponentProps: { + ...legacyPricingDialogProps, + modal: false + } }) return } @@ -144,7 +140,7 @@ export const useSubscriptionDialog = () => { dialogComponentProps: { // Reka (the default renderer) sizes via size/contentClass; a PrimeVue // `style` width is ignored here and collapses the table to the default - // `md` frame. `w-fit` lets each step hug its content — the pricing + // `md` frame. `w-fit` lets each step hug its content -- the pricing // table fills its 1280px content while the compact confirm/success // steps shrink (the content root sets its own width per checkoutStep). renderer: 'reka', @@ -167,7 +163,7 @@ export const useSubscriptionDialog = () => { reason: options?.reason, onChooseTeam: () => startTeamWorkspaceUpgradeFlow() }, - dialogComponentProps + dialogComponentProps: legacyPricingDialogProps }) } From 5bf41a41bd161e6c9492eede05349491ad9acc05 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com> Date: Wed, 15 Jul 2026 11:38:12 +0900 Subject: [PATCH 11/21] 1.48.3 (#13606) Patch version increment to 1.48.3 **Base branch:** `main` --------- Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: github-actions <github-actions@github.com> --- package.json | 2 +- src/locales/ar/main.json | 42 +++++++++++++-- src/locales/ar/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/en/main.json | 1 + src/locales/en/nodeDefs.json | 95 ++++++++++++++++++++++++++++++++- src/locales/es/main.json | 42 +++++++++++++-- src/locales/es/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/fa/main.json | 42 +++++++++++++-- src/locales/fa/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/fr/main.json | 42 +++++++++++++-- src/locales/fr/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/he/main.json | 42 +++++++++++++-- src/locales/he/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/ja/main.json | 42 +++++++++++++-- src/locales/ja/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/ko/main.json | 42 +++++++++++++-- src/locales/ko/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/pt-BR/main.json | 42 +++++++++++++-- src/locales/pt-BR/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/ru/main.json | 42 +++++++++++++-- src/locales/ru/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/tr/main.json | 42 +++++++++++++-- src/locales/tr/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/zh-TW/main.json | 42 +++++++++++++-- src/locales/zh-TW/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ src/locales/zh/main.json | 42 +++++++++++++-- src/locales/zh/nodeDefs.json | 91 +++++++++++++++++++++++++++++++ 27 files changed, 1643 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index 7845cbf8fa..848084f4b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.48.2", + "version": "1.48.3", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index e56a2f5216..4287bb2160 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -366,6 +366,7 @@ "typeText": "نص" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "وضع {mode}، إجراءات سير العمل", "app": "التطبيق", "blueprint": "المخطط", "clearWorkflow": "مسح سير العمل", @@ -2078,7 +2079,10 @@ "appBuilder": "منشئ التطبيقات", "apps": "التطبيقات", "appsEmptyMessage": "سيتم عرض التطبيقات المحفوظة هنا.\nانقر أدناه لبناء تطبيقك الأول.", - "appsEmptyMessageAction": "انقر أدناه لإنشاء أول تطبيق لك." + "appsEmptyMessageAction": "انقر أدناه لإنشاء أول تطبيق لك.", + "buildAnApp": "إنشاء تطبيق", + "create": "إنشاء", + "createApp": "إنشاء تطبيق" }, "arrange": { "atLeastOne": "عقدة واحدة على الأقل", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "الانتقال إلى المخرجات" }, "backToWorkflow": "العودة إلى سير العمل", - "beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك", - "buildAnApp": "أنشئ تطبيقًا", "builder": { "exit": "خروج من البناء", "exitConfirmMessage": "لديك تغييرات غير محفوظة ستفقد\nهل تريد الخروج بدون حفظ؟", @@ -2130,6 +2132,7 @@ "requiresGraph": "حدث خطأ أثناء التوليد. قد يكون ذلك بسبب مدخلات مخفية غير صالحة، أو موارد مفقودة، أو مشاكل في إعداد سير العمل.", "support": "الاتصال بالدعم" }, + "feedbackLoadError": "فشل تحميل نموذج الملاحظات. يرجى المحاولة لاحقًا.", "giveFeedback": "إعطاء ملاحظات", "graphMode": "وضع الرسم البياني", "hasCreditCost": "يتطلب أرصدة إضافية", @@ -2198,6 +2201,7 @@ "loadingModel": "جارٍ تحميل النموذج ثلاثي الأبعاد...", "materialMode": "وضع المادة", "materialModes": { + "clay": "طين", "depth": "العمق", "lineart": "الرسم الخطي", "normal": "عادي", @@ -2205,7 +2209,29 @@ "pointCloud": "سحابة نقطية", "wireframe": "إطار سلكي" }, + "menuBar": { + "bgColor": "لون الخلفية", + "bgImage": "صورة الخلفية", + "deleteRecording": "حذف التسجيل", + "downloadRecording": "تحميل التسجيل", + "fov": "زاوية الرؤية", + "intensity": "الشدة", + "material": "الخامة", + "originalMaterialOnly": "الخامة الأصلية فقط", + "panorama": "بانوراما", + "record": "تسجيل", + "recording": "جارٍ التسجيل", + "removeBackground": "إزالة الخلفية", + "showGrid": "إظهار الشبكة", + "skeleton": "الهيكل العظمي", + "startNewRecording": "بدء تسجيل جديد", + "stopRecording": "إيقاف التسجيل", + "switchProjection": "تبديل الإسقاط", + "upDirection": "اتجاه الأعلى", + "videoRecordingTooltip": "تسجيل فيديو للمشهد [mp4]" + }, "model": "النموذج", + "model3d": "نموذج ثلاثي الأبعاد", "openIn3DViewer": "افتح في عارض ثلاثي الأبعاد", "panoramaMode": "وضع البانوراما", "previewOutput": "معاينة المخرج", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3d", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "نص", "training": "تدريب", "transform": "تحويل", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "يوجد سر بهذا الاسم بالفعل", "duplicateProvider": "يوجد سر لهذا المزود بالفعل", + "fileReadFailed": "تعذر قراءة الملف المحدد. يرجى المحاولة مرة أخرى", + "fileTooLarge": "الملف كبير جدًا. يرجى رفع بيانات اعتماد JSON أقل من ١ ميجابايت", + "invalidJson": "يجب أن يحتوي الملف على كائن JSON صالح", "nameRequired": "الاسم مطلوب", "nameTooLong": "يجب ألا يزيد الاسم عن ٢٥٥ حرفًا", "providerRequired": "المزود مطلوب", "secretValueRequired": "قيمة السر مطلوبة" }, + "jsonFileHint": "الصق ملف JSON أو قم برفعه. سيتم تشفيره ولن يكون بالإمكان عرضه مرة أخرى.", + "jsonFileHintEdit": "اتركه فارغًا للاحتفاظ بالقيمة الحالية، أو ارفع ملف JSON جديد لاستبداله.", + "jsonFilePlaceholder": "قم برفع أو لصق بيانات اعتماد المزود بصيغة JSON", "lastUsed": "آخر استخدام في {date}", "modelProviders": "مزودو النماذج", "name": "الاسم", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "اترك الحقل فارغًا للاحتفاظ بالقيمة الحالية.", "secretValuePlaceholder": "أدخل مفتاح API الخاص بك", "secretValuePlaceholderEdit": "أدخل قيمة جديدة للتغيير", - "title": "مفاتيح API والأسرار" + "title": "مفاتيح API والأسرار", + "uploadJsonFile": "رفع ملف JSON" }, "selectionToolbox": { "Bypass Group Nodes": "تجاوز عقد المجموعة", diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index 862a4fe5fa..bfe046fc86 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "إعادة مزامنة حركة الفم في الفيديو مع صوت كلام جديد باستخدام sync.so. يتعامل تلقائيًا مع اللقطات القريبة، والملفات الجانبية، والعوائق مع الحفاظ على تعبير المتحدث. التكلفة تعتمد على مدة الإخراج.", + "display_name": "مزامنة الشفاه sync.so", + "inputs": { + "audio": { + "name": "صوت", + "tooltip": "الصوت الذي سيتم مزامنة حركة الفم معه." + }, + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "model": { + "name": "النموذج", + "tooltip": "نموذج التوليد الخاص بـ sync.so." + }, + "model_speaker_frame": { + "name": "إطار المتحدث" + }, + "model_speaker_selection": { + "name": "اختيار المتحدث" + }, + "model_speaker_x": { + "name": "إحداثي X للمتحدث" + }, + "model_speaker_y": { + "name": "إحداثي Y للمتحدث" + }, + "model_sync_mode": { + "name": "وضع المزامنة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة." + }, + "video": { + "name": "فيديو", + "tooltip": "لقطة للمتحدث لإعادة المزامنة. حتى دقة 4K ‏(4096×2160)؛ معدل الإطارات الثابت 24/25/30 إطار في الثانية هو الأفضل." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "حوّل صورة ثابتة إلى فيديو متحدث مدفوع بصوت الكلام، باستخدام نموذج sync-3 من sync.so. مدة الإخراج تطابق مدة الصوت. التكلفة تعتمد على مدة الإخراج.", + "display_name": "صورة متحدثة sync.so", + "inputs": { + "audio": { + "name": "صوت", + "tooltip": "الصوت الذي يحرك الفيديو المتحدث؛ مدة الإخراج تطابقه. يمكنك ربط أي عقدة TTS هنا لتحريك الصورة من نص." + }, + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "image": { + "name": "صورة", + "tooltip": "صورة واحدة بوجه واضح، حتى دقة 4K ‏(4096×2160)." + }, + "model": { + "name": "النموذج", + "tooltip": "نموذج التوليد الخاص بـ sync.so. إدخال الصورة حصري لنموذج sync-3." + }, + "model_auto_downscale": { + "name": "تصغير تلقائي" + }, + "model_speaker_selection": { + "name": "اختيار المتحدث" + }, + "model_speaker_x": { + "name": "إحداثي X للمتحدث" + }, + "model_speaker_y": { + "name": "إحداثي Y للمتحدث" + }, + "prompt": { + "name": "موجه", + "tooltip": "إرشادات اختيارية حول كيفية إحياء الصورة، مثل: 'اجعل الشخص يبتسم وينظر إلى الكاميرا'. اتركه فارغًا لحركة حديث طبيعية." + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "خيارات محلل T5", "inputs": { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 297b3de1b7..377752faaa 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1821,6 +1821,7 @@ "supir": "supir", "stable video 3d": "stable video 3d", "stable video": "stable video", + "sync_so": "sync.so", "Tencent": "Tencent", "boogu": "boogu", "qwen image": "qwen image", diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index 5bdd045f03..e9c72d9fb1 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -1168,7 +1168,7 @@ }, "ByteDanceSeedAudio": { "display_name": "ByteDance Seed Audio 1.0", - "description": "Generate speech, music, sound effects and multi-speaker dialogue from a single prompt with ByteDance Seed Audio 1.0. Describe the voice(s), emotion, ambience, background music and sound effects in the prompt, and include the lines to speak. Optionally pick a built-in preset voice, clone voices from up to 3 reference clips (tagged @Audio1-3 in the prompt), or derive a voice from a character image. Up to 2 minutes of audio per run.", + "description": "Generate speech, music, sound effects and multi-speaker dialogue from a single prompt with ByteDance Seed Audio 1.0. Describe the voice(s), emotion, ambience, background music and sound effects in the prompt, and include the lines to speak. Optionally pick a built-in preset voice, clone voices from up to 3 reference clips (tagged {'@'}Audio1-3 in the prompt), or derive a voice from a character image. Up to 2 minutes of audio per run.", "inputs": { "text_prompt": { "name": "text_prompt", @@ -17740,7 +17740,7 @@ }, "SaveVideo": { "display_name": "Save Video", - "description": "Saves the input images to your ComfyUI output directory.", + "description": "Saves the input videos to your ComfyUI output directory.", "inputs": { "video": { "name": "video", @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "display_name": "sync.so Lip Sync", + "description": "Re-sync mouth movement in a video to new speech audio using sync.so. Handles close-ups, profiles and obstructions automatically while preserving the speaker's expression. Cost scales with output duration.", + "inputs": { + "video": { + "name": "video", + "tooltip": "Footage of the speaker to re-sync. Up to 4K (4096x2160); a constant frame rate of 24/25/30 fps works best." + }, + "audio": { + "name": "audio", + "tooltip": "Speech audio to sync the mouth to." + }, + "seed": { + "name": "seed", + "tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed." + }, + "model": { + "name": "model", + "tooltip": "sync.so generation model." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_speaker_frame": { + "name": "speaker_frame" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "model_sync_mode": { + "name": "sync_mode" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "display_name": "sync.so Talking Image", + "description": "Animate a still portrait into a talking video driven by speech audio, using sync.so's sync-3 model. The output duration matches the audio. Cost scales with output duration.", + "inputs": { + "image": { + "name": "image", + "tooltip": "A single image with a clearly visible face, up to 4K (4096x2160)." + }, + "audio": { + "name": "audio", + "tooltip": "Speech audio driving the talking video; the output duration matches it. Chain any TTS node here to drive the animation from text." + }, + "prompt": { + "name": "prompt", + "tooltip": "Optional guidance for how the portrait comes to life, e.g. 'make the subject smile and look at the camera'. Leave empty for natural talking motion." + }, + "seed": { + "name": "seed", + "tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed." + }, + "model": { + "name": "model", + "tooltip": "sync.so generation model. Image input is exclusive to sync-3." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_auto_downscale": { + "name": "auto_downscale" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5 Tokenizer Options", "inputs": { diff --git a/src/locales/es/main.json b/src/locales/es/main.json index 6e50af52b7..0af34af1e5 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -366,6 +366,7 @@ "typeText": "texto" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "Modo {mode}, acciones del flujo de trabajo", "app": "Aplicación", "blueprint": "Plano", "clearWorkflow": "Limpiar flujo de trabajo", @@ -2078,7 +2079,10 @@ "appBuilder": "Constructor de aplicaciones", "apps": "Aplicaciones", "appsEmptyMessage": "Las aplicaciones guardadas aparecerán aquí.\nHaz clic abajo para crear tu primera aplicación.", - "appsEmptyMessageAction": "Haz clic abajo para crear tu primera aplicación." + "appsEmptyMessageAction": "Haz clic abajo para crear tu primera aplicación.", + "buildAnApp": "Crear una aplicación", + "create": "Crear", + "createApp": "Crear aplicación" }, "arrange": { "atLeastOne": "al menos uno", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "Cambiar a Salidas" }, "backToWorkflow": "Volver al flujo de trabajo", - "beta": "Modo App Beta - Enviar comentarios", - "buildAnApp": "Crear una aplicación", "builder": { "exit": "Salir del constructor", "exitConfirmMessage": "Tienes cambios sin guardar que se perderán\n¿Salir sin guardar?", @@ -2130,6 +2132,7 @@ "requiresGraph": "Algo salió mal durante la generación. Esto puede deberse a entradas ocultas no válidas, recursos faltantes o problemas de configuración del flujo de trabajo.", "support": "contacta a nuestro soporte" }, + "feedbackLoadError": "No se pudo cargar el formulario de comentarios. Por favor, inténtalo de nuevo más tarde.", "giveFeedback": "Enviar comentarios", "graphMode": "Modo gráfico", "hasCreditCost": "Requiere créditos adicionales", @@ -2198,6 +2201,7 @@ "loadingModel": "Cargando modelo 3D...", "materialMode": "Modo de material", "materialModes": { + "clay": "Arcilla", "depth": "Profundidad", "lineart": "Arte lineal", "normal": "Normal", @@ -2205,7 +2209,29 @@ "pointCloud": "Nube de puntos", "wireframe": "Malla" }, + "menuBar": { + "bgColor": "Color de fondo", + "bgImage": "Imagen de fondo", + "deleteRecording": "Eliminar grabación", + "downloadRecording": "Descargar grabación", + "fov": "FOV", + "intensity": "Intensidad", + "material": "Material", + "originalMaterialOnly": "Solo material original", + "panorama": "Panorama", + "record": "Grabar", + "recording": "Grabando", + "removeBackground": "Quitar fondo", + "showGrid": "Mostrar cuadrícula", + "skeleton": "Esqueleto", + "startNewRecording": "Iniciar nueva grabación", + "stopRecording": "Detener grabación", + "switchProjection": "Cambiar proyección", + "upDirection": "Dirección arriba", + "videoRecordingTooltip": "Grabación de video de la escena [mp4]" + }, "model": "Modelo", + "model3d": "Modelo 3D", "openIn3DViewer": "Abrir en Visor 3D", "panoramaMode": "Panorama", "previewOutput": "Vista previa de salida", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3d", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "texto", "training": "entrenamiento", "transform": "transformar", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "Ya existe un secreto con este nombre", "duplicateProvider": "Ya existe un secreto para este proveedor", + "fileReadFailed": "No se pudo leer el archivo seleccionado", + "fileTooLarge": "El archivo es demasiado grande. Sube unas credenciales JSON de menos de 1 MB", + "invalidJson": "El archivo debe contener un objeto JSON válido", "nameRequired": "El nombre es obligatorio", "nameTooLong": "El nombre debe tener 255 caracteres o menos", "providerRequired": "El proveedor es obligatorio", "secretValueRequired": "El valor secreto es obligatorio" }, + "jsonFileHint": "Pega el JSON o sube un archivo. Será cifrado y no podrá verse de nuevo.", + "jsonFileHintEdit": "Déjalo en blanco para mantener el valor actual, o sube un nuevo archivo JSON para reemplazarlo.", + "jsonFilePlaceholder": "Sube o pega las credenciales JSON del proveedor", "lastUsed": "Último uso el {date}", "modelProviders": "Proveedores de modelos", "name": "Nombre", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "Déjalo en blanco para mantener el valor actual.", "secretValuePlaceholder": "Introduce tu clave API", "secretValuePlaceholderEdit": "Introduce un nuevo valor para cambiarlo", - "title": "Claves API y Secretos" + "title": "Claves API y Secretos", + "uploadJsonFile": "Subir archivo JSON" }, "selectionToolbox": { "Bypass Group Nodes": "Omitir nodos de grupo", diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index 3de3c9f47f..795d97df90 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "Vuelve a sincronizar el movimiento de la boca en un video con un nuevo audio de voz usando sync.so. Maneja automáticamente primeros planos, perfiles y obstrucciones, preservando la expresión del hablante. El costo escala según la duración del resultado.", + "display_name": "sync.so Lip Sync", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Audio de voz para sincronizar con la boca." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model", + "tooltip": "Modelo de generación de sync.so." + }, + "model_speaker_frame": { + "name": "speaker_frame" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "model_sync_mode": { + "name": "sync_mode" + }, + "seed": { + "name": "seed", + "tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla." + }, + "video": { + "name": "video", + "tooltip": "Metraje del hablante para volver a sincronizar. Hasta 4K (4096x2160); una tasa de fotogramas constante de 24/25/30 fps funciona mejor." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "Anima un retrato estático en un video parlante impulsado por audio de voz, usando el modelo sync-3 de sync.so. La duración del resultado coincide con el audio. El costo escala según la duración del resultado.", + "display_name": "sync.so Talking Image", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Audio de voz que impulsa el video parlante; la duración del resultado coincide con él. Encadena cualquier nodo TTS aquí para animar desde texto." + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "Una sola imagen con un rostro claramente visible, hasta 4K (4096x2160)." + }, + "model": { + "name": "model", + "tooltip": "Modelo de generación de sync.so. La entrada de imagen es exclusiva de sync-3." + }, + "model_auto_downscale": { + "name": "auto_downscale" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "prompt": { + "name": "prompt", + "tooltip": "Orientación opcional sobre cómo cobra vida el retrato, por ejemplo: 'haz que el sujeto sonría y mire a la cámara'. Déjalo vacío para un movimiento natural al hablar." + }, + "seed": { + "name": "seed", + "tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5TokenizerOptions", "inputs": { diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index 94824a15c4..4483c1fa27 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -366,6 +366,7 @@ "typeText": "متن" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "حالت {mode}، عملیات گردش‌کار", "app": "برنامه", "blueprint": "نقشه راه", "clearWorkflow": "پاک‌سازی workflow", @@ -2078,7 +2079,10 @@ "appBuilder": "سازنده اپلیکیشن", "apps": "اپلیکیشن‌ها", "appsEmptyMessage": "اپلیکیشن‌های ذخیره‌شده اینجا نمایش داده می‌شوند.\nبرای ساخت اولین اپلیکیشن خود، روی دکمه زیر کلیک کنید.", - "appsEmptyMessageAction": "برای ساخت اولین اپلیکیشن خود، روی دکمه زیر کلیک کنید." + "appsEmptyMessageAction": "برای ساخت اولین اپلیکیشن خود، روی دکمه زیر کلیک کنید.", + "buildAnApp": "ساخت اپلیکیشن", + "create": "ایجاد", + "createApp": "ایجاد اپلیکیشن" }, "arrange": { "atLeastOne": "یک", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "رفتن به خروجی‌ها" }, "backToWorkflow": "بازگشت به جریان کاری", - "beta": "حالت برنامه بتا - ارسال بازخورد", - "buildAnApp": "ساخت اپلیکیشن", "builder": { "exit": "خروج از حالت ساخت", "exitConfirmMessage": "تغییرات ذخیره‌نشده شما از بین خواهد رفت\nخروج بدون ذخیره؟", @@ -2130,6 +2132,7 @@ "requiresGraph": "در طول تولید مشکلی پیش آمد. ممکن است به دلیل ورودی‌های مخفی نامعتبر، منابع گمشده یا مشکلات پیکربندی workflow باشد.", "support": "تماس با پشتیبانی ما" }, + "feedbackLoadError": "بارگذاری فرم بازخورد ناموفق بود. لطفاً بعداً دوباره تلاش کنید.", "giveFeedback": "ارسال بازخورد", "graphMode": "حالت گراف", "hasCreditCost": "نیازمند اعتبار اضافی", @@ -2198,6 +2201,7 @@ "loadingModel": "در حال بارگذاری مدل سه‌بعدی...", "materialMode": "حالت متریال", "materialModes": { + "clay": "خاک رس", "depth": "عمق", "lineart": "خطی", "normal": "عادی", @@ -2205,7 +2209,29 @@ "pointCloud": "ابر نقاط", "wireframe": "سیمی" }, + "menuBar": { + "bgColor": "رنگ پس‌زمینه", + "bgImage": "تصویر پس‌زمینه", + "deleteRecording": "حذف ضبط‌شده", + "downloadRecording": "دانلود ضبط‌شده", + "fov": "زاویه دید (FOV)", + "intensity": "شدت", + "material": "متریال", + "originalMaterialOnly": "فقط متریال اصلی", + "panorama": "پانوراما", + "record": "ضبط", + "recording": "در حال ضبط", + "removeBackground": "حذف پس‌زمینه", + "showGrid": "نمایش شبکه", + "skeleton": "اسکلت", + "startNewRecording": "شروع ضبط جدید", + "stopRecording": "توقف ضبط", + "switchProjection": "تغییر پرسپکتیو", + "upDirection": "جهت بالا", + "videoRecordingTooltip": "ضبط ویدیویی صحنه [mp4]" + }, "model": "مدل", + "model3d": "مدل سه‌بعدی", "openIn3DViewer": "باز کردن در نمایشگر سه‌بعدی", "panoramaMode": "حالت پانوراما", "previewOutput": "پیش‌نمایش خروجی", @@ -2888,6 +2914,7 @@ "stable video 3d": "ویدیوی سه‌بعدی stable", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "متن", "training": "آموزش", "transform": "تبدیل", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "کلیدی با این نام قبلاً وجود دارد", "duplicateProvider": "کلیدی برای این ارائه‌دهنده قبلاً وجود دارد", + "fileReadFailed": "خواندن فایل انتخاب‌شده امکان‌پذیر نبود", + "fileTooLarge": "فایل بیش از حد بزرگ است. لطفاً یک اعتبارنامه JSON کمتر از ۱ مگابایت بارگذاری کنید", + "invalidJson": "فایل باید شامل یک شیء JSON معتبر باشد", "nameRequired": "وارد کردن نام الزامی است", "nameTooLong": "نام باید حداکثر ۲۵۵ کاراکتر باشد", "providerRequired": "وارد کردن ارائه‌دهنده الزامی است", "secretValueRequired": "وارد کردن مقدار کلید محرمانه الزامی است" }, + "jsonFileHint": "JSON را جای‌گذاری یا فایل را بارگذاری کنید. این اطلاعات رمزگذاری می‌شود و دیگر قابل مشاهده نخواهد بود.", + "jsonFileHintEdit": "برای حفظ مقدار فعلی، خالی بگذارید یا برای جایگزینی، فایل JSON جدید بارگذاری کنید.", + "jsonFilePlaceholder": "فایل JSON اعتبارسنجی ارائه‌دهنده را بارگذاری یا جای‌گذاری کنید", "lastUsed": "آخرین استفاده {date}", "modelProviders": "ارائه‌دهندگان مدل", "name": "نام", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "برای حفظ مقدار فعلی، این قسمت را خالی بگذارید.", "secretValuePlaceholder": "کلید API خود را وارد کنید", "secretValuePlaceholderEdit": "برای تغییر مقدار جدید را وارد کنید", - "title": "کلیدها و اسرار API" + "title": "کلیدها و اسرار API", + "uploadJsonFile": "بارگذاری فایل JSON" }, "selectionToolbox": { "Bypass Group Nodes": "عبور از نودهای گروه", diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index 132738ce46..36c65d7c5c 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "همگام‌سازی مجدد حرکت دهان در یک ویدیو با صدای گفتاری جدید با استفاده از sync.so. به‌طور خودکار کلوزآپ‌ها، نمای نیم‌رخ و موانع را مدیریت می‌کند و در عین حال حالت چهره گوینده را حفظ می‌نماید. هزینه بر اساس مدت زمان خروجی افزایش می‌یابد.", + "display_name": "sync.so Lip Sync", + "inputs": { + "audio": { + "name": "صدا", + "tooltip": "صدای گفتاری برای همگام‌سازی حرکت دهان." + }, + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل", + "tooltip": "مدل تولید sync.so." + }, + "model_speaker_frame": { + "name": "speaker_frame" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "model_sync_mode": { + "name": "sync_mode" + }, + "seed": { + "name": "seed", + "tooltip": "seed تعیین می‌کند که node باید مجدداً اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند." + }, + "video": { + "name": "ویدیو", + "tooltip": "تصویربرداری از گوینده برای همگام‌سازی مجدد. تا کیفیت ۴K (۴۰۹۶×۲۱۶۰)؛ نرخ فریم ثابت ۲۴/۲۵/۳۰ فریم بر ثانیه بهترین عملکرد را دارد." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "یک تصویر پرتره ثابت را با صدای گفتاری به ویدیوی صحبت‌کننده تبدیل کنید، با استفاده از مدل sync-3 از sync.so. مدت زمان خروجی مطابق با صوت خواهد بود. هزینه بر اساس مدت زمان خروجی افزایش می‌یابد.", + "display_name": "sync.so Talking Image", + "inputs": { + "audio": { + "name": "صدا", + "tooltip": "صدای گفتاری که ویدیوی صحبت‌کننده را هدایت می‌کند؛ مدت زمان خروجی مطابق با آن است. هر node تبدیل متن به گفتار (TTS) را می‌توانید اینجا متصل کنید تا انیمیشن از متن هدایت شود." + }, + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "image": { + "name": "تصویر", + "tooltip": "یک تصویر با چهره واضح، تا کیفیت ۴K (۴۰۹۶×۲۱۶۰)." + }, + "model": { + "name": "مدل", + "tooltip": "مدل تولید sync.so. ورودی تصویر فقط برای sync-3 فعال است." + }, + "model_auto_downscale": { + "name": "auto_downscale" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "prompt": { + "name": "پرامپت", + "tooltip": "راهنمای اختیاری برای نحوه جان گرفتن پرتره، مثلاً «سوژه را لبخند بزن و به دوربین نگاه کند». برای حرکت طبیعی صحبت کردن، خالی بگذارید." + }, + "seed": { + "name": "seed", + "tooltip": "seed تعیین می‌کند که node باید مجدداً اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "گزینه‌های T5Tokenizer", "inputs": { diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 46c95bcc99..aae2176d33 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -366,6 +366,7 @@ "typeText": "texte" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "Mode {mode}, actions du workflow", "app": "Application", "blueprint": "Plan", "clearWorkflow": "Effacer le workflow", @@ -2078,7 +2079,10 @@ "appBuilder": "Créateur d'applications", "apps": "Applications", "appsEmptyMessage": "Les applications enregistrées apparaîtront ici.\nCliquez ci-dessous pour créer votre première application.", - "appsEmptyMessageAction": "Cliquez ci-dessous pour créer votre première application." + "appsEmptyMessageAction": "Cliquez ci-dessous pour créer votre première application.", + "buildAnApp": "Créer une application", + "create": "Créer", + "createApp": "Créer une application" }, "arrange": { "atLeastOne": "au moins un", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "Passer aux sorties" }, "backToWorkflow": "Retour au workflow", - "beta": "Mode App Bêta - Donnez votre avis", - "buildAnApp": "Créer une application", "builder": { "exit": "Quitter le mode créateur", "exitConfirmMessage": "Vous avez des modifications non enregistrées qui seront perdues\nQuitter sans enregistrer ?", @@ -2130,6 +2132,7 @@ "requiresGraph": "Une erreur s’est produite lors de la génération. Cela peut être dû à des entrées cachées invalides, des ressources manquantes ou des problèmes de configuration du workflow.", "support": "contacter notre support" }, + "feedbackLoadError": "Échec du chargement du formulaire de retour. Veuillez réessayer plus tard.", "giveFeedback": "Donner un avis", "graphMode": "Mode graphique", "hasCreditCost": "Nécessite des crédits supplémentaires", @@ -2198,6 +2201,7 @@ "loadingModel": "Chargement du modèle 3D...", "materialMode": "Mode Matériel", "materialModes": { + "clay": "Argile", "depth": "Profondeur", "lineart": "Lineart", "normal": "Normal", @@ -2205,7 +2209,29 @@ "pointCloud": "Nuage de points", "wireframe": "Fil de fer" }, + "menuBar": { + "bgColor": "Couleur de fond", + "bgImage": "Image de fond", + "deleteRecording": "Supprimer l'enregistrement", + "downloadRecording": "Télécharger l'enregistrement", + "fov": "Champ de vision", + "intensity": "Intensité", + "material": "Matériau", + "originalMaterialOnly": "Matériau d'origine uniquement", + "panorama": "Panorama", + "record": "Enregistrer", + "recording": "Enregistrement", + "removeBackground": "Supprimer le fond", + "showGrid": "Afficher la grille", + "skeleton": "Squelette", + "startNewRecording": "Commencer un nouvel enregistrement", + "stopRecording": "Arrêter l'enregistrement", + "switchProjection": "Changer de projection", + "upDirection": "Direction vers le haut", + "videoRecordingTooltip": "Enregistrement vidéo de la scène [mp4]" + }, "model": "Modèle", + "model3d": "Modèle 3D", "openIn3DViewer": "Ouvrir dans le visualiseur 3D", "panoramaMode": "Panorama", "previewOutput": "Aperçu de la sortie", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3d", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "texte", "training": "entraînement", "transform": "transformer", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "Un secret avec ce nom existe déjà", "duplicateProvider": "Un secret pour ce fournisseur existe déjà", + "fileReadFailed": "Impossible de lire le fichier sélectionné.", + "fileTooLarge": "Le fichier est trop volumineux. Téléversez un identifiant JSON de moins de 1 Mo", + "invalidJson": "Le fichier doit contenir un objet JSON valide", "nameRequired": "Le nom est requis", "nameTooLong": "Le nom doit comporter 255 caractères ou moins", "providerRequired": "Le fournisseur est requis", "secretValueRequired": "La valeur du secret est requise" }, + "jsonFileHint": "Collez le JSON ou téléversez un fichier. Il sera chiffré et ne pourra plus être consulté.", + "jsonFileHintEdit": "Laissez vide pour conserver la valeur actuelle, ou téléversez un nouveau fichier JSON pour remplacer.", + "jsonFilePlaceholder": "Téléversez ou collez les identifiants JSON du fournisseur", "lastUsed": "Dernière utilisation le {date}", "modelProviders": "Fournisseurs de modèles", "name": "Nom", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "Laissez vide pour conserver la valeur actuelle.", "secretValuePlaceholder": "Saisissez votre clé API", "secretValuePlaceholderEdit": "Saisissez une nouvelle valeur pour modifier", - "title": "Clés API et secrets" + "title": "Clés API et secrets", + "uploadJsonFile": "Téléverser un fichier JSON" }, "selectionToolbox": { "Bypass Group Nodes": "Contourner les nœuds de groupe", diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index f7abe8a921..72ed8889a8 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "Re-synchronisez le mouvement de la bouche dans une vidéo avec un nouvel audio de parole grâce à sync.so. Gère automatiquement les gros plans, les profils et les obstructions tout en préservant l'expression du locuteur. Le coût dépend de la durée de la sortie.", + "display_name": "sync.so Lip Sync", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Audio de parole auquel synchroniser la bouche." + }, + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle", + "tooltip": "Modèle de génération sync.so." + }, + "model_speaker_frame": { + "name": "image_locuteur" + }, + "model_speaker_selection": { + "name": "sélection_locuteur" + }, + "model_speaker_x": { + "name": "locuteur_x" + }, + "model_speaker_y": { + "name": "locuteur_y" + }, + "model_sync_mode": { + "name": "mode_sync" + }, + "seed": { + "name": "graine", + "tooltip": "La graine contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit la graine." + }, + "video": { + "name": "vidéo", + "tooltip": "Séquence du locuteur à resynchroniser. Jusqu'à 4K (4096x2160) ; un taux d'images constant de 24/25/30 fps est optimal." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "Animez un portrait fixe en une vidéo parlante pilotée par un audio de parole, grâce au modèle sync-3 de sync.so. La durée de sortie correspond à celle de l'audio. Le coût dépend de la durée de la sortie.", + "display_name": "sync.so Talking Image", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Audio de parole pilotant la vidéo parlante ; la durée de sortie correspond à celle-ci. Chaînez ici n'importe quel nœud TTS pour animer à partir d'un texte." + }, + "control_after_generate": { + "name": "contrôle après génération" + }, + "image": { + "name": "image", + "tooltip": "Une seule image avec un visage clairement visible, jusqu'à 4K (4096x2160)." + }, + "model": { + "name": "modèle", + "tooltip": "Modèle de génération sync.so. L'entrée image est exclusive à sync-3." + }, + "model_auto_downscale": { + "name": "auto_redimensionnement" + }, + "model_speaker_selection": { + "name": "sélection_locuteur" + }, + "model_speaker_x": { + "name": "locuteur_x" + }, + "model_speaker_y": { + "name": "locuteur_y" + }, + "prompt": { + "name": "invite", + "tooltip": "Indication optionnelle sur la façon dont le portrait prend vie, par exemple « faire sourire le sujet et regarder la caméra ». Laissez vide pour un mouvement naturel de la parole." + }, + "seed": { + "name": "graine", + "tooltip": "La graine contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit la graine." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5TokenizerOptions", "inputs": { diff --git a/src/locales/he/main.json b/src/locales/he/main.json index a8f6be62bf..8c91909a85 100644 --- a/src/locales/he/main.json +++ b/src/locales/he/main.json @@ -366,6 +366,7 @@ "typeText": "טקסט" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "מצב {mode}, פעולות תהליך עבודה", "app": "יישום", "blueprint": "תבנית", "clearWorkflow": "ניקוי תהליך העבודה", @@ -2078,7 +2079,10 @@ "appBuilder": "בונה היישומים", "apps": "יישומים", "appsEmptyMessage": "יישומים שנשמרו יופיעו כאן.", - "appsEmptyMessageAction": "לחץ למטה כדי לבנות את היישום הראשון שלך." + "appsEmptyMessageAction": "לחץ למטה כדי לבנות את היישום הראשון שלך.", + "buildAnApp": "בנה אפליקציה", + "create": "צור", + "createApp": "צור אפליקציה" }, "arrange": { "atLeastOne": "לפחות אחד", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "מעבר לפלטים" }, "backToWorkflow": "חזרה לתהליך העבודה", - "beta": "מצב יישום בבטא", - "buildAnApp": "בניית יישום", "builder": { "exit": "יציאה מהבונה", "exitConfirmMessage": "יש לך שינויים שלא נשמרו אשר יאבדו\nלצאת מבלי לשמור?", @@ -2130,6 +2132,7 @@ "requiresGraph": "משהו השתבש במהלך היצירה. הדבר עשוי לנבוע מקלטים מוסתרים לא תקינים, משאבים חסרים או בעיות בהגדרת תהליך העבודה.", "support": "צור קשר עם התמיכה שלנו" }, + "feedbackLoadError": "טעינת טופס המשוב נכשלה. נסה שוב מאוחר יותר.", "giveFeedback": "שליחת משוב", "graphMode": "מצב גרף", "hasCreditCost": "דורש קרדיטים נוספים", @@ -2198,6 +2201,7 @@ "loadingModel": "טוען מודל תלת-ממד...", "materialMode": "מצב חומר", "materialModes": { + "clay": "חימר", "depth": "עומק", "lineart": "ציור קווי (Lineart)", "normal": "רגיל", @@ -2205,7 +2209,29 @@ "pointCloud": "ענן נקודות", "wireframe": "מסגרת חוטים (Wireframe)" }, + "menuBar": { + "bgColor": "צבע רקע", + "bgImage": "תמונת רקע", + "deleteRecording": "מחק הקלטה", + "downloadRecording": "הורד הקלטה", + "fov": "שדה ראייה (FOV)", + "intensity": "עוצמה", + "material": "חומר", + "originalMaterialOnly": "חומר מקורי בלבד", + "panorama": "פנורמה", + "record": "הקלט", + "recording": "בהקלטה", + "removeBackground": "הסר רקע", + "showGrid": "הצג רשת", + "skeleton": "שלד", + "startNewRecording": "התחל הקלטה חדשה", + "stopRecording": "הפסק הקלטה", + "switchProjection": "החלף הקרנה", + "upDirection": "כיוון מעלה", + "videoRecordingTooltip": "הקלטת וידאו של הסצנה [mp4]" + }, "model": "מודל", + "model3d": "מודל תלת-ממד", "openIn3DViewer": "פתיחה במציג התלת-ממד", "panoramaMode": "פנורמה", "previewOutput": "תצוגה מקדימה של הפלט", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3d", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "text", "training": "training", "transform": "transform", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "כבר קיים סוד עם שם זה", "duplicateProvider": "כבר קיים סוד עבור ספק זה", + "fileReadFailed": "לא ניתן לקרוא את הקובץ שנבחר", + "fileTooLarge": "הקובץ גדול מדי. העלה אישור JSON קטן מ-1 מגה-בייט", + "invalidJson": "הקובץ חייב להכיל אובייקט JSON תקני", "nameRequired": "שם הוא שדה חובה", "nameTooLong": "השם חייב להכיל 255 תווים או פחות", "providerRequired": "ספק הוא שדה חובה", "secretValueRequired": "ערך הסוד הוא שדה חובה" }, + "jsonFileHint": "הדבק את ה-JSON או העלה קובץ. הוא יוצפן ולא יהיה ניתן לצפייה חוזרת.", + "jsonFileHintEdit": "השאר ריק כדי לשמור את הערך הנוכחי, או העלה קובץ JSON חדש להחלפה.", + "jsonFilePlaceholder": "העלה או הדבק את אישור ה-JSON של הספק", "lastUsed": "שימוש אחרון {date}", "modelProviders": "ספקי מודלים", "name": "שם", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "השאר ריק כדי לשמור על הערך הנוכחי.", "secretValuePlaceholder": "הזן את מפתח ה-API שלך", "secretValuePlaceholderEdit": "הזן ערך חדש כדי לשנות", - "title": "מפתחות API וסודות" + "title": "מפתחות API וסודות", + "uploadJsonFile": "העלה קובץ JSON" }, "selectionToolbox": { "Bypass Group Nodes": "עקיפת צמתי הקבוצה", diff --git a/src/locales/he/nodeDefs.json b/src/locales/he/nodeDefs.json index 76f0a39e21..f14defc6a9 100644 --- a/src/locales/he/nodeDefs.json +++ b/src/locales/he/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "סנכרון מחדש של תנועות הפה בווידאו לדיבור חדש באמצעות sync.so. מטפל אוטומטית בתקריבים, פרופילים והסתרות, תוך שמירה על הבעות הדובר. העלות משתנה בהתאם לאורך הפלט.", + "display_name": "sync.so Lip Sync", + "inputs": { + "audio": { + "name": "אודיו", + "tooltip": "אודיו של דיבור לסנכרון תנועות הפה." + }, + "control_after_generate": { + "name": "בקרה לאחר יצירה" + }, + "model": { + "name": "מודל", + "tooltip": "מודל יצירה של sync.so." + }, + "model_speaker_frame": { + "name": "speaker_frame" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "model_sync_mode": { + "name": "sync_mode" + }, + "seed": { + "name": "seed", + "tooltip": "ה-seed קובע אם הצומת ירוץ מחדש; התוצאות אינן דטרמיניסטיות ללא קשר ל-seed." + }, + "video": { + "name": "וידאו", + "tooltip": "קטע וידאו של הדובר לסנכרון מחדש. עד 4K ‏(4096x2160); קצב פריימים קבוע של 24/25/30 fps נותן תוצאות מיטביות." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "הנפשת פורטרט סטטי לווידאו מדבר המונע על ידי אודיו של דיבור, באמצעות מודל sync-3 של sync.so. משך הפלט תואם לאורך האודיו. העלות משתנה בהתאם לאורך הפלט.", + "display_name": "sync.so Talking Image", + "inputs": { + "audio": { + "name": "אודיו", + "tooltip": "אודיו של דיבור שמניע את הווידאו; משך הפלט תואם לו. ניתן לשרשר כאן כל צומת TTS כדי להנפיש מטקסט." + }, + "control_after_generate": { + "name": "בקרה לאחר יצירה" + }, + "image": { + "name": "תמונה", + "tooltip": "תמונה בודדת עם פנים ברורות, עד 4K ‏(4096x2160)." + }, + "model": { + "name": "מודל", + "tooltip": "מודל יצירה של sync.so. קלט תמונה זמין רק עבור sync-3." + }, + "model_auto_downscale": { + "name": "auto_downscale" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "prompt": { + "name": "הנחיה", + "tooltip": "הנחיה אופציונלית לאופן שבו הפורטרט מתעורר לחיים, לדוג' 'להפוך את הדמות למחייכת ומביטה למצלמה'. השאר ריק לתנועת דיבור טבעית." + }, + "seed": { + "name": "seed", + "tooltip": "ה-seed קובע אם הצומת ירוץ מחדש; התוצאות אינן דטרמיניסטיות ללא קשר ל-seed." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5TokenizerOptions", "inputs": { diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index f007372a6a..65fe78fa70 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -366,6 +366,7 @@ "typeText": "テキスト" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "{mode}モード、ワークフローアクション", "app": "アプリ", "blueprint": "ブループリント", "clearWorkflow": "ワークフローをクリア", @@ -2078,7 +2079,10 @@ "appBuilder": "アプリビルダー", "apps": "アプリ", "appsEmptyMessage": "保存されたアプリはここに表示されます。\n下をクリックして最初のアプリを作成しましょう。", - "appsEmptyMessageAction": "下のボタンをクリックして最初のアプリを作成しましょう。" + "appsEmptyMessageAction": "下のボタンをクリックして最初のアプリを作成しましょう。", + "buildAnApp": "アプリを作成", + "create": "作成", + "createApp": "アプリを作成" }, "arrange": { "atLeastOne": "少なくとも1つ", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "出力に切り替え" }, "backToWorkflow": "ワークフローに戻る", - "beta": "アプリモード ベータ版 - フィードバックを送る", - "buildAnApp": "アプリを作成", "builder": { "exit": "ビルダーを終了", "exitConfirmMessage": "保存されていない変更は失われます。\n保存せずに終了しますか?", @@ -2130,6 +2132,7 @@ "requiresGraph": "生成中に問題が発生しました。無効な非表示入力、リソースの不足、またはワークフロー設定の問題が原因の可能性があります。", "support": "サポートに連絡" }, + "feedbackLoadError": "フィードバックフォームの読み込みに失敗しました。後でもう一度お試しください。", "giveFeedback": "フィードバックを送る", "graphMode": "グラフモード", "hasCreditCost": "追加クレジットが必要です", @@ -2198,6 +2201,7 @@ "loadingModel": "3Dモデルを読み込んでいます...", "materialMode": "マテリアルモード", "materialModes": { + "clay": "クレイ", "depth": "深度", "lineart": "線画", "normal": "ノーマル", @@ -2205,7 +2209,29 @@ "pointCloud": "ポイントクラウド", "wireframe": "ワイヤーフレーム" }, + "menuBar": { + "bgColor": "背景色", + "bgImage": "背景画像", + "deleteRecording": "録画を削除", + "downloadRecording": "録画をダウンロード", + "fov": "視野角 (FOV)", + "intensity": "強度", + "material": "マテリアル", + "originalMaterialOnly": "オリジナルマテリアルのみ", + "panorama": "パノラマ", + "record": "録画", + "recording": "録画中", + "removeBackground": "背景を削除", + "showGrid": "グリッドを表示", + "skeleton": "スケルトン", + "startNewRecording": "新しい録画を開始", + "stopRecording": "録画を停止", + "switchProjection": "投影を切り替え", + "upDirection": "上方向", + "videoRecordingTooltip": "シーンのビデオ録画 [mp4]" + }, "model": "モデル", + "model3d": "3Dモデル", "openIn3DViewer": "3Dビューアで開く", "panoramaMode": "パノラマ", "previewOutput": "出力のプレビュー", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3d", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "テキスト", "training": "トレーニング", "transform": "変換", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "この名前のシークレットはすでに存在します", "duplicateProvider": "このプロバイダーのシークレットはすでに存在します", + "fileReadFailed": "選択したファイルを読み取れませんでした", + "fileTooLarge": "ファイルが大きすぎます。1MB未満のJSON認証情報をアップロードしてください", + "invalidJson": "ファイルは有効なJSONオブジェクトを含む必要があります", "nameRequired": "名前は必須です", "nameTooLong": "名前は255文字以内で入力してください", "providerRequired": "プロバイダーは必須です", "secretValueRequired": "シークレット値は必須です" }, + "jsonFileHint": "JSONを貼り付けるか、ファイルをアップロードしてください。暗号化され、再度表示することはできません。", + "jsonFileHintEdit": "現在の値を保持する場合は空欄のままにしてください。新しいJSONファイルをアップロードすると置き換えられます。", + "jsonFilePlaceholder": "プロバイダーのJSON認証情報をアップロードまたは貼り付けてください", "lastUsed": "最終使用日:{date}", "modelProviders": "モデルプロバイダー", "name": "名前", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "現在の値を保持する場合は空欄のままにしてください。", "secretValuePlaceholder": "APIキーを入力してください", "secretValuePlaceholderEdit": "変更する場合は新しい値を入力", - "title": "APIキーとシークレット" + "title": "APIキーとシークレット", + "uploadJsonFile": "JSONファイルをアップロード" }, "selectionToolbox": { "Bypass Group Nodes": "グループノードをバイパス", diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index 792f989516..5e39dc6afe 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "sync.so を使用して、ビデオ内の口の動きを新しい音声に合わせて再同期します。クローズアップ、横顔、遮蔽物にも自動対応し、話者の表情を維持します。コストは出力時間に比例します。", + "display_name": "sync.so リップシンク", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "口の動きと同期させる音声。" + }, + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "model", + "tooltip": "sync.so の生成モデル。" + }, + "model_speaker_frame": { + "name": "speaker_frame" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "model_sync_mode": { + "name": "sync_mode" + }, + "seed": { + "name": "seed", + "tooltip": "seedはノードの再実行を制御しますが、結果はseedに関わらず非決定的です。" + }, + "video": { + "name": "video", + "tooltip": "再同期する話者の映像。最大4K(4096x2160)対応。フレームレートは24/25/30 fpsの固定が最適です。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "sync.so の sync-3 モデルを使い、静止画像のポートレートを音声に合わせて話す動画にアニメーション化します。出力時間は音声と一致します。コストは出力時間に比例します。", + "display_name": "sync.so トーキングイメージ", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "話す動画を駆動する音声。出力時間はこの音声と一致します。TTSノードをここに接続すればテキストからアニメーションを生成できます。" + }, + "control_after_generate": { + "name": "生成後のコントロール" + }, + "image": { + "name": "image", + "tooltip": "顔がはっきり写っている単一画像。最大4K(4096x2160)対応。" + }, + "model": { + "name": "model", + "tooltip": "sync.so の生成モデル。画像入力は sync-3 専用です。" + }, + "model_auto_downscale": { + "name": "auto_downscale" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "prompt": { + "name": "prompt", + "tooltip": "ポートレートの動き方のガイダンス(例:「被写体を笑顔にしてカメラを見るように」)。空欄の場合は自然な話し方になります。" + }, + "seed": { + "name": "seed", + "tooltip": "seedはノードの再実行を制御しますが、結果はseedに関わらず非決定的です。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5TokenizerOptions", "inputs": { diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 01c14a321c..06809f19ae 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -366,6 +366,7 @@ "typeText": "텍스트" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "{mode} 모드, 워크플로우 작업", "app": "앱", "blueprint": "블루프린트", "clearWorkflow": "워크플로 내용 지우기", @@ -2078,7 +2079,10 @@ "appBuilder": "앱 빌더", "apps": "앱", "appsEmptyMessage": "저장된 앱이 여기에 표시됩니다.\n아래를 클릭하여 첫 번째 앱을 만들어보세요.", - "appsEmptyMessageAction": "아래를 클릭하여 첫 번째 앱을 만들어보세요." + "appsEmptyMessageAction": "아래를 클릭하여 첫 번째 앱을 만들어보세요.", + "buildAnApp": "앱 만들기", + "create": "생성", + "createApp": "앱 생성" }, "arrange": { "atLeastOne": "최소 한 개", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "출력으로 전환" }, "backToWorkflow": "워크플로우로 돌아가기", - "beta": "앱 모드 베타 - 피드백 보내기", - "buildAnApp": "앱 만들기", "builder": { "exit": "빌더 종료", "exitConfirmMessage": "저장되지 않은 변경사항이 사라집니다\n저장하지 않고 종료하시겠습니까?", @@ -2130,6 +2132,7 @@ "requiresGraph": "생성 중 문제가 발생했습니다. 잘못된 숨겨진 입력, 누락된 리소스 또는 워크플로우 구성 문제일 수 있습니다.", "support": "고객 지원 문의" }, + "feedbackLoadError": "피드백 폼을 불러오지 못했습니다. 나중에 다시 시도해 주세요.", "giveFeedback": "피드백 보내기", "graphMode": "그래프 모드", "hasCreditCost": "추가 크레딧 필요", @@ -2198,6 +2201,7 @@ "loadingModel": "3D 모델 로딩 중...", "materialMode": "재질 모드", "materialModes": { + "clay": "클레이", "depth": "깊이", "lineart": "라인아트", "normal": "노멀(normal)", @@ -2205,7 +2209,29 @@ "pointCloud": "포인트 클라우드", "wireframe": "와이어프레임" }, + "menuBar": { + "bgColor": "배경색", + "bgImage": "배경 이미지", + "deleteRecording": "녹화 삭제", + "downloadRecording": "녹화 다운로드", + "fov": "시야각", + "intensity": "강도", + "material": "재질", + "originalMaterialOnly": "원본 재질만", + "panorama": "파노라마", + "record": "녹화", + "recording": "녹화 중", + "removeBackground": "배경 제거", + "showGrid": "그리드 표시", + "skeleton": "스켈레톤", + "startNewRecording": "새 녹화 시작", + "stopRecording": "녹화 중지", + "switchProjection": "투영 방식 전환", + "upDirection": "위쪽 방향", + "videoRecordingTooltip": "장면 비디오 녹화 [mp4]" + }, "model": "모델", + "model3d": "3D 모델", "openIn3DViewer": "3D 뷰어에서 열기", "panoramaMode": "파노라마", "previewOutput": "출력 미리보기", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3D", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "텍스트", "training": "학습", "transform": "변환", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "이 이름의 시크릿이 이미 존재합니다", "duplicateProvider": "이 공급자의 시크릿이 이미 존재합니다", + "fileReadFailed": "선택한 파일을 읽을 수 없습니다. 다시 시도해 주세요", + "fileTooLarge": "파일이 너무 큽니다. 1MB 미만의 JSON 자격 증명을 업로드하세요", + "invalidJson": "파일에는 올바른 JSON 객체가 포함되어야 합니다", "nameRequired": "이름은 필수 항목입니다", "nameTooLong": "이름은 255자 이하여야 합니다", "providerRequired": "공급자는 필수 항목입니다", "secretValueRequired": "시크릿 값은 필수 항목입니다" }, + "jsonFileHint": "JSON을 붙여넣거나 파일을 업로드하세요. 암호화되어 다시 볼 수 없습니다.", + "jsonFileHintEdit": "현재 값을 유지하려면 비워 두거나, 새 JSON 파일을 업로드하여 교체하세요.", + "jsonFilePlaceholder": "프로바이더 JSON 자격 증명을 업로드하거나 붙여넣으세요", "lastUsed": "마지막 사용: {date}", "modelProviders": "모델 공급자", "name": "이름", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "현재 값을 유지하려면 비워 두세요.", "secretValuePlaceholder": "API 키를 입력하세요", "secretValuePlaceholderEdit": "변경하려면 새 값을 입력하세요", - "title": "API 키 및 시크릿" + "title": "API 키 및 시크릿", + "uploadJsonFile": "JSON 파일 업로드" }, "selectionToolbox": { "Bypass Group Nodes": "그룹 노드 우회", diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index c600d6ca68..9f6d3cffcb 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "sync.so를 사용하여 비디오에서 입 모양 움직임을 새로운 음성 오디오에 맞게 다시 동기화합니다. 클로즈업, 측면, 장애물 상황에서도 자동으로 처리하며, 화자의 표정을 그대로 유지합니다. 비용은 출력 길이에 따라 달라집니다.", + "display_name": "sync.so Lip Sync", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "입 모양을 동기화할 음성 오디오입니다." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model", + "tooltip": "sync.so 생성 모델입니다." + }, + "model_speaker_frame": { + "name": "speaker_frame" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "model_sync_mode": { + "name": "sync_mode" + }, + "seed": { + "name": "seed", + "tooltip": "seed는 노드가 다시 실행될지 여부를 제어합니다. seed와 관계없이 결과는 비결정적입니다." + }, + "video": { + "name": "video", + "tooltip": "동기화할 화자의 영상입니다. 최대 4K(4096x2160)까지 지원하며, 24/25/30 fps의 일정한 프레임 속도가 가장 적합합니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "sync.so의 sync-3 모델을 사용하여 정지된 인물 사진을 음성 오디오에 맞춰 말하는 비디오로 애니메이션화합니다. 출력 길이는 오디오와 일치합니다. 비용은 출력 길이에 따라 달라집니다.", + "display_name": "sync.so Talking Image", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "말하는 비디오를 구동할 음성 오디오입니다. 출력 길이는 오디오와 일치합니다. 텍스트에서 애니메이션을 구동하려면 TTS 노드를 연결하세요." + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "얼굴이 명확하게 보이는 단일 이미지입니다. 최대 4K(4096x2160)까지 지원합니다." + }, + "model": { + "name": "model", + "tooltip": "sync.so 생성 모델입니다. 이미지 입력은 sync-3에서만 지원됩니다." + }, + "model_auto_downscale": { + "name": "auto_downscale" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "prompt": { + "name": "prompt", + "tooltip": "인물 사진이 어떻게 살아 움직일지에 대한 선택적 안내입니다. 예: '피사체가 미소를 짓고 카메라를 바라보게 해주세요'. 비워두면 자연스러운 말하기 동작이 생성됩니다." + }, + "seed": { + "name": "seed", + "tooltip": "seed는 노드가 다시 실행될지 여부를 제어합니다. seed와 관계없이 결과는 비결정적입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5 토큰생성기 옵션", "inputs": { diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 8effca835d..8ac1e18314 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -366,6 +366,7 @@ "typeText": "texto" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "Modo {mode}, ações do fluxo de trabalho", "app": "App", "blueprint": "Blueprint", "clearWorkflow": "Limpar Fluxo de Trabalho", @@ -2078,7 +2079,10 @@ "appBuilder": "Construtor de aplicativos", "apps": "Aplicativos", "appsEmptyMessage": "Os aplicativos salvos aparecerão aqui.\nClique abaixo para criar seu primeiro aplicativo.", - "appsEmptyMessageAction": "Clique abaixo para criar seu primeiro app." + "appsEmptyMessageAction": "Clique abaixo para criar seu primeiro app.", + "buildAnApp": "Criar um app", + "create": "Criar", + "createApp": "Criar app" }, "arrange": { "atLeastOne": "pelo menos um", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "Ir para Saídas" }, "backToWorkflow": "Voltar para o fluxo de trabalho", - "beta": "Modo App Beta - Envie seu feedback", - "buildAnApp": "Criar um app", "builder": { "exit": "Sair do construtor", "exitConfirmMessage": "Você tem alterações não salvas que serão perdidas\nSair sem salvar?", @@ -2130,6 +2132,7 @@ "requiresGraph": "Algo deu errado durante a geração. Isso pode ser devido a entradas ocultas inválidas, recursos ausentes ou problemas de configuração do fluxo de trabalho.", "support": "contate nosso suporte" }, + "feedbackLoadError": "Falha ao carregar o formulário de feedback. Por favor, tente novamente mais tarde.", "giveFeedback": "Enviar feedback", "graphMode": "Modo Gráfico", "hasCreditCost": "Requer créditos adicionais", @@ -2198,6 +2201,7 @@ "loadingModel": "Carregando Modelo 3D...", "materialMode": "Modo de Material", "materialModes": { + "clay": "Argila", "depth": "Profundidade", "lineart": "Lineart", "normal": "Normal", @@ -2205,7 +2209,29 @@ "pointCloud": "Nuvem de Pontos", "wireframe": "Wireframe" }, + "menuBar": { + "bgColor": "Cor de fundo", + "bgImage": "Imagem de fundo", + "deleteRecording": "Excluir gravação", + "downloadRecording": "Baixar gravação", + "fov": "Campo de visão", + "intensity": "Intensidade", + "material": "Material", + "originalMaterialOnly": "Apenas material original", + "panorama": "Panorama", + "record": "Gravar", + "recording": "Gravando", + "removeBackground": "Remover fundo", + "showGrid": "Mostrar grade", + "skeleton": "Esqueleto", + "startNewRecording": "Iniciar nova gravação", + "stopRecording": "Parar gravação", + "switchProjection": "Alternar projeção", + "upDirection": "Direção para cima", + "videoRecordingTooltip": "Gravação de vídeo da cena [mp4]" + }, "model": "Modelo", + "model3d": "Modelo 3D", "openIn3DViewer": "Abrir no Visualizador 3D", "panoramaMode": "Panorama", "previewOutput": "Visualizar Saída", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3d", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "texto", "training": "treinamento", "transform": "transformar", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "Já existe um segredo com este nome", "duplicateProvider": "Já existe um segredo para este provedor", + "fileReadFailed": "Não foi possível ler o arquivo selecionado.", + "fileTooLarge": "O arquivo é muito grande. Envie uma credencial JSON com menos de 1 MB", + "invalidJson": "O arquivo deve conter um objeto JSON válido", "nameRequired": "O nome é obrigatório", "nameTooLong": "O nome deve ter no máximo 255 caracteres", "providerRequired": "O provedor é obrigatório", "secretValueRequired": "O valor do segredo é obrigatório" }, + "jsonFileHint": "Cole o JSON ou envie um arquivo. Ele será criptografado e não poderá ser visualizado novamente.", + "jsonFileHintEdit": "Deixe em branco para manter o valor atual ou envie um novo arquivo JSON para substituir.", + "jsonFilePlaceholder": "Envie ou cole as credenciais JSON do provedor", "lastUsed": "Último uso em {date}", "modelProviders": "Provedores de Modelos", "name": "Nome", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "Deixe em branco para manter o valor atual.", "secretValuePlaceholder": "Digite sua chave de API", "secretValuePlaceholderEdit": "Digite um novo valor para alterar", - "title": "Chaves de API & Segredos" + "title": "Chaves de API & Segredos", + "uploadJsonFile": "Enviar arquivo JSON" }, "selectionToolbox": { "Bypass Group Nodes": "Ignorar Nós de Grupo", diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index 948d124bc3..129ee71c4a 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "Re-sincronize o movimento da boca em um vídeo com um novo áudio de fala usando sync.so. Lida automaticamente com closes, perfis e obstruções, preservando a expressão do orador. O custo aumenta conforme a duração do resultado.", + "display_name": "sync.so Lip Sync", + "inputs": { + "audio": { + "name": "áudio", + "tooltip": "Áudio de fala para sincronizar com a boca." + }, + "control_after_generate": { + "name": "controle após gerar" + }, + "model": { + "name": "modelo", + "tooltip": "Modelo de geração do sync.so." + }, + "model_speaker_frame": { + "name": "quadro_orador" + }, + "model_speaker_selection": { + "name": "seleção_orador" + }, + "model_speaker_x": { + "name": "orador_x" + }, + "model_speaker_y": { + "name": "orador_y" + }, + "model_sync_mode": { + "name": "modo_sincronização" + }, + "seed": { + "name": "semente", + "tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente." + }, + "video": { + "name": "vídeo", + "tooltip": "Filmagem do orador para re-sincronizar. Até 4K (4096x2160); uma taxa de quadros constante de 24/25/30 fps funciona melhor." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "Anime um retrato estático em um vídeo falante guiado por áudio de fala, usando o modelo sync-3 do sync.so. A duração do resultado corresponde ao áudio. O custo aumenta conforme a duração do resultado.", + "display_name": "sync.so Talking Image", + "inputs": { + "audio": { + "name": "áudio", + "tooltip": "Áudio de fala que guia o vídeo falante; a duração do resultado corresponde a ele. Encadeie qualquer nó TTS aqui para animar a partir de texto." + }, + "control_after_generate": { + "name": "controle após gerar" + }, + "image": { + "name": "imagem", + "tooltip": "Uma única imagem com um rosto claramente visível, até 4K (4096x2160)." + }, + "model": { + "name": "modelo", + "tooltip": "Modelo de geração do sync.so. A entrada de imagem é exclusiva do sync-3." + }, + "model_auto_downscale": { + "name": "redução_automática" + }, + "model_speaker_selection": { + "name": "seleção_orador" + }, + "model_speaker_x": { + "name": "orador_x" + }, + "model_speaker_y": { + "name": "orador_y" + }, + "prompt": { + "name": "prompt", + "tooltip": "Orientação opcional de como o retrato deve ganhar vida, por exemplo: 'faça o sujeito sorrir e olhar para a câmera'. Deixe vazio para um movimento natural de fala." + }, + "seed": { + "name": "semente", + "tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "Opções do T5Tokenizer", "inputs": { diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 0458806ccb..4e8bf9f277 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -366,6 +366,7 @@ "typeText": "текст" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "{mode} режим, действия с рабочим процессом", "app": "Приложение", "blueprint": "Чертёж", "clearWorkflow": "Очистить рабочий процесс", @@ -2078,7 +2079,10 @@ "appBuilder": "Конструктор приложений", "apps": "Приложения", "appsEmptyMessage": "Сохранённые приложения появятся здесь.\nНажмите ниже, чтобы создать своё первое приложение.", - "appsEmptyMessageAction": "Нажмите ниже, чтобы создать первое приложение." + "appsEmptyMessageAction": "Нажмите ниже, чтобы создать первое приложение.", + "buildAnApp": "Создать приложение", + "create": "Создать", + "createApp": "Создать приложение" }, "arrange": { "atLeastOne": "хотя бы один", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "Перейти к выводам" }, "backToWorkflow": "Вернуться к рабочему процессу", - "beta": "Режим приложения Бета - Оставить отзыв", - "buildAnApp": "Создать приложение", "builder": { "exit": "Выйти из конструктора", "exitConfirmMessage": "У вас есть несохранённые изменения, которые будут потеряны\nВыйти без сохранения?", @@ -2130,6 +2132,7 @@ "requiresGraph": "Что-то пошло не так во время генерации. Возможные причины: неверные скрытые входные данные, отсутствующие ресурсы или ошибки конфигурации рабочего процесса.", "support": "связаться с поддержкой" }, + "feedbackLoadError": "Не удалось загрузить форму обратной связи. Пожалуйста, попробуйте позже.", "giveFeedback": "Оставить отзыв", "graphMode": "Графовый режим", "hasCreditCost": "Требуются дополнительные кредиты", @@ -2198,6 +2201,7 @@ "loadingModel": "Загрузка 3D модели...", "materialMode": "Режим Материала", "materialModes": { + "clay": "Глина", "depth": "Глубина", "lineart": "Линейный арт", "normal": "Нормальный", @@ -2205,7 +2209,29 @@ "pointCloud": "Облако точек", "wireframe": "Каркас" }, + "menuBar": { + "bgColor": "Цвет фона", + "bgImage": "Фоновое изображение", + "deleteRecording": "Удалить запись", + "downloadRecording": "Скачать запись", + "fov": "Угол обзора", + "intensity": "Интенсивность", + "material": "Материал", + "originalMaterialOnly": "Только оригинальный материал", + "panorama": "Панорама", + "record": "Запись", + "recording": "Идёт запись", + "removeBackground": "Удалить фон", + "showGrid": "Показать сетку", + "skeleton": "Скелет", + "startNewRecording": "Начать новую запись", + "stopRecording": "Остановить запись", + "switchProjection": "Сменить проекцию", + "upDirection": "Направление вверх", + "videoRecordingTooltip": "Видеозапись сцены [mp4]" + }, "model": "Модель", + "model3d": "3D-модель", "openIn3DViewer": "Открыть в 3D просмотрщике", "panoramaMode": "Панорама", "previewOutput": "Предварительный просмотр", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3d", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "текст", "training": "обучение", "transform": "преобразование", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "Секрет с таким именем уже существует", "duplicateProvider": "Секрет для этого провайдера уже существует", + "fileReadFailed": "Не удалось прочитать выбранный файл.", + "fileTooLarge": "Файл слишком большой. Загрузите JSON-учётные данные размером до 1 МБ", + "invalidJson": "Файл должен содержать корректный JSON-объект", "nameRequired": "Имя обязательно", "nameTooLong": "Имя должно содержать не более 255 символов", "providerRequired": "Провайдер обязателен", "secretValueRequired": "Значение секрета обязательно" }, + "jsonFileHint": "Вставьте JSON или загрузите файл. Он будет зашифрован и не может быть просмотрен повторно.", + "jsonFileHintEdit": "Оставьте пустым, чтобы сохранить текущее значение, или загрузите новый JSON-файл для замены.", + "jsonFilePlaceholder": "Загрузите или вставьте JSON-учётные данные провайдера", "lastUsed": "Последнее использование {date}", "modelProviders": "Провайдеры моделей", "name": "Имя", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "Оставьте пустым, чтобы сохранить текущее значение.", "secretValuePlaceholder": "Введите ваш API-ключ", "secretValuePlaceholderEdit": "Введите новое значение для изменения", - "title": "API-ключи и секреты" + "title": "API-ключи и секреты", + "uploadJsonFile": "Загрузить JSON-файл" }, "selectionToolbox": { "Bypass Group Nodes": "Обойти групповые узлы", diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index b03cd52e0a..3f9963ccec 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "Повторная синхронизация движения рта на видео с новой речевой дорожкой с помощью sync.so. Автоматически обрабатывает крупные планы, профиль и препятствия, сохраняя выражение лица говорящего. Стоимость зависит от продолжительности результата.", + "display_name": "sync.so Lip Sync", + "inputs": { + "audio": { + "name": "аудио", + "tooltip": "Речевая аудиодорожка для синхронизации с движением рта." + }, + "control_after_generate": { + "name": "контроль после генерации" + }, + "model": { + "name": "модель", + "tooltip": "Генеративная модель sync.so." + }, + "model_speaker_frame": { + "name": "speaker_frame" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "model_sync_mode": { + "name": "sync_mode" + }, + "seed": { + "name": "seed", + "tooltip": "Seed управляет необходимостью повторного запуска узла; результаты всегда недетерминированы, независимо от значения seed." + }, + "video": { + "name": "видео", + "tooltip": "Видеозапись говорящего для повторной синхронизации. До 4K (4096x2160); оптимально использовать постоянную частоту кадров 24/25/30 fps." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "Анимируйте статичный портрет в говорящее видео, управляемое речевой аудиодорожкой, с помощью модели sync-3 от sync.so. Длительность результата соответствует длительности аудио. Стоимость зависит от продолжительности результата.", + "display_name": "sync.so Talking Image", + "inputs": { + "audio": { + "name": "аудио", + "tooltip": "Речевая аудиодорожка, управляющая говорящим видео; длительность результата будет соответствовать ей. Для анимации по тексту подключите сюда любой TTS-узел." + }, + "control_after_generate": { + "name": "контроль после генерации" + }, + "image": { + "name": "изображение", + "tooltip": "Одиночное изображение с чётко видимым лицом, до 4K (4096x2160)." + }, + "model": { + "name": "модель", + "tooltip": "Генеративная модель sync.so. Ввод изображения поддерживается только sync-3." + }, + "model_auto_downscale": { + "name": "auto_downscale" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "prompt": { + "name": "промпт", + "tooltip": "Необязательное указание, как должен оживать портрет, например: «заставьте персонажа улыбнуться и смотреть в камеру». Оставьте пустым для естественного движения при разговоре." + }, + "seed": { + "name": "seed", + "tooltip": "Seed управляет необходимостью повторного запуска узла; результаты всегда недетерминированы, независимо от значения seed." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5TokenizerOptions", "inputs": { diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index 348a6b5cff..ed6b5611bd 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -366,6 +366,7 @@ "typeText": "metin" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "{mode} modu, iş akışı eylemleri", "app": "Uygulama", "blueprint": "Plan", "clearWorkflow": "İş Akışını Temizle", @@ -2078,7 +2079,10 @@ "appBuilder": "Uygulama oluşturucu", "apps": "Uygulamalar", "appsEmptyMessage": "Kaydedilen uygulamalar burada görünecek.\nİlk uygulamanızı oluşturmak için aşağıya tıklayın.", - "appsEmptyMessageAction": "İlk uygulamanızı oluşturmak için aşağıya tıklayın." + "appsEmptyMessageAction": "İlk uygulamanızı oluşturmak için aşağıya tıklayın.", + "buildAnApp": "Uygulama Oluştur", + "create": "Oluştur", + "createApp": "Uygulama Oluştur" }, "arrange": { "atLeastOne": "en az bir", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "Çıktılara Geç" }, "backToWorkflow": "Çalışma akışına geri dön", - "beta": "Uygulama Modu Beta - Geri Bildirim Verin", - "buildAnApp": "Bir uygulama oluştur", "builder": { "exit": "Oluşturucudan çık", "exitConfirmMessage": "Kaydedilmemiş değişiklikleriniz kaybolacak\nKaydetmeden çıkılsın mı?", @@ -2130,6 +2132,7 @@ "requiresGraph": "Oluşturma sırasında bir şeyler ters gitti. Bu, geçersiz gizli girdiler, eksik kaynaklar veya iş akışı yapılandırma sorunlarından kaynaklanıyor olabilir.", "support": "destek ekibimizle iletişime geçin" }, + "feedbackLoadError": "Geri bildirim formu yüklenemedi. Lütfen daha sonra tekrar deneyin.", "giveFeedback": "Geri bildirim ver", "graphMode": "Grafik Modu", "hasCreditCost": "Ekstra kredi gerektirir", @@ -2198,6 +2201,7 @@ "loadingModel": "3D Model Yükleniyor...", "materialMode": "Malzeme Modu", "materialModes": { + "clay": "Kil", "depth": "Derinlik", "lineart": "Çizgi Sanatı", "normal": "Normal", @@ -2205,7 +2209,29 @@ "pointCloud": "Nokta Bulutu", "wireframe": "Tel Kafes" }, + "menuBar": { + "bgColor": "Arka Plan Rengi", + "bgImage": "Arka Plan Görseli", + "deleteRecording": "Kaydı Sil", + "downloadRecording": "Kaydı İndir", + "fov": "Görüş Açısı (FOV)", + "intensity": "Yoğunluk", + "material": "Malzeme", + "originalMaterialOnly": "Sadece orijinal malzeme", + "panorama": "Panorama", + "record": "Kaydet", + "recording": "Kayıt Yapılıyor", + "removeBackground": "Arka Planı Kaldır", + "showGrid": "Izgarayı Göster", + "skeleton": "İskelet", + "startNewRecording": "Yeni Kayıt Başlat", + "stopRecording": "Kaydı Durdur", + "switchProjection": "Projeksiyonu Değiştir", + "upDirection": "Yukarı Yönü", + "videoRecordingTooltip": "Sahnenin video kaydı [mp4]" + }, "model": "Model", + "model3d": "3D Model", "openIn3DViewer": "3D Görüntüleyicide Aç", "panoramaMode": "Panorama", "previewOutput": "Çıktıyı Önizle", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3d", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "metin", "training": "eğitim", "transform": "dönüştür", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "Bu adla zaten bir gizli bilgi mevcut", "duplicateProvider": "Bu sağlayıcı için zaten bir gizli bilgi mevcut", + "fileReadFailed": "Seçilen dosya okunamadı.", + "fileTooLarge": "Dosya çok büyük. 1 MB altında bir JSON kimlik bilgisi yükleyin", + "invalidJson": "Dosya geçerli bir JSON nesnesi içermelidir", "nameRequired": "Ad zorunludur", "nameTooLong": "Ad 255 karakter veya daha kısa olmalıdır", "providerRequired": "Sağlayıcı zorunludur", "secretValueRequired": "Gizli değer zorunludur" }, + "jsonFileHint": "JSON'u yapıştırın veya bir dosya yükleyin. Şifrelenir ve tekrar görüntülenemez.", + "jsonFileHintEdit": "Mevcut değeri korumak için boş bırakın veya değiştirmek için yeni bir JSON dosyası yükleyin.", + "jsonFilePlaceholder": "Sağlayıcı JSON kimlik bilgisini yükleyin veya yapıştırın", "lastUsed": "Son kullanım {date}", "modelProviders": "Model Sağlayıcıları", "name": "Ad", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "Mevcut değeri korumak için boş bırakın.", "secretValuePlaceholder": "API anahtarınızı girin", "secretValuePlaceholderEdit": "Değiştirmek için yeni değeri girin", - "title": "API Anahtarları & Gizli Bilgiler" + "title": "API Anahtarları & Gizli Bilgiler", + "uploadJsonFile": "JSON Dosyası Yükle" }, "selectionToolbox": { "Bypass Group Nodes": "Bypass Group Nodes", diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index 719da678aa..82e6ca9143 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "Bir videodaki ağız hareketini yeni konuşma sesine sync.so ile yeniden senkronize edin. Yakın planlar, profiller ve engelleri otomatik olarak işler ve konuşmacının ifadesini korur. Maliyet, çıktı süresiyle orantılıdır.", + "display_name": "sync.so Dudak Senkronizasyonu", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Ağız hareketinin senkronize edileceği konuşma sesi." + }, + "control_after_generate": { + "name": "oluşturduktan sonra kontrol et" + }, + "model": { + "name": "model", + "tooltip": "sync.so üretim modeli." + }, + "model_speaker_frame": { + "name": "speaker_frame" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "model_sync_mode": { + "name": "sync_mode" + }, + "seed": { + "name": "seed", + "tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir." + }, + "video": { + "name": "video", + "tooltip": "Yeniden senkronize edilecek konuşmacının videosu. 4K'ya kadar (4096x2160); sabit bir 24/25/30 fps kare hızı en iyi sonucu verir." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "Bir portreyi, sync.so'nun sync-3 modeliyle konuşma sesiyle yönlendirilen konuşan bir videoya dönüştürün. Çıktı süresi sesle eşleşir. Maliyet, çıktı süresiyle orantılıdır.", + "display_name": "sync.so Konuşan Görsel", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Konuşan videoyu yönlendiren konuşma sesi; çıktı süresi bununla eşleşir. Animasyonu metinden yönlendirmek için buraya herhangi bir TTS düğümü zincirleyin." + }, + "control_after_generate": { + "name": "oluşturduktan sonra kontrol et" + }, + "image": { + "name": "image", + "tooltip": "Yüzü net bir şekilde görünen tek bir görsel, 4K'ya kadar (4096x2160)." + }, + "model": { + "name": "model", + "tooltip": "sync.so üretim modeli. Görsel girişi yalnızca sync-3 ile kullanılabilir." + }, + "model_auto_downscale": { + "name": "auto_downscale" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "prompt": { + "name": "prompt", + "tooltip": "Portreye hayat verme konusunda isteğe bağlı yönlendirme, örn. 'konu gülümsesin ve kameraya baksın'. Doğal konuşma hareketi için boş bırakın." + }, + "seed": { + "name": "seed", + "tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5JetonlaştırıcıSeçenekleri", "inputs": { diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 28c8f110d8..12d397dfaa 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -366,6 +366,7 @@ "typeText": "文字" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "{mode} 模式,工作流程操作", "app": "應用程式", "blueprint": "藍圖", "clearWorkflow": "清除工作流程", @@ -2078,7 +2079,10 @@ "appBuilder": "應用建立器", "apps": "應用", "appsEmptyMessage": "已儲存的應用程式將顯示在這裡。\n點擊下方開始建立您的第一個應用程式。", - "appsEmptyMessageAction": "點擊下方開始建立你的第一個應用程式。" + "appsEmptyMessageAction": "點擊下方開始建立你的第一個應用程式。", + "buildAnApp": "建立應用程式", + "create": "建立", + "createApp": "建立應用程式" }, "arrange": { "atLeastOne": "至少一個", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "切換到輸出" }, "backToWorkflow": "返回工作流程", - "beta": "App 模式 Beta - 提供回饋", - "buildAnApp": "建立應用程式", "builder": { "exit": "離開建構器", "exitConfirmMessage": "您有尚未儲存的變更將會遺失\n確定要不儲存直接離開嗎?", @@ -2130,6 +2132,7 @@ "requiresGraph": "產生過程中發生錯誤。可能原因包括無效的隱藏輸入、缺少資源或工作流程設定問題。", "support": "聯絡我們的支援" }, + "feedbackLoadError": "意見回饋表單載入失敗。請稍後再試。", "giveFeedback": "提供回饋", "graphMode": "圖形模式", "hasCreditCost": "需要額外點數", @@ -2198,6 +2201,7 @@ "loadingModel": "正在載入 3D 模型...", "materialMode": "材質模式", "materialModes": { + "clay": "陶土", "depth": "深度", "lineart": "線稿", "normal": "一般", @@ -2205,7 +2209,29 @@ "pointCloud": "點雲", "wireframe": "線框" }, + "menuBar": { + "bgColor": "背景顏色", + "bgImage": "背景圖片", + "deleteRecording": "刪除錄影", + "downloadRecording": "下載錄影", + "fov": "視野(FOV)", + "intensity": "強度", + "material": "材質", + "originalMaterialOnly": "僅原始材質", + "panorama": "全景", + "record": "錄製", + "recording": "錄製中", + "removeBackground": "移除背景", + "showGrid": "顯示格線", + "skeleton": "骨架", + "startNewRecording": "開始新錄製", + "stopRecording": "停止錄製", + "switchProjection": "切換投影", + "upDirection": "上方方向", + "videoRecordingTooltip": "場景錄影 [mp4]" + }, "model": "模型", + "model3d": "3D模型", "openIn3DViewer": "在 3D 檢視器中開啟", "panoramaMode": "全景", "previewOutput": "預覽輸出", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3d", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "文字", "training": "訓練", "transform": "轉換", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "已存在同名的機密", "duplicateProvider": "此供應商已存在機密", + "fileReadFailed": "無法讀取所選檔案。請再試一次", + "fileTooLarge": "檔案過大。請上傳小於1 MB的JSON憑證", + "invalidJson": "檔案必須包含有效的JSON物件", "nameRequired": "名稱為必填項目", "nameTooLong": "名稱長度必須在 255 個字元以內", "providerRequired": "供應商為必填項目", "secretValueRequired": "機密值為必填項目" }, + "jsonFileHint": "請貼上JSON或上傳檔案。將會加密儲存且無法再次檢視。", + "jsonFileHintEdit": "若要保留目前的值請留空,或上傳新的JSON檔案以取代。", + "jsonFilePlaceholder": "上傳或貼上提供者的JSON憑證", "lastUsed": "上次使用 {date}", "modelProviders": "模型供應商", "name": "名稱", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "若要保留目前的值,請留空。", "secretValuePlaceholder": "請輸入您的 API 金鑰", "secretValuePlaceholderEdit": "輸入新值以變更", - "title": "API 金鑰與機密" + "title": "API 金鑰與機密", + "uploadJsonFile": "上傳JSON檔案" }, "selectionToolbox": { "Bypass Group Nodes": "繞過群組節點", diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index 8a37e07bb1..b7b67ea951 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "使用 sync.so 重新同步影片中說話者的嘴型與新的語音音訊。可自動處理特寫、側臉及遮擋,同時保留說話者的表情。費用依輸出時長計算。", + "display_name": "sync.so 口型同步", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "要同步嘴型的語音音訊。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model", + "tooltip": "sync.so 生成模型。" + }, + "model_speaker_frame": { + "name": "speaker_frame" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "model_sync_mode": { + "name": "sync_mode" + }, + "seed": { + "name": "seed", + "tooltip": "Seed 控制此節點是否重新執行;無論 seed 為何,結果皆為非決定性。" + }, + "video": { + "name": "video", + "tooltip": "要重新同步的說話者影片。最高支援 4K (4096x2160);建議使用恆定幀率 24/25/30 fps。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "使用 sync.so 的 sync-3 模型,將靜態肖像圖像動畫化為由語音音訊驅動的說話影片。輸出時長與音訊相同。費用依輸出時長計算。", + "display_name": "sync.so 說話肖像", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "驅動說話影片的語音音訊;輸出時長與其一致。可串接任意 TTS 節點,從文字驅動動畫。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "一張清晰可見臉部的單一圖像,最高支援 4K (4096x2160)。" + }, + "model": { + "name": "model", + "tooltip": "sync.so 生成模型。圖像輸入僅支援 sync-3。" + }, + "model_auto_downscale": { + "name": "auto_downscale" + }, + "model_speaker_selection": { + "name": "speaker_selection" + }, + "model_speaker_x": { + "name": "speaker_x" + }, + "model_speaker_y": { + "name": "speaker_y" + }, + "prompt": { + "name": "prompt", + "tooltip": "可選的肖像動畫指引,例如「讓主角微笑並看向鏡頭」。留空則呈現自然說話動作。" + }, + "seed": { + "name": "seed", + "tooltip": "Seed 控制此節點是否重新執行;無論 seed 為何,結果皆為非決定性。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5 分詞器選項", "inputs": { diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index c68bea7d2d..a5a2665712 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -366,6 +366,7 @@ "typeText": "text" }, "breadcrumbsMenu": { + "activeModeWorkflowActions": "{mode}模式,工作流操作", "app": "应用", "blueprint": "蓝图", "clearWorkflow": "清除工作流", @@ -2078,7 +2079,10 @@ "appBuilder": "应用构建器", "apps": "应用", "appsEmptyMessage": "已保存的应用会显示在这里。\n点击下方开始构建你的第一个应用。", - "appsEmptyMessageAction": "点击下方开始构建你的第一个应用。" + "appsEmptyMessageAction": "点击下方开始构建你的第一个应用。", + "buildAnApp": "构建应用", + "create": "创建", + "createApp": "创建应用" }, "arrange": { "atLeastOne": "至少一个", @@ -2091,8 +2095,6 @@ "switchToOutputsButton": "切换到输出" }, "backToWorkflow": "返回工作流", - "beta": "App 模式测试版 - 提供反馈", - "buildAnApp": "构建应用", "builder": { "exit": "退出构建器", "exitConfirmMessage": "您有未保存的更改将会丢失\n确定不保存直接退出吗?", @@ -2130,6 +2132,7 @@ "requiresGraph": "生成过程中出现问题。可能由于无效的隐藏输入、缺失资源或工作流配置问题导致。", "support": "联系我们的支持团队" }, + "feedbackLoadError": "反馈表单加载失败,请稍后再试。", "giveFeedback": "提供反馈", "graphMode": "图形模式", "hasCreditCost": "需要额外积分", @@ -2198,6 +2201,7 @@ "loadingModel": "正在加载3D模型...", "materialMode": "材质模式", "materialModes": { + "clay": "陶土", "depth": "深度", "lineart": "线稿", "normal": "法线", @@ -2205,7 +2209,29 @@ "pointCloud": "点云", "wireframe": "线框" }, + "menuBar": { + "bgColor": "背景颜色", + "bgImage": "背景图片", + "deleteRecording": "删除录制视频", + "downloadRecording": "下载录制视频", + "fov": "视场角", + "intensity": "强度", + "material": "材质", + "originalMaterialOnly": "仅原始材质", + "panorama": "全景", + "record": "录制", + "recording": "正在录制", + "removeBackground": "移除背景", + "showGrid": "显示网格", + "skeleton": "骨骼", + "startNewRecording": "开始新录制", + "stopRecording": "停止录制", + "switchProjection": "切换投影", + "upDirection": "上方向", + "videoRecordingTooltip": "场景视频录制 [mp4]" + }, "model": "模型", + "model3d": "3D模型", "openIn3DViewer": "在 3D 查看器中打开", "panoramaMode": "全景", "previewOutput": "预览输出", @@ -2888,6 +2914,7 @@ "stable video 3d": "stable video 3D", "stable zero123": "stable zero123", "supir": "supir", + "sync_so": "sync.so", "text": "文本", "training": "训练", "transform": "变换", @@ -3238,11 +3265,17 @@ "errors": { "duplicateName": "已存在同名机密", "duplicateProvider": "该提供商的机密已存在", + "fileReadFailed": "无法读取所选文件", + "fileTooLarge": "文件过大。请上传小于1 MB的JSON凭证", + "invalidJson": "文件必须包含有效的JSON对象", "nameRequired": "名称为必填项", "nameTooLong": "名称长度不能超过 255 个字符", "providerRequired": "提供商为必填项", "secretValueRequired": "机密值为必填项" }, + "jsonFileHint": "粘贴JSON或上传文件。它将被加密,无法再次查看。", + "jsonFileHintEdit": "留空以保留当前值,或上传新JSON文件进行替换。", + "jsonFilePlaceholder": "上传或粘贴提供者的JSON凭证", "lastUsed": "上次使用 {date}", "modelProviders": "模型提供商", "name": "名称", @@ -3259,7 +3292,8 @@ "secretValueHintEdit": "如需保留当前值,请留空。", "secretValuePlaceholder": "请输入您的 API 密钥", "secretValuePlaceholderEdit": "输入新值以更改", - "title": "API 密钥与机密" + "title": "API 密钥与机密", + "uploadJsonFile": "上传JSON文件" }, "selectionToolbox": { "Bypass Group Nodes": "绕过分组节点", diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index 1a3386ab2b..ecc0b7a07e 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -19135,6 +19135,97 @@ } } }, + "SyncLipSyncNode": { + "description": "使用 sync.so 重新同步视频中嘴部动作与新的语音音频。可自动处理特写、侧脸和遮挡,同时保留说话者的表情。费用随输出时长增加。", + "display_name": "sync.so 唇形同步", + "inputs": { + "audio": { + "name": "音频", + "tooltip": "用于同步嘴部动作的语音音频。" + }, + "control_after_generate": { + "name": "生成后控制" + }, + "model": { + "name": "模型", + "tooltip": "sync.so 生成模型。" + }, + "model_speaker_frame": { + "name": "说话者帧" + }, + "model_speaker_selection": { + "name": "说话者选择" + }, + "model_speaker_x": { + "name": "说话者 X" + }, + "model_speaker_y": { + "name": "说话者 Y" + }, + "model_sync_mode": { + "name": "同步模式" + }, + "seed": { + "name": "种子", + "tooltip": "种子用于控制节点是否重新运行;无论种子如何,结果都是非确定性的。" + }, + "video": { + "name": "视频", + "tooltip": "需要重新同步的说话者视频素材。支持最高 4K (4096x2160);建议使用恒定帧率 24/25/30 fps。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SyncTalkingImageNode": { + "description": "使用 sync.so 的 sync-3 模型,将静态肖像动画化为由语音音频驱动的说话视频。输出时长与音频一致。费用随输出时长增加。", + "display_name": "sync.so 说话图像", + "inputs": { + "audio": { + "name": "音频", + "tooltip": "驱动说话视频的语音音频;输出时长与其一致。可在此连接任意 TTS 节点,实现文本驱动动画。" + }, + "control_after_generate": { + "name": "生成后控制" + }, + "image": { + "name": "图像", + "tooltip": "包含清晰可见人脸的单张图片,支持最高 4K (4096x2160)。" + }, + "model": { + "name": "模型", + "tooltip": "sync.so 生成模型。图像输入仅支持 sync-3。" + }, + "model_auto_downscale": { + "name": "自动降采样" + }, + "model_speaker_selection": { + "name": "说话者选择" + }, + "model_speaker_x": { + "name": "说话者 X" + }, + "model_speaker_y": { + "name": "说话者 Y" + }, + "prompt": { + "name": "提示词", + "tooltip": "可选,用于指导肖像如何生动起来,例如“让人物微笑并看向镜头”。留空则为自然说话动作。" + }, + "seed": { + "name": "种子", + "tooltip": "种子用于控制节点是否重新运行;无论种子如何,结果都是非确定性的。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "T5TokenizerOptions": { "display_name": "T5Tokenizer设置", "inputs": { From 98700cfcc791d498cb94e71c4fe400a7bd93078e Mon Sep 17 00:00:00 2001 From: CodeJuggernaut <81205671+CodeJuggernaut@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:20:31 -0700 Subject: [PATCH 12/21] feat: marquee select and Ctrl/Cmd+A in the Media Assets panel (#13323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds marquee (rubber-band) multi-select and Ctrl/Cmd+A select-all to the Media Assets panel, clips the canvas drag-selection rectangle to the canvas panel, and turns on live (real-time) node-graph rubber-band selection by default. ## Changes - **Marquee select** — rubber-band drag from empty grid space selects the covered cards; hold Ctrl/Cmd to start a marquee from over a card; Ctrl/Cmd or Shift alone makes the marquee additive to the current selection, Ctrl/Cmd+Shift subtracts the covered cards from it (designer-approved), and no modifier replaces it. Cards update their selected state live during the drag. - **Ctrl/Cmd+A** — selects all loaded assets when the pointer is over the panel, otherwise falls through to the canvas (select all nodes). It `stopImmediatePropagation`s so a panel select-all never also fires the global node select-all, and it yields while an `aria-modal` dialog is open or a text input is focused. - **Select-all recovers after "deselect all"** — the shortcut was gated only on `useElementHover`, which latched stale when the floating selection bar under the cursor unmounted on deselect. It now also checks the live pointer position against the panel rect, so a second Ctrl/Cmd+A right after deselecting no longer falls through to the browser's native page select-all. - **Canvas rectangle clip** — the canvas drag-selection rectangle is clamped to the canvas panel bounds (`SelectionRectangle.vue`, display-only). - **Graph live selection on by default** — flips the existing `Comfy.Graph.LiveSelection` setting's default to on, so node-graph rubber-band selection updates in real time during the drag (matching the assets panel) instead of committing only on mouse-up. The behavior was already implemented behind the setting; this changes only the default, and users with an explicit value keep it. - **Robustness/UX** — the pointer is captured on drag-engage rather than on press (so a Ctrl/Cmd-click on a card isn't hijacked); no global `document.body.userSelect` mutation (replaced by a panel-scoped `selectstart` guard); the marquee overlay uses the semantic `primary-background` token; post-drag click-suppression auto-resets so a cancelled drag can't swallow a later click; `setPointerCapture` is wrapped in try/catch; a Ctrl/Cmd-held card `dragstart` is cancelled so no native ghost-drag image appears. - **Breaking**: none — `useAssetSelection` is extended additively (new `setSelectedIds` helper, nothing removed or altered) and the new composable exposes only `{ marqueeStyle }`. - **Dependencies**: none. ## Review Focus - **`SelectionRectangle.vue`** is shared canvas code; the change is display-only (clamps the rectangle to the panel; no node-selection behavior change). - **`coreSettings.ts`** — a one-line `Comfy.Graph.LiveSelection` default flip is the only change that affects graph behavior; the live-select code path itself is pre-existing. - **`useAssetGridSelection.ts`** — listener lifecycle/teardown, the panel-scoped `selectstart` guard, the click-suppression timer, the capture-on-drag-engage logic, the pointer-position select-all fallback, and the subtractive-mode snapshot at pointerdown. - **Ctrl/Cmd+A routing** — panel hover (or a live pointer inside the panel) gates select-all vs. the canvas, and `stopImmediatePropagation` prevents double-handling. - Pure geometry/selection logic is extracted into `marqueeSelectionUtil.ts` and unit-tested in isolation (`RectEdges` is `Pick<DOMRect, ...>`, the DOM edge subset); `MediaAssetCard.dragStart` keeps `main`'s `display_name` payload. Relates to Linear **FE-910**. ## Testing - **Unit:** `useAssetGridSelection` (39 cases — marquee selection, additive/replace, subtractive Ctrl/Cmd+Shift (incl. the macOS Cmd variant and a shrink-restore drag), interactive-element + list-view guards, `selectstart` scoping, click-suppression auto-reset, pointer-capture-throw and capture-on-drag-not-press, modal-aware Ctrl/Cmd+A, non-propagation, and the deselect-recovery pointer-in-panel path), plus `MediaAssetCard`, `marqueeSelectionUtil` (11 cases incl. subtractive, and a 5-case fast-check property suite pinning the additive/subtractive set invariants), `SelectionRectangle`, `useAssetSelection`, and `mathUtil`. - **E2E (`assetsSidebarTab.spec.ts`):** 10 Playwright scenarios running in CI — Ctrl/Cmd+A hover vs. canvas; a marquee from the panel header; a modifier-held additive marquee; a Ctrl/Cmd+Shift subtractive marquee; Ctrl/Cmd-drag from a card and within a single card; Ctrl/Cmd+A ignored in a focused search box and under an aria-modal dialog; and a drag from the search box not marquee-selecting. The empty-space marquee path is covered by the panel-header scenario plus the unit suite (a dedicated empty-space e2e could not run headless without a local backend and was dropped as redundant). ## Future work - **Escape key** — not handled by the marquee/select-all flow yet (the composable handles only Ctrl/Cmd+A). Follow-up: press Escape to cancel an in-progress marquee drag (abort the rubber-band and restore the pre-drag selection) and to clear the current selection while the panel has focus. - **Ctrl+A across pagination** — select-all covers the loaded assets only (confirmed as the intended behavior with design); a load-all-then-select variant can follow if needed. ## Demo https://github.com/user-attachments/assets/3841bf3c-db75-4229-a5e7-fb363b4882d6 --- .../fixtures/components/SidebarTab.ts | 4 + .../tests/sidebar/assetsSidebarTab.spec.ts | 252 ++++++ src/components/graph/GraphCanvas.vue | 11 +- .../graph/SelectionRectangle.test.ts | 106 +++ src/components/graph/SelectionRectangle.vue | 51 +- .../sidebar/tabs/AssetsSidebarTab.vue | 59 +- .../assets/components/MediaAssetCard.test.ts | 118 +++ .../assets/components/MediaAssetCard.vue | 6 + .../composables/useAssetGridSelection.test.ts | 800 ++++++++++++++++++ .../composables/useAssetGridSelection.ts | 250 ++++++ .../composables/useAssetSelection.test.ts | 30 + .../assets/composables/useAssetSelection.ts | 14 + .../marqueeSelectionUtil.property.test.ts | 93 ++ .../assets/utils/marqueeSelectionUtil.test.ts | 93 ++ .../assets/utils/marqueeSelectionUtil.ts | 48 ++ .../settings/constants/coreSettings.ts | 2 +- src/utils/mathUtil.test.ts | 26 + src/utils/mathUtil.ts | 24 + 18 files changed, 1961 insertions(+), 26 deletions(-) create mode 100644 src/components/graph/SelectionRectangle.test.ts create mode 100644 src/platform/assets/components/MediaAssetCard.test.ts create mode 100644 src/platform/assets/composables/useAssetGridSelection.test.ts create mode 100644 src/platform/assets/composables/useAssetGridSelection.ts create mode 100644 src/platform/assets/utils/marqueeSelectionUtil.property.test.ts create mode 100644 src/platform/assets/utils/marqueeSelectionUtil.test.ts create mode 100644 src/platform/assets/utils/marqueeSelectionUtil.ts diff --git a/browser_tests/fixtures/components/SidebarTab.ts b/browser_tests/fixtures/components/SidebarTab.ts index 3db6d04bc1..bcece779a3 100644 --- a/browser_tests/fixtures/components/SidebarTab.ts +++ b/browser_tests/fixtures/components/SidebarTab.ts @@ -322,6 +322,9 @@ export class AssetsSidebarTab extends SidebarTab { // --- Folder view --- public readonly backToAssetsButton: Locator + // --- Panel chrome --- + public readonly panelHeader: Locator + // --- Loading --- public readonly skeletonLoaders: Locator @@ -358,6 +361,7 @@ 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' ) diff --git a/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts b/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts index becfffc4cb..30fb7ec8af 100644 --- a/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts +++ b/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts @@ -276,3 +276,255 @@ 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() + }) + }) +}) diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 0d46429c1f..426e6a44fb 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -39,6 +39,10 @@ <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" @@ -89,7 +93,10 @@ /> <!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets --> - <SelectionRectangle v-if="comfyAppReady" /> + <SelectionRectangle + v-if="comfyAppReady" + :panel-el="canvasPanelBoundsRef ?? undefined" + /> <NodeTooltip v-if="tooltipEnabled" /> <NodeSearchboxPopover ref="nodeSearchboxPopoverRef" /> @@ -116,6 +123,7 @@ import { onUnmounted, ref, shallowRef, + useTemplateRef, watch, watchEffect } from 'vue' @@ -202,6 +210,7 @@ const emit = defineEmits<{ ready: [] }>() const canvasRef = ref<HTMLCanvasElement | null>(null) +const canvasPanelBoundsRef = useTemplateRef('canvasPanelBoundsRef') const nodeSearchboxPopoverRef = shallowRef<InstanceType< typeof NodeSearchboxPopover > | null>(null) diff --git a/src/components/graph/SelectionRectangle.test.ts b/src/components/graph/SelectionRectangle.test.ts new file mode 100644 index 0000000000..8bfc4f9798 --- /dev/null +++ b/src/components/graph/SelectionRectangle.test.ts @@ -0,0 +1,106 @@ +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') + }) +}) diff --git a/src/components/graph/SelectionRectangle.vue b/src/components/graph/SelectionRectangle.vue index 7fc886d586..c971996286 100644 --- a/src/components/graph/SelectionRectangle.vue +++ b/src/components/graph/SelectionRectangle.vue @@ -1,6 +1,7 @@ <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" /> @@ -11,6 +12,13 @@ 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() @@ -20,17 +28,18 @@ const selectionRect = ref<{ w: number h: number } | null>(null) +const panelBounds = ref<RectEdges>() useRafFn(() => { const canvas = canvasStore.canvas - if (!canvas) { - selectionRect.value = null - return - } + if (!canvas) 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 @@ -39,25 +48,47 @@ 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 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) + 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 return { left: `${left}px`, top: `${top}px`, - width: `${width}px`, - height: `${height}px` + width: `${right - left}px`, + height: `${bottom - top}px` } }) </script> diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index e30e8b7725..d406406fe8 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -1,5 +1,6 @@ <template> <SidebarTabTemplate + ref="panelRef" :title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')" v-bind="$attrs" > @@ -100,18 +101,19 @@ @context-menu="handleAssetContextMenu" @approach-end="handleApproachEnd" /> - <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 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> </div> </template> <template #footer> @@ -125,6 +127,13 @@ /> </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" @@ -151,6 +160,7 @@ <script setup lang="ts"> import { + unrefElement, useAsyncState, useDebounceFn, useStorage, @@ -164,6 +174,7 @@ import { onMounted, onUnmounted, ref, + useTemplateRef, watch } from 'vue' import { useI18n } from 'vue-i18n' @@ -182,6 +193,7 @@ 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' @@ -239,7 +251,7 @@ const contextMenuFileKind = computed<MediaKind>(() => getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '') ) -const shouldShowOutputCount = (item: AssetItem): boolean => { +const showOutputCount = (item: AssetItem): boolean => { if (activeTab.value !== 'output' || isInFolderView.value) { return false } @@ -259,7 +271,10 @@ const outputAssets = useAssetsApi('output') // Asset selection const { isSelected, + selectedIds, handleAssetClick, + selectAll, + setSelectedIds, hasSelection, clearSelection, getSelectedAssets, @@ -270,6 +285,12 @@ const { deactivate: deactivateSelection } = useAssetSelection() +const panelRef = useTemplateRef('panelRef') +const marqueePanelRef = computed(() => { + const el = unrefElement(panelRef) + return el instanceof HTMLElement ? el : undefined +}) + const { downloadAssets, deleteAssets, @@ -337,6 +358,16 @@ 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)) @@ -575,7 +606,7 @@ const handleDeselectAll = () => { } const handleEmptySpaceClick = () => { - if (hasSelection) { + if (hasSelection.value) { clearSelection() } } diff --git a/src/platform/assets/components/MediaAssetCard.test.ts b/src/platform/assets/components/MediaAssetCard.test.ts new file mode 100644 index 0000000000..b41ef2429a --- /dev/null +++ b/src/platform/assets/components/MediaAssetCard.test.ts @@ -0,0 +1,118 @@ +import { createTestingPinia } from '@pinia/testing' +import { render, screen } from '@testing-library/vue' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' + +vi.mock('@/stores/assetsStore', () => ({ + useAssetsStore: () => ({ isAssetDeleting: () => false }) +})) + +vi.mock('../composables/useMediaAssetActions', () => ({ + useMediaAssetActions: () => ({ downloadAssets: vi.fn() }) +})) + +vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({ + getOutputAssetMetadata: () => ({ + allOutputs: [ + { + filename: 'a.png', + subfolder: '', + type: 'output', + display_name: 'Display A' + } + ] + }) +})) + +const asset: AssetItem = { + id: 'a', + name: 'a.png', + tags: [], + preview_url: '/preview.png' +} + +function renderCard() { + setActivePinia(createTestingPinia({ stubActions: false })) + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} }, + missingWarn: false, + fallbackWarn: false + }) + return render(MediaAssetCard, { + props: { asset, loading: true }, + global: { + plugins: [i18n], + stubs: { + IconGroup: true, + LoadingOverlay: true, + Button: true, + MediaTitle: true + }, + directives: { tooltip: {} } + } + }) +} + +function dispatchDragStart( + init: { ctrlKey?: boolean; metaKey?: boolean } = {} +) { + const dataTransfer = new DataTransfer() + const add = vi.spyOn(dataTransfer.items, 'add').mockImplementation(() => null) + const event = new DragEvent('dragstart', { bubbles: true, cancelable: true }) + // happy-dom's DragEvent ignores dataTransfer/modifier init, so set them here. + Object.defineProperties(event, { + dataTransfer: { value: dataTransfer, configurable: true }, + ctrlKey: { value: init.ctrlKey ?? false, configurable: true }, + metaKey: { value: init.metaKey ?? false, configurable: true } + }) + screen.getByRole('button').dispatchEvent(event) + return { event, add } +} + +describe('MediaAssetCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('dragStart', () => { + it('cancels the native drag when Ctrl is held so a marquee can start over the card', () => { + renderCard() + + const { event, add } = dispatchDragStart({ ctrlKey: true }) + + expect(event.defaultPrevented).toBe(true) + expect(add).not.toHaveBeenCalled() + }) + + it('cancels the native drag when Meta is held', () => { + renderCard() + + const { event } = dispatchDragStart({ metaKey: true }) + + expect(event.defaultPrevented).toBe(true) + }) + + it('includes the asset metadata with display_name in the drag payload', () => { + renderCard() + + const { event, add } = dispatchDragStart() + + expect(event.defaultPrevented).toBe(false) + expect(add).toHaveBeenCalledWith( + JSON.stringify({ + filename: 'a.png', + subfolder: '', + type: 'output', + display_name: 'Display A' + }), + expect.any(String) + ) + }) + }) +}) diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index 63d58207eb..4fdde80ca8 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -21,6 +21,7 @@ ) " :data-selected="selected" + :data-asset-id="asset?.id" :draggable="true" @click.stop="$emit('click')" @contextmenu.prevent.stop=" @@ -316,6 +317,11 @@ const handleOutputCountClick = () => { emit('output-count-click') } function dragStart(e: DragEvent) { + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + return + } + if (!asset?.preview_url) return const { dataTransfer } = e diff --git a/src/platform/assets/composables/useAssetGridSelection.test.ts b/src/platform/assets/composables/useAssetGridSelection.test.ts new file mode 100644 index 0000000000..757309db99 --- /dev/null +++ b/src/platform/assets/composables/useAssetGridSelection.test.ts @@ -0,0 +1,800 @@ +import { render, screen } from '@testing-library/vue' +import { fromPartial } from '@total-typescript/shoehorn' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick, ref } from 'vue' + +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' + +import { useAssetGridSelection } from './useAssetGridSelection' + +const assets: AssetItem[] = [ + { id: 'a', name: 'a.png', tags: [] }, + { id: 'b', name: 'b.png', tags: [] }, + { id: 'c', name: 'c.png', tags: [] } +] + +const cardBoxes: Record<string, { left: number; right: number }> = { + a: { left: 0, right: 50 }, + b: { left: 60, right: 110 }, + c: { left: 120, right: 170 } +} + +function pointer(type: string, init: PointerEventInit = {}) { + return new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + isPrimary: true, + ...init + }) +} + +function createCallbacks(overrides: Record<string, unknown> = {}) { + return { + getAssets: () => assets, + getSelectedIds: vi.fn(() => [] as string[]), + setSelectedIds: vi.fn(), + selectAll: vi.fn(), + ...overrides + } +} + +async function renderHarness(callbacks: ReturnType<typeof createCallbacks>) { + const Harness = defineComponent({ + setup() { + const gridContainerRef = ref<HTMLElement>() + const hoverTargetRef = ref<HTMLElement>() + const { marqueeStyle } = useAssetGridSelection({ + marqueeContainerRef: gridContainerRef, + hoverTargetRef, + getAssets: callbacks.getAssets, + getSelectedIds: callbacks.getSelectedIds, + setSelectedIds: callbacks.setSelectedIds, + selectAll: callbacks.selectAll + }) + return { gridContainerRef, hoverTargetRef, marqueeStyle } + }, + template: ` + <div ref="hoverTargetRef" data-testid="panel"> + <input data-testid="search" /> + <textarea data-testid="textarea"></textarea> + <div contenteditable="true" data-testid="editable"></div> + <div ref="gridContainerRef" data-testid="grid"> + <button data-testid="grid-button">x</button> + <div data-asset-id="a" data-testid="card-a"></div> + <div data-asset-id="b" data-testid="card-b"></div> + <div data-asset-id="c" data-testid="card-c"></div> + </div> + <div + v-if="marqueeStyle" + data-testid="marquee" + :style="marqueeStyle" + ></div> + </div> + ` + }) + + render(Harness) + await nextTick() + vi.spyOn(screen.getByTestId('grid'), 'getBoundingClientRect').mockReturnValue( + fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 1000 }) + ) + for (const id of Object.keys(cardBoxes)) { + vi.spyOn( + screen.getByTestId(`card-${id}`), + 'getBoundingClientRect' + ).mockReturnValue( + fromPartial<DOMRect>({ + left: cardBoxes[id].left, + right: cardBoxes[id].right, + top: 0, + bottom: 50 + }) + ) + } +} + +const grid = () => screen.getByTestId('grid') +const panel = () => screen.getByTestId('panel') +const card = (id: string) => screen.getByTestId(`card-${id}`) + +describe('useAssetGridSelection', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('marquee', () => { + it('selects intersecting cards when dragging from empty space', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent( + pointer('pointermove', { clientX: 110, clientY: 50 }) + ) + + expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith( + ['a', 'b'], + assets + ) + }) + + it('unions with the current selection when a modifier is held', async () => { + const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['c']) }) + await renderHarness(callbacks) + + grid().dispatchEvent( + pointer('pointerdown', { clientX: 0, clientY: 0, shiftKey: true }) + ) + window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 })) + + expect( + [...callbacks.setSelectedIds.mock.lastCall![0]].sort( + (a: string, b: string) => a.localeCompare(b) + ) + ).toEqual(['a', 'c']) + }) + + it('removes covered cards from the selection when Ctrl+Shift is held (subtractive)', async () => { + const callbacks = createCallbacks({ + getSelectedIds: vi.fn(() => ['a', 'b']) + }) + await renderHarness(callbacks) + + grid().dispatchEvent( + pointer('pointerdown', { + clientX: 0, + clientY: 0, + ctrlKey: true, + shiftKey: true + }) + ) + window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 })) + + expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b']) + }) + + it('removes covered cards when Cmd+Shift is held (macOS subtractive)', async () => { + const callbacks = createCallbacks({ + getSelectedIds: vi.fn(() => ['a', 'b']) + }) + await renderHarness(callbacks) + + grid().dispatchEvent( + pointer('pointerdown', { + clientX: 0, + clientY: 0, + metaKey: true, + shiftKey: true + }) + ) + window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 })) + + expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b']) + }) + + it('restores cards when the subtractive marquee shrinks back off them', async () => { + const callbacks = createCallbacks({ + getSelectedIds: vi.fn(() => ['a', 'b']) + }) + await renderHarness(callbacks) + + grid().dispatchEvent( + pointer('pointerdown', { + clientX: 55, + clientY: 55, + ctrlKey: true, + shiftKey: true + }) + ) + window.dispatchEvent(pointer('pointermove', { clientX: 5, clientY: 45 })) + expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b']) + + window.dispatchEvent(pointer('pointermove', { clientX: 54, clientY: 54 })) + expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['a', 'b']) + }) + + it('ignores movement below the drag threshold', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 })) + + expect(callbacks.setSelectedIds).not.toHaveBeenCalled() + }) + + it('does not start a marquee on a plain pointer-down on a card', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + card('a').dispatchEvent( + pointer('pointerdown', { clientX: 5, clientY: 5 }) + ) + window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 })) + + expect(callbacks.setSelectedIds).not.toHaveBeenCalled() + }) + + it('starts a marquee on a card when Ctrl is held and blocks native drag', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + card('a').dispatchEvent( + pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true }) + ) + const dragEvent = new DragEvent('dragstart', { + bubbles: true, + cancelable: true + }) + card('a').dispatchEvent(dragEvent) + window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 })) + + expect(dragEvent.defaultPrevented).toBe(true) + expect(callbacks.setSelectedIds).toHaveBeenCalled() + }) + + it('does not block native drag when no marquee is tracking', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + const dragEvent = new DragEvent('dragstart', { + bubbles: true, + cancelable: true + }) + card('a').dispatchEvent(dragEvent) + + expect(dragEvent.defaultPrevented).toBe(false) + }) + + it('shows a marquee overlay while dragging and removes it on release', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 })) + await nextTick() + + const overlay = screen.getByTestId('marquee') + expect(overlay.style.width).toBe('30px') + expect(overlay.style.height).toBe('40px') + + window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 })) + await nextTick() + expect(screen.queryByTestId('marquee')).toBeNull() + }) + + it('clips the overlay to the grid container when dragging past its edge', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 10, clientY: 10 })) + window.dispatchEvent( + pointer('pointermove', { clientX: 2000, clientY: 2000 }) + ) + await nextTick() + + const overlay = screen.getByTestId('marquee') + expect(overlay.style.left).toBe('10px') + expect(overlay.style.top).toBe('10px') + expect(overlay.style.width).toBe('990px') + expect(overlay.style.height).toBe('990px') + }) + + it('clears the overlay on dragend when a native drag swallows pointerup', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 })) + await nextTick() + expect(screen.getByTestId('marquee')).toBeTruthy() + + window.dispatchEvent(new DragEvent('dragend', { bubbles: true })) + await nextTick() + expect(screen.queryByTestId('marquee')).toBeNull() + }) + + it('suppresses the click that trails a drag, but not a plain click', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 })) + window.dispatchEvent(pointer('pointerup', { clientX: 80, clientY: 40 })) + + const trailing = new MouseEvent('click', { + bubbles: true, + cancelable: true + }) + window.dispatchEvent(trailing) + expect(trailing.defaultPrevented).toBe(true) + + const next = new MouseEvent('click', { bubbles: true, cancelable: true }) + window.dispatchEvent(next) + expect(next.defaultPrevented).toBe(false) + }) + + it('does not start a marquee for a non-primary mouse button', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent( + pointer('pointerdown', { clientX: 0, clientY: 0, button: 2 }) + ) + window.dispatchEvent( + pointer('pointermove', { clientX: 110, clientY: 50 }) + ) + + expect(callbacks.setSelectedIds).not.toHaveBeenCalled() + }) + + it('does not start a marquee when the press lands on an interactive control', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + screen + .getByTestId('grid-button') + .dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent( + pointer('pointermove', { clientX: 110, clientY: 50 }) + ) + + expect(callbacks.setSelectedIds).not.toHaveBeenCalled() + }) + + it('starts a marquee on a card when Cmd/Meta is held', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + card('a').dispatchEvent( + pointer('pointerdown', { clientX: 5, clientY: 5, metaKey: true }) + ) + window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 })) + + expect(callbacks.setSelectedIds).toHaveBeenCalled() + }) + + it('clears the selection when a no-modifier marquee covers no card', async () => { + const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['a']) }) + await renderHarness(callbacks) + + grid().dispatchEvent( + pointer('pointerdown', { clientX: 300, clientY: 300 }) + ) + window.dispatchEvent( + pointer('pointermove', { clientX: 400, clientY: 400 }) + ) + + expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith([], assets) + }) + + it('prevents text selection during a marquee and releases it on pointercancel', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 })) + await nextTick() + expect(screen.getByTestId('marquee')).toBeTruthy() + + const duringDrag = new Event('selectstart', { + bubbles: true, + cancelable: true + }) + grid().dispatchEvent(duringDrag) + expect(duringDrag.defaultPrevented).toBe(true) + + window.dispatchEvent( + pointer('pointercancel', { clientX: 30, clientY: 40 }) + ) + await nextTick() + expect(screen.queryByTestId('marquee')).toBeNull() + + const afterEnd = new Event('selectstart', { + bubbles: true, + cancelable: true + }) + grid().dispatchEvent(afterEnd) + expect(afterEnd.defaultPrevented).toBe(false) + }) + + it('auto-resets click suppression when a drag ends without a trailing click', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 })) + window.dispatchEvent( + pointer('pointercancel', { clientX: 80, clientY: 40 }) + ) + + await new Promise((resolve) => setTimeout(resolve)) + + const laterClick = new MouseEvent('click', { + bubbles: true, + cancelable: true + }) + window.dispatchEvent(laterClick) + expect(laterClick.defaultPrevented).toBe(false) + }) + + it('only blocks text selection inside the grid container during a marquee', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 })) + await nextTick() + + const insideGrid = new Event('selectstart', { + bubbles: true, + cancelable: true + }) + card('a').dispatchEvent(insideGrid) + expect(insideGrid.defaultPrevented).toBe(true) + + const outsideGrid = new Event('selectstart', { + bubbles: true, + cancelable: true + }) + screen.getByTestId('search').dispatchEvent(outsideGrid) + expect(outsideGrid.defaultPrevented).toBe(false) + }) + + it('stops blocking text selection after a normal marquee release', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 })) + + const duringDrag = new Event('selectstart', { + bubbles: true, + cancelable: true + }) + card('a').dispatchEvent(duringDrag) + expect(duringDrag.defaultPrevented).toBe(true) + + window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 })) + + const afterRelease = new Event('selectstart', { + bubbles: true, + cancelable: true + }) + card('a').dispatchEvent(afterRelease) + expect(afterRelease.defaultPrevented).toBe(false) + }) + + it('does not suppress the click after a sub-threshold press', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 })) + window.dispatchEvent(pointer('pointerup', { clientX: 2, clientY: 2 })) + + const click = new MouseEvent('click', { bubbles: true, cancelable: true }) + window.dispatchEvent(click) + expect(click.defaultPrevented).toBe(false) + }) + + it('does not marquee or clear selection when the container has no cards', async () => { + const setSelectedIds = vi.fn() + const Harness = defineComponent({ + setup() { + const containerRef = ref<HTMLElement>() + useAssetGridSelection({ + marqueeContainerRef: containerRef, + hoverTargetRef: containerRef, + getAssets: () => assets, + getSelectedIds: () => ['a'], + setSelectedIds, + selectAll: vi.fn() + }) + return { containerRef } + }, + template: ` + <div ref="containerRef" data-testid="list"> + <div data-testid="row">a.png</div> + </div> + ` + }) + render(Harness) + await nextTick() + + screen + .getByTestId('list') + .dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent( + pointer('pointermove', { clientX: 110, clientY: 50 }) + ) + + expect(setSelectedIds).not.toHaveBeenCalled() + }) + + it('does not start a marquee when disabled (e.g. list view)', async () => { + const setSelectedIds = vi.fn() + const Harness = defineComponent({ + setup() { + const containerRef = ref<HTMLElement>() + useAssetGridSelection({ + marqueeContainerRef: containerRef, + hoverTargetRef: containerRef, + getAssets: () => assets, + getSelectedIds: () => [], + setSelectedIds, + selectAll: vi.fn(), + isEnabled: () => false + }) + return { containerRef } + }, + template: ` + <div ref="containerRef" data-testid="disabled-grid"> + <div data-asset-id="a"></div> + <div data-asset-id="b"></div> + </div> + ` + }) + render(Harness) + await nextTick() + + screen + .getByTestId('disabled-grid') + .dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent( + pointer('pointermove', { clientX: 110, clientY: 50 }) + ) + + expect(setSelectedIds).not.toHaveBeenCalled() + }) + + it('ignores a reentrant pointer-down and does not leave text selection blocked', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + grid().dispatchEvent(pointer('pointerdown', { clientX: 5, clientY: 5 })) + window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 })) + + const selection = new Event('selectstart', { + bubbles: true, + cancelable: true + }) + grid().dispatchEvent(selection) + expect(selection.defaultPrevented).toBe(false) + }) + + it('captures the pointer once a marquee drag starts, not on press', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + const capture = vi.spyOn(grid(), 'setPointerCapture') + + card('a').dispatchEvent( + pointer('pointerdown', { clientX: 5, clientY: 5 }) + ) + expect(capture).not.toHaveBeenCalled() + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + expect(capture).not.toHaveBeenCalled() + + window.dispatchEvent( + pointer('pointermove', { clientX: 110, clientY: 50 }) + ) + expect(capture).toHaveBeenCalledWith(1) + }) + + it('does not capture the pointer on a Ctrl/Cmd-click of a card', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + const capture = vi.spyOn(grid(), 'setPointerCapture') + + card('a').dispatchEvent( + pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true }) + ) + window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 })) + + expect(capture).not.toHaveBeenCalled() + }) + + it('still tracks a marquee when setPointerCapture throws', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + vi.spyOn(grid(), 'setPointerCapture').mockImplementation(() => { + throw new Error('stale pointer id') + }) + + grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 })) + window.dispatchEvent( + pointer('pointermove', { clientX: 110, clientY: 50 }) + ) + expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith( + ['a', 'b'], + assets + ) + + window.dispatchEvent(pointer('pointerup', { clientX: 110, clientY: 50 })) + await nextTick() + expect(screen.queryByTestId('marquee')).toBeNull() + }) + }) + + describe('ctrl/cmd + A', () => { + function pressSelectAll(init: KeyboardEventInit = {}) { + const event = new KeyboardEvent('keydown', { + key: 'a', + ctrlKey: true, + bubbles: true, + cancelable: true, + ...init + }) + window.dispatchEvent(event) + return event + } + + it('selects all visible assets and blocks the event when hovered', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + panel().dispatchEvent(new MouseEvent('mouseenter')) + const event = pressSelectAll() + + expect(callbacks.selectAll).toHaveBeenCalledWith(assets) + expect(event.defaultPrevented).toBe(true) + }) + + it('selects all with the Cmd/Meta key while hovered', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + panel().dispatchEvent(new MouseEvent('mouseenter')) + pressSelectAll({ ctrlKey: false, metaKey: true }) + + expect(callbacks.selectAll).toHaveBeenCalledWith(assets) + }) + + it('does nothing when the panel is not hovered', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + pressSelectAll() + + expect(callbacks.selectAll).not.toHaveBeenCalled() + }) + + it('still selects all when hover desyncs but the pointer stays inside the panel', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue( + fromPartial<DOMRect>({ + left: 0, + top: 0, + right: 500, + bottom: 500, + width: 500, + height: 500 + }) + ) + + panel().dispatchEvent(new MouseEvent('mouseenter')) + window.dispatchEvent( + pointer('pointermove', { clientX: 100, clientY: 100 }) + ) + // The selection bar under the cursor unmounts on "deselect all", which + // latches useElementHover false while the pointer is still inside. + panel().dispatchEvent(new MouseEvent('mouseleave')) + + const event = pressSelectAll() + + expect(callbacks.selectAll).toHaveBeenCalledWith(assets) + expect(event.defaultPrevented).toBe(true) + }) + + it('does not select all when the live pointer is outside the panel rect', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue( + fromPartial<DOMRect>({ + left: 0, + top: 0, + right: 500, + bottom: 500, + width: 500, + height: 500 + }) + ) + + window.dispatchEvent( + pointer('pointermove', { clientX: 900, clientY: 900 }) + ) + panel().dispatchEvent(new MouseEvent('mouseleave')) + + const event = pressSelectAll() + + expect(callbacks.selectAll).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) + + it('ignores other keys and the unmodified A while hovered', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + panel().dispatchEvent(new MouseEvent('mouseenter')) + pressSelectAll({ ctrlKey: false }) + pressSelectAll({ key: 'b' }) + + expect(callbacks.selectAll).not.toHaveBeenCalled() + }) + + it('does not hijack select-all while typing in a field', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + panel().dispatchEvent(new MouseEvent('mouseenter')) + screen.getByTestId<HTMLInputElement>('search').focus() + pressSelectAll() + + expect(callbacks.selectAll).not.toHaveBeenCalled() + }) + + it('does not hijack select-all while focused in a textarea', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + panel().dispatchEvent(new MouseEvent('mouseenter')) + screen.getByTestId<HTMLTextAreaElement>('textarea').focus() + pressSelectAll() + + expect(callbacks.selectAll).not.toHaveBeenCalled() + }) + + it('does not hijack select-all while focused in a contenteditable element', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + panel().dispatchEvent(new MouseEvent('mouseenter')) + screen.getByTestId('editable').focus() + pressSelectAll() + + expect(callbacks.selectAll).not.toHaveBeenCalled() + }) + + it('does not hijack select-all while an aria-modal dialog is open', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + + panel().dispatchEvent(new MouseEvent('mouseenter')) + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + dialog.setAttribute('aria-modal', 'true') + document.body.appendChild(dialog) + + const event = pressSelectAll() + + expect(callbacks.selectAll).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + + dialog.remove() + }) + + it('stops the select-all keystroke from reaching other handlers when hovered', async () => { + const callbacks = createCallbacks() + await renderHarness(callbacks) + const downstream = vi.fn() + window.addEventListener('keydown', downstream) + + panel().dispatchEvent(new MouseEvent('mouseenter')) + pressSelectAll() + expect(callbacks.selectAll).toHaveBeenCalledTimes(1) + expect(downstream).not.toHaveBeenCalled() + + panel().dispatchEvent(new MouseEvent('mouseleave')) + pressSelectAll() + expect(callbacks.selectAll).toHaveBeenCalledTimes(1) + expect(downstream).toHaveBeenCalledTimes(1) + + window.removeEventListener('keydown', downstream) + }) + }) +}) diff --git a/src/platform/assets/composables/useAssetGridSelection.ts b/src/platform/assets/composables/useAssetGridSelection.ts new file mode 100644 index 0000000000..3e782fbfa0 --- /dev/null +++ b/src/platform/assets/composables/useAssetGridSelection.ts @@ -0,0 +1,250 @@ +import { useElementHover, useEventListener } from '@vueuse/core' +import type { Ref } from 'vue' +import { computed, onScopeDispose, ref } from 'vue' + +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import { + normalizeMarqueeRect, + selectMarqueeIds +} from '@/platform/assets/utils/marqueeSelectionUtil' +import { clipRectToBounds } from '@/utils/mathUtil' +import type { RectEdges } from '@/utils/mathUtil' + +const DRAG_THRESHOLD_PX = 4 +const CARD_SELECTOR = '[data-asset-id]' +const INTERACTIVE_SELECTOR = + 'button, input, textarea, select, a[href], [role="slider"], [role="tab"], [contenteditable]' + +interface AssetGridSelectionOptions { + marqueeContainerRef: Ref<HTMLElement | undefined> + hoverTargetRef: Ref<HTMLElement | undefined> + getAssets: () => AssetItem[] + getSelectedIds: () => string[] + setSelectedIds: (ids: string[], allAssets: AssetItem[]) => void + selectAll: (assets: AssetItem[]) => void + isEnabled?: () => boolean +} + +function isTextEntryTarget(element: Element | null): boolean { + return ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + (element instanceof HTMLElement && element.isContentEditable) + ) +} + +export function useAssetGridSelection(options: AssetGridSelectionOptions) { + const { + marqueeContainerRef, + hoverTargetRef, + getAssets, + getSelectedIds, + setSelectedIds, + selectAll, + isEnabled = () => true + } = options + + const marqueeRect = ref<RectEdges | null>(null) + const isHoveringPanel = useElementHover(hoverTargetRef) + + let startX = 0 + let startY = 0 + let pointerId = 0 + let baseIds: string[] = [] + let isSubtractive = false + let isTracking = false + let isDragging = false + let suppressNextClick = false + let suppressClickResetTimeout: ReturnType<typeof setTimeout> | null = null + let dragCards: { id: string; rect: DOMRect }[] = [] + let dragBounds: DOMRect | null = null + let pointerClientX = 0 + let pointerClientY = 0 + let hasPointerSample = false + + function collectCards(container: HTMLElement) { + return [...container.querySelectorAll<HTMLElement>(CARD_SELECTOR)].flatMap( + (el) => { + const id = el.dataset.assetId + return id ? [{ id, rect: el.getBoundingClientRect() }] : [] + } + ) + } + + function snapshotDrag() { + const container = marqueeContainerRef.value + if (!container) return + dragCards = collectCards(container) + dragBounds = container.getBoundingClientRect() + } + + function applyMarquee(clientX: number, clientY: number) { + if (!dragBounds) return + const rect = clipRectToBounds( + normalizeMarqueeRect( + { x: startX, y: startY }, + { x: clientX, y: clientY } + ), + dragBounds + ) + marqueeRect.value = rect + setSelectedIds( + [...selectMarqueeIds(dragCards, rect, baseIds, isSubtractive)], + getAssets() + ) + } + + function onPointerMove(e: PointerEvent) { + pointerClientX = e.clientX + pointerClientY = e.clientY + hasPointerSample = true + if (!isTracking) return + if ( + !isDragging && + Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX + ) { + return + } + if (!isDragging) { + isDragging = true + snapshotDrag() + capturePointer() + } + applyMarquee(e.clientX, e.clientY) + } + + function capturePointer() { + try { + marqueeContainerRef.value?.setPointerCapture(pointerId) + } catch { + // Stale/invalid pointerId: window listeners still end the drag. + } + } + + function endDrag() { + if (!isTracking) return + isTracking = false + marqueeRect.value = null + dragCards = [] + dragBounds = null + if (isDragging) scheduleSuppressNextClick() + isDragging = false + } + + function scheduleSuppressNextClick() { + suppressNextClick = true + clearSuppressTimer() + suppressClickResetTimeout = setTimeout(() => { + suppressNextClick = false + suppressClickResetTimeout = null + }, 0) + } + + function clearSuppressTimer() { + if (suppressClickResetTimeout !== null) { + clearTimeout(suppressClickResetTimeout) + suppressClickResetTimeout = null + } + } + + function preventDragStart(e: Event) { + if (isTracking) e.preventDefault() + } + + function preventTextSelection(e: Event) { + if (!isTracking) return + const container = marqueeContainerRef.value + if (container && e.target instanceof Node && container.contains(e.target)) { + e.preventDefault() + } + } + + function onPointerDown(e: PointerEvent) { + if (e.button !== 0) return + if (isTracking) return + if (!isEnabled()) return + suppressNextClick = false + clearSuppressTimer() + const container = marqueeContainerRef.value + if (!container) return + if (!container.querySelector(CARD_SELECTOR)) return + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest(INTERACTIVE_SELECTOR)) return + + const onCard = target.closest(CARD_SELECTOR) + if (onCard && !e.ctrlKey && !e.metaKey) return + + startX = e.clientX + startY = e.clientY + pointerId = e.pointerId + baseIds = e.shiftKey || e.ctrlKey || e.metaKey ? getSelectedIds() : [] + isSubtractive = (e.ctrlKey || e.metaKey) && e.shiftKey + isDragging = false + isTracking = true + } + + function onClickCapture(e: MouseEvent) { + if (!suppressNextClick) return + suppressNextClick = false + clearSuppressTimer() + e.stopImmediatePropagation() + e.preventDefault() + } + + // useElementHover latches stale-false when an overlay under the cursor (the + // selection bar) unmounts on "deselect all"; recheck the live pointer against + // the panel rect so Ctrl/Cmd+A still resolves against the panel it is over. + function isPointerInsidePanel(): boolean { + const el = hoverTargetRef.value + if (!el || !hasPointerSample) return false + const { left, top, right, bottom } = el.getBoundingClientRect() + return ( + pointerClientX >= left && + pointerClientX <= right && + pointerClientY >= top && + pointerClientY <= bottom + ) + } + + function onKeydown(e: KeyboardEvent) { + if (!(e.ctrlKey || e.metaKey) || (e.key !== 'a' && e.key !== 'A')) return + if ( + !(isHoveringPanel.value || isPointerInsidePanel()) || + isTextEntryTarget(document.activeElement) || + document.querySelector('[role="dialog"][aria-modal="true"]') + ) { + return + } + e.preventDefault() + e.stopImmediatePropagation() + selectAll(getAssets()) + } + + useEventListener(marqueeContainerRef, 'pointerdown', onPointerDown) + useEventListener(marqueeContainerRef, 'dragstart', preventDragStart, { + capture: true + }) + useEventListener(window, 'pointermove', onPointerMove) + useEventListener(window, ['pointerup', 'pointercancel', 'dragend'], endDrag) + useEventListener(window, 'click', onClickCapture, { capture: true }) + useEventListener(window, 'keydown', onKeydown, { capture: true }) + useEventListener(window, 'selectstart', preventTextSelection, { + capture: true + }) + + onScopeDispose(clearSuppressTimer) + + const marqueeStyle = computed(() => { + const rect = marqueeRect.value + if (!rect) return null + return { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.right - rect.left}px`, + height: `${rect.bottom - rect.top}px` + } + }) + + return { marqueeStyle } +} diff --git a/src/platform/assets/composables/useAssetSelection.test.ts b/src/platform/assets/composables/useAssetSelection.test.ts index 4ac0f09910..ab789e16f8 100644 --- a/src/platform/assets/composables/useAssetSelection.test.ts +++ b/src/platform/assets/composables/useAssetSelection.test.ts @@ -248,6 +248,36 @@ describe('useAssetSelection', () => { }) }) + describe('setSelectedIds', () => { + it('replaces selection and anchors on the last selected asset', () => { + const selection = useAssetSelection() + const store = useAssetSelectionStore() + const assets = createMockAssets(5) + + selection.setSelectedIds(['asset-1', 'asset-3'], assets) + + expect(Array.from(store.selectedAssetIds).sort()).toEqual([ + 'asset-1', + 'asset-3' + ]) + expect(store.lastSelectedIndex).toBe(3) + expect(store.lastSelectedAssetId).toBe('asset-3') + }) + + it('clears the anchor when the selection is empty', () => { + const selection = useAssetSelection() + const store = useAssetSelectionStore() + const assets = createMockAssets(3) + store.setLastSelectedIndex(2) + store.setLastSelectedAssetId('asset-2') + + selection.setSelectedIds([], assets) + + expect(store.lastSelectedIndex).toBe(-1) + expect(store.lastSelectedAssetId).toBeNull() + }) + }) + describe('clearSelection', () => { it('clears all selections', () => { const { handleAssetClick, clearSelection, selectedCount } = diff --git a/src/platform/assets/composables/useAssetSelection.ts b/src/platform/assets/composables/useAssetSelection.ts index fd2512152f..5a31642e01 100644 --- a/src/platform/assets/composables/useAssetSelection.ts +++ b/src/platform/assets/composables/useAssetSelection.ts @@ -101,6 +101,19 @@ export function useAssetSelection() { } } + /** + * Replace the selection (e.g. from a marquee) and keep the shift-range anchor + * on the last selected asset, the same way selectAll maintains it. + */ + function setSelectedIds(ids: string[], allAssets: AssetItem[]) { + selectionStore.setSelection(ids) + const selected = new Set(ids) + const anchorIndex = allAssets.findLastIndex((asset) => + selected.has(asset.id) + ) + setAnchor(anchorIndex, anchorIndex >= 0 ? allAssets[anchorIndex].id : null) + } + /** * Get the actual asset objects for selected IDs */ @@ -182,6 +195,7 @@ export function useAssetSelection() { // Selection actions handleAssetClick, selectAll, + setSelectedIds, clearSelection: () => selectionStore.clearSelection(), getSelectedAssets, reconcileSelection, diff --git a/src/platform/assets/utils/marqueeSelectionUtil.property.test.ts b/src/platform/assets/utils/marqueeSelectionUtil.property.test.ts new file mode 100644 index 0000000000..b194eed947 --- /dev/null +++ b/src/platform/assets/utils/marqueeSelectionUtil.property.test.ts @@ -0,0 +1,93 @@ +import * as fc from 'fast-check' +import { describe, expect, it } from 'vitest' + +import type { MarqueeCard } from './marqueeSelectionUtil' +import { normalizeMarqueeRect, selectMarqueeIds } from './marqueeSelectionUtil' + +const ID_POOL = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const + +const arbPoint = fc.record({ + x: fc.integer({ min: -100, max: 100 }), + y: fc.integer({ min: -100, max: 100 }) +}) + +const arbRect = fc + .tuple(arbPoint, arbPoint) + .map(([start, end]) => normalizeMarqueeRect(start, end)) + +const arbCards: fc.Arbitrary<MarqueeCard[]> = fc.uniqueArray( + fc.record({ id: fc.constantFrom(...ID_POOL), rect: arbRect }), + { selector: (card) => card.id, maxLength: ID_POOL.length } +) + +const arbBaseIds = fc.uniqueArray(fc.constantFrom(...ID_POOL), { + maxLength: ID_POOL.length +}) + +describe('marqueeSelectionUtil properties', () => { + it('additive result is the union of the base and the covered ids', () => { + fc.assert( + fc.property(arbCards, arbRect, arbBaseIds, (cards, marquee, base) => { + const covered = selectMarqueeIds(cards, marquee) + const result = selectMarqueeIds(cards, marquee, base) + expect([...result].sort()).toEqual( + [...new Set([...base, ...covered])].sort() + ) + }) + ) + }) + + it('subtractive result is the base minus the covered ids', () => { + fc.assert( + fc.property(arbCards, arbRect, arbBaseIds, (cards, marquee, base) => { + const covered = selectMarqueeIds(cards, marquee) + const result = selectMarqueeIds(cards, marquee, base, true) + expect([...result].sort()).toEqual( + base.filter((id) => !covered.has(id)).sort() + ) + }) + ) + }) + + it('subtractive mode preserves the base order of surviving ids', () => { + fc.assert( + fc.property(arbCards, arbRect, arbBaseIds, (cards, marquee, base) => { + const covered = selectMarqueeIds(cards, marquee) + const result = selectMarqueeIds(cards, marquee, base, true) + expect([...result]).toEqual(base.filter((id) => !covered.has(id))) + }) + ) + }) + + it('never mutates baseIds in either mode', () => { + fc.assert( + fc.property( + arbCards, + arbRect, + arbBaseIds, + fc.boolean(), + (cards, marquee, base, subtract) => { + const original = [...base] + selectMarqueeIds(cards, marquee, base, subtract) + expect(base).toEqual(original) + } + ) + ) + }) + + it('normalizeMarqueeRect always yields ordered edges containing both points', () => { + fc.assert( + fc.property(arbPoint, arbPoint, (start, end) => { + const rect = normalizeMarqueeRect(start, end) + expect(rect.left).toBeLessThanOrEqual(rect.right) + expect(rect.top).toBeLessThanOrEqual(rect.bottom) + for (const point of [start, end]) { + expect(point.x).toBeGreaterThanOrEqual(rect.left) + expect(point.x).toBeLessThanOrEqual(rect.right) + expect(point.y).toBeGreaterThanOrEqual(rect.top) + expect(point.y).toBeLessThanOrEqual(rect.bottom) + } + }) + ) + }) +}) diff --git a/src/platform/assets/utils/marqueeSelectionUtil.test.ts b/src/platform/assets/utils/marqueeSelectionUtil.test.ts new file mode 100644 index 0000000000..124e253064 --- /dev/null +++ b/src/platform/assets/utils/marqueeSelectionUtil.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' + +import type { RectEdges } from '@/utils/mathUtil' + +import type { MarqueeCard } from './marqueeSelectionUtil' +import { normalizeMarqueeRect, selectMarqueeIds } from './marqueeSelectionUtil' + +const box = ( + left: number, + top: number, + right: number, + bottom: number +): RectEdges => ({ + left, + top, + right, + bottom +}) + +describe('normalizeMarqueeRect', () => { + it('orders corners when dragging down-right', () => { + expect(normalizeMarqueeRect({ x: 10, y: 20 }, { x: 50, y: 80 })).toEqual( + box(10, 20, 50, 80) + ) + }) + + it('orders corners when dragging up-left', () => { + expect(normalizeMarqueeRect({ x: 50, y: 80 }, { x: 10, y: 20 })).toEqual( + box(10, 20, 50, 80) + ) + }) + + it('orders corners when dragging across axes', () => { + expect(normalizeMarqueeRect({ x: 50, y: 20 }, { x: 10, y: 80 })).toEqual( + box(10, 20, 50, 80) + ) + }) +}) + +describe('selectMarqueeIds', () => { + const cards: MarqueeCard[] = [ + { id: 'a', rect: box(0, 0, 10, 10) }, + { id: 'b', rect: box(20, 0, 30, 10) }, + { id: 'c', rect: box(40, 0, 50, 10) } + ] + + it('selects only intersecting cards when base is empty (replace)', () => { + const result = selectMarqueeIds(cards, box(15, 0, 35, 10)) + expect([...result]).toEqual(['b']) + }) + + it('unions intersecting cards with the base selection (additive)', () => { + const result = selectMarqueeIds(cards, box(35, 0, 55, 10), ['a']) + expect([...result].sort()).toEqual(['a', 'c']) + }) + + it('removes intersecting cards from the base selection (subtractive)', () => { + const result = selectMarqueeIds(cards, box(15, 0, 35, 10), ['a', 'b'], true) + expect([...result]).toEqual(['a']) + }) + + it('returns a copy of the base when nothing intersects', () => { + const base = new Set(['a']) + const result = selectMarqueeIds(cards, box(100, 100, 110, 110), base) + expect([...result]).toEqual(['a']) + expect(result).not.toBe(base) + }) + + it('does not mutate the provided base set', () => { + const base = new Set(['a']) + selectMarqueeIds(cards, box(15, 0, 35, 10), base) + expect([...base]).toEqual(['a']) + }) + + it('includes a card whose edge merely touches the marquee', () => { + const touching = [{ id: 'edge', rect: box(0, 0, 10, 10) }] + expect([...selectMarqueeIds(touching, box(10, 0, 20, 10))]).toEqual([ + 'edge' + ]) + }) + + it('includes a card that contains the marquee and vice versa', () => { + const around = [{ id: 'around', rect: box(40, 40, 60, 60) }] + expect([...selectMarqueeIds(around, box(0, 0, 100, 100))]).toEqual([ + 'around' + ]) + }) + + it('excludes a card separated on a single axis', () => { + const below = [{ id: 'below', rect: box(0, 20, 10, 30) }] + expect([...selectMarqueeIds(below, box(0, 0, 10, 10))]).toEqual([]) + }) +}) diff --git a/src/platform/assets/utils/marqueeSelectionUtil.ts b/src/platform/assets/utils/marqueeSelectionUtil.ts new file mode 100644 index 0000000000..288f7b234e --- /dev/null +++ b/src/platform/assets/utils/marqueeSelectionUtil.ts @@ -0,0 +1,48 @@ +import type { RectEdges } from '@/utils/mathUtil' + +export interface MarqueeCard { + id: string + rect: RectEdges +} + +export function normalizeMarqueeRect( + start: { x: number; y: number }, + end: { x: number; y: number } +): RectEdges { + return { + left: Math.min(start.x, end.x), + top: Math.min(start.y, end.y), + right: Math.max(start.x, end.x), + bottom: Math.max(start.y, end.y) + } +} + +function rectsIntersect(a: RectEdges, b: RectEdges): boolean { + return !( + a.right < b.left || + a.left > b.right || + a.bottom < b.top || + a.top > b.bottom + ) +} + +/** + * Resolve the asset ids a marquee covers, starting from `baseIds` (the selection + * to preserve when a modifier makes the drag additive). With `subtract`, covered + * ids are removed from `baseIds` instead of added. A fresh Set is returned; + * `baseIds` is never mutated. + */ +export function selectMarqueeIds( + cards: readonly MarqueeCard[], + marquee: RectEdges, + baseIds: Iterable<string> = [], + subtract = false +): Set<string> { + const result = new Set(baseIds) + for (const { id, rect } of cards) { + if (!rectsIntersect(rect, marquee)) continue + if (subtract) result.delete(id) + else result.add(id) + } + return result +} diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 98bbaf3268..d59802d81d 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -779,7 +779,7 @@ export const CORE_SETTINGS: SettingParams[] = [ tooltip: 'When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools.', type: 'boolean', - defaultValue: false, + defaultValue: true, versionAdded: '1.36.1' }, { diff --git a/src/utils/mathUtil.test.ts b/src/utils/mathUtil.test.ts index 3d0d6021a7..1521b46269 100644 --- a/src/utils/mathUtil.test.ts +++ b/src/utils/mathUtil.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' import { + clipRectToBounds, computeUnionBounds, denormalize, gcd, @@ -137,4 +138,29 @@ describe('mathUtil', () => { expect(result!.height).toBe(242) }) }) + + describe('clipRectToBounds', () => { + const bounds = { left: 0, top: 0, right: 100, bottom: 100 } + + it('returns the rect unchanged when fully inside the bounds', () => { + expect( + clipRectToBounds({ left: 10, top: 10, right: 40, bottom: 40 }, bounds) + ).toEqual({ left: 10, top: 10, right: 40, bottom: 40 }) + }) + + it('clamps every edge that extends past the bounds', () => { + expect( + clipRectToBounds( + { left: -20, top: -10, right: 150, bottom: 130 }, + bounds + ) + ).toEqual({ left: 0, top: 0, right: 100, bottom: 100 }) + }) + + it('clamps only the overflowing side', () => { + expect( + clipRectToBounds({ left: 10, top: 10, right: 200, bottom: 40 }, bounds) + ).toEqual({ left: 10, top: 10, right: 100, bottom: 40 }) + }) + }) }) diff --git a/src/utils/mathUtil.ts b/src/utils/mathUtil.ts index fd840abb9d..347a516dfa 100644 --- a/src/utils/mathUtil.ts +++ b/src/utils/mathUtil.ts @@ -1,6 +1,30 @@ +import { clamp } from 'es-toolkit/math' + import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' import type { Bounds } from '@/renderer/core/layout/types' +/** A rectangle's viewport edges: the DOMRect subset, so a DOMRect is directly assignable. */ +export type RectEdges = Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom'> + +/** + * Clips a rectangle to `bounds`: each edge is limited independently, so the + * rect shrinks to the overlapping region rather than being moved to fit. + * Keeps an interaction rectangle (the canvas selection box, the assets + * marquee) inside its own surface. Both the rect and the bounds use + * viewport-style edges (left/top/right/bottom), e.g. a DOMRect. + */ +export function clipRectToBounds( + rect: RectEdges, + bounds: RectEdges +): RectEdges { + return { + left: clamp(rect.left, bounds.left, bounds.right), + top: clamp(rect.top, bounds.top, bounds.bottom), + right: clamp(rect.right, bounds.left, bounds.right), + bottom: clamp(rect.bottom, bounds.top, bounds.bottom) + } +} + /** * Linearly maps a value from [min, max] to [0, 1]. * Returns 0 when min equals max to avoid division by zero. From 4341972be338e758c43916169db8852259b1a7be Mon Sep 17 00:00:00 2001 From: imick-io <153135517+imick-io@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:29:55 -0400 Subject: [PATCH 13/21] fix(website): remove opacity-80 washing out footer logo colors (#13648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The animated logo in the site footer (a webp frame sequence drawn to a `<canvas>`) renders with dull, darkened colors. The brand neon yellow `rgb(242, 255, 90)` shows up as a muddy olive `≈ rgb(198, 209, 80)`, and the gray/pastel faces are darkened and purple-tinted. ## Cause The footer `<canvas>` carried an `opacity-80` Tailwind class. At 80% opacity the browser composites the animation 20% over the dark purple footer background (`rgb(33, 25, 39)`), shifting every color. The source webp frames themselves are correct — the shift only happens at display time. ## Fix Remove `opacity-80` from the canvas in `SiteFooter.vue` so it renders the authored frame colors at full opacity. ## Verify - Pre-check (no deploy): in DevTools, select the footer `<canvas>` and untick `opacity: 0.8` — colors pop back immediately. - After change: sample a yellow cube face in the footer animation with a color picker — it should read `#F2FF5A` / `rgb(242, 255, 90)`, not `rgb(198, 209, 80)`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- apps/website/src/components/common/SiteFooter.vue | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/website/src/components/common/SiteFooter.vue b/apps/website/src/components/common/SiteFooter.vue index 4a6060b4eb..eb72376597 100644 --- a/apps/website/src/components/common/SiteFooter.vue +++ b/apps/website/src/components/common/SiteFooter.vue @@ -175,10 +175,7 @@ const contactColumn: { title: string; links: FooterLink[] } = { </div> <!-- Logo --> - <canvas - ref="canvasRef" - class="pointer-events-none size-52 opacity-80 lg:mt-28" - /> + <canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" /> </div> </footer> </template> From 6d0bbd7d7cb7fdcd2710c9a72477e9cd2b1394f9 Mon Sep 17 00:00:00 2001 From: ShihChi Huang <huge.huang+github@gmail.com> Date: Wed, 15 Jul 2026 13:08:33 -0700 Subject: [PATCH 14/21] perf: shard Chromium E2E across 16 jobs (#13650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary > [!NOTE] > Bumping to [24 shards](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13664) reduces time from 11m14s to 9m31s, but costs 15% more in GitHub Action runner time. Keep it 16 for now to be conservative and bump to 24 if it works well for a week Split Chromium E2E into 16 shards with two Playwright workers each, preserving the current per-runner worker density while doubling effective concurrency. ### 16 shards | Run | E2E time | Shards | |---|---:|---:| | [1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29370163062) | 11m38s | 16/16 pass | | [2](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29371313957) | 11m32s | 16/16 pass | | [3](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29372283949) | 10m50s | 16/16 pass | | [4](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29373163198) | 11m00s | 16/16 pass | | [5](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29374055787) | 11m12s | 16/16 pass | ### 24 shards | Run | E2E time | Shards | |---|---:|---:| | [1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29379798083) | 9m13s | 24/24 pass | | [2](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29380286857) | 9m33s | 24/24 pass | | [3](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29380761689) | 9m38s | 24/24 pass | | [4](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29381219308) | 10m02s | 24/24 pass | | [5](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29381669526) | 9m07s | 24/24 pass | ## Changes - **What**: Run Chromium as 16 shards with explicit `--workers=2`. ## Review Focus - Compare full E2E time, shard skew, runner queue time, and flakiness with the 8×2 baseline. ## Validation - YAML parse and oxfmt check - Pre-commit formatting, linting, and root typecheck - Pre-push Knip - Local CodeRabbit review is rate-limited for 24 minutes; server-side CodeRabbit remains pending. Created by Codex <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are limited to CI workflow and Playwright config; no application runtime, auth, or data paths are touched. > > **Overview** > **Chromium E2E CI** now runs as **16 parallel shards** (up from 8), with **`workers: 2` on CI** in `playwright.config.ts` so each runner keeps two Playwright workers while overall concurrency doubles. > > Reporter wiring shifts so **blob output is chosen in config** when `PLAYWRIGHT_BLOB_OUTPUT_DIR` is set (default reporter is `html` otherwise); workflow steps drop inline `--reporter=blob` from the Playwright CLI for both sharded Chromium and the other browser matrix jobs. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 3707e70611fd5ce07e1204a37e08799e9a45b8e7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: huang47 <157390+huang47@users.noreply.github.com> --- .github/workflows/ci-tests-e2e.yaml | 8 ++++---- playwright.config.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-tests-e2e.yaml b/.github/workflows/ci-tests-e2e.yaml index edd6b65a8f..f5cfd8046d 100644 --- a/.github/workflows/ci-tests-e2e.yaml +++ b/.github/workflows/ci-tests-e2e.yaml @@ -73,8 +73,8 @@ jobs: strategy: fail-fast: false matrix: - shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] - shardTotal: [8] + shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + shardTotal: [16] 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 }} --reporter=blob + run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} env: PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report COLLECT_COVERAGE: 'true' @@ -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 }} --reporter=blob + run: pnpm exec playwright test --project=${{ matrix.browser }} env: PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report diff --git a/playwright.config.ts b/playwright.config.ts index 85ea7595ff..c1221c4245 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,6 +16,7 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL } : { retries: process.env.CI ? 3 : 0, + workers: process.env.CI ? 2 : undefined, use: { trace: 'on-first-retry' } @@ -25,7 +26,7 @@ export default defineConfig({ testDir: './browser_tests', fullyParallel: true, forbidOnly: !!process.env.CI, - reporter: 'html', + reporter: process.env.PLAYWRIGHT_BLOB_OUTPUT_DIR ? 'blob' : 'html', ...maybeLocalOptions, globalSetup: './browser_tests/globalSetup.ts', From 5da3e16f332d903a728f25c7cbd9ad903f9e70fa Mon Sep 17 00:00:00 2001 From: jaeone94 <89377375+jaeone94@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:52:11 +0900 Subject: [PATCH 15/21] fix: preserve single-select values on reselection (#13608) ## Summary Prevent single-select dropdowns from clearing their value when the currently selected item is selected again. This fixes Load Image and Load Video previews switching to a missing-media error after media reselection. Linear: FE-1239 ## Changes - **What**: Keep the current single-select value, close the dropdown, and restore trigger focus when the selected item is chosen again. - **Breaking**: None. - **Dependencies**: None. ## Review Focus The behavior is implemented in the shared `FormDropdown` selection handler, so it applies consistently to all single-select dropdown consumers rather than special-casing media widgets. Multi-select toggling remains unchanged. ## Red-Green Verification - `383576446` adds the unit and Playwright regressions without the production fix. CI Unit run `29196142841` failed on the new empty-selection assertion, proving red. - `d4ae9eb2c` adds the production fix after the red result was confirmed. CI Unit run `29196531028` passed the same Vitest suite green. ## Test Plan - Unit regression verifies no selection update is emitted and the dropdown closes with focus restored. - Playwright regression reselects `example.png` in Load Image and verifies the menu closes, the value remains selected, and no load error appears. - Targeted Playwright regression passed 10 consecutive local runs with the production fix applied locally. ## Screenshots (if applicable) Before https://github.com/user-attachments/assets/c3724e16-04fc-4b88-bd92-87004db71596 After https://github.com/user-attachments/assets/c99924f1-e446-4eca-aef7-cb8df961ab22 --- .../widgets/load/uploadWidgets.spec.ts | 39 ++++++++++++++++ .../form/dropdown/FormDropdown.test.ts | 44 +++++++++++++++++++ .../components/form/dropdown/FormDropdown.vue | 10 ++++- 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts index b50865bce7..dc6d8b57db 100644 --- a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts @@ -7,6 +7,45 @@ 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') diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts index a8d3a16621..01115dc8fb 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts @@ -47,6 +47,14 @@ const MockFormDropdownMenu = { 'candidateLabel' ], template: `<div class="mock-menu" data-testid="dropdown-menu" :data-candidate-index="candidateIndex" :data-candidate-label="candidateLabel ?? ''" :data-items="JSON.stringify(items)"> + <button + v-for="(item, index) in items" + :key="item.id" + type="button" + @click="$emit('item-click', item, index)" + > + {{ item.label ?? item.name }} + </button> <button type="button" @click="$emit('search-enter')">Search enter</button> </div>` } @@ -79,6 +87,7 @@ interface MountDropdownOptions { onCleanup: (cleanupFn: () => void) => void ) => Promise<FormDropdownItem[]> multiple?: boolean | number + selected?: Set<string> searchQuery?: string onUpdateSelected?: (selected: Set<string>) => void onUpdateIsOpen?: (isOpen: boolean) => void @@ -97,6 +106,7 @@ function mountDropdown( props: { items, multiple: options.multiple, + selected: options.selected, searcher: options.searcher, searchQuery: options.searchQuery, 'onUpdate:selected': options.onUpdateSelected, @@ -256,6 +266,40 @@ describe('FormDropdown', () => { expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus() }) + it('keeps the selected item when it is clicked again in single-select mode', async () => { + const onUpdateSelected = vi.fn() + const onUpdateIsOpen = vi.fn() + const item = createItem('image', 'photo.png') + const { user } = mountDropdown([item], { + selected: new Set([item.id]), + onUpdateSelected, + onUpdateIsOpen + }) + await openDropdown(user) + + await user.click(screen.getByRole('button', { name: item.label })) + + expect(onUpdateSelected).not.toHaveBeenCalled() + expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false) + expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus() + }) + + it('replaces the selected item when a different item is clicked in single-select mode', async () => { + const onUpdateSelected = vi.fn() + const firstItem = createItem('first', 'first.png') + const secondItem = createItem('second', 'second.png') + const { user } = mountDropdown([firstItem, secondItem], { + selected: new Set([firstItem.id]), + onUpdateSelected + }) + await openDropdown(user) + + await user.click(screen.getByRole('button', { name: secondItem.label })) + + expect(onUpdateSelected).toHaveBeenCalledWith(new Set([secondItem.id])) + expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus() + }) + it('does not select when Enter is pressed with an empty search query', async () => { const onUpdateSelected = vi.fn() const { user } = mountDropdown( diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue index 2c249afc92..81136a03c7 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue @@ -265,8 +265,14 @@ function handleFileChange(event: Event) { function handleSelection(item: FormDropdownItem, index: number) { if (disabled) return + const itemIsSelected = internalIsSelected(item, index) + if (isSingleSelect.value && itemIsSelected) { + closeDropdown({ restoreFocus: true }) + return + } + const sel = selected.value - if (internalIsSelected(item, index)) { + if (itemIsSelected) { sel.delete(item.id) } else { if (sel.size < maxSelectable.value) { @@ -281,7 +287,7 @@ function handleSelection(item: FormDropdownItem, index: number) { } selected.value = new Set(sel) - if (maxSelectable.value === 1) { + if (isSingleSelect.value) { closeDropdown({ restoreFocus: true }) } } From b52f6ce764871424aec2ff89d4a98fa57f77507c Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah <mobeenabdullah@gmail.com> Date: Thu, 16 Jul 2026 05:07:49 +0500 Subject: [PATCH 16/21] feat(website): strengthen the ComfyUI entity links in JSON-LD (#13689) ## Summary Implements the genuine parts of the schema enrichment follow-up to #13480 (FE-1232): the ComfyUI `SoftwareApplication` now names its canonical page and links back to its `SoftwareSourceCode`. Several other items from the recommendations doc are deliberately **not** implemented, with reasoning below. ## Changes - **`mainEntityOfPage` on the ComfyUI application.** The WebPage already pointed at the app via `mainEntity`; the app now points back at the home page, completing the relationship. - **`isBasedOn` linking the app to its source code.** `SoftwareSourceCode` already declared `targetProduct` -> the app, but only on the home page, and the app had no reverse link. The app now references the source node, so the application and its GitHub repository resolve as one connected entity. - **Download pages now emit the source code node** (en + zh-CN). They render the application node, so without this the new `isBasedOn` reference would not resolve inside their graph. `/download` previously described the app with no link to the repo at all. - Extracted a shared `comfyUiSourceCodeId()` so the `#sourcecode` anchor has one definition, matching the `comfyUiSoftwareId()` pattern from #13480 review. - 3 unit tests: canonical page is set, `isBasedOn` round-trips to the source node's real `@id`, and third-party software gets neither. ## Review Focus **Why `mainEntityOfPage` is a plain URL, not an `@id` reference.** The app node is shared by `/` and `/download` under one site-wide `@id` (`/#software`). A per-page reference would make the same entity claim two different canonical pages, and pointing at the home page's `#webpage` id would leave an unresolved reference on `/download`, which `validate:jsonld` fails the build on. A plain URL keeps the node byte-identical everywhere it is emitted. **Coupling worth knowing about:** any page emitting `comfyUiApplicationNode` must also emit `comfyUiSourceCodeNode`, or the `isBasedOn` reference dangles. This is enforced at build time by `validate:jsonld`, so it fails loudly rather than shipping a broken graph. **Third-party software is untouched.** Packs and models get neither property, the same isolation already applied to `author`, `publisher`, `seller` and `sameAs`. They are not our application and do not share our repository. ## Not implemented, and why The recommendations doc proposed six schema changes. Four are not actionable: 1. **`Review` + `AggregateRating` (4.8 / 126, "Verified User")** - invented numbers with no real review system behind them. Google disallows self-serving ratings that are not backed by genuine on-page reviews, and this repo's own build validator fails on fabricated ratings. 2. **`brand` on the `SoftwareApplication`** - not valid. Per schema.org, `brand` applies to Organization, Person, Product and Service. `SoftwareApplication` is a `CreativeWork`, not a `Product`, so `brand` is outside its domain. The doc's own proposed code puts `brand` on a `Product` node, not on a `SoftwareApplication`, so the recommendation does not match the example it ships with. The relationship it is reaching for already exists here: the app declares `author` and `publisher` -> Comfy Org, the free Offer declares `seller` -> Comfy Org, and our actual `Product` (Comfy Cloud, on `/cloud/pricing`) already carries `brand` -> Comfy Org. 3. **`seller` on the Offer** - already shipped in #13480. Live on the free Offer and on all three pricing tiers. 4. **Strengthening the Organization (logo, `sameAs`)** - already shipped, and the proposed version is a downgrade: it uses `favicon.ico` in place of our 512x512 PNG, and its `sameAs` points at the Wikipedia article for "Comfort" (the feeling). The accompanying `about`/`mentions` entities map our title words to unrelated pages (Canvas -> Canvas Networks, Scratch -> Abrasion). We already link the correct Wikidata entities, the ComfyUI Wikipedia article and G2. The document's larger "Watch Page" checklist targets a video watch/listing page that does not exist on this site, and is mostly content work rather than schema. ## Scope note This is entity-graph hygiene, not a ranking change. `mainEntityOfPage` and `SoftwareSourceCode` do not produce rich results. The value is that machines can resolve that Comfy Org publishes ComfyUI, an open-source application whose code lives on GitHub. ## Verification `test:unit` 199 passed, `astro check` 0 errors, `knip` clean, `build` 498 pages, **`validate:jsonld` passed across 501 pages** (confirms no unresolved `@id` on any page), and e2e for the download, cloud-nodes and affiliates specs (the JSON-LD asserting ones) 38 passed. No visual change: this is `<head>` structured data only, so there is nothing to screenshot. Ref: FE-1232 --- apps/website/src/pages/download.astro | 3 ++- apps/website/src/pages/zh-CN/download.astro | 3 ++- apps/website/src/utils/jsonLd.test.ts | 25 +++++++++++++++++++++ apps/website/src/utils/jsonLd.ts | 14 ++++++++++-- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/website/src/pages/download.astro b/apps/website/src/pages/download.astro index 7f633803b8..d7e3e2de58 100644 --- a/apps/website/src/pages/download.astro +++ b/apps/website/src/pages/download.astro @@ -12,6 +12,7 @@ import { absoluteUrl, comfyUiApplicationNode, comfyUiSoftwareId, + comfyUiSourceCodeNode, pageContext, } from '../utils/jsonLd' @@ -30,7 +31,7 @@ const { siteUrl, locale } = pageContext( { name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') }, { name: t('breadcrumb.download', locale) }, ]} - extraJsonLd={[comfyUiApplicationNode(siteUrl)]} + extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]} keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']} > <CloudBannerSection /> diff --git a/apps/website/src/pages/zh-CN/download.astro b/apps/website/src/pages/zh-CN/download.astro index b226c8cbfb..8499f48d54 100644 --- a/apps/website/src/pages/zh-CN/download.astro +++ b/apps/website/src/pages/zh-CN/download.astro @@ -12,6 +12,7 @@ import { absoluteUrl, comfyUiApplicationNode, comfyUiSoftwareId, + comfyUiSourceCodeNode, pageContext, } from '../../utils/jsonLd' @@ -33,7 +34,7 @@ const { siteUrl, locale } = pageContext( }, { name: t('breadcrumb.download', locale) }, ]} - extraJsonLd={[comfyUiApplicationNode(siteUrl)]} + extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]} keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']} > <CloudBannerSection locale="zh-CN" /> diff --git a/apps/website/src/utils/jsonLd.test.ts b/apps/website/src/utils/jsonLd.test.ts index 33d64a96c3..7b7dbed6e8 100644 --- a/apps/website/src/utils/jsonLd.test.ts +++ b/apps/website/src/utils/jsonLd.test.ts @@ -171,6 +171,31 @@ 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( diff --git a/apps/website/src/utils/jsonLd.ts b/apps/website/src/utils/jsonLd.ts index 10f5c7734a..0857957208 100644 --- a/apps/website/src/utils/jsonLd.ts +++ b/apps/website/src/utils/jsonLd.ts @@ -194,6 +194,8 @@ export interface SoftwareAppInput { authorName?: string isFree?: boolean sameAs?: string[] + mainEntityOfPage?: string + isBasedOnId?: string } export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode { @@ -219,6 +221,8 @@ 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', @@ -257,6 +261,10 @@ 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, @@ -267,14 +275,16 @@ export function comfyUiApplicationNode(siteUrl: string): JsonLdNode { applicationCategory: 'MultimediaApplication', operatingSystem: 'Windows, macOS, Linux', isFree: true, - sameAs: comfyUiSameAs + sameAs: comfyUiSameAs, + mainEntityOfPage: `${siteUrl}/`, + isBasedOnId: comfyUiSourceCodeId(siteUrl) }) } export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode { return softwareSourceCodeNode({ siteUrl, - id: `${siteUrl}/#sourcecode`, + id: comfyUiSourceCodeId(siteUrl), name: 'ComfyUI', codeRepository: externalLinks.github, programmingLanguage: 'Python', From 86bccf8d4c4420cecec7f26e5abcde5e19d6395c Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah <mobeenabdullah@gmail.com> Date: Thu, 16 Jul 2026 05:07:59 +0500 Subject: [PATCH 17/21] feat(website): add brand portal page at /brand (#13484) ## Summary Add the Brand Portal page at `/brand` (and `/zh-CN/brand`), implementing the Figma design in English and Simplified Chinese. ## Changes - **What**: New marketing page with hero, logo system, color palette, voice guidelines, trademark, and questions sections. Reuses `BaseLayout`, `Button`, and `SectionHeader`; adds `data/brandColors.ts` (palette with hex/rgb/hsl/cmyk) and `templates/brand/` sections. The hero "View brand guidelines" downloads the guidelines PDF; both "Download logos" actions download the brand-assets zip. Route registered in `config/routes.ts`; copy in `i18n/translations.ts` (en + zh-CN). - **Dependencies**: none ## Review Focus - Typography, spacing, and palette values were matched to the Figma per section; the Chinese is a first-pass translation. - The guidelines PDF and logo zip are served from `media.comfy.org` with `Content-Disposition: attachment` so the buttons download rather than open inline. - Contact links point to `/contact` in the same tab (the Figma prototype annotation used a new tab). Implements FE-1217. ## Screenshots **Desktop** <img width="1440" height="3648" alt="brand-portal-desktop" src="https://github.com/user-attachments/assets/050855b2-dd2a-4616-b5c8-c2c79b049df8" /> **Mobile** <img width="390" height="6342" alt="brand-portal-mobile" src="https://github.com/user-attachments/assets/85f3ca97-655e-4fcc-987d-976faeae2efa" /> ## Test plan - [x] `pnpm --filter @comfyorg/website typecheck` - [x] `pnpm lint` - [x] `pnpm format` - [x] `e2e/brand.spec.ts` (sections render; logo bundle and guidelines-PDF download links) - [x] Verified responsive at 1440 / 834 / 390 --- apps/website/e2e/brand.spec.ts | 51 ++++++ apps/website/public/icons/comfyicon.svg | 4 + .../src/components/common/SiteFooter.vue | 1 + .../website/src/components/ui/button/index.ts | 2 + apps/website/src/config/routes.ts | 3 +- apps/website/src/data/affiliateBrandAssets.ts | 5 +- apps/website/src/data/brandAssets.ts | 9 + apps/website/src/data/brandColors.ts | 91 ++++++++++ apps/website/src/i18n/translations.ts | 156 ++++++++++++++++++ apps/website/src/pages/brand.astro | 28 ++++ apps/website/src/pages/zh-CN/brand.astro | 26 +++ .../src/templates/brand/BrandBackground.vue | 112 +++++++++++++ .../src/templates/brand/BrandColorSection.vue | 93 +++++++++++ .../src/templates/brand/BrandHeroSection.vue | 55 ++++++ .../src/templates/brand/BrandLogosSection.vue | 58 +++++++ .../templates/brand/BrandQuestionsSection.vue | 33 ++++ .../templates/brand/BrandTrademarkSection.vue | 37 +++++ .../src/templates/brand/BrandVoiceSection.vue | 100 +++++++++++ 18 files changed, 860 insertions(+), 4 deletions(-) create mode 100644 apps/website/e2e/brand.spec.ts create mode 100644 apps/website/public/icons/comfyicon.svg create mode 100644 apps/website/src/data/brandAssets.ts create mode 100644 apps/website/src/data/brandColors.ts create mode 100644 apps/website/src/pages/brand.astro create mode 100644 apps/website/src/pages/zh-CN/brand.astro create mode 100644 apps/website/src/templates/brand/BrandBackground.vue create mode 100644 apps/website/src/templates/brand/BrandColorSection.vue create mode 100644 apps/website/src/templates/brand/BrandHeroSection.vue create mode 100644 apps/website/src/templates/brand/BrandLogosSection.vue create mode 100644 apps/website/src/templates/brand/BrandQuestionsSection.vue create mode 100644 apps/website/src/templates/brand/BrandTrademarkSection.vue create mode 100644 apps/website/src/templates/brand/BrandVoiceSection.vue diff --git a/apps/website/e2e/brand.spec.ts b/apps/website/e2e/brand.spec.ts new file mode 100644 index 0000000000..ca831a9071 --- /dev/null +++ b/apps/website/e2e/brand.spec.ts @@ -0,0 +1,51 @@ +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) + }) +}) diff --git a/apps/website/public/icons/comfyicon.svg b/apps/website/public/icons/comfyicon.svg new file mode 100644 index 0000000000..a6d3b8dc8c --- /dev/null +++ b/apps/website/public/icons/comfyicon.svg @@ -0,0 +1,4 @@ +<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> diff --git a/apps/website/src/components/common/SiteFooter.vue b/apps/website/src/components/common/SiteFooter.vue index eb72376597..7d04879fbd 100644 --- a/apps/website/src/components/common/SiteFooter.vue +++ b/apps/website/src/components/common/SiteFooter.vue @@ -85,6 +85,7 @@ 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 } diff --git a/apps/website/src/components/ui/button/index.ts b/apps/website/src/components/ui/button/index.ts index 393a9a6c86..c2391bbe16 100644 --- a/apps/website/src/components/ui/button/index.ts +++ b/apps/website/src/components/ui/button/index.ts @@ -20,6 +20,8 @@ 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' diff --git a/apps/website/src/config/routes.ts b/apps/website/src/config/routes.ts index f36a2e02cf..5d54dfd4af 100644 --- a/apps/website/src/config/routes.ts +++ b/apps/website/src/config/routes.ts @@ -21,7 +21,8 @@ const baseRoutes = { affiliateTerms: '/affiliates/terms', contact: '/contact', models: '/p/supported-models', - mcp: '/mcp' + mcp: '/mcp', + brand: '/brand' } as const type Routes = typeof baseRoutes diff --git a/apps/website/src/data/affiliateBrandAssets.ts b/apps/website/src/data/affiliateBrandAssets.ts index a5d573a8e6..6aba6d5547 100644 --- a/apps/website/src/data/affiliateBrandAssets.ts +++ b/apps/website/src/data/affiliateBrandAssets.ts @@ -1,5 +1,7 @@ import type { LocalizedText } from '../i18n/translations' +import { BRAND_ASSETS_ZIP } from './brandAssets' + interface AffiliateBrandAsset { id: string title: LocalizedText @@ -7,9 +9,6 @@ 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', diff --git a/apps/website/src/data/brandAssets.ts b/apps/website/src/data/brandAssets.ts new file mode 100644 index 0000000000..40e7d636ab --- /dev/null +++ b/apps/website/src/data/brandAssets.ts @@ -0,0 +1,9 @@ +// 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' diff --git a/apps/website/src/data/brandColors.ts b/apps/website/src/data/brandColors.ts new file mode 100644 index 0000000000..1cc1f9af70 --- /dev/null +++ b/apps/website/src/data/brandColors.ts @@ -0,0 +1,91 @@ +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 diff --git a/apps/website/src/i18n/translations.ts b/apps/website/src/i18n/translations.ts index 3996efaecf..7c1bf00135 100644 --- a/apps/website/src/i18n/translations.ts +++ b/apps/website/src/i18n/translations.ts @@ -2181,6 +2181,7 @@ 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': '下载桌面版' }, @@ -4446,6 +4447,161 @@ 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>> diff --git a/apps/website/src/pages/brand.astro b/apps/website/src/pages/brand.astro new file mode 100644 index 0000000000..d11af023d4 --- /dev/null +++ b/apps/website/src/pages/brand.astro @@ -0,0 +1,28 @@ +--- +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> diff --git a/apps/website/src/pages/zh-CN/brand.astro b/apps/website/src/pages/zh-CN/brand.astro new file mode 100644 index 0000000000..5163c44bcc --- /dev/null +++ b/apps/website/src/pages/zh-CN/brand.astro @@ -0,0 +1,26 @@ +--- +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> diff --git a/apps/website/src/templates/brand/BrandBackground.vue b/apps/website/src/templates/brand/BrandBackground.vue new file mode 100644 index 0000000000..94b63a162b --- /dev/null +++ b/apps/website/src/templates/brand/BrandBackground.vue @@ -0,0 +1,112 @@ +<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> diff --git a/apps/website/src/templates/brand/BrandColorSection.vue b/apps/website/src/templates/brand/BrandColorSection.vue new file mode 100644 index 0000000000..c74a4f8408 --- /dev/null +++ b/apps/website/src/templates/brand/BrandColorSection.vue @@ -0,0 +1,93 @@ +<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> diff --git a/apps/website/src/templates/brand/BrandHeroSection.vue b/apps/website/src/templates/brand/BrandHeroSection.vue new file mode 100644 index 0000000000..acd981552a --- /dev/null +++ b/apps/website/src/templates/brand/BrandHeroSection.vue @@ -0,0 +1,55 @@ +<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> diff --git a/apps/website/src/templates/brand/BrandLogosSection.vue b/apps/website/src/templates/brand/BrandLogosSection.vue new file mode 100644 index 0000000000..066b893da8 --- /dev/null +++ b/apps/website/src/templates/brand/BrandLogosSection.vue @@ -0,0 +1,58 @@ +<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> diff --git a/apps/website/src/templates/brand/BrandQuestionsSection.vue b/apps/website/src/templates/brand/BrandQuestionsSection.vue new file mode 100644 index 0000000000..4ab2ed1eb4 --- /dev/null +++ b/apps/website/src/templates/brand/BrandQuestionsSection.vue @@ -0,0 +1,33 @@ +<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> diff --git a/apps/website/src/templates/brand/BrandTrademarkSection.vue b/apps/website/src/templates/brand/BrandTrademarkSection.vue new file mode 100644 index 0000000000..ea6bbfbd94 --- /dev/null +++ b/apps/website/src/templates/brand/BrandTrademarkSection.vue @@ -0,0 +1,37 @@ +<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> diff --git a/apps/website/src/templates/brand/BrandVoiceSection.vue b/apps/website/src/templates/brand/BrandVoiceSection.vue new file mode 100644 index 0000000000..017b1e1517 --- /dev/null +++ b/apps/website/src/templates/brand/BrandVoiceSection.vue @@ -0,0 +1,100 @@ +<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> From 6ac9b653bbe049a13edbfad56c89c2de0df75b27 Mon Sep 17 00:00:00 2001 From: Deep Mehta <42841935+deepme987@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:18:19 -0700 Subject: [PATCH 18/21] feat(website): six-card MCP capabilities and nine-item FAQ (stacked) (#13694) ## Summary Stacked on #13693. Ports the capabilities six-card expansion and the nine-item FAQ rewrite from #13649 (imick-io) onto the reworked page, keeping the two-option setup module from the base branch as the single setup implementation. ## Changes - **What**: - Capabilities section grows from three to six cards (Direct any model, Generate in batches, Ship it as an app) with real media wired; `FeatureRows01` and `VideoPlayer` gain a `fit: cover | contain` option and a fixed-ratio media column. - FAQ rewritten to nine items, reordered so setup facts come first; `FAQSplit01` autolinks URLs in answers (regex includes coderabbit's character-class fix from the source PR). - Quality pass on top of the port: FAQ links get the same branded `focus-visible` rings as the setup section, "download the assets to local" copy polished, and two new e2e tests cover the six cards and the nine-item FAQ with its autolinked server URL. - Deliberately NOT ported from #13649: its `SetupSection`/`FeatureGrid01` Step 2 changes and the `cloudMcpServer` route entry. Those solve the buried-URL problem on the old three-step layout; the base branch's two-option redesign already covers it via `externalLinks.mcpEndpoint`. ## Review Focus - Translation splice: `mcp.tools.*` and `mcp.faq.*` blocks taken verbatim from #13649's branch (en + zh-CN); base branch's hero/setup keys untouched. - All 7 e2e tests pass against the production build; tools and FAQ sections verified rendering with live media. Linear: [PM-132](https://linear.app/comfyorg/issue/PM-132/comfyorgmcp-faq-is-stale-and-contradicts-the-page-rewrite-faq-fix) Source: #13649 ## Screenshots (if applicable) --------- Co-authored-by: Michael B <michael@imick.io> --- apps/website/e2e/mcp.spec.ts | 112 ++++++++ .../src/components/blocks/FAQSplit01.vue | 48 +++- .../src/components/blocks/FeatureGrid01.vue | 120 --------- .../src/components/blocks/FeatureRows01.vue | 23 +- .../src/components/common/VideoPlayer.vue | 18 +- apps/website/src/config/routes.ts | 1 + apps/website/src/i18n/translations.ts | 239 ++++++++++++------ .../src/templates/mcp/ComfyMcpDemo.vue | 17 +- apps/website/src/templates/mcp/FAQSection.vue | 2 +- .../website/src/templates/mcp/HeroSection.vue | 3 +- .../src/templates/mcp/HowItWorksSection.vue | 2 +- .../src/templates/mcp/SetupSection.vue | 205 +++++++++++---- .../src/templates/mcp/ToolsSection.vue | 40 ++- apps/website/src/templates/mcp/ctas.ts | 13 +- 14 files changed, 569 insertions(+), 274 deletions(-) create mode 100644 apps/website/e2e/mcp.spec.ts delete mode 100644 apps/website/src/components/blocks/FeatureGrid01.vue diff --git a/apps/website/e2e/mcp.spec.ts b/apps/website/e2e/mcp.spec.ts new file mode 100644 index 0000000000..e1ce31b0cc --- /dev/null +++ b/apps/website/e2e/mcp.spec.ts @@ -0,0 +1,112 @@ +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() + }) +}) diff --git a/apps/website/src/components/blocks/FAQSplit01.vue b/apps/website/src/components/blocks/FAQSplit01.vue index 6cc804df82..432dc0d08b 100644 --- a/apps/website/src/components/blocks/FAQSplit01.vue +++ b/apps/website/src/components/blocks/FAQSplit01.vue @@ -1,6 +1,6 @@ <script setup lang="ts"> import { cn } from '@comfyorg/tailwind-utils' -import { reactive, watch } from 'vue' +import { computed, reactive, watch } from 'vue' type Faq = { id: string; question: string; answer: string } @@ -9,6 +9,31 @@ 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( @@ -40,7 +65,7 @@ function toggle(index: number) { <!-- Right FAQ list --> <div class="flex-1"> <div - v-for="(faq, index) in faqs" + v-for="(faq, index) in parsedFaqs" :key="faq.id" class="border-b border-primary-comfy-canvas/20" > @@ -83,8 +108,23 @@ function toggle(index: number) { :aria-labelledby="`faq-trigger-${faq.id}`" class="pb-6" > - <p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70"> - {{ faq.answer }} + <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> </section> </div> diff --git a/apps/website/src/components/blocks/FeatureGrid01.vue b/apps/website/src/components/blocks/FeatureGrid01.vue deleted file mode 100644 index 8e1d83c97e..0000000000 --- a/apps/website/src/components/blocks/FeatureGrid01.vue +++ /dev/null @@ -1,120 +0,0 @@ -<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> diff --git a/apps/website/src/components/blocks/FeatureRows01.vue b/apps/website/src/components/blocks/FeatureRows01.vue index c4637cf2f5..246bc08821 100644 --- a/apps/website/src/components/blocks/FeatureRows01.vue +++ b/apps/website/src/components/blocks/FeatureRows01.vue @@ -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 } + | { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' } | { type: 'video' src: string @@ -20,6 +20,7 @@ type RowMedia = loop?: boolean minimal?: boolean hideControls?: boolean + fit?: 'cover' | 'contain' } export interface FeatureRow { @@ -58,7 +59,7 @@ const { <div :class=" cn( - 'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12', + 'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12', i % 2 === 0 ? 'lg:order-1' : 'lg:order-2' ) " @@ -72,10 +73,11 @@ const { </div> <!-- Media: image or video --> + <!-- 620/364 and w-155 (620px) match the card media asset dimensions --> <div :class=" cn( - 'order-1 flex lg:w-1/2', + 'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0', i % 2 === 0 ? 'lg:order-2' : 'lg:order-1' ) " @@ -86,7 +88,12 @@ const { :alt="row.media.alt ?? row.title" loading="lazy" decoding="async" - class="aspect-4/3 w-full rounded-4xl object-cover" + :class=" + cn( + 'absolute inset-0 size-full rounded-4xl', + row.media.fit === 'contain' ? 'object-contain' : 'object-cover' + ) + " /> <VideoPlayer v-else @@ -99,7 +106,13 @@ const { :loop="row.media.loop" :minimal="row.media.minimal" :hide-controls="row.media.hideControls" - class="w-full" + :fit="row.media.fit" + :class=" + cn( + 'absolute inset-0 size-full', + row.media.fit === 'contain' && 'bg-transparent' + ) + " /> </div> </GlassCard> diff --git a/apps/website/src/components/common/VideoPlayer.vue b/apps/website/src/components/common/VideoPlayer.vue index a28296068b..b9eccef0ea 100644 --- a/apps/website/src/components/common/VideoPlayer.vue +++ b/apps/website/src/components/common/VideoPlayer.vue @@ -10,6 +10,7 @@ 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' @@ -30,7 +31,9 @@ const { autoplay = false, loop = false, minimal = false, - hideControls = false + hideControls = false, + fit = 'cover', + class: className } = defineProps<{ locale?: Locale src?: string @@ -40,6 +43,8 @@ const { loop?: boolean minimal?: boolean hideControls?: boolean + fit?: 'cover' | 'contain' + class?: HTMLAttributes['class'] }>() const playerEl = useTemplateRef<HTMLDivElement>('playerEl') @@ -189,7 +194,12 @@ function toggleFullscreen() { <template> <div ref="playerEl" - class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black" + :class=" + cn( + 'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black', + className + ) + " @pointermove="showControls" @pointerdown="showControls" @focusin="showControls" @@ -197,7 +207,9 @@ function toggleFullscreen() { <video v-if="src" ref="videoEl" - class="size-full object-cover" + :class=" + cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover') + " :src :poster :preload="autoplay ? 'auto' : 'metadata'" diff --git a/apps/website/src/config/routes.ts b/apps/website/src/config/routes.ts index 5d54dfd4af..3992097487 100644 --- a/apps/website/src/config/routes.ts +++ b/apps/website/src/config/routes.ts @@ -88,6 +88,7 @@ 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', diff --git a/apps/website/src/i18n/translations.ts b/apps/website/src/i18n/translations.ts index 7c1bf00135..22f1124990 100644 --- a/apps/website/src/i18n/translations.ts +++ b/apps/website/src/i18n/translations.ts @@ -1864,10 +1864,26 @@ const translations = { 'zh-CN': 'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。' }, - 'mcp.hero.demoPrompt': { + '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': { 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': '查看文档' @@ -1876,10 +1892,6 @@ const translations = { en: 'INSTALL MCP', 'zh-CN': '安装 MCP' }, - 'mcp.hero.runWorkflow': { - en: 'RUN A WORKFLOW', - 'zh-CN': '运行工作流' - }, 'mcp.hero.demoGenerate': { en: 'GENERATE', 'zh-CN': '生成' @@ -1897,60 +1909,90 @@ const translations = { 'zh-CN': '放大图像' }, - // MCP – SetupStepsSection + // MCP – SetupSection 'mcp.setup.label': { en: 'GET STARTED', 'zh-CN': '快速开始' }, 'mcp.setup.heading': { - en: 'Set up Comfy MCP in three steps', - 'zh-CN': '三步完成 Comfy MCP 配置' + en: 'Set up Comfy MCP', + 'zh-CN': '配置 Comfy MCP' }, 'mcp.setup.subtitle': { - 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.', + 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.', 'zh-CN': - '将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次,ComfyUI 全套工具即可直接在对话中使用。' + '两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次,ComfyUI 全套工具即可直接在对话中使用。' }, - 'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' }, - 'mcp.setup.step1.title': { + 'mcp.setup.option1.label': { en: 'OPTION 1', 'zh-CN': '方式一' }, + 'mcp.setup.option1.title': { en: 'Ask your agent to install Comfy MCP', 'zh-CN': '让你的智能体安装 Comfy MCP' }, - 'mcp.setup.step1.command': { + 'mcp.setup.option1.command': { en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}', 'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。' }, - 'mcp.setup.step1.description': { + 'mcp.setup.option1.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.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' }, - 'mcp.setup.step2.title': { - en: 'Or add it by hand', - 'zh-CN': '或手动添加' + 'mcp.setup.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' }, + 'mcp.setup.option2.title': { + en: 'Install manually', + 'zh-CN': '手动安装' }, - 'mcp.setup.step2.description': { - en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.', + '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.', 'zh-CN': - '想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。' + '想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。' }, - 'mcp.setup.step2.cta': { - en: 'COMFY CLOUD MCP DOCS', - 'zh-CN': 'COMFY CLOUD MCP 文档' + 'mcp.setup.option2.tabsLabel': { + en: 'Pick your client', + 'zh-CN': '选择你的客户端' }, - 'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' }, - 'mcp.setup.step3.title': { - en: 'Connect and sign in', - 'zh-CN': '连接并登录' + '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.description': { - en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.', - 'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。' + '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.cta': { - en: 'COMFY CLOUD SKILLS', - '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 – WhyBuildSection @@ -1971,9 +2013,9 @@ const translations = { 'zh-CN': '开放协议,\n任意客户端。' }, 'mcp.why.1.description': { - 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.', + 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.', 'zh-CN': - 'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop,更多客户端即将推出。' + 'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。Claude Code、Claude Desktop 和 Codex 通过 OAuth 登录,其他智能体使用 API 密钥连接。' }, 'mcp.why.2.title': { en: 'The full engine,\nnot a sandbox.', @@ -2037,14 +2079,53 @@ const translations = { 'zh-CN': '运行真实工作流' }, 'mcp.tools.3.description': { - en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.', + 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.', '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': { @@ -2091,71 +2172,81 @@ const translations = { 'zh-CN': '支持哪些客户端?' }, 'mcp.faq.1.a': { - en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.', + 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.", 'zh-CN': - '目前支持 Claude Code 和 Claude Desktop,均通过 OAuth 登录。更多客户端的支持即将推出。' + '对于 Claude Code、Claude Desktop 或 Codex,在任意客户端中将 https://cloud.comfy.org/mcp 添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。\n对于不支持 OAuth 的客户端,请使用 Comfy API 密钥连接。将文档 https://docs.comfy.org/agent-tools/cloud 发送给你的智能体,它会为你完成安装。' }, '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.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 不需要,它们使用 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.', + 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.', 'zh-CN': - '不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。' + 'Claude Code、Claude Desktop 和 Codex 不需要。Cursor、Hermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud,你的智能体就会为你完成安装。' }, 'mcp.faq.4.q': { - en: "The sign-in didn't open a browser.", - 'zh-CN': '登录时没有打开浏览器。' + en: 'Does it cost anything?', + 'zh-CN': '需要付费吗?' }, 'mcp.faq.4.a': { - en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.', + 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.", 'zh-CN': - '在 Claude Code 中,运行 /mcp,选择 comfy-cloud,然后选择 Authenticate(授权)。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。' + '使用 Comfy 账户连接是免费的,搜索模型、节点和模板也不消耗积分。运行生成会使用 Comfy Cloud 积分,需要订阅或积分余额。智能体在消费前会先与你确认。' }, 'mcp.faq.5.q': { - en: 'How do I connect in Claude Code?', - 'zh-CN': '如何在 Claude Code 中连接?' + en: 'Can I use it with my local ComfyUI?', + 'zh-CN': '可以配合我的本地 ComfyUI 使用吗?' }, 'mcp.faq.5.a': { - 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.', + en: 'Coming soon. Today, to drive a local ComfyUI, you can use comfy-cli: https://github.com/Comfy-Org/comfy-cli', 'zh-CN': - '添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate(授权)。一步即可添加连接和斜杠命令。' + '即将推出。目前,若要操作本地 ComfyUI,你可以使用 comfy-cli:https://github.com/Comfy-Org/comfy-cli' }, '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.7.a': { - en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.', + '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;搜索模型、节点和模板;并运行 ComfyUI 工作流——全部在对话中完成。' + '• 生成图像、视频、音频和 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.', + 'zh-CN': + '保存到你的 Comfy Cloud 资产库,你可以复用、二次创作和分享——还能在画布上打开任意运行继续编辑。你也可以让智能体把资产下载到本地。' }, '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.8.a': { - en: 'Comfy Cloud MCP is in open beta and available to everyone.', - 'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。' + '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 账户的人都可以使用。' }, // SiteNav diff --git a/apps/website/src/templates/mcp/ComfyMcpDemo.vue b/apps/website/src/templates/mcp/ComfyMcpDemo.vue index d6d76c4d42..675c25bd65 100644 --- a/apps/website/src/templates/mcp/ComfyMcpDemo.vue +++ b/apps/website/src/templates/mcp/ComfyMcpDemo.vue @@ -7,35 +7,40 @@ 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, @@ -65,15 +70,15 @@ function schedule(fn: () => void, ms: number) { }, ms) } -function typePrompt(onDone: () => void) { +function typePrompt(prompt: string, 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 @@ -94,8 +99,8 @@ function revealNextCard() { return } - // Type the prompt, then slide in the next card - typePrompt(() => { + // Type the next card's prompt, then slide that card in + typePrompt(t(cards[visibleCount.value].promptKey, locale), () => { visibleCount.value++ schedule(revealNextCard, 400) }) diff --git a/apps/website/src/templates/mcp/FAQSection.vue b/apps/website/src/templates/mcp/FAQSection.vue index 673dac106f..8aec525c8a 100644 --- a/apps/website/src/templates/mcp/FAQSection.vue +++ b/apps/website/src/templates/mcp/FAQSection.vue @@ -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] as const +const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const const faqs = faqNumbers.map((n) => ({ id: String(n), diff --git a/apps/website/src/templates/mcp/HeroSection.vue b/apps/website/src/templates/mcp/HeroSection.vue index 3048126ce9..2328fa36cd 100644 --- a/apps/website/src/templates/mcp/HeroSection.vue +++ b/apps/website/src/templates/mcp/HeroSection.vue @@ -11,9 +11,10 @@ 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-screen" + class="min-h-[calc(100svh-5rem)] lg:min-h-[calc(100svh-6.75rem)]" badge-text="MCP" :title="t('mcp.hero.heading', locale)" :subtitle="t('mcp.hero.subtitle', locale)" diff --git a/apps/website/src/templates/mcp/HowItWorksSection.vue b/apps/website/src/templates/mcp/HowItWorksSection.vue index e299f3901d..defbd45b7e 100644 --- a/apps/website/src/templates/mcp/HowItWorksSection.vue +++ b/apps/website/src/templates/mcp/HowItWorksSection.vue @@ -23,7 +23,7 @@ const steps: FeatureStep[] = stepNumbers.map((n) => ({ <FeatureGrid02 :heading="t('mcp.howItWorks.heading', locale)" :steps="steps" - :primary-cta="ctas.runWorkflow" + :primary-cta="ctas.installMcp" :secondary-cta="ctas.docs" /> </template> diff --git a/apps/website/src/templates/mcp/SetupSection.vue b/apps/website/src/templates/mcp/SetupSection.vue index de879581a3..ce5052a57e 100644 --- a/apps/website/src/templates/mcp/SetupSection.vue +++ b/apps/website/src/templates/mcp/SetupSection.vue @@ -1,69 +1,180 @@ <script setup lang="ts"> -import { ArrowUpRight } from '@lucide/vue' +import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui' -import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue' -import type { FeatureCard } from '../../components/blocks/FeatureGrid01.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 { externalLinks } from '../../config/routes' import type { Locale } from '../../i18n/translations' import { t } from '../../i18n/translations' const { locale = 'en' } = defineProps<{ locale?: Locale }>() -const cards: FeatureCard[] = [ +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[] = [ { - 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: '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: '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: 'codex', + name: 'Codex', + step: t('mcp.setup.clients.codex.step', locale), + command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}` }, { - 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' + 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 } } ] + +const copyLabel = t('ui.copy', locale) +const copiedLabel = t('ui.copied', locale) </script> <template> - <FeatureGrid01 + <section id="setup" - 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)" - /> + 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> </template> diff --git a/apps/website/src/templates/mcp/ToolsSection.vue b/apps/website/src/templates/mcp/ToolsSection.vue index 9ab68a5973..c85863b3d6 100644 --- a/apps/website/src/templates/mcp/ToolsSection.vue +++ b/apps/website/src/templates/mcp/ToolsSection.vue @@ -7,16 +7,21 @@ import { t } from '../../i18n/translations' const { locale = 'en' } = defineProps<{ locale?: Locale }>() type ToolMedia = - | { type: 'image'; src: string } + | { type: 'image'; src: string; fit?: 'cover' | 'contain' } | { type: 'video' src: string autoplay?: boolean loop?: boolean hideControls?: boolean + fit?: 'cover' | 'contain' } -const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [ +const tools: { + n: 1 | 2 | 3 | 4 | 5 | 6 + media: ToolMedia + altKey?: TranslationKey +}[] = [ { n: 1, media: { @@ -40,9 +45,38 @@ const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [ src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4', autoplay: true, loop: true, - hideControls: true + hideControls: true, + fit: 'contain' }, 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' } ] diff --git a/apps/website/src/templates/mcp/ctas.ts b/apps/website/src/templates/mcp/ctas.ts index 28aefd9143..d58ba6155c 100644 --- a/apps/website/src/templates/mcp/ctas.ts +++ b/apps/website/src/templates/mcp/ctas.ts @@ -1,4 +1,4 @@ -import { externalLinks, getRoutes } from '../../config/routes' +import { externalLinks } from '../../config/routes' import type { Locale } from '../../i18n/translations' import { t } from '../../i18n/translations' @@ -9,14 +9,13 @@ export interface McpCta { } /** - * 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. + * 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. */ export function mcpCtas(locale: Locale): { docs: McpCta installMcp: McpCta - runWorkflow: McpCta } { return { docs: { @@ -27,10 +26,6 @@ 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 } } } From 5bce9c487465e681f571fb0126772ffcdf0634cc Mon Sep 17 00:00:00 2001 From: Benjamin Lu <benjaminlu1107@gmail.com> Date: Wed, 15 Jul 2026 17:44:39 -0700 Subject: [PATCH 19/21] fix: sequence Customer.io identification before auth events (#13677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need this fix urgently, blocking all welcome emails and fixing several vectors. ## Summary Identify Customer.io users with Firebase UID and email traits, then wait for that profile update before sending `app:user_auth_completed` so Welcome Onboarding entrants satisfy the Valid Email Address segment. Preserve the correct session or anonymous identity after the auth event is handed to the SDK. ## Changes - **Auth delivery ordering**: Queue auth events with their Firebase identity and send the event only after Customer.io `identify(uid, { email })` settles. Missing-email identities fall back to UID-only identification, and rejected identifies are logged without suppressing auth telemetry. - **Identity lifecycle**: Serialize identity transitions and event handoff, deduplicate matching `identify()` operations, and snapshot resolved UID/email together. After auth delivery, restore the configured or resolved session identity—or reset to anonymous—including for SDK-late buffered events and auth callbacks that arrive after logout. Ordinary event delivery does not hold the identity queue open while its network request completes. - **Payload privacy**: Use email as a Customer.io person trait while excluding raw `email` and `share_id` from Customer.io event properties. - **Dependencies**: None. ## Incident evidence - Campaign 1 triggers on `app:user_auth_completed` with `is_new_user=true` and requires the Valid Email Address segment. - In a recent 15-minute sample, all 30 new-user events had an email property, while all 30 corresponding Customer.io person profiles had `identifiers.email=null`. - Starts fell from 556/day through June 30 to 23.5/day after July 1, excluding a one-day spike. - Event properties do not update Customer.io person identifiers, and the previous fire-and-forget identify allowed the auth event to race the profile update. ## Review Focus The auth result's `user_id` is the authoritative Firebase UID for the identify that gates `app:user_auth_completed`. Once the SDK snapshots that event, the provider restores the current configured/resolved identity or the signed-out anonymous state before handing off later events. Matching identity operations are deduplicated; repeated auth completions remain distinct telemetry events. A rejected identify must not suppress auth telemetry. This supersedes the partial fixes in #13556 (email trait without ordering) and #12888 (reset/re-login and redaction without the email-profile race fix). ## Validation - `pnpm test:unit src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts` — 35 passed - `pnpm typecheck` - File-scoped type-aware Oxlint - File-scoped ESLint - File-scoped oxfmt check - `pnpm knip` via the pre-push hook A browser E2E test is not practical for this SDK delivery-ordering fix: the regressions require controlling unresolved Customer.io `identify`/`track` promises and observing the active identity when each event is handed to the SDK. The focused unit suite controls those boundaries directly and covers SDK-late queueing, configured overrides, identity restoration, logout races, missing email, rejection fallback, payload redaction, and non-blocking event delivery. ## Rollout / backfill - Deploy or backport this change to the active cloud release line. - Verify new-user Customer.io profiles receive a non-null email identifier before the auth event is processed, then monitor Campaign 1 starts against the pre-July baseline. - Backfill or re-trigger already affected new users separately; this frontend change repairs future auth deliveries and does not replay historical events. - No live Customer.io campaign changes are included. --- .../cloud/CustomerIoTelemetryProvider.test.ts | 537 +++++++++++++++++- .../cloud/CustomerIoTelemetryProvider.ts | 144 ++++- 2 files changed, 647 insertions(+), 34 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts index 8182c782a6..565aad6343 100644 --- a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts @@ -1,26 +1,41 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const hoisted = vi.hoisted(() => { const analytics = { - identify: vi.fn(), - track: vi.fn(), + identify: vi.fn().mockResolvedValue(undefined), + track: vi.fn().mockResolvedValue(undefined), reset: vi.fn(), register: vi.fn().mockResolvedValue(undefined) } let resolvedCb: ((user: { id: string }) => void) | undefined let logoutCb: (() => void) | undefined + const resolvedUserInfo = { value: null as { id: string } | null } return { analytics, load: vi.fn(() => analytics), inAppPlugin: vi.fn(() => ({ name: 'Customer.io In-App Plugin' })), + userEmail: { value: null as string | null }, onUserResolved: vi.fn((cb: (user: { id: string }) => void) => { resolvedCb = cb + if (resolvedUserInfo.value) cb(resolvedUserInfo.value) }), onUserLogout: vi.fn((cb: () => void) => { logoutCb = cb }), - resolveUser: (id: string) => resolvedCb?.({ id }), - logoutUser: () => logoutCb?.() + resolvedUserInfo, + resolveUser: (id: string) => { + resolvedUserInfo.value = { id } + resolvedCb?.({ id }) + }, + logoutUser: () => { + resolvedUserInfo.value = null + logoutCb?.() + }, + resetCallbacks: () => { + resolvedCb = undefined + logoutCb = undefined + resolvedUserInfo.value = null + } } }) @@ -31,6 +46,8 @@ vi.mock('@customerio/cdp-analytics-browser', () => ({ vi.mock('@/composables/auth/useCurrentUser', () => ({ useCurrentUser: () => ({ + userEmail: hoisted.userEmail, + resolvedUserInfo: hoisted.resolvedUserInfo, onUserResolved: hoisted.onUserResolved, onUserLogout: hoisted.onUserLogout }) @@ -54,14 +71,32 @@ function createProvider( return new CustomerIoTelemetryProvider() } +function createDeferred() { + let resolve = () => {} + const promise = new Promise<void>((complete) => { + resolve = complete + }) + return { promise, resolve } +} + describe('CustomerIoTelemetryProvider', () => { beforeEach(() => { vi.clearAllMocks() + hoisted.resetCallbacks() hoisted.load.mockReturnValue(hoisted.analytics) + hoisted.analytics.identify.mockResolvedValue(undefined) + hoisted.analytics.track.mockResolvedValue(undefined) + hoisted.analytics.reset.mockReset().mockResolvedValue(undefined) hoisted.analytics.register.mockResolvedValue(undefined) + hoisted.userEmail.value = null window.__CONFIG__ = {} as typeof window.__CONFIG__ }) + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + it('loads the client and registers the in-app plugin with the site id', async () => { createProvider() await vi.dynamicImportSettled() @@ -89,13 +124,19 @@ describe('CustomerIoTelemetryProvider', () => { expect(hoisted.load).not.toHaveBeenCalled() }) - it('identifies the person by uid only on auth resolve', async () => { + it('identifies the resolved user with uid and email traits', async () => { createProvider() await vi.dynamicImportSettled() + hoisted.userEmail.value = 'user@example.com' hoisted.resolveUser('test-uid-7f3a9c') - expect(hoisted.analytics.identify).toHaveBeenCalledWith('test-uid-7f3a9c') + await vi.waitFor(() => + expect(hoisted.analytics.identify).toHaveBeenCalledWith( + 'test-uid-7f3a9c', + { email: 'user@example.com' } + ) + ) }) it('identifies with the configured user_id override without waiting for auth', async () => { @@ -108,8 +149,54 @@ describe('CustomerIoTelemetryProvider', () => { }) await vi.dynamicImportSettled() - expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid') - expect(hoisted.onUserResolved).not.toHaveBeenCalled() + expect(hoisted.analytics.identify).toHaveBeenCalledWith( + 'forced-uid', + undefined + ) + expect(hoisted.onUserResolved).toHaveBeenCalledOnce() + }) + + it('identifies a restored session with the configured user id once', async () => { + hoisted.userEmail.value = 'restored@example.com' + hoisted.resolvedUserInfo.value = { id: 'resolved-uid' } + + createProvider({ + customer_io: { + write_key: WRITE_KEY, + site_id: SITE_ID, + user_id: 'forced-uid' + } + }) + await vi.dynamicImportSettled() + + expect(hoisted.analytics.identify).toHaveBeenCalledOnce() + expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid', { + email: 'restored@example.com' + }) + }) + + it('re-identifies with the configured user id after logout and re-login', async () => { + createProvider({ + customer_io: { + write_key: WRITE_KEY, + site_id: SITE_ID, + user_id: 'forced-uid' + } + }) + await vi.dynamicImportSettled() + + hoisted.logoutUser() + hoisted.userEmail.value = 'returning@example.com' + hoisted.resolveUser('resolved-uid') + + await vi.waitFor(() => + expect(hoisted.analytics.identify).toHaveBeenNthCalledWith( + 2, + 'forced-uid', + { email: 'returning@example.com' } + ) + ) + expect(hoisted.analytics.reset).toHaveBeenCalledOnce() }) it('identifies before flushing events buffered before the SDK loads', async () => { @@ -129,13 +216,99 @@ describe('CustomerIoTelemetryProvider', () => { expect(identifyOrder).toBeLessThan(trackOrder) }) + it('restores the resolved user after flushing an older auth event', async () => { + let activeUser: string | null = null + const trackedUsers: Array<[string, string | null]> = [] + hoisted.analytics.identify.mockImplementation((userId: string) => { + activeUser = userId + return Promise.resolve() + }) + hoisted.analytics.track.mockImplementation((event: string) => { + trackedUsers.push([event, activeUser]) + return Promise.resolve() + }) + hoisted.userEmail.value = 'current@example.com' + hoisted.resolvedUserInfo.value = { id: 'current-uid' } + const provider = createProvider() + + provider.trackAuth({ + user_id: 'queued-uid', + email: 'queued@example.com' + }) + provider.trackWorkflowExecution() + await vi.dynamicImportSettled() + + await vi.waitFor(() => + expect(hoisted.analytics.identify.mock.calls).toEqual([ + ['current-uid', { email: 'current@example.com' }], + ['queued-uid', { email: 'queued@example.com' }], + ['current-uid', { email: 'current@example.com' }] + ]) + ) + expect(activeUser).toBe('current-uid') + expect(trackedUsers).toEqual([ + ['app:user_auth_completed', 'queued-uid'], + ['execution_start', 'current-uid'] + ]) + }) + + it('resets identity after flushing auth for a signed-out user', async () => { + let activeUser: string | null = null + let trackedUser: string | null = null + hoisted.analytics.identify.mockImplementation((userId: string) => { + activeUser = userId + return Promise.resolve() + }) + hoisted.analytics.track.mockImplementation(() => { + trackedUser = activeUser + return Promise.resolve() + }) + hoisted.analytics.reset.mockImplementation(() => { + activeUser = null + }) + const provider = createProvider() + + provider.trackAuth({ + user_id: 'queued-uid', + email: 'queued@example.com' + }) + await vi.dynamicImportSettled() + + await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled()) + expect(trackedUser).toBe('queued-uid') + expect(activeUser).toBeNull() + }) + it('resets on logout', async () => { createProvider() await vi.dynamicImportSettled() hoisted.logoutUser() - expect(hoisted.analytics.reset).toHaveBeenCalledOnce() + await vi.waitFor(() => + expect(hoisted.analytics.reset).toHaveBeenCalledOnce() + ) + }) + + it('continues tracking after reset fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + hoisted.analytics.reset.mockRejectedValueOnce(new Error('reset failed')) + const provider = createProvider() + await vi.dynamicImportSettled() + + hoisted.logoutUser() + provider.trackWorkflowExecution() + + await vi.waitFor(() => + expect(hoisted.analytics.track).toHaveBeenCalledWith( + 'execution_start', + SOURCE + ) + ) + expect(console.error).toHaveBeenCalledWith( + 'Failed to process Customer.io operation:', + expect.any(Error) + ) }) const DIRECT_EVENTS: Array<{ @@ -189,6 +362,7 @@ describe('CustomerIoTelemetryProvider', () => { invoke: (p) => p.trackShareFlow({ step: 'dialog_opened', + share_id: 'share-1', view_mode: 'graph', is_app_mode: false }), @@ -209,10 +383,280 @@ describe('CustomerIoTelemetryProvider', () => { invoke(provider) - expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected) + await vi.waitFor(() => + expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected) + ) } ) + it('awaits auth identification before tracking without raw identifiers', async () => { + const identifyResult = createDeferred() + hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise) + const provider = createProvider() + + provider.trackAuth({ + method: 'google', + is_new_user: true, + user_id: 'uid-1', + email: 'person@example.com', + share_id: 'share-1' + }) + await vi.dynamicImportSettled() + + expect(hoisted.analytics.identify).toHaveBeenCalledWith('uid-1', { + email: 'person@example.com' + }) + expect(hoisted.analytics.track).not.toHaveBeenCalled() + + identifyResult.resolve() + + await vi.waitFor(() => + expect(hoisted.analytics.track).toHaveBeenCalledWith( + 'app:user_auth_completed', + { + ...SOURCE, + method: 'google', + is_new_user: true, + user_id: 'uid-1' + } + ) + ) + expect(hoisted.analytics.identify.mock.invocationCallOrder[0]).toBeLessThan( + hoisted.analytics.track.mock.invocationCallOrder[0] + ) + }) + + it('reuses matching resolved-user identification for auth delivery', async () => { + const identifyResult = createDeferred() + hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise) + const provider = createProvider() + await vi.dynamicImportSettled() + + hoisted.userEmail.value = 'person@example.com' + hoisted.resolveUser('uid-1') + provider.trackAuth({ + user_id: 'uid-1', + email: 'person@example.com' + }) + + await vi.waitFor(() => + expect(hoisted.analytics.identify).toHaveBeenCalledOnce() + ) + expect(hoisted.analytics.track).not.toHaveBeenCalled() + identifyResult.resolve() + + await vi.waitFor(() => + expect(hoisted.analytics.track).toHaveBeenCalledWith( + 'app:user_auth_completed', + { ...SOURCE, user_id: 'uid-1' } + ) + ) + expect(hoisted.analytics.identify).toHaveBeenCalledOnce() + }) + + it('tracks auth before resetting identity on logout', async () => { + const identifyResult = createDeferred() + const trackResult = createDeferred() + let activeUser: string | null = null + let trackedUser: string | null = null + hoisted.analytics.identify.mockImplementationOnce((userId: string) => { + activeUser = userId + return identifyResult.promise + }) + hoisted.analytics.track.mockImplementationOnce(() => { + trackedUser = activeUser + return trackResult.promise + }) + hoisted.analytics.reset.mockImplementationOnce(() => { + activeUser = null + }) + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackAuth({ + user_id: 'uid-1', + email: 'person@example.com' + }) + hoisted.logoutUser() + identifyResult.resolve() + + await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled()) + expect(trackedUser).toBe('uid-1') + expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan( + hoisted.analytics.reset.mock.invocationCallOrder[0] + ) + trackResult.resolve() + }) + + it('restores signed-out identity when auth delivery follows logout', async () => { + let activeUser: string | null = null + let trackedUser: string | null = null + hoisted.analytics.identify.mockImplementation((userId: string) => { + activeUser = userId + return Promise.resolve() + }) + hoisted.analytics.track.mockImplementation(() => { + trackedUser = activeUser + return Promise.resolve() + }) + hoisted.analytics.reset.mockImplementation(() => { + activeUser = null + }) + const provider = createProvider() + await vi.dynamicImportSettled() + + hoisted.userEmail.value = 'person@example.com' + hoisted.resolveUser('uid-1') + hoisted.logoutUser() + await vi.waitFor(() => + expect(hoisted.analytics.reset).toHaveBeenCalledOnce() + ) + + provider.trackAuth({ + user_id: 'uid-1', + email: 'person@example.com' + }) + + await vi.waitFor(() => + expect(hoisted.analytics.reset).toHaveBeenCalledTimes(2) + ) + expect(trackedUser).toBe('uid-1') + expect(activeUser).toBeNull() + expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan( + hoisted.analytics.reset.mock.invocationCallOrder[1] + ) + }) + + it('restores a configured identity after tracking auth with the Firebase uid', async () => { + const provider = createProvider({ + customer_io: { + write_key: WRITE_KEY, + site_id: SITE_ID, + user_id: 'forced-uid' + } + }) + await vi.dynamicImportSettled() + hoisted.analytics.identify.mockClear() + + provider.trackAuth({ + user_id: 'firebase-uid', + email: 'person@example.com' + }) + + await vi.waitFor(() => + expect(hoisted.analytics.identify.mock.calls).toEqual([ + ['firebase-uid', { email: 'person@example.com' }], + ['forced-uid', undefined] + ]) + ) + expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan( + hoisted.analytics.identify.mock.invocationCallOrder[1] + ) + }) + + it('does not reset identity when login resolution follows auth tracking', async () => { + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' }) + hoisted.userEmail.value = 'person@example.com' + hoisted.resolveUser('uid-1') + + await vi.waitFor(() => + expect(hoisted.analytics.track).toHaveBeenCalledWith( + 'app:user_auth_completed', + { ...SOURCE, user_id: 'uid-1' } + ) + ) + expect(hoisted.analytics.identify).toHaveBeenCalledOnce() + expect(hoisted.analytics.reset).not.toHaveBeenCalled() + }) + + it('does not stall later events when identification never settles', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => {}) + hoisted.analytics.identify.mockReturnValueOnce(new Promise(() => {})) + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' }) + provider.trackWorkflowExecution() + expect(hoisted.analytics.track).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(10_000) + + await vi.waitFor(() => + expect(hoisted.analytics.track.mock.calls).toEqual([ + ['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }], + ['execution_start', SOURCE] + ]) + ) + expect(console.error).toHaveBeenCalledWith( + 'Failed to identify Customer.io user:', + expect.any(Error) + ) + }) + + it('tracks auth after identifying a user without an email', async () => { + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackAuth({ user_id: 'uid-without-email' }) + + await vi.waitFor(() => + expect(hoisted.analytics.identify).toHaveBeenCalledWith( + 'uid-without-email', + undefined + ) + ) + await vi.waitFor(() => + expect(hoisted.analytics.track).toHaveBeenCalledWith( + 'app:user_auth_completed', + { ...SOURCE, user_id: 'uid-without-email' } + ) + ) + }) + + it('tracks auth without identifying when user_id is absent', async () => { + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackAuth({ method: 'google', email: 'person@example.com' }) + + await vi.waitFor(() => + expect(hoisted.analytics.track).toHaveBeenCalledWith( + 'app:user_auth_completed', + { ...SOURCE, method: 'google' } + ) + ) + expect(hoisted.analytics.identify).not.toHaveBeenCalled() + }) + + it('tracks auth after identification fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + hoisted.analytics.identify.mockRejectedValueOnce( + new Error('identify failed') + ) + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackAuth({ + user_id: 'uid-1', + email: 'person@example.com' + }) + + await vi.waitFor(() => + expect(hoisted.analytics.track).toHaveBeenCalledWith( + 'app:user_auth_completed', + { ...SOURCE, user_id: 'uid-1' } + ) + ) + expect(console.error).toHaveBeenCalledWith( + 'Failed to identify Customer.io user:', + expect.any(Error) + ) + }) + it('flushes events buffered before load once, in order', async () => { const provider = createProvider() provider.trackWorkflowExecution() @@ -227,6 +671,67 @@ describe('CustomerIoTelemetryProvider', () => { ]) }) + it('waits for queued auth identification before later events', async () => { + const identifyResult = createDeferred() + hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise) + const provider = createProvider() + provider.trackAuth({ + user_id: 'uid-1', + email: 'person@example.com' + }) + provider.trackWorkflowExecution() + + await vi.dynamicImportSettled() + + expect(hoisted.analytics.track).not.toHaveBeenCalled() + identifyResult.resolve() + await vi.waitFor(() => + expect(hoisted.analytics.track.mock.calls).toEqual([ + ['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }], + ['execution_start', SOURCE] + ]) + ) + }) + + it('does not wait for event delivery before handing off later events', async () => { + const trackResult = createDeferred() + hoisted.analytics.track.mockReturnValueOnce(trackResult.promise) + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackWorkflowExecution() + provider.trackAddApiCreditButtonClicked() + + await vi.waitFor(() => + expect(hoisted.analytics.track.mock.calls).toEqual([ + ['execution_start', SOURCE], + ['app:add_api_credit_button_clicked', SOURCE] + ]) + ) + trackResult.resolve() + }) + + it('snapshots resolved user email before queued identification', async () => { + const identifyResult = createDeferred() + hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise) + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackAuth({ user_id: 'blocking-uid' }) + hoisted.userEmail.value = 'first@example.com' + hoisted.resolveUser('resolved-uid') + hoisted.userEmail.value = 'second@example.com' + identifyResult.resolve() + + await vi.waitFor(() => + expect(hoisted.analytics.identify).toHaveBeenNthCalledWith( + 2, + 'resolved-uid', + { email: 'first@example.com' } + ) + ) + }) + it('disables tracking when the SDK fails to load', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) hoisted.load.mockImplementation(() => { @@ -253,12 +758,12 @@ describe('CustomerIoTelemetryProvider', () => { provider.trackWorkflowExecution() provider.trackAddApiCreditButtonClicked() - expect(hoisted.analytics.track).toHaveBeenCalledTimes(2) await vi.waitFor(() => - expect(console.error).toHaveBeenCalledWith( - 'Failed to track Customer.io event:', - expect.any(Error) - ) + expect(hoisted.analytics.track).toHaveBeenCalledTimes(2) + ) + expect(console.error).toHaveBeenCalledWith( + 'Failed to track Customer.io event:', + expect.any(Error) ) }) }) diff --git a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts index ecb34ad8f8..faf9824330 100644 --- a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts @@ -1,6 +1,8 @@ import type { AnalyticsBrowser } from '@customerio/cdp-analytics-browser' +import { omit, withTimeout } from 'es-toolkit' import { useCurrentUser } from '@/composables/auth/useCurrentUser' +import type { AuthUserInfo } from '@/types/authTypes' import { TelemetryEvents } from '../../types' import type { @@ -16,9 +18,17 @@ import type { export const EVENT_SOURCE = 'web-sdk' +const SDK_OPERATION_TIMEOUT_MS = 10_000 + interface QueuedEvent { event: string properties: Record<string, unknown> + identity?: CustomerIoIdentity +} + +interface CustomerIoIdentity { + userId: string + email?: string } /** @@ -32,6 +42,9 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider { private analytics: AnalyticsBrowser | null = null private isEnabled = true private eventQueue: QueuedEvent[] = [] + private identifiedUser: CustomerIoIdentity | null = null + private sessionIdentity: CustomerIoIdentity | null = null + private operationQueue: Promise<void> = Promise.resolve() constructor() { const { @@ -39,6 +52,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider { site_id: siteId, user_id: userIdOverride } = window.__CONFIG__?.customer_io ?? {} + this.sessionIdentity = userIdOverride ? { userId: userIdOverride } : null if (!writeKey || !siteId) { this.isEnabled = false return @@ -60,14 +74,29 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider { this.analytics = analytics const currentUser = useCurrentUser() - if (userIdOverride) { - void analytics.identify(userIdOverride) - } else { - currentUser.onUserResolved((user) => void analytics.identify(user.id)) + const identifyResolvedUser = (user: AuthUserInfo) => { + const identity = { + userId: userIdOverride || user.id, + email: currentUser.userEmail.value || undefined + } + this.sessionIdentity = identity + return this.enqueueOperation(() => this.identify(identity)) } - currentUser.onUserLogout(() => void analytics.reset()) - this.flushQueue() + if (userIdOverride && !currentUser.resolvedUserInfo.value) { + void this.enqueueOperation(() => + this.identify({ userId: userIdOverride }) + ) + } + currentUser.onUserResolved((user) => { + void identifyResolvedUser(user) + }) + currentUser.onUserLogout(() => { + this.sessionIdentity = null + void this.enqueueOperation(() => this.resetIdentity()) + }) + + void this.flushQueue() }) .catch((error) => { console.error('Failed to load Customer.io:', error) @@ -76,32 +105,111 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider { }) } - private send(event: string, properties: Record<string, unknown>): void { - void this.analytics?.track(event, properties)?.catch((error) => { - console.error('Failed to track Customer.io event:', error) + private enqueueOperation( + operation: () => Promise<void> | void + ): Promise<void> { + this.operationQueue = this.operationQueue.then(operation).catch((error) => { + console.error('Failed to process Customer.io operation:', error) }) + return this.operationQueue } - private track(event: string, metadata?: TelemetryEventProperties): void { + private async resetIdentity(): Promise<void> { + this.identifiedUser = null + const analytics = this.analytics + if (!analytics) return + await withTimeout(async () => { + await analytics.reset() + }, SDK_OPERATION_TIMEOUT_MS) + } + + private async restoreSessionIdentity(): Promise<void> { + if (this.sessionIdentity) { + await this.identify(this.sessionIdentity) + } else { + await this.resetIdentity() + } + } + + private async identify(identity: CustomerIoIdentity): Promise<void> { + const analytics = this.analytics + if (!analytics) return + + if ( + this.identifiedUser?.userId === identity.userId && + this.identifiedUser.email === identity.email + ) { + return + } + + this.identifiedUser = identity + try { + await withTimeout(async () => { + await analytics.identify( + identity.userId, + identity.email ? { email: identity.email } : undefined + ) + }, SDK_OPERATION_TIMEOUT_MS) + } catch (error) { + this.identifiedUser = null + console.error('Failed to identify Customer.io user:', error) + } + } + + private async send( + event: string, + properties: Record<string, unknown>, + identity?: CustomerIoIdentity + ): Promise<void> { + const analytics = this.analytics + if (!analytics) return + + if (identity) await this.identify(identity) + + void analytics.track(event, properties).catch((error) => { + console.error('Failed to track Customer.io event:', error) + }) + + if (identity) await this.restoreSessionIdentity() + } + + private track( + event: string, + metadata?: TelemetryEventProperties, + identity?: CustomerIoIdentity + ): void { if (!this.isEnabled) return const properties = { ...metadata, event_source: EVENT_SOURCE } if (this.analytics) { - this.send(event, properties) + void this.enqueueOperation(() => this.send(event, properties, identity)) } else { - this.eventQueue.push({ event, properties }) + this.eventQueue.push({ event, properties, identity }) } } - private flushQueue(): void { + private async flushQueue(): Promise<void> { if (!this.analytics) return - for (const { event, properties } of this.eventQueue) { - this.send(event, properties) - } + const queue = this.eventQueue this.eventQueue = [] + await this.enqueueOperation(async () => { + for (const { event, properties, identity } of queue) { + await this.send(event, properties, identity) + } + }) } trackAuth(metadata: AuthMetadata): void { - this.track(TelemetryEvents.USER_AUTH_COMPLETED, metadata) + const identity = metadata.user_id + ? { + userId: metadata.user_id, + email: metadata.email || undefined + } + : undefined + this.track( + TelemetryEvents.USER_AUTH_COMPLETED, + omit(metadata, ['email', 'share_id']), + identity + ) } trackSubscription( @@ -137,6 +245,6 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider { } trackShareFlow(metadata: ShareFlowMetadata): void { - this.track(TelemetryEvents.SHARE_FLOW, metadata) + this.track(TelemetryEvents.SHARE_FLOW, omit(metadata, ['share_id'])) } } From 98c654df201f32d21b789ff7163a0d643bc96494 Mon Sep 17 00:00:00 2001 From: Benjamin Lu <benjaminlu1107@gmail.com> Date: Wed, 15 Jul 2026 19:02:59 -0700 Subject: [PATCH 20/21] fix: report page views to Customer.io in-app SDK (#13560) ## Summary Report the current URL to Customer.io after its in-app plugin initializes and on every client-side navigation, allowing queued web in-app messages to satisfy page rules and render. ## Changes - **What**: Wait for the in-app plugin to register, flush an initial URL-based `page()` call, and consume the router's existing page-view dispatches for subsequent SPA navigation. - **Tests**: Cover initial page reporting, plugin-registration ordering, and client-side route changes. ## Review Focus Customer.io intentionally receives `page()` without an explicit page name. Passing the telemetry page title would make the in-app SDK match page rules against that title instead of the full URL; the live campaign requires `contains cloud.comfy.org`. Root cause: the production SDK debugger showed a correctly identified user and active SSE connection, but `Route: NONE` and no eligible messages because the provider did not implement `trackPageView()`. Complements #13556, which addresses identity stitching for existing email-keyed Customer.io profiles. Validation: targeted unit tests (21/21), `pnpm typecheck`, targeted ESLint and oxfmt checks, and the pre-push `knip` gate. --- .../cloud/CustomerIoTelemetryProvider.test.ts | 84 +++++++++++++++++++ .../cloud/CustomerIoTelemetryProvider.ts | 39 ++++++++- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts index 565aad6343..beeb0545f2 100644 --- a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const hoisted = vi.hoisted(() => { const analytics = { identify: vi.fn().mockResolvedValue(undefined), + page: vi.fn(), track: vi.fn().mockResolvedValue(undefined), reset: vi.fn(), register: vi.fn().mockResolvedValue(undefined) @@ -108,6 +109,89 @@ describe('CustomerIoTelemetryProvider', () => { expect(hoisted.analytics.register).toHaveBeenCalled() }) + it('reports the current page after registering the in-app plugin', async () => { + const provider = createProvider() + provider.trackPageView('workflow_editor', { + path: 'https://cloud.comfy.org/' + }) + await vi.dynamicImportSettled() + + expect(hoisted.analytics.page).toHaveBeenCalledOnce() + expect(hoisted.analytics.page).toHaveBeenCalledWith() + expect(hoisted.analytics.register.mock.invocationCallOrder[0]).toBeLessThan( + hoisted.analytics.page.mock.invocationCallOrder[0] + ) + }) + + it('queues page views until the in-app plugin is registered', async () => { + let resolveRegistration: (() => void) | undefined + const registration = new Promise<void>((resolve) => { + resolveRegistration = resolve + }) + hoisted.analytics.register.mockReturnValue(registration) + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackPageView('workflow_editor', { + path: 'https://cloud.comfy.org/' + }) + expect(hoisted.analytics.page).not.toHaveBeenCalled() + + resolveRegistration?.() + await vi.waitFor(() => + expect(hoisted.analytics.page).toHaveBeenCalledOnce() + ) + }) + + it('reports client-side route changes', async () => { + const provider = createProvider() + await vi.dynamicImportSettled() + + expect(hoisted.analytics.page).not.toHaveBeenCalled() + + provider.trackPageView('workflow_editor', { + path: 'https://cloud.comfy.org/' + }) + + expect(hoisted.analytics.page).toHaveBeenCalledOnce() + expect(hoisted.analytics.page).toHaveBeenCalledWith() + }) + + it('continues tracking events and page views when the in-app plugin fails to register', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const registrationError = new Error('in-app setup failed') + hoisted.analytics.register.mockRejectedValue(registrationError) + const provider = createProvider() + provider.trackWorkflowExecution() + provider.trackPageView('workflow_editor', { + path: 'https://cloud.comfy.org/' + }) + + await vi.dynamicImportSettled() + + expect(hoisted.analytics.track).toHaveBeenCalledWith( + 'execution_start', + SOURCE + ) + expect(hoisted.analytics.page).toHaveBeenCalledOnce() + expect(consoleError).toHaveBeenCalledWith( + 'Failed to initialize Customer.io in-app plugin:', + registrationError + ) + + provider.trackAddApiCreditButtonClicked() + await vi.waitFor(() => + expect(hoisted.analytics.track).toHaveBeenCalledWith( + 'app:add_api_credit_button_clicked', + SOURCE + ) + ) + provider.trackPageView('settings', { + path: 'https://cloud.comfy.org/settings' + }) + expect(hoisted.analytics.page).toHaveBeenCalledTimes(2) + }) + it('does not initialize without a write key', async () => { const provider = createProvider({ customer_io: { site_id: SITE_ID } }) await vi.dynamicImportSettled() diff --git a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts index faf9824330..7ff79cd05c 100644 --- a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts @@ -8,6 +8,7 @@ import { TelemetryEvents } from '../../types' import type { AuthMetadata, ExecutionSuccessMetadata, + PageViewMetadata, ShareFlowMetadata, SubscriptionMetadata, TelemetryEventProperties, @@ -41,7 +42,9 @@ interface CustomerIoIdentity { export class CustomerIoTelemetryProvider implements TelemetryProvider { private analytics: AnalyticsBrowser | null = null private isEnabled = true + private isPageViewTrackingReady = false private eventQueue: QueuedEvent[] = [] + private pageViewQueued = false private identifiedUser: CustomerIoIdentity | null = null private sessionIdentity: CustomerIoIdentity | null = null private operationQueue: Promise<void> = Promise.resolve() @@ -61,7 +64,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider { void import('@customerio/cdp-analytics-browser') .then(({ AnalyticsBrowser, InAppPlugin }) => { const analytics = AnalyticsBrowser.load({ writeKey }) - void analytics.register( + const inAppRegistration = analytics.register( InAppPlugin({ siteId, events: null, @@ -97,6 +100,17 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider { }) void this.flushQueue() + void inAppRegistration + .catch((error) => { + console.error( + 'Failed to initialize Customer.io in-app plugin:', + error + ) + }) + .finally(() => { + this.isPageViewTrackingReady = true + this.flushPageView() + }) }) .catch((error) => { console.error('Failed to load Customer.io:', error) @@ -198,6 +212,29 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider { }) } + private sendPageView(): void { + void this.analytics?.page()?.catch((error) => { + console.error('Failed to track Customer.io page view:', error) + }) + } + + private flushPageView(): void { + if (!this.isPageViewTrackingReady || !this.pageViewQueued) { + return + } + this.pageViewQueued = false + this.sendPageView() + } + + trackPageView(_pageName: string, _properties?: PageViewMetadata): void { + if (!this.isEnabled) return + if (!this.isPageViewTrackingReady) { + this.pageViewQueued = true + return + } + this.sendPageView() + } + trackAuth(metadata: AuthMetadata): void { const identity = metadata.user_id ? { From d1d55585f913f91460fc106ccddcbbeff6b12c32 Mon Sep 17 00:00:00 2001 From: Benjamin Lu <benjaminlu1107@gmail.com> Date: Wed, 15 Jul 2026 21:22:41 -0700 Subject: [PATCH 21/21] [GTM-278] Report Firebase auth state to Desktop telemetry (#13687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reports each hosted Cloud renderer's declarative Firebase auth state to Desktop. Desktop remains the sole owner of PostHog identity and arbitrates all live `WebContents` before changing the process-global person. Linear: [GTM-278](https://linear.app/comfyorg/issue/GTM-278) · Parent: [GTM-93](https://linear.app/comfyorg/issue/GTM-93) ## Changes - reports `pending` as soon as host auth synchronization starts - waits for Firebase initialization, then reports `signed_out` or `signed_in` with the Firebase UID - reports restored sessions, logout, and direct account switches through the same declarative state contract - makes repeated same-UID Firebase updates a no-op - adds the optional `reportFirebaseAuthState` contract to the Desktop bridge types - keeps host bridge failures from interrupting renderer startup or Firebase auth ## Ownership and privacy - the frontend never calls PostHog `identify`, `alias`, bind, or unbind APIs - browser PostHog provider behavior is unchanged - the auth-state message is local to Desktop; Desktop #1272 applies its own telemetry-consent gate before any PostHog identify or emission - `enable_telemetry` controls only the frontend `HostTelemetrySink` (renderer event forwarding), so this observer deliberately does not share that rollout flag - no canonical `comfy_user_id`, `/api/user` lookup, Cloud API dependency, Redis state, token redemption, or website transport change is added ## Review and rollout order Review [Comfy-Desktop #1272](https://github.com/Comfy-Org/Comfy-Desktop/pull/1272) first because it defines the trusted state-reporting API and consensus policy. Deploy this frontend first: the method is optional and therefore a no-op on older Desktop builds. Then release Desktop #1272, and only afterward deploy [comfy-router #34](https://github.com/Comfy-Org/comfy-router/pull/34). Auth [Comfy-Desktop #1222](https://github.com/Comfy-Org/Comfy-Desktop/pull/1222) must not become an identity authority; with Desktop #1272 its legacy imperative bind is inert and the post-reload Firebase report is the source of truth. ## Testing - `pnpm test:unit src/platform/telemetry/hostUserIdSync.test.ts src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts` — 53 tests passed - `pnpm typecheck` - targeted Oxlint and ESLint - targeted `oxfmt --check` - `pnpm knip` - pre-commit typecheck/lint/format hooks - pre-push `knip` - `git diff --check` No user-facing UI changes. --- .../comfyDesktopBridge.d.ts | 6 + .../comfyui-desktop-bridge-types/package.json | 2 +- src/main.ts | 5 + src/platform/telemetry/hostUserIdSync.test.ts | 183 ++++++++++++++++++ src/platform/telemetry/hostUserIdSync.ts | 49 +++++ 5 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 src/platform/telemetry/hostUserIdSync.test.ts create mode 100644 src/platform/telemetry/hostUserIdSync.ts diff --git a/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts b/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts index a3c606c3a9..a7c51e6387 100644 --- a/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts +++ b/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts @@ -40,6 +40,11 @@ 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> @@ -60,6 +65,7 @@ export interface ComfyDesktop2LogsBridge { export interface ComfyDesktop2TelemetryBridge { capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void + reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void } export interface ComfyDesktop2Bridge { diff --git a/packages/comfyui-desktop-bridge-types/package.json b/packages/comfyui-desktop-bridge-types/package.json index d68019c2af..c3cdbe2a5d 100644 --- a/packages/comfyui-desktop-bridge-types/package.json +++ b/packages/comfyui-desktop-bridge-types/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-desktop-bridge-types", - "version": "0.1.2", + "version": "0.1.3", "description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge", "homepage": "https://comfy.org", "license": "MIT", diff --git a/src/main.ts b/src/main.ts index a68d7478e2..e38f0e63ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,7 @@ import { configValueOrDefault, remoteConfig } from '@/platform/remoteConfig/remoteConfig' +import { syncHostUserIdWithFirebaseAuth } from '@/platform/telemetry/hostUserIdSync' import '@/lib/litegraph/public/css/litegraph.css' import router from '@/router' import { isDesktop, isNightly } from '@/platform/distribution/types' @@ -141,6 +142,10 @@ app modules: [VueFireAuth()] }) +if (isCloud && hasHostTelemetryBridge) { + syncHostUserIdWithFirebaseAuth() +} + LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) => flushProxyWidgetMigration({ hostNode, diff --git a/src/platform/telemetry/hostUserIdSync.test.ts b/src/platform/telemetry/hostUserIdSync.test.ts new file mode 100644 index 0000000000..453cc1ce6c --- /dev/null +++ b/src/platform/telemetry/hostUserIdSync.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as VueModule from 'vue' +import { nextTick } from 'vue' + +type MockAuthStore = { + isInitialized: boolean + currentUser: { uid: string } | null +} + +const hoisted = vi.hoisted(() => ({ + authStore: null as unknown as MockAuthStore +})) + +vi.mock('@/stores/authStore', async () => { + const { reactive } = await vi.importActual<typeof VueModule>('vue') + hoisted.authStore = reactive<MockAuthStore>({ + isInitialized: false, + currentUser: null + }) + return { useAuthStore: () => hoisted.authStore } +}) + +import { syncHostUserIdWithFirebaseAuth } from './hostUserIdSync' + +const stopHandles: Array<() => void> = [] + +function installTelemetryBridge() { + const reportFirebaseAuthState = vi.fn() + window.__comfyDesktop2 = { + isRemote: () => false, + Telemetry: { + capture: vi.fn(), + reportFirebaseAuthState + } + } + return { reportFirebaseAuthState } +} + +function startSync(): void { + const stop = syncHostUserIdWithFirebaseAuth() + if (stop) stopHandles.push(stop) +} + +describe('host user ID sync', () => { + beforeEach(() => { + vi.clearAllMocks() + hoisted.authStore.isInitialized = false + hoisted.authStore.currentUser = null + }) + + afterEach(() => { + while (stopHandles.length) stopHandles.pop()?.() + delete window.__comfyDesktop2 + }) + + it('waits for Firebase auth initialization before reporting a user', async () => { + const { reportFirebaseAuthState } = installTelemetryBridge() + startSync() + + expect(reportFirebaseAuthState).toHaveBeenCalledOnce() + expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({ + status: 'pending' + }) + + hoisted.authStore.currentUser = { uid: 'firebase-user-a' } + await nextTick() + + expect(reportFirebaseAuthState).toHaveBeenCalledOnce() + + hoisted.authStore.isInitialized = true + await nextTick() + + expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({ + status: 'signed_in', + userId: 'firebase-user-a' + }) + }) + + it('reports a restored Firebase session immediately', () => { + const { reportFirebaseAuthState } = installTelemetryBridge() + hoisted.authStore.currentUser = { uid: 'firebase-user-a' } + hoisted.authStore.isInitialized = true + + startSync() + + expect(reportFirebaseAuthState.mock.calls).toEqual([ + [{ status: 'pending' }], + [{ status: 'signed_in', userId: 'firebase-user-a' }] + ]) + }) + + it('reports an initially signed-out Firebase session', () => { + const { reportFirebaseAuthState } = installTelemetryBridge() + hoisted.authStore.isInitialized = true + + startSync() + + expect(reportFirebaseAuthState.mock.calls).toEqual([ + [{ status: 'pending' }], + [{ status: 'signed_out' }] + ]) + }) + + it('reports signed out when Firebase finishes initialization', async () => { + const { reportFirebaseAuthState } = installTelemetryBridge() + startSync() + + expect(reportFirebaseAuthState).toHaveBeenCalledOnce() + + hoisted.authStore.isInitialized = true + await nextTick() + + expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({ + status: 'signed_out' + }) + }) + + it('reports account switches, logout, and subsequent login', async () => { + const { reportFirebaseAuthState } = installTelemetryBridge() + hoisted.authStore.currentUser = { uid: 'firebase-user-a' } + hoisted.authStore.isInitialized = true + startSync() + + hoisted.authStore.currentUser = { uid: 'firebase-user-b' } + await nextTick() + + expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({ + status: 'signed_in', + userId: 'firebase-user-b' + }) + + hoisted.authStore.currentUser = null + await nextTick() + + expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({ + status: 'signed_out' + }) + + hoisted.authStore.currentUser = { uid: 'firebase-user-c' } + await nextTick() + + expect(reportFirebaseAuthState.mock.calls).toEqual([ + [{ status: 'pending' }], + [{ status: 'signed_in', userId: 'firebase-user-a' }], + [{ status: 'signed_in', userId: 'firebase-user-b' }], + [{ status: 'signed_out' }], + [{ status: 'signed_in', userId: 'firebase-user-c' }] + ]) + }) + + it('does not report again when Firebase replaces the user object with the same UID', async () => { + const { reportFirebaseAuthState } = installTelemetryBridge() + hoisted.authStore.currentUser = { uid: 'firebase-user-a' } + hoisted.authStore.isInitialized = true + startSync() + + hoisted.authStore.currentUser = { uid: 'firebase-user-a' } + await nextTick() + + expect(reportFirebaseAuthState.mock.calls).toEqual([ + [{ status: 'pending' }], + [{ status: 'signed_in', userId: 'firebase-user-a' }] + ]) + }) + + it('does not let a host reporting failure interrupt Firebase state sync', async () => { + const { reportFirebaseAuthState } = installTelemetryBridge() + reportFirebaseAuthState.mockImplementationOnce(() => { + throw new Error('host unavailable') + }) + + expect(() => startSync()).not.toThrow() + + hoisted.authStore.currentUser = { uid: 'firebase-user-a' } + hoisted.authStore.isInitialized = true + await nextTick() + + expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({ + status: 'signed_in', + userId: 'firebase-user-a' + }) + }) +}) diff --git a/src/platform/telemetry/hostUserIdSync.ts b/src/platform/telemetry/hostUserIdSync.ts new file mode 100644 index 0000000000..a0642d6ae3 --- /dev/null +++ b/src/platform/telemetry/hostUserIdSync.ts @@ -0,0 +1,49 @@ +import { watch } from 'vue' +import type { WatchStopHandle } from 'vue' + +import { useAuthStore } from '@/stores/authStore' + +function safelyReportFirebaseAuthState(report: () => void): void { + try { + report() + } catch { + // A host bridge failure must not block renderer startup or Firebase auth. + } +} + +/** + * Keep the Desktop main-process telemetry identity aligned with Firebase auth. + * Must run after Pinia and VueFire are installed. + */ +export function syncHostUserIdWithFirebaseAuth(): WatchStopHandle | undefined { + const telemetry = window.__comfyDesktop2?.Telemetry + if (!telemetry) return + + // Register this Cloud renderer before Firebase resolves. Desktop may host + // multiple Cloud main frames whose isolated browser partitions have + // different auth states, so main owns all cross-WebContents arbitration. + safelyReportFirebaseAuthState(() => + telemetry.reportFirebaseAuthState?.({ status: 'pending' }) + ) + + const authStore = useAuthStore() + + return watch( + () => + authStore.isInitialized + ? (authStore.currentUser?.uid ?? null) + : undefined, + (userId) => { + if (userId === undefined) return + + safelyReportFirebaseAuthState(() => + telemetry.reportFirebaseAuthState?.( + userId === null + ? { status: 'signed_out' } + : { status: 'signed_in', userId } + ) + ) + }, + { immediate: true } + ) +}