Compare commits

...

3 Commits

Author SHA1 Message Date
Terry Jia
97e6ae2a48 feat: compress large images to previews in the mask editor 2026-07-17 16:13:05 -04:00
Angelo Lesniak
e6c04359b5 fix: load mask editor layers from the saved image's subfolder (#13744)
## Summary

Since #12318 the mask editor uploads its layer files to the input root,
but the loader still looked for them in the `clipspace/` subfolder, so
reopening the editor after saving a mask silently failed (four 404s,
`[MaskEditorContent] Initialization failed`, dialog never opens).
Affects v1.47.3+.

## Changes

- **What**: `useMaskEditorLoader` derives the layer-file subfolder from
the image the widget references (input root for saves under the unified
upload contract, `clipspace/` for saves made before #12318) instead of
hardcoding `clipspace`. When layer files are missing entirely, it falls
back to loading the node image (mask restored from its alpha channel) so
the editor still opens instead of failing silently.

## Review Focus

- The cloud path (`/files/mask-layers`) now also resolves layer refs
with the widget ref's subfolder instead of hardcoded `clipspace`.
Verified against a local OSS backend (save → reopen round-trip, plus
masks saved by 1.45.x with `clipspace/`-prefixed widget values and by
1.47.x with root-stored files); not verified against cloud.
- Repro/verification: unit tests and the new e2e round-trip test fail on
`main` and pass with this change.
2026-07-17 09:45:21 -04:00
Deep Mehta
27859cb41c feat(website): section-level client tabs on /mcp setup with click tracking (#13731)
## Summary

Stacked on #13728. Implements the setup-section redesign from the design
mock: the client picker moves out of the Option 1 card into a bordered
tab tray directly under the section header, and the selected client
drives the step + command shown inside the Option 1 (manual install)
card.

## Changes

- **What**:
- Tabs render at section level in a bordered tray (`rounded-2xl border
p-1`, yellow fill-active pills); `TabsRoot` wraps the tray and the grid
so the panels stay inside the manual card.
- Added OpenClaw as a sixth client (skill install + `openclaw mcp set`
commands from the setup docs); the catch-all tab is renamed to "Others".
- The agent card's skills note now sits inline under the command instead
of pinned to the card bottom.
- PostHog: new `website:mcp_client_tab_clicked` event (property:
`client`) captured on tab click via the existing `posthog.ts` helper
pattern; two unit tests added alongside the download-click tests.
Heatmap/autocapture data comes for free once on the page, this gives
named click-volume per client.
- e2e: OpenClaw tab assertion added; all 7 mcp tests pass; card heights
verified stable across all six tabs.

## Review Focus

- Event-capture verification in headless browsers is structurally
impossible (posthog-js drops events for `navigator.webdriver` clients),
so runtime verification is unit-level, matching how
`captureDownloadClick` is tested.
- Copy kept from #13728 (subtitle lists manual first; agent card leads
"Prefer to let your agent do it?") rather than the mock's carried-over
screenshot text.

Linear:
[PM-132](https://linear.app/comfyorg/issue/PM-132/comfyorgmcp-faq-is-stale-and-contradicts-the-page-rewrite-faq-fix)

## Screenshots (if applicable)
2026-07-17 08:47:18 +00:00
19 changed files with 882 additions and 146 deletions

View File

@@ -1,9 +1,20 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
// The setup island hydrates on visibility; clicks before hydration are
// no-ops, so retry until the tab actually activates.
async function selectClientTab(setup: Locator, name: string) {
const tab = setup.getByRole('tab', { name })
await expect(async () => {
await tab.click()
await expect(tab).toHaveAttribute('data-state', 'active', { timeout: 500 })
}).toPass()
}
test.describe('MCP page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mcp')
@@ -19,46 +30,72 @@ test.describe('MCP page @smoke', () => {
}
})
test('setup section shows both install options', async ({ page }) => {
test('Claude Desktop is the default tab and shows only the connector card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('tab', { name: 'Claude Desktop' })
).toHaveAttribute('data-state', 'active')
await expect(
setup.getByRole('heading', { name: 'Add Custom Connector' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toBeVisible()
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
).toHaveCount(0)
await expect(setup.locator('video')).toBeVisible()
})
test('client tabs swap install instructions', async ({ page }) => {
test('client tabs swap install instructions and agent-card visibility', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
const agentHeading = setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
// Claude Code is the default tab and carries the CLI command
await expect(
setup.getByRole('tab', { name: 'Claude Code' })
).toHaveAttribute('data-state', 'active')
await expect(activePanel).toContainText('Add custom connector')
// First interaction retries until the island hydrates; later switches
// assert synchronously so steady-state click regressions fail.
await selectClientTab(setup, 'Claude Code Terminal')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
await expect(activePanel).toContainText('Add custom connector')
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
await expect(agentHeading).toHaveCount(0)
await expect(setup.locator('video')).toBeVisible()
await setup.getByRole('tab', { name: 'Cursor' }).click()
await expect(activePanel).toContainText('X-API-Key')
await expect(
activePanel.getByRole('link', { name: 'platform.comfy.org' })
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Codex' }).click()
await setup.getByRole('tab', { name: 'OpenClaw' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
'openclaw skills install @comfy-org/comfy'
)
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Others' }).click()
await expect(activePanel).toContainText('remote MCP server')
await expect(agentHeading).toBeVisible()
})
test('skills plugin link lives in the agent option card', async ({
@@ -66,6 +103,7 @@ test.describe('MCP page @smoke', () => {
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await selectClientTab(setup, 'Claude Code Terminal')
await expect(
setup.getByRole('link', { name: 'View on GitHub' })
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
@@ -105,7 +143,10 @@ test.describe('MCP page zh-CN @smoke', () => {
await page.goto('/zh-CN/mcp')
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(setup.getByText('方式一')).toBeVisible()
await expect(
setup.getByRole('heading', { name: '添加自定义连接器' })
).toBeVisible()
await selectClientTab(setup, 'Claude Code Terminal')
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})

View File

@@ -33,6 +33,7 @@ const {
minimal = false,
hideControls = false,
fit = 'cover',
ariaLabel,
class: className
} = defineProps<{
locale?: Locale
@@ -44,6 +45,7 @@ const {
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
ariaLabel?: string
class?: HTMLAttributes['class']
}>()
@@ -93,6 +95,28 @@ watch(videoEl, syncNativeDuration)
useEventListener(videoEl, 'loadedmetadata', syncNativeDuration)
useEventListener(videoEl, 'durationchange', syncNativeDuration)
// The muted attribute only sets defaultMuted, so SSR-rendered autoplay
// videos count as unmuted and get blocked; force the property and kick
// playback. Scoped to hideControls (decorative) clips so chrome-visible
// consumers keep native semantics. flush: 'post' guarantees this runs
// after useMediaControls' internal muted watcher on the same source.
watch(
[videoEl, () => src],
([el]) => {
if (!el || !autoplay || !hideControls) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
el.pause()
return
}
el.muted = true
el.play().catch((error: unknown) => {
if (error instanceof Error && error.name === 'AbortError') return
console.warn('VideoPlayer autoplay failed', error)
})
},
{ flush: 'post' }
)
const effectiveDuration = computed(() => duration.value || nativeDuration.value)
// Scrubber (modeled after VueUse demo Scrubber.vue)
@@ -207,6 +231,7 @@ function toggleFullscreen() {
<video
v-if="src"
ref="videoEl"
:aria-label="ariaLabel"
:class="
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
"

View File

@@ -1919,42 +1919,48 @@ const translations = {
'zh-CN': '配置 Comfy MCP'
},
'mcp.setup.subtitle': {
en: 'Two ways to connect: ask your agent to install it, or add the server yourself. Sign in once, and the full ComfyUI toolset is available right in your chat.',
en: 'Two ways to connect: add the server yourself, or ask your agent to install it. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
'两种接入方式:自行添加服务器,或让你的智能体自动安装。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'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.option1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'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.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
'mcp.setup.option2.title': {
'mcp.setup.manual.title': {
en: 'Install manually',
'zh-CN': '手动安装'
},
'mcp.setup.option2.description': {
en: 'Prefer manual setup? Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
'mcp.setup.manual.description': {
en: 'Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
'zh-CN':
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
'将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
},
'mcp.setup.option2.tabsLabel': {
'mcp.setup.manual.tabsLabel': {
en: 'Pick your client',
'zh-CN': '选择你的客户端'
},
'mcp.setup.agent.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.agent.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.agent.description': {
en: 'Prefer to let your agent do it? 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.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.walkthroughAlt': {
en: '{client} setup walkthrough',
'zh-CN': '{client} 设置演示'
},
'mcp.setup.clients.claudeDesktop.manualTitle': {
en: 'Add Custom Connector',
'zh-CN': '添加自定义连接器'
},
'mcp.setup.clients.claudeDesktop.step': {
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
'zh-CN':
@@ -1973,9 +1979,13 @@ const translations = {
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.openclaw.step': {
en: 'Run these in your terminal, then openclaw mcp login comfy to sign in.',
'zh-CN': '在终端运行以下命令,然后执行 openclaw mcp login comfy 登录。'
},
'mcp.setup.clients.other.name': {
en: 'Other clients',
'zh-CN': '其他客户端'
en: 'Others',
'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 ',
@@ -2190,9 +2200,9 @@ const translations = {
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.3.a': {
en: 'Not for Claude Code, Claude Desktop, or Codex. You need a Comfy API key for Cursor, Hermes, and OpenClaw for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
en: 'Not for Claude Code, Claude Desktop, Codex, or OpenClaw. You need a Comfy API key for Cursor and Hermes 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 DesktopCodex 不需要。CursorHermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
'Claude Code、Claude DesktopCodex 和 OpenClaw 不需要。CursorHermes 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
},
'mcp.faq.4.q': {
en: 'Does it cost anything?',

View File

@@ -78,3 +78,28 @@ describe('captureDownloadClick', () => {
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})
describe('captureMcpClientTabClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the tab click with the client id', async () => {
const { initPostHog, captureMcpClientTabClick } = await import('./posthog')
initPostHog()
captureMcpClientTabClick('claude-code')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:mcp_client_tab_clicked',
{ client: 'claude-code' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureMcpClientTabClick } = await import('./posthog')
captureMcpClientTabClick('cursor')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -49,3 +49,12 @@ export function captureDownloadClick(platform: Platform) {
console.error('PostHog download click capture failed', error)
}
}
export function captureMcpClientTabClick(client: string) {
if (!initialized) return
try {
posthog.capture('website:mcp_client_tab_clicked', { client })
} catch (error) {
console.error('PostHog MCP client tab capture failed', error)
}
}

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { computed, ref } from 'vue'
import SectionHeader from '../../components/common/SectionHeader.vue'
import SectionLabel from '../../components/common/SectionLabel.vue'
import VideoPlayer from '../../components/common/VideoPlayer.vue'
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { captureMcpClientTabClick } from '../../scripts/posthog'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const agentCommand = t('mcp.setup.option1.command', locale).replace(
const agentCommand = t('mcp.setup.agent.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
@@ -21,19 +23,35 @@ interface McpClient {
step: string
command?: string
link?: { label: string; href: string }
manualTitle?: string
showAgentCard: boolean
// Walkthrough clip shown in place of the agent card (source: docs.comfy.org/agent-tools/mcp)
video?: string
}
const clients: McpClient[] = [
{
id: 'claude-code',
name: 'Claude Code',
step: t('mcp.setup.clients.claudeCode.step', locale),
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`
},
{
id: 'claude-desktop',
name: 'Claude Desktop',
step: t('mcp.setup.clients.claudeDesktop.step', locale)
step: t('mcp.setup.clients.claudeDesktop.step', locale),
manualTitle: t('mcp.setup.clients.claudeDesktop.manualTitle', locale),
showAgentCard: false,
video: '/videos/mcp/setup-claude-desktop-v1.mp4'
},
{
id: 'claude-code',
name: 'Claude Code Terminal',
step: t('mcp.setup.clients.claudeCode.step', locale),
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`,
showAgentCard: true
},
{
id: 'codex',
name: 'Codex',
step: t('mcp.setup.clients.codex.step', locale),
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`,
showAgentCard: false,
video: '/videos/mcp/setup-codex-oauth-v1.mp4'
},
{
id: 'cursor',
@@ -42,13 +60,15 @@ const clients: McpClient[] = [
link: {
label: t('mcp.setup.clients.cursor.linkLabel', locale),
href: externalLinks.apiKeys
}
},
showAgentCard: true
},
{
id: 'codex',
name: 'Codex',
step: t('mcp.setup.clients.codex.step', locale),
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
id: 'openclaw',
name: 'OpenClaw',
step: t('mcp.setup.clients.openclaw.step', locale),
command: `openclaw skills install @comfy-org/comfy\nopenclaw mcp set comfy '{"url":"${externalLinks.mcpEndpoint}","transport":"streamable-http","auth":"oauth"}'`,
showAgentCard: true
},
{
id: 'other',
@@ -57,10 +77,38 @@ const clients: McpClient[] = [
link: {
label: t('mcp.setup.clients.other.linkLabel', locale),
href: externalLinks.docsMcp
}
},
showAgentCard: true
}
]
const activeClientId = ref(clients[0].id)
const activeClient = computed(
() =>
clients.find((client) => client.id === activeClientId.value) ?? clients[0]
)
const manualTitle = computed(
() => activeClient.value.manualTitle ?? t('mcp.setup.manual.title', locale)
)
// reka-ui re-emits update:modelValue even when the value is unchanged
// (re-clicking the active tab), so dedupe before capturing.
let lastTrackedClientId: string | undefined
function onClientTabChange(value: string | number | undefined) {
if (!value) return
const id = String(value)
if (id === lastTrackedClientId) return
lastTrackedClientId = id
captureMcpClientTabClick(id)
}
const walkthroughLabel = computed(() =>
t('mcp.setup.walkthroughAlt', locale).replace(
'{client}',
activeClient.value.name
)
)
const copyLabel = t('ui.copy', locale)
const copiedLabel = t('ui.copied', locale)
</script>
@@ -83,77 +131,48 @@ const copiedLabel = t('ui.copied', locale)
</template>
</SectionHeader>
<div class="mt-16 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
<TabsRoot
v-model="activeClientId"
activation-mode="manual"
class="mt-10 block"
@update:model-value="onClientTabChange"
>
<TabsList
:aria-label="t('mcp.setup.manual.tabsLabel', locale)"
class="grid grid-cols-1 gap-px rounded-2xl border border-white/15 bg-primary-comfy-ink p-1 min-[360px]:grid-cols-2 lg:inline-flex lg:flex-nowrap"
>
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
<TabsTrigger
v-for="client in clients"
:key="client.id"
:value="client.id"
class="focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow shrink-0 cursor-pointer rounded-lg bg-white/8 px-2 py-2.5 text-[10px] font-bold tracking-wider whitespace-nowrap text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink lg:rounded-none lg:px-6 lg:text-xs lg:first:rounded-l-xl lg:last:rounded-r-xl"
>
{{ t('mcp.setup.option1.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option1.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="agentCommand"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<p class="mt-auto pt-6 text-sm text-smoke-700">
{{ t('mcp.setup.skillsNote', locale)
}}<a
:href="externalLinks.mcpSkills"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ t('mcp.setup.skillsLink', locale) }}</a
>
</p>
</div>
{{ client.name }}
</TabsTrigger>
</TabsList>
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<SectionLabel>{{ t('mcp.setup.option2.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
<div class="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
{{ t('mcp.setup.option2.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option2.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="externalLinks.mcpEndpoint"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<TabsRoot default-value="claude-code" class="mt-6">
<TabsList
:aria-label="t('mcp.setup.option2.tabsLabel', locale)"
class="flex flex-wrap gap-2"
>
<TabsTrigger
v-for="client in clients"
:key="client.id"
:value="client.id"
class="bg-transparency-white-t4 focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow cursor-pointer rounded-full px-4 py-2 text-xs font-bold tracking-wider text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink"
>
{{ client.name }}
</TabsTrigger>
</TabsList>
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
{{ manualTitle }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.manual.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="externalLinks.mcpEndpoint"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<TabsContent
v-for="client in clients"
:key="client.id"
:value="client.id"
class="mt-4 flex min-h-24 flex-col gap-3"
class="mt-6 flex min-h-36 flex-col gap-3"
>
<p class="text-sm text-smoke-700">
{{ client.step
@@ -173,8 +192,57 @@ const copiedLabel = t('ui.copied', locale)
:copied-label="copiedLabel"
/>
</TabsContent>
</TabsRoot>
</div>
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl"
:class="
activeClient.showAgentCard
? 'p-6 lg:p-8'
: 'relative overflow-hidden max-lg:aspect-video'
"
>
<template v-if="activeClient.showAgentCard">
<h3
class="text-xl font-light text-primary-comfy-canvas lg:text-2xl"
>
{{ t('mcp.setup.agent.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.agent.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="agentCommand"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<p class="mt-6 text-sm text-smoke-700">
{{ t('mcp.setup.skillsNote', locale)
}}<a
:href="externalLinks.mcpSkills"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ t('mcp.setup.skillsLink', locale) }}</a
>
</p>
</template>
<VideoPlayer
v-else-if="activeClient.video"
:key="activeClient.id"
:locale="locale"
:aria-label="walkthroughLabel"
:src="activeClient.video"
autoplay
loop
hide-controls
fit="contain"
class="absolute inset-0 size-full bg-transparent"
/>
</div>
</div>
</div>
</TabsRoot>
</section>
</template>

View File

@@ -58,6 +58,17 @@ class MaskEditorHelper {
return dialog
}
async reopenDialog(): Promise<Locator> {
const imagePreview = this.page.locator('.image-preview').first()
await imagePreview.getByRole('region').hover()
await this.page.getByLabel('Edit or mask image').click()
const dialog = this.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
return dialog
}
async drawStrokeOnPointerZone(dialog: Locator) {
const pointerZone = dialog.getByTestId('pointer-zone')
await expect(pointerZone).toBeVisible()

View File

@@ -67,6 +67,22 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeVisible()
})
test('Reopening the editor after save restores the drawn mask', async ({
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
const savedMask = await maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX)
await dialog.getByRole('button', { name: 'Save' }).click()
await expect(dialog).toBeHidden()
await maskEditor.reopenDialog()
await expect
.poll(() => maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX))
.toBe(savedMask)
})
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { previewCompressionParams } from './previewCompressionParams'
describe('previewCompressionParams', () => {
it('returns no params when compression is disabled and no preview format is set', () => {
expect(
previewCompressionParams({
compressionEnabled: false,
previewFormat: '',
maxSize: 4096
})
).toBe('')
})
it('keeps the global preview format when compression is disabled', () => {
expect(
previewCompressionParams({
compressionEnabled: false,
previewFormat: 'jpeg;80',
maxSize: 4096
})
).toBe('&preview=jpeg;80')
})
it('adds max_size and a default format when compression is enabled', () => {
expect(
previewCompressionParams({
compressionEnabled: true,
previewFormat: '',
maxSize: 4096
})
).toBe('&preview=webp;90&max_size=4096')
})
it('respects the global preview format when compression is enabled', () => {
expect(
previewCompressionParams({
compressionEnabled: true,
previewFormat: 'jpeg;80',
maxSize: 2048
})
).toBe('&preview=jpeg;80&max_size=2048')
})
it('clamps out-of-range max sizes into the 512-8192 range', () => {
expect(
previewCompressionParams({
compressionEnabled: true,
previewFormat: '',
maxSize: 0
})
).toBe('&preview=webp;90&max_size=512')
expect(
previewCompressionParams({
compressionEnabled: true,
previewFormat: '',
maxSize: 100000
})
).toBe('&preview=webp;90&max_size=8192')
})
it('rounds fractional max sizes to an integer', () => {
expect(
previewCompressionParams({
compressionEnabled: true,
previewFormat: '',
maxSize: 1536.4
})
).toBe('&preview=webp;90&max_size=1536')
})
})

View File

@@ -0,0 +1,23 @@
import { clamp } from 'es-toolkit'
const DEFAULT_PREVIEW_FORMAT = 'webp;90'
const MIN_PREVIEW_SIZE = 512
const MAX_PREVIEW_SIZE = 8192
export function previewCompressionParams(options: {
compressionEnabled: boolean
previewFormat: string
maxSize: number
}): string {
const { compressionEnabled, previewFormat, maxSize } = options
if (!compressionEnabled) {
return previewFormat ? `&preview=${previewFormat}` : ''
}
const format = previewFormat || DEFAULT_PREVIEW_FORMAT
const clampedSize = clamp(
Math.round(maxSize),
MIN_PREVIEW_SIZE,
MAX_PREVIEW_SIZE
)
return `&preview=${format}&max_size=${clampedSize}`
}

View File

@@ -0,0 +1,199 @@
import { fromAny } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { useMaskEditorLoader } from './useMaskEditorLoader'
// ---- Module Mocks ----
const mockDataStore: Record<string, unknown> = {
inputData: null,
sourceNode: null,
setLoading: vi.fn()
}
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: vi.fn(() => mockDataStore)
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: vi.fn(() => ({
getNodeOutputs: vi.fn(() => undefined)
}))
}))
const distribution = vi.hoisted(() => ({ isCloud: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => false)
}))
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((route: string) => `http://localhost:8188/api${route}`)
}
}))
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => ''),
getRandParam: vi.fn(() => '')
}
}))
// Mock Image constructor so the loader's image fetches resolve without a
// network. Records every requested URL; URLs matching failUrlPattern reject
// like a 404 would (reset per test in beforeEach).
const requestedUrls: string[] = []
let failUrlPattern: RegExp | null = null
class MockImage {
crossOrigin = ''
onload: ((ev: Event) => void) | null = null
onerror: ((ev: unknown) => void) | null = null
private _src = ''
get src() {
return this._src
}
set src(value: string) {
this._src = value
requestedUrls.push(value)
queueMicrotask(() => {
if (failUrlPattern?.test(value)) this.onerror?.(new Event('error'))
else this.onload?.(new Event('load'))
})
}
}
function createLoadImageNode(widgetValue: string): LGraphNode {
return fromAny<LGraphNode, unknown>({
id: 7,
type: 'LoadImage',
imgs: [{ src: 'http://localhost:8188/api/view?filename=whatever.png' }],
images: undefined,
widgets: [{ name: 'image', value: widgetValue }]
})
}
function subfolderOf(url: string): string | null {
return new URL(url).searchParams.get('subfolder')
}
function requestedLayerUrls(layerFilename: string): string[] {
return requestedUrls.filter((url) => url.includes(layerFilename))
}
describe('useMaskEditorLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
requestedUrls.length = 0
failUrlPattern = null
distribution.isCloud = false
mockDataStore.inputData = null
mockDataStore.sourceNode = null
vi.stubGlobal('Image', MockImage)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('loads layer files from the input root for saves without a subfolder prefix', async () => {
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
await useMaskEditorLoader().loadFromNode(node)
for (const layerFilename of [
'clipspace-mask-123.png',
'clipspace-paint-123.png'
]) {
const layerUrls = requestedLayerUrls(layerFilename)
expect(layerUrls.length).toBeGreaterThan(0)
for (const url of layerUrls) {
expect(subfolderOf(url)).toBeNull()
}
}
expect(mockDataStore.inputData).toMatchObject({
sourceRef: { filename: 'clipspace-mask-123.png', type: 'input' },
nodeId: 7
})
})
it('loads layer files from the clipspace subfolder for legacy saves', async () => {
const node = createLoadImageNode(
'clipspace/clipspace-painted-masked-123.png [input]'
)
await useMaskEditorLoader().loadFromNode(node)
for (const layerFilename of [
'clipspace-mask-123.png',
'clipspace-paint-123.png'
]) {
const layerUrls = requestedLayerUrls(layerFilename)
expect(layerUrls.length).toBeGreaterThan(0)
for (const url of layerUrls) {
expect(subfolderOf(url)).toBe('clipspace')
}
}
})
it('keeps the clipspace subfolder for cloud-resolved layer files', async () => {
distribution.isCloud = true
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
painted_masked: 'hash-painted-masked.png',
painted: 'hash-painted.png',
paint: 'hash-paint.png',
mask: 'hash-mask.png'
})
} as Response)
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
await useMaskEditorLoader().loadFromNode(node)
for (const layerFilename of ['hash-painted-masked.png', 'hash-paint.png']) {
const layerUrls = requestedLayerUrls(layerFilename)
expect(layerUrls.length).toBeGreaterThan(0)
for (const url of layerUrls) {
expect(subfolderOf(url)).toBe('clipspace')
}
}
})
it('falls back to the node image when layer files are missing', async () => {
failUrlPattern = /clipspace-(mask|paint)-123\.png/
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
await useMaskEditorLoader().loadFromNode(node)
expect(mockDataStore.inputData).toMatchObject({
sourceRef: { filename: 'clipspace-painted-masked-123.png' },
paintLayer: undefined,
nodeId: 7
})
})
it('still fails when the node image itself cannot be loaded', async () => {
failUrlPattern = /./
const node = createLoadImageNode('some-regular-image.png [input]')
await expect(useMaskEditorLoader().loadFromNode(node)).rejects.toThrow()
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
false,
expect.any(String)
)
})
})

View File

@@ -3,9 +3,11 @@ import type { ImageRef, ImageLayer } from '@/stores/maskEditorDataStore'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { previewCompressionParams } from './previewCompressionParams'
export function extractWidgetStringValue(value: unknown): string | undefined {
if (typeof value === 'string') return value
@@ -53,15 +55,15 @@ function imageLayerFilenamesIfApplicable(
}
}
function toRef(filename: string): ImageRef {
function toRef(filename: string, subfolder: string): ImageRef {
return {
filename,
subfolder: 'clipspace',
subfolder,
type: 'input'
}
}
function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
function mkFileUrl(props: { ref: ImageRef }): string {
const params = new URLSearchParams()
params.set('filename', props.ref.filename)
if (props.ref.subfolder) {
@@ -71,10 +73,15 @@ function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
params.set('type', props.ref.type)
}
const settingStore = useSettingStore()
const pathPlusQueryParams = api.apiURL(
'/view?' +
params.toString() +
app.getPreviewFormatParam() +
previewCompressionParams({
compressionEnabled: settingStore.get('Comfy.Image.PreviewCompression'),
previewFormat: settingStore.get('Comfy.PreviewFormat'),
maxSize: settingStore.get('Comfy.Image.PreviewMaxSize')
}) +
app.getRandParam()
)
const imageElement = new Image()
@@ -150,34 +157,72 @@ export function useMaskEditorLoader() {
}
}
const baseImageUrl = imageLayerFilenames?.maskedImage
? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) })
: nodeImageUrl
// Pattern-matched layer files live next to the painted-masked image the
// widget points at: the input root for saves under the unified upload
// contract (#12318), or the clipspace subfolder for saves made before
// it. Cloud resolves the hash filenames from its mask-layers API
// server-side, so its refs keep the subfolder they always used.
const layerSubfolder = maskLayersFromApi
? 'clipspace'
: (nodeImageRef.subfolder ?? '')
const sourceRef = imageLayerFilenames?.maskedImage
? parseImageRef(baseImageUrl)
: nodeImageRef
const layeredBaseUrl = imageLayerFilenames?.maskedImage
? mkFileUrl({
ref: toRef(imageLayerFilenames.maskedImage, layerSubfolder)
})
: undefined
const baseImageUrl = layeredBaseUrl ?? nodeImageUrl
let paintLayerUrl: string | null = null
if (maskLayersFromApi?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(maskLayersFromApi.paint) })
paintLayerUrl = mkFileUrl({
ref: toRef(maskLayersFromApi.paint, layerSubfolder)
})
} else if (imageLayerFilenames?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
paintLayerUrl = mkFileUrl({
ref: toRef(imageLayerFilenames.paint, layerSubfolder)
})
}
const [baseLayer, maskLayer, paintLayer] = await Promise.all([
loadImageLayer(baseImageUrl, 'rgb'),
loadImageLayer(baseImageUrl, 'a'),
paintLayerUrl
? loadPaintLayer(paintLayerUrl)
: Promise.resolve(undefined)
])
const loadLayers = (baseUrl: string, paintUrl: string | null) =>
Promise.all([
loadImageLayer(baseUrl, 'rgb'),
loadImageLayer(baseUrl, 'a'),
paintUrl ? loadPaintLayer(paintUrl) : Promise.resolve(undefined)
])
const loadInputData = async () => {
try {
const [baseLayer, maskLayer, paintLayer] = await loadLayers(
baseImageUrl,
paintLayerUrl
)
return {
baseLayer,
maskLayer,
paintLayer,
sourceRef: layeredBaseUrl
? parseImageRef(baseImageUrl)
: nodeImageRef
}
} catch (error) {
if (!layeredBaseUrl) throw error
console.warn(
'[MaskEditorLoader] Failed to load editor layer files, falling back to the node image',
error
)
const [baseLayer, maskLayer] = await loadLayers(nodeImageUrl, null)
return {
baseLayer,
maskLayer,
paintLayer: undefined,
sourceRef: nodeImageRef
}
}
}
dataStore.inputData = {
baseLayer,
maskLayer,
paintLayer,
sourceRef,
...(await loadInputData()),
nodeId: node.id
}

View File

@@ -75,6 +75,18 @@ vi.mock('@/scripts/app', () => ({
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
const mockSettings = vi.hoisted(() => ({ previewCompression: false }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) =>
key === 'Comfy.Image.PreviewCompression'
? mockSettings.previewCompression
: undefined
)
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
@@ -93,6 +105,7 @@ describe('useMaskEditorSaver', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
mockSettings.previewCompression = false
app.nodeOutputs = {}
app.nodePreviewImages = {}
@@ -201,4 +214,71 @@ describe('useMaskEditorSaver', () => {
expect(body.get('type')).toBe('input')
expect(body.get('subfolder')).toBeNull()
})
it('composites on the server via /upload/mask when preview compression is enabled', async () => {
mockSettings.previewCompression = true
const fetchApiMock = vi.mocked(api.fetchApi)
const { save } = useMaskEditorSaver()
await save()
expect(fetchApiMock).toHaveBeenCalledTimes(1)
const [route, init] = fetchApiMock.mock.calls[0]
expect(route).toBe('/upload/mask')
const body = init?.body as FormData
expect(body).toBeInstanceOf(FormData)
expect(body.get('original_ref')).toBe(
JSON.stringify({ filename: 'original.png', subfolder: '', type: 'input' })
)
expect(body.get('image')).toBeInstanceOf(Blob)
expect(body.get('paint')).toBeInstanceOf(Blob)
expect(String(body.get('paint_filename'))).toMatch(/^clipspace-paint-/)
expect(String(body.get('painted_filename'))).toMatch(/^clipspace-painted-/)
expect(String(body.get('painted_masked_filename'))).toMatch(
/^clipspace-painted-masked-/
)
})
it('updates layer refs from the server composite response', async () => {
mockSettings.previewCompression = true
const { save } = useMaskEditorSaver()
await save()
const outputData = mockDataStore.outputData as {
maskedImage: { ref: { filename: string; subfolder: string } }
paintedMaskedImage: { ref: { subfolder: string; type: string } }
}
expect(outputData.maskedImage.ref.filename).toBe(
'clipspace-painted-masked-123.png'
)
expect(outputData.paintedMaskedImage.ref.subfolder).toBe('clipspace')
expect(outputData.paintedMaskedImage.ref.type).toBe('input')
})
it('throws an actionable error when the composite response is not valid JSON', async () => {
mockSettings.previewCompression = true
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.reject(new Error('Unexpected end of JSON input'))
} as Response)
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(/Invalid composite upload response/)
})
it('throws an error when the server composite request fails', async () => {
mockSettings.previewCompression = true
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Server Error')
} as Response)
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(
/Failed to composite mask layers on server \(500: Internal Server Error\)/
)
})
})

View File

@@ -1,5 +1,7 @@
import type { UploadImageResponse } from '@comfyorg/ingest-types'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -49,7 +51,11 @@ export function useMaskEditorSaver() {
await updateNodePreview(sourceNode, outputData)
await uploadAllLayers(outputData)
if (shouldCompositeOnServer()) {
await uploadLayersWithServerComposite(outputData)
} else {
await uploadAllLayers(outputData)
}
updateNodeWithServerReferences(sourceNode, outputData)
@@ -209,6 +215,77 @@ export function useMaskEditorSaver() {
return { canvas, blob, ref }
}
function shouldCompositeOnServer(): boolean {
return (
!isCloud &&
useSettingStore().get('Comfy.Image.PreviewCompression') &&
!!dataStore.inputData?.sourceRef
)
}
async function uploadLayersWithServerComposite(
outputData: EditorOutputData
): Promise<void> {
const sourceRef = dataStore.inputData!.sourceRef
const formData = new FormData()
formData.append(
'image',
outputData.maskedImage.blob,
outputData.maskedImage.ref.filename
)
formData.append('type', 'input')
formData.append('original_ref', JSON.stringify(sourceRef))
formData.append(
'paint',
outputData.paintLayer.blob,
outputData.paintLayer.ref.filename
)
formData.append('paint_filename', outputData.paintLayer.ref.filename)
formData.append('painted_filename', outputData.paintedImage.ref.filename)
formData.append(
'painted_masked_filename',
outputData.paintedMaskedImage.ref.filename
)
const response = await api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(
`Failed to composite mask layers on server (${response.status}${body ? `: ${body}` : ''})`
)
}
let data: UploadImageResponse
try {
data = await response.json()
} catch (error) {
throw new Error(
`Invalid composite upload response: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
)
}
if (!data?.name) {
throw new Error('Composite upload response missing file name')
}
const subfolder = data.subfolder || ''
const type = data.type || 'input'
outputData.maskedImage.ref = { filename: data.name, subfolder, type }
for (const layer of [
outputData.paintLayer,
outputData.paintedImage,
outputData.paintedMaskedImage
]) {
layer.ref = { ...layer.ref, subfolder, type }
}
}
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
const actualMaskedRef = await uploadLayer(outputData.maskedImage)
const actualPaintRef = await uploadLayer(outputData.paintLayer)

View File

@@ -210,6 +210,14 @@
"Comfy_Locale": {
"name": "Language"
},
"Comfy_Image_PreviewCompression": {
"name": "Load large images as compressed previews",
"tooltip": "When enabled, image editors (currently the mask editor) load images as compressed, downscaled previews. This greatly improves performance with very large images; edits are applied to the original at full resolution on save. Downscaling requires the assets system on the server (--enable-assets); without it images are only recompressed at full resolution."
},
"Comfy_Image_PreviewMaxSize": {
"name": "Compressed preview maximum size",
"tooltip": "Maximum size in pixels for the longest edge of a compressed preview. Images whose longest edge exceeds this are downscaled proportionally until it fits; the aspect ratio is preserved and images are never upscaled."
},
"Comfy_MaskEditor_BrushAdjustmentSpeed": {
"name": "Brush adjustment speed multiplier",
"tooltip": "Controls how quickly the brush size and hardness change when adjusting. Higher values mean faster changes."

View File

@@ -520,6 +520,31 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'text',
defaultValue: ''
},
{
id: 'Comfy.Image.PreviewCompression',
category: ['Comfy', 'Preview', 'PreviewCompression'],
name: 'Load large images as compressed previews',
tooltip:
'When enabled, image editors (currently the mask editor) load images as compressed, downscaled previews. This greatly improves performance with very large images; edits are applied to the original at full resolution on save. Downscaling requires the assets system on the server (--enable-assets); without it images are only recompressed at full resolution.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.48.0'
},
{
id: 'Comfy.Image.PreviewMaxSize',
category: ['Comfy', 'Preview', 'PreviewMaxSize'],
name: 'Compressed preview maximum size',
tooltip:
'Maximum size in pixels for the longest edge of a compressed preview. Images whose longest edge exceeds this are downscaled proportionally until it fits; the aspect ratio is preserved and images are never upscaled.',
type: 'slider',
attrs: {
min: 512,
max: 8192,
step: 256
},
defaultValue: 4096,
versionAdded: '1.48.0'
},
{
id: 'Comfy.DisableSliders',
category: ['LiteGraph', 'Node Widget', 'DisableSliders'],

View File

@@ -438,6 +438,8 @@ const zSettings = z.object({
'Comfy-Desktop.UV.TorchInstallMirror': z.string(),
'Comfy.MaskEditor.BrushAdjustmentSpeed': z.number(),
'Comfy.MaskEditor.UseDominantAxis': z.boolean(),
'Comfy.Image.PreviewCompression': z.boolean(),
'Comfy.Image.PreviewMaxSize': z.number(),
'Comfy.Load3D.ShowGrid': z.boolean(),
'Comfy.Load3D.BackgroundColor': z.string(),
'Comfy.Load3D.LightIntensity': z.number(),