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..3992097487 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
@@ -87,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/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 67aecfce44..22f1124990 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.',
@@ -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
@@ -2181,6 +2272,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': '下载桌面版' },
@@ -3483,8 +3575,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 +3988,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
sales@comfy.org.'
},
'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 +4453,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 +4479,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.',
@@ -4446,6 +4538,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
>
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'
---
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(
---
+
+
+
+
+
+
+
+
+
+
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(
---
+
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'
---
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')
---
+
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
---
-
+
+
diff --git a/apps/website/src/pages/download.astro b/apps/website/src/pages/download.astro
index 3e06fc3751..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'
@@ -23,14 +24,14 @@ const { siteUrl, locale } = pageContext(
---
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'
---
-
+
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(
---
+
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
---
= {
---
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.
---
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'
---
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'
---
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'
---
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'
---
-
+
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(
---
+
+
+
+
+
+
+
+
+
+
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(
---
+
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'
---
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')
---
+
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
---
-
+
+
diff --git a/apps/website/src/pages/zh-CN/download.astro b/apps/website/src/pages/zh-CN/download.astro
index 6dfff8d5e1..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'
@@ -23,7 +24,7 @@ const { siteUrl, locale } = pageContext(
---
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'
---
-
+
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(
---
+
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
---
+
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'
---
-
+
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'
---
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'
---
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'
---
-
+
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 @@
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ t('brand.colors.heading', locale) }}
+
+
+ {{ t('brand.colors.subheading', locale) }}
+
+
+
+
+ {{ liveMessage }}
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ t('brand.hero.heading', locale) }}
+
+
+ {{ t('brand.hero.subheading', locale) }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ t('brand.logos.heading', locale) }}
+
+
+ {{ t('brand.logos.subheading', locale) }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ t('brand.questions.heading', locale) }}
+
+
+
+ {{ t('brand.questions.body', locale) }}
+
+
+
+
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 @@
+
+
+
+
+
+ {{ t('brand.trademark.heading', locale) }}
+
+
+
+
{{ t('brand.trademark.body1', locale) }}
+
{{ t('brand.trademark.body2', locale) }}
+
+ {{ t('brand.trademark.body3', locale) }}
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ t('brand.voice.heading', locale) }}
+
+
+
+
+
- {{ principle.title }}
+ - {{ principle.body }}
+
+
+
+
+
+
+
+
+ {{ t('brand.voice.doLabel', locale) }}
+
+
+
+
+
+
+
+
+
+ {{ t('brand.voice.dontLabel', locale) }}
+
+
+
+
+
+
+
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)
+
({
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 @@
-
+ class="max-w-9xl mx-auto scroll-mt-24 px-6 py-16 lg:scroll-mt-36 lg:py-24"
+ >
+
+ {{ t('mcp.setup.heading', locale) }}
+
+
+ {{ t('mcp.setup.subtitle', locale) }}
+
+
+
+
+
+
+
{{ t('mcp.setup.option1.label', locale) }}
+
+ {{ t('mcp.setup.option1.title', locale) }}
+
+
+ {{ t('mcp.setup.option1.description', locale) }}
+
+
+
+
+
+ {{ t('mcp.setup.skillsNote', locale)
+ }}{{ t('mcp.setup.skillsLink', locale) }}
+
+
+
+
+
{{ t('mcp.setup.option2.label', locale) }}
+
+ {{ t('mcp.setup.option2.title', locale) }}
+
+
+ {{ t('mcp.setup.option2.description', locale) }}
+
+
+
+
+
+
+
+
+ {{ client.name }}
+
+
+
+
+ {{ client.step
+ }}{{ client.link.label }}
+
+
+
+
+
+
+
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
}
}
}
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',
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/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 {
+ 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/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/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
+
+
+
+## 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 , 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/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/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
unsubscribe(installationId?: string): Promise
@@ -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/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/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',
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/.`.
+ * 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/. (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 [frontend-repo-path]'
- )
- process.exit(1)
+ if (!comfyuiRepoPath) {
+ console.error(
+ 'Usage: resolve-comfyui-release.ts [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
+}
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/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/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(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({ 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({ 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 @@
@@ -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()
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`
}
})
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(
+ '@/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) {
groupsMap: Map,
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/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 @@
@@ -100,18 +101,19 @@
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
-
+
@@ -125,6 +127,13 @@
/>
+
+
+
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(() =>
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/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
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: (
- action: (...args: TArgs) => Promise | TReturn
- ) => action,
+ wrapWithErrorHandlingAsync:
+ (
+ action: (...args: TArgs) => Promise | 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/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 {
+ 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 {
+ 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 {
+ 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][])(
+ '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 }
+}
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 | null>,
+ nodeErrors: Ref | null>,
missingModelStore: ReturnType,
missingMediaStore: ReturnType
): () => 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
+): Record {
+ const output: Record = {}
+ 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/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 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
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)[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/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/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/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/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)
+ })
+})
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": {
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/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 = {
+ 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 = {}) {
+ return {
+ getAssets: () => assets,
+ getSelectedIds: vi.fn(() => [] as string[]),
+ setSelectedIds: vi.fn(),
+ selectAll: vi.fn(),
+ ...overrides
+ }
+}
+
+async function renderHarness(callbacks: ReturnType) {
+ const Harness = defineComponent({
+ setup() {
+ const gridContainerRef = ref()
+ const hoverTargetRef = ref()
+ const { marqueeStyle } = useAssetGridSelection({
+ marqueeContainerRef: gridContainerRef,
+ hoverTargetRef,
+ getAssets: callbacks.getAssets,
+ getSelectedIds: callbacks.getSelectedIds,
+ setSelectedIds: callbacks.setSelectedIds,
+ selectAll: callbacks.selectAll
+ })
+ return { gridContainerRef, hoverTargetRef, marqueeStyle }
+ },
+ template: `
+
+ `
+ })
+
+ render(Harness)
+ await nextTick()
+ vi.spyOn(screen.getByTestId('grid'), 'getBoundingClientRect').mockReturnValue(
+ fromPartial({ left: 0, top: 0, right: 1000, bottom: 1000 })
+ )
+ for (const id of Object.keys(cardBoxes)) {
+ vi.spyOn(
+ screen.getByTestId(`card-${id}`),
+ 'getBoundingClientRect'
+ ).mockReturnValue(
+ fromPartial({
+ 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()
+ useAssetGridSelection({
+ marqueeContainerRef: containerRef,
+ hoverTargetRef: containerRef,
+ getAssets: () => assets,
+ getSelectedIds: () => ['a'],
+ setSelectedIds,
+ selectAll: vi.fn()
+ })
+ return { containerRef }
+ },
+ template: `
+
+ `
+ })
+ 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()
+ useAssetGridSelection({
+ marqueeContainerRef: containerRef,
+ hoverTargetRef: containerRef,
+ getAssets: () => assets,
+ getSelectedIds: () => [],
+ setSelectedIds,
+ selectAll: vi.fn(),
+ isEnabled: () => false
+ })
+ return { containerRef }
+ },
+ template: `
+
+ `
+ })
+ 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({
+ 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({
+ 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('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('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
+ hoverTargetRef: Ref
+ 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(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 | 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(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 = 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 = [],
+ subtract = false
+): Set {
+ 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/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
+) {
+ 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/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 =
+ 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 = {
+ ...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)[key])
+ return formatCatalogValue(getInputConfigBounds(error)[key])
}
function getInputConfigType(error: NodeValidationError): string | undefined {
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/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('vue')
+ hoisted.authStore = reactive({
+ 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 }
+ )
+}
diff --git a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts
index 8182c782a6..beeb0545f2 100644
--- a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts
+++ b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts
@@ -1,26 +1,42 @@
-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),
+ page: vi.fn(),
+ 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 +47,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 +72,32 @@ function createProvider(
return new CustomerIoTelemetryProvider()
}
+function createDeferred() {
+ let resolve = () => {}
+ const promise = new Promise((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()
@@ -73,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((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()
@@ -89,13 +208,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 +233,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 +300,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 +446,7 @@ describe('CustomerIoTelemetryProvider', () => {
invoke: (p) =>
p.trackShareFlow({
step: 'dialog_opened',
+ share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
}),
@@ -209,10 +467,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 +755,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 +842,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..7ff79cd05c 100644
--- a/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts
+++ b/src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.ts
@@ -1,11 +1,14 @@
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 {
AuthMetadata,
ExecutionSuccessMetadata,
+ PageViewMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
TelemetryEventProperties,
@@ -16,9 +19,17 @@ import type {
export const EVENT_SOURCE = 'web-sdk'
+const SDK_OPERATION_TIMEOUT_MS = 10_000
+
interface QueuedEvent {
event: string
properties: Record
+ identity?: CustomerIoIdentity
+}
+
+interface CustomerIoIdentity {
+ userId: string
+ email?: string
}
/**
@@ -31,7 +42,12 @@ interface QueuedEvent {
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 = Promise.resolve()
constructor() {
const {
@@ -39,6 +55,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
@@ -47,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,
@@ -60,14 +77,40 @@ 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()
+ 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)
@@ -76,32 +119,134 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
})
}
- private send(event: string, properties: Record): void {
- void this.analytics?.track(event, properties)?.catch((error) => {
- console.error('Failed to track Customer.io event:', error)
+ private enqueueOperation(
+ operation: () => Promise | void
+ ): Promise {
+ 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 {
+ this.identifiedUser = null
+ const analytics = this.analytics
+ if (!analytics) return
+ await withTimeout(async () => {
+ await analytics.reset()
+ }, SDK_OPERATION_TIMEOUT_MS)
+ }
+
+ private async restoreSessionIdentity(): Promise {
+ if (this.sessionIdentity) {
+ await this.identify(this.sessionIdentity)
+ } else {
+ await this.resetIdentity()
+ }
+ }
+
+ private async identify(identity: CustomerIoIdentity): Promise {
+ 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,
+ identity?: CustomerIoIdentity
+ ): Promise {
+ 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 {
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)
+ }
+ })
+ }
+
+ 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 {
- 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 +282,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']))
}
}
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'))
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/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: ``
}
@@ -79,6 +87,7 @@ interface MountDropdownOptions {
onCleanup: (cleanupFn: () => void) => void
) => Promise
multiple?: boolean | number
+ selected?: Set
searchQuery?: string
onUpdateSelected?: (selected: Set) => 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 })
}
}
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,
executionId: NodeExecutionId,
slotName?: string
- ): void {
- if (!lastNodeErrors.value) return
- const nodeError = lastNodeErrors.value[executionId]
- if (!nodeError) return
+ ): Record | 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,
+ 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(() => {
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>(
() => {
- if (!lastNodeErrors.value) return {}
+ if (!surfacedNodeErrors.value) return {}
const map: Record = {}
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
-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 = {},
+ 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/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)
}
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`).
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
+
+/**
+ * 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.