Compare commits

...

6 Commits

Author SHA1 Message Date
PabloWiedemann
e8a0a9808a fix: render all canvas background settings; adapt e2e to modal picker
- Give BackgroundPattern and BackgroundColor distinct category leaf
  segments. Settings sharing an identical category path collide in the
  settings tree (buildTree overwrites node.data), so only the last one
  rendered in the dialog. This restored the previously-hidden background
  image and pattern rows.
- Rewrite backgroundImageUpload.spec.ts for the new ImageUpload component
  (thumbnail + base name + remove button) instead of the old URL/upload/
  clear layout.
- Dismiss the now-modal color picker popover before interacting with the
  rest of the Customize Folder dialog in the bookmark-color e2e test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:16:12 -07:00
PabloWiedemann
6450db97b4 feat: native canvas background patterns and color
Add opinionated canvas background customization without an extension:
a Background selector (Dots / Grid / None / Image) plus a color picker,
surfaced in the right-side panel CANVAS section and the settings dialog.

- Generate dots/grid pattern tiles natively, replacing per-palette
  BACKGROUND_IMAGE; pattern marks auto-contrast from the background's
  luminance. Custom uploaded images still take precedence.
- New settings Comfy.Canvas.BackgroundPattern and BackgroundColor;
  empty color follows the active theme.
- New reusable ui/image-upload/ImageUpload component (thumbnail,
  click-to-browse, clear) used by BackgroundImageUpload.
- Fix ColorPicker stacking below dialogs, model echo on external
  change, and marquee-on-dismiss by making the popover modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:52:51 -07:00
Robin Huang
02adfd4b83 feat: identify prompt source via comfy_usage_source extra_data (#12772)
Adds `comfy_usage_source: 'comfyui-frontend'` to the prompt body's
`extra_data`. The backend forwards this to API nodes' upstream requests
via the `Comfy-Usage-Source` header, so partner node API usage can be
attributed to the frontend.

Used in https://github.com/Comfy-Org/ComfyUI/pull/14404
2026-06-10 22:43:34 +00:00
Robin Huang
7c2c78b537 feat: send deploy_environment as Comfy-Env header on /releases requests (#12771)
Reads `system.deploy_environment` from `/system_stats` (added in
Comfy-Org/ComfyUI#14402) and sends it as the `Comfy-Env` header when
fetching `/releases`, matching the header name the backend already uses
for outbound API node requests. The header is omitted when the backend
doesn't report the field, so older backends are unaffected.

Note: api.comfy.org must allow `Comfy-Env` in
`Access-Control-Allow-Headers` for the CORS preflight to pass.
2026-06-10 21:30:08 +00:00
Matt Miller
bd1fd0680e feat(assets): walk getAllAssetsByTag via keyset cursor (#12720)
## ELI-5

When the app needs *all* the assets for a tag (like every input image),
it asks the server for them one page at a time. Today it says "give me
page starting at item #500" (offset paging). If items get added or
removed while it's flipping through, pages shift and it can show the
same thing twice or skip something.

This switches to "give me the page *after this bookmark*" (cursor
paging). The server now hands back a `next_cursor` bookmark with each
page; we pass it to fetch the next one. Bookmarks don't slip when the
list changes underneath, so the walk is stable and drift-free.

## What

Migrates the full-walk asset pager (`getAllAssetsByTag`) from offset to
keyset (`after` / `next_cursor`) pagination, now that the list-assets
endpoint exposes a cursor contract in the generated types.

- `handleAssetRequest` accepts an `after` cursor and sends it instead of
`offset` when present (the server ignores `offset` alongside a cursor)
- `getAllAssetsByTag` resumes each page from the prior response's
`next_cursor`, and terminates when `has_more` is false or `next_cursor`
is omitted
- `next_cursor` is exposed on the asset response schema; `after` is
threaded through `getAssetsByTag` / `getAssetsPageByTag` for
cursor-aware callers
- offset remains supported for random-access callers; only the full-walk
path changes

## Why

Offset pagination double-counts or skips records when the underlying set
changes mid-walk. Keyset cursors are stable under concurrent
inserts/deletes and scale better than deep offsets.

## Stacking

Based on `update-ingest-types` because the `after`/`next_cursor` types
land there first; this targets that branch and will retarget to the
default branch once it merges. Changes here touch only the asset
service/schema, disjoint from the generated types.

## Follow-ups

The asset store's bespoke offset loops (model loader, flat-output
infinite scroll) and the missing-media resolver still walk by offset;
those migrate in separate PRs.

## Tests

`assetService.test.ts` updated to assert the cursor walk, that the first
page carries neither `after` nor `offset`, that subsequent pages resume
from `next_cursor`, and that the walk halts when `next_cursor` is absent
even if `has_more` is true. Full asset/service + missing-media + store
suites pass locally (193 tests).

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-10 21:15:19 +00:00
Robin Huang
9617e498c9 feat: track desktop download button clicks on website (#12770)
Adds a `website:download_button_clicked` PostHog event (with `platform`
property) fired when a user clicks the desktop installer download button
on comfy.org. Previously we only had `/download` pageviews as a proxy —
autocapture is not active on the website project, so these clicks were
untracked. Includes unit tests for the new capture helper.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:44:57 +00:00
32 changed files with 1228 additions and 332 deletions

View File

@@ -8,6 +8,7 @@ import {
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -69,6 +70,7 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img

View File

@@ -53,3 +53,28 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -38,3 +38,12 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: string) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
@@ -17,245 +18,87 @@ test.describe('Background Image Upload', () => {
await comfyPage.settings.setSetting('Comfy.Canvas.BackgroundImage', '')
})
const openBackgroundImageSetting = async (comfyPage: ComfyPage) => {
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.locator('text=Appearance').click()
return comfyPage.page.locator('#Comfy\\.Canvas\\.BackgroundImage')
}
test('should show background image upload component in settings', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
await expect(backgroundImageSetting).toBeVisible()
// Verify the component has the expected elements using semantic selectors
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toBeVisible()
await expect(urlInput).toHaveAttribute('placeholder')
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
await expect(uploadButton).toBeVisible()
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeDisabled() // Should be disabled when no image
// With no image set: placeholder shown, no remove button
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
await expect(
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
).toBeHidden()
})
test('should upload image file and set as background', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Click the upload button to trigger file input
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
// Set up file upload handler
// Clicking the row opens the system file browser
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await uploadButton.click()
await backgroundImageSetting
.getByRole('button', { name: 'Choose image' })
.click()
const fileChooser = await fileChooserPromise
// Upload the test image
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
// The row shows the uploaded file's base name and a remove button
await expect(
backgroundImageSetting.getByText('image32x32.webp')
).toBeVisible()
await expect(
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
).toBeVisible()
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Verify the setting value was actually set
// The setting value points at the uploaded file
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
})
test('should accept URL input for background image', async ({
test('should show the base name of an existing background image', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Enter URL in the input field
const urlInput = backgroundImageSetting.getByRole('textbox')
await urlInput.fill(testImageUrl)
// Trigger blur event to ensure the value is set
await urlInput.blur()
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Verify the setting value was updated
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe(testImageUrl)
})
test('should clear background image when clear button is clicked', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// First set a background image
await comfyPage.settings.setSetting(
'Comfy.Canvas.BackgroundImage',
testImageUrl
'/api/view?filename=backgrounds%2Ftest-image.png&type=input&subfolder=backgrounds'
)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Verify the input has the test URL
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toHaveValue(testImageUrl)
// Verify clear button is enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Click the clear button
await clearButton.click()
// Verify the input is now empty
await expect(urlInput).toHaveValue('')
// Verify clear button is now disabled
await expect(clearButton).toBeDisabled()
// Verify the setting value was cleared
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe('')
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
await expect(
backgroundImageSetting.getByText('test-image.png')
).toBeVisible()
})
test('should show tooltip on upload and clear buttons', async ({
test('should clear background image with the remove button', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
await comfyPage.settings.setSetting(
'Comfy.Canvas.BackgroundImage',
'/api/view?filename=test-image.png&type=input'
)
// Hover over upload button and verify tooltip appears
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
await uploadButton.hover()
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(uploadTooltip).toBeVisible()
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
await backgroundImageSetting
.getByRole('button', { name: 'Remove image' })
.click()
// Move away to hide tooltip
await comfyPage.page.locator('body').hover()
// Set a background to enable clear button
const urlInput = backgroundImageSetting.getByRole('textbox')
await urlInput.fill('https://example.com/test.png')
await urlInput.blur()
// Hover over clear button and verify tooltip appears
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await clearButton.hover()
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(clearTooltip).toBeVisible()
})
test('should maintain reactive updates between URL input and clear button state', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
const urlInput = backgroundImageSetting.getByRole('textbox')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
// Initially clear button should be disabled
await expect(clearButton).toBeDisabled()
// Type some text - clear button should become enabled
await urlInput.fill('test')
await expect(clearButton).toBeEnabled()
// Clear the text manually - clear button should become disabled again
await urlInput.fill('')
await expect(clearButton).toBeDisabled()
// Add text again - clear button should become enabled
await urlInput.fill('https://example.com/image.png')
await expect(clearButton).toBeEnabled()
// Use clear button - should clear input and disable itself
await clearButton.click()
await expect(urlInput).toHaveValue('')
await expect(clearButton).toBeDisabled()
// Placeholder returns, remove button disappears, setting cleared
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
await expect(
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
).toBeHidden()
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.Canvas.BackgroundImage'))
.toBe('')
})
})

View File

@@ -292,6 +292,10 @@ test.describe('Node library sidebar', () => {
const dialog = comfyPage.page.getByRole('dialog', {
name: 'Customize Folder'
})
// Capture the dialog header position before opening the modal color
// picker: while the picker is open it sets aria-hidden on the dialog,
// so it can no longer be located by role.
const dialogBox = await dialog.boundingBox()
await dialog
.locator('.color-customization-selector-container > button')
.last()
@@ -300,6 +304,17 @@ test.describe('Node library sidebar', () => {
.getByLabel('Color saturation and brightness')
.click({ position: { x: 10, y: 10 } })
// The color picker popover is modal: while it is open the rest of the
// dialog is inert (pointer-events disabled), so dismiss it before
// interacting with other controls. A coordinate click on the dialog
// header lands on the dismiss layer and closes the popover.
if (dialogBox) {
await comfyPage.page.mouse.click(dialogBox.x + 40, dialogBox.y + 16)
}
await expect(
comfyPage.page.getByLabel('Color saturation and brightness')
).toBeHidden()
// Select Folder icon (2nd button in Icon group)
const iconGroup = dialog.getByText('Icon').locator('..').getByRole('group')
await iconGroup.getByRole('button').nth(1).click()

View File

@@ -22,7 +22,7 @@
},
"litegraph_base": {
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=",
"CLEAR_BACKGROUND_COLOR": "#141414",
"CLEAR_BACKGROUND_COLOR": "#222222",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_COLOR": "#AAA",

View File

@@ -1,58 +1,23 @@
<template>
<div class="flex gap-2">
<InputText
v-model="modelValue"
class="flex-1"
:placeholder="$t('g.imageUrl')"
/>
<Button
v-tooltip="$t('g.upload')"
variant="secondary"
size="sm"
:aria-label="$t('g.upload')"
:disabled="isUploading"
@click="triggerFileInput"
>
<i :class="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" />
</Button>
<Button
v-tooltip="$t('g.clear')"
variant="destructive"
size="sm"
:aria-label="$t('g.clear')"
:disabled="!modelValue"
@click="clearImage"
>
<i class="pi pi-trash" />
</Button>
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/*"
@change="handleFileUpload"
/>
</div>
<ImageUpload
v-model="modelValue"
:loading="isUploading"
@file-selected="handleFileUpload"
/>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import ImageUpload from '@/components/ui/image-upload/ImageUpload.vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
const modelValue = defineModel<string>()
const fileInput = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const triggerFileInput = () => {
fileInput.value?.click()
}
const uploadFile = async (file: File): Promise<string | null> => {
const body = new FormData()
body.append('image', file)
@@ -74,36 +39,24 @@ const uploadFile = async (file: File): Promise<string | null> => {
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
isUploading.value = true
try {
const uploadedPath = await uploadFile(file)
if (uploadedPath) {
// Set the value to the API view URL with subfolder parameter
const params = new URLSearchParams({
filename: uploadedPath,
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {
useToastStore().addAlert(`Upload error: ${String(error)}`)
} finally {
isUploading.value = false
const handleFileUpload = async (file: File) => {
isUploading.value = true
try {
const uploadedPath = await uploadFile(file)
if (uploadedPath) {
// Set the value to the API view URL with subfolder parameter
const params = new URLSearchParams({
filename: uploadedPath,
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
}
}
}
const clearImage = () => {
modelValue.value = ''
if (fileInput.value) {
fileInput.value.value = ''
} catch (error) {
useToastStore().addAlert(`Upload error: ${String(error)}`)
} finally {
isUploading.value = false
}
}
</script>

View File

@@ -376,13 +376,17 @@ watch(
)
watch(
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
[
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
() => settingStore.get('Comfy.Canvas.BackgroundPattern'),
() => settingStore.get('Comfy.Canvas.BackgroundColor')
],
async () => {
if (!canvasStore.canvas) return
const currentPaletteId = colorPaletteStore.activePaletteId
if (!currentPaletteId) return
// Reload color palette to apply background image
// Reload color palette to apply background image/pattern/color
await colorPaletteService.loadColorPalette(currentPaletteId)
// Mark background canvas as dirty
canvasStore.canvas.setDirty(false, true)

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue'
import Button from '@/components/ui/button/Button.vue'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
@@ -12,6 +14,9 @@ import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { CanvasBackgroundPattern } from '@/utils/canvasPatternUtil'
import { getEffectiveCanvasBackgroundColor } from '@/utils/canvasPatternUtil'
import { cn } from '@comfyorg/tailwind-utils'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
@@ -40,6 +45,77 @@ const nodes2Enabled = computed({
})
// CANVAS settings
const colorPaletteStore = useColorPaletteStore()
type CanvasBackgroundMode = CanvasBackgroundPattern | 'image'
// Keeps the Image option selected while no image is set, e.g. before the
// first upload or right after removing the current one.
const imageModeSelected = ref(false)
const backgroundImage = computed({
get: () => settingStore.get('Comfy.Canvas.BackgroundImage') ?? '',
set: (value) => {
if (!value) imageModeSelected.value = true
settingStore.set('Comfy.Canvas.BackgroundImage', value)
}
})
const isBackgroundImageSet = computed(() => !!backgroundImage.value)
const backgroundMode = computed<CanvasBackgroundMode>({
get: () =>
isBackgroundImageSet.value || imageModeSelected.value
? 'image'
: settingStore.get('Comfy.Canvas.BackgroundPattern'),
set: (value) => {
if (value === 'image') {
imageModeSelected.value = true
return
}
imageModeSelected.value = false
if (isBackgroundImageSet.value) {
settingStore.set('Comfy.Canvas.BackgroundImage', '')
}
settingStore.set('Comfy.Canvas.BackgroundPattern', value)
}
})
const backgroundOptions = computed(() => [
{
value: 'dots',
label: t('settings.Comfy_Canvas_BackgroundPattern.options.Dots')
},
{
value: 'grid',
label: t('settings.Comfy_Canvas_BackgroundPattern.options.Grid')
},
{ value: 'none', label: t('g.none') },
{ value: 'image', label: t('rightSidePanel.globalSettings.image') }
])
const hasCustomBackgroundColor = computed(
() => settingStore.get('Comfy.Canvas.BackgroundColor') !== ''
)
const backgroundColor = computed({
get: () =>
getEffectiveCanvasBackgroundColor(
settingStore.get('Comfy.Canvas.BackgroundColor'),
colorPaletteStore.completedActivePalette.colors.litegraph_base
.CLEAR_BACKGROUND_COLOR
),
set: (value) =>
settingStore.set(
'Comfy.Canvas.BackgroundColor',
value.replace(/^#/, '').slice(0, 6)
)
})
async function resetBackgroundColor() {
await settingStore.set('Comfy.Canvas.BackgroundColor', '')
}
const gridSpacing = computed({
get: () => settingStore.get('Comfy.SnapToGrid.GridSize'),
set: (value) => settingStore.set('Comfy.SnapToGrid.GridSize', value)
@@ -128,6 +204,55 @@ function openFullSettings() {
{{ t('rightSidePanel.globalSettings.canvas') }}
</template>
<div class="space-y-4 px-4 py-3">
<LayoutField
:label="t('rightSidePanel.globalSettings.background')"
:tooltip="t('settings.Comfy_Canvas_BackgroundPattern.tooltip')"
>
<Select
v-model="backgroundMode"
:options="backgroundOptions"
:aria-label="t('rightSidePanel.globalSettings.background')"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: cn('min-w-[4ch] truncate', $slots.default && 'mr-5'),
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
option-label="label"
option-value="value"
/>
</LayoutField>
<BackgroundImageUpload
v-if="backgroundMode === 'image'"
v-model="backgroundImage"
/>
<LayoutField
v-else
:label="t('rightSidePanel.globalSettings.backgroundColor')"
:tooltip="t('settings.Comfy_Canvas_BackgroundColor.tooltip')"
>
<div class="flex items-center gap-1">
<ColorPicker
v-model="backgroundColor"
class="min-w-0 grow"
:aria-label="t('rightSidePanel.globalSettings.backgroundColor')"
/>
<Button
v-if="hasCustomBackgroundColor"
variant="muted-textonly"
size="icon"
:aria-label="
t('rightSidePanel.globalSettings.resetBackgroundColor')
"
@click="resetBackgroundColor"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
</Button>
</div>
</LayoutField>
<LayoutField :label="t('rightSidePanel.globalSettings.gridSpacing')">
<div
:class="

View File

@@ -0,0 +1,34 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import ColorPicker from './ColorPicker.vue'
describe('ColorPicker', () => {
it('does not echo a write back when the model is changed externally', async () => {
const onUpdate = vi.fn()
const { rerender } = render(ColorPicker, {
props: { modelValue: '#823182', 'onUpdate:modelValue': onUpdate }
})
await rerender({ modelValue: '' })
await nextTick()
await nextTick()
expect(onUpdate).not.toHaveBeenCalled()
})
it('shows the latest external color without writing back', async () => {
const onUpdate = vi.fn()
const { rerender } = render(ColorPicker, {
props: { modelValue: '#823182', 'onUpdate:modelValue': onUpdate }
})
await rerender({ modelValue: '#222222' })
await nextTick()
await nextTick()
expect(onUpdate).not.toHaveBeenCalled()
expect(screen.getByText('#222222')).toBeTruthy()
})
})

View File

@@ -5,6 +5,7 @@ import {
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { ZIndex } from '@primeuix/utils/zindex'
import { computed, ref, watch } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
@@ -23,9 +24,15 @@ const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
const displayMode = ref<'hex' | 'rgba'>('hex')
// Guard against echoing external model changes back: hex -> hsva -> hex is
// not an identity (rounding), so without the flag an outside write (e.g.
// resetting a setting to '') would immediately be overwritten.
let syncingFromModel = false
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
syncingFromModel = true
hsva.value = hexToHsva(newVal || '#000000')
}
})
@@ -33,6 +40,10 @@ watch(modelValue, (newVal) => {
watch(
hsva,
(newHsva) => {
if (syncingFromModel) {
syncingFromModel = false
return
}
const hex = hsvaToHex(newHsva)
if (hex !== modelValue.value) {
modelValue.value = hex
@@ -60,10 +71,29 @@ const previewColor = computed(() => {
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
const isOpen = ref(false)
// The popover portals to body, so a static z-index can lose to dialogs that
// take theirs from the shared PrimeVue ZIndex counter (see vRekaZIndex.ts).
// Reka copies the content's z-index onto its popper wrapper, so compute the
// content z-index from the same counter on each open to stack above
// whichever dialog opened the picker.
const BASE_POPOVER_Z_INDEX = 1700
const popoverZIndex = ref(BASE_POPOVER_Z_INDEX)
watch(isOpen, (open) => {
if (open) {
popoverZIndex.value = Math.max(
BASE_POPOVER_Z_INDEX,
ZIndex.getCurrent('modal') + 1
)
}
})
</script>
<template>
<PopoverRoot v-model:open="isOpen">
<!-- Modal so the click that dismisses the popover cannot fall through to
the canvas and start a drag-select marquee. -->
<PopoverRoot v-model:open="isOpen" modal>
<PopoverTrigger as-child>
<button
type="button"
@@ -115,7 +145,7 @@ const isOpen = ref(false)
align="start"
:side-offset="7"
:collision-padding="10"
class="z-1700"
:style="{ zIndex: popoverZIndex }"
>
<ColorPickerPanel
v-model:hsva="hsva"

View File

@@ -0,0 +1,68 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import ImageUpload from './ImageUpload.vue'
const meta: Meta<ComponentPropsAndSlots<typeof ImageUpload>> = {
title: 'Components/ImageUpload',
component: ImageUpload,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
(story) => ({
components: { story },
template: '<div class="w-60"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ImageUpload },
setup() {
const url = ref('')
return { url }
},
template: '<ImageUpload v-model="url" />'
})
}
export const WithImage: Story = {
render: () => ({
components: { ImageUpload },
setup() {
const url = ref('/api/view?filename=mountain+lake.png&type=input')
return { url }
},
template: '<ImageUpload v-model="url" />'
})
}
export const Loading: Story = {
render: () => ({
components: { ImageUpload },
setup() {
const url = ref('')
return { url }
},
template: '<ImageUpload v-model="url" loading />'
})
}
export const Disabled: Story = {
render: () => ({
components: { ImageUpload },
setup() {
const url = ref('')
return { url }
},
template: '<ImageUpload v-model="url" disabled />'
})
}

View File

@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import ImageUpload from './ImageUpload.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderImageUpload(props: Record<string, unknown> = {}) {
return render(ImageUpload, {
props,
global: { plugins: [i18n] }
})
}
describe('ImageUpload', () => {
it('shows a placeholder when no image is set', () => {
renderImageUpload({ modelValue: '' })
expect(screen.getByText('Choose image')).toBeTruthy()
expect(screen.queryByLabelText('Remove image')).toBeNull()
})
it('shows the image base name extracted from the URL', () => {
renderImageUpload({
modelValue:
'/api/view?filename=backgrounds%2Fmountain+lake.png&type=input'
})
expect(screen.getByText('mountain lake.png')).toBeTruthy()
})
it('opens the file browser when the row is clicked', async () => {
const user = userEvent.setup({ applyAccept: false })
renderImageUpload({ modelValue: '' })
const fileInput = screen.getByTestId<HTMLInputElement>('image-upload-input')
const clickSpy = vi.spyOn(fileInput, 'click')
await user.click(screen.getByText('Choose image'))
expect(clickSpy).toHaveBeenCalled()
})
it('emits fileSelected when a file is picked', async () => {
const user = userEvent.setup({ applyAccept: false })
const { emitted } = renderImageUpload({ modelValue: '' })
const file = new File(['x'], 'photo.png', { type: 'image/png' })
await user.upload(
screen.getByTestId<HTMLInputElement>('image-upload-input'),
file
)
expect(emitted('fileSelected')).toEqual([[file]])
})
it('clears the model when the remove button is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderImageUpload({
modelValue: '/api/view?filename=bg.png'
})
await user.click(screen.getByLabelText('Remove image'))
expect(emitted('update:modelValue')).toEqual([['']])
})
})

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
const {
class: className,
disabled = false,
loading = false
} = defineProps<{
class?: string
disabled?: boolean
loading?: boolean
}>()
const modelValue = defineModel<string>({ default: '' })
const emit = defineEmits<{
fileSelected: [file: File]
}>()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
const previewFailed = ref(false)
watch(modelValue, () => {
previewFailed.value = false
})
const imageBaseName = computed(() => {
if (!modelValue.value) return ''
try {
const url = new URL(modelValue.value, window.location.origin)
const filename =
url.searchParams.get('filename') ?? url.pathname.split('/').pop() ?? ''
return filename.split('/').pop() ?? ''
} catch {
return modelValue.value
}
})
function openFileBrowser() {
fileInput.value?.click()
}
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) emit('fileSelected', file)
input.value = ''
}
function clearImage() {
modelValue.value = ''
}
</script>
<template>
<div
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg bg-component-node-widget-background hover:bg-component-node-widget-background-hovered',
(disabled || loading) && 'cursor-not-allowed opacity-50',
className
)
"
>
<button
type="button"
:disabled="disabled || loading"
class="flex h-full min-w-0 flex-1 cursor-pointer items-center border-none bg-transparent p-0 outline-none disabled:cursor-not-allowed"
@click="openFileBrowser"
>
<span class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="loading"
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<img
v-else-if="modelValue && !previewFailed"
:src="modelValue"
alt=""
class="size-5 rounded-sm object-cover"
@error="previewFailed = true"
/>
<i v-else class="icon-[lucide--image] size-4 text-muted-foreground" />
</span>
<span
:class="
cn(
'min-w-0 flex-1 truncate text-left text-xs',
imageBaseName
? 'text-component-node-foreground'
: 'text-muted-foreground'
)
"
>
{{ imageBaseName || t('g.chooseImage') }}
</span>
</button>
<button
v-if="modelValue && !loading"
type="button"
:disabled="disabled"
:aria-label="t('g.removeImage')"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-component-node-foreground outline-none disabled:cursor-not-allowed"
@click="clearImage"
>
<i class="icon-[lucide--x] size-4" />
</button>
<input
ref="fileInput"
data-testid="image-upload-input"
type="file"
class="hidden"
accept="image/*"
@change="handleFileChange"
/>
</div>
</template>

View File

@@ -204,6 +204,7 @@
"control_after_generate": "control after generate",
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"chooseImage": "Choose image",
"uploadAlreadyInProgress": "Upload already in progress",
"uploadTimedOut": "Upload timed out. Please try again.",
"capture": "capture",
@@ -3596,6 +3597,10 @@
"showInfoBadges": "Show info badges",
"showToolbox": "Show toolbox on selection",
"nodes2": "Nodes 2.0",
"background": "Background",
"image": "Image",
"backgroundColor": "Background color",
"resetBackgroundColor": "Reset background color to theme default",
"gridSpacing": "Grid spacing",
"snapNodesToGrid": "Snap nodes to grid",
"linkShape": "Link shape",

View File

@@ -29,10 +29,23 @@
"name": "Disable animations",
"tooltip": "Turns off most CSS animations and transitions. Speeds up inference when the display GPU is also used for generation."
},
"Comfy_Canvas_BackgroundColor": {
"name": "Canvas background color",
"tooltip": "Custom canvas background color. Leave empty to follow the active theme."
},
"Comfy_Canvas_BackgroundImage": {
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
},
"Comfy_Canvas_BackgroundPattern": {
"name": "Canvas background pattern",
"tooltip": "Pattern drawn on the canvas background. Not shown while a custom background image is set.",
"options": {
"Dots": "Dots",
"Grid": "Grid",
"None": "None"
}
},
"Comfy_Canvas_LeftMouseClickBehavior": {
"name": "Left Mouse Click Behavior",
"options": {

View File

@@ -22,7 +22,7 @@ const zAsset = z.object({
})
const zAssetResponse = zListAssetsResponse
.pick({ total: true, has_more: true })
.pick({ total: true, has_more: true, next_cursor: true })
.extend({
assets: z.array(zAsset)
})

View File

@@ -53,6 +53,7 @@ const fetchApiMock = vi.mocked(api.fetchApi)
type AssetListResponseOptions = {
hasMore?: AssetResponse['has_more']
total?: AssetResponse['total']
nextCursor?: AssetResponse['next_cursor']
}
function buildResponse(
@@ -68,9 +69,18 @@ function buildResponse(
function buildAssetListResponse(
assets: AssetItem[],
{ hasMore = false, total = assets.length }: AssetListResponseOptions = {}
{
hasMore = false,
total = assets.length,
nextCursor
}: AssetListResponseOptions = {}
): Response {
return buildResponse({ assets, total, has_more: hasMore })
return buildResponse({
assets,
total,
has_more: hasMore,
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
})
}
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
@@ -512,7 +522,7 @@ describe(assetService.getAllAssetsByTag, () => {
vi.clearAllMocks()
})
it('paginates tagged asset requests with include_public=true', async () => {
it('walks pages by keyset cursor with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
@@ -520,7 +530,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
{ hasMore: true, nextCursor: 'cursor-page-2' }
)
)
.mockResolvedValueOnce(
@@ -538,6 +548,8 @@ describe(assetService.getAllAssetsByTag, () => {
expect(firstParams.get('include_public')).toBe('true')
expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(firstParams.get('limit')).toBe('2')
// First page carries neither a cursor nor an offset.
expect(firstParams.has('after')).toBe(false)
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
@@ -545,7 +557,9 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('include_public')).toBe('true')
expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(secondParams.get('limit')).toBe('2')
expect(secondParams.get('offset')).toBe('2')
// Subsequent pages resume from the prior response's next_cursor, never offset.
expect(secondParams.get('after')).toBe('cursor-page-2')
expect(secondParams.has('offset')).toBe(false)
})
it('honors has_more when walking tagged asset pages', async () => {
@@ -556,7 +570,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
{ hasMore: true }
{ hasMore: true, nextCursor: 'cursor-next' }
)
)
.mockResolvedValueOnce(
@@ -577,7 +591,45 @@ describe(assetService.getAllAssetsByTag, () => {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('offset')).toBe('2')
expect(secondParams.get('after')).toBe('cursor-next')
})
it('stops walking when next_cursor is absent even if has_more is true', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'only', tags: ['input'] })], {
hasMore: true
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['only'])
expect(fetchApiMock).toHaveBeenCalledOnce()
})
it('stops walking when the server returns a non-advancing cursor', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'b', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 1
})
expect(assets.map((a) => a.id)).toEqual(['a', 'b'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it.for([
@@ -636,7 +688,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
{ hasMore: true, nextCursor: 'cursor-page-2' }
)
})

View File

@@ -31,6 +31,11 @@ export interface PaginationOptions {
}
interface AssetPaginationOptions extends PaginationOptions {
/**
* Opaque keyset cursor from a prior response's `next_cursor`. When set, the
* server resumes after that cursor and `offset` is ignored.
*/
after?: string
signal?: AbortSignal
}
@@ -38,6 +43,7 @@ interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
excludeTags?: string[]
includePublic?: boolean
after?: string
signal?: AbortSignal
}
@@ -286,6 +292,7 @@ function createAssetService() {
excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
limit = DEFAULT_LIMIT,
offset,
after,
includePublic,
signal
} = options
@@ -299,7 +306,11 @@ function createAssetService() {
if (normalizedExcludeTags.length > 0) {
queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
}
if (offset !== undefined && offset > 0) {
// `after` (keyset cursor) takes precedence over `offset`; the server ignores
// `offset` when a cursor is supplied, so we avoid sending a redundant param.
if (after) {
queryParams.set('after', after)
} else if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
if (includePublic !== undefined) {
@@ -481,11 +492,17 @@ function createAssetService() {
async function getAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const data = await getAssetsPageByTag(tag, includePublic, {
limit,
offset,
after,
signal
})
@@ -498,17 +515,27 @@ function createAssetService() {
async function getAssetsPageByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
): Promise<AssetResponse> {
return await handleAssetRequest(
{ includeTags: [tag], limit, offset, includePublic, signal },
{ includeTags: [tag], limit, offset, after, includePublic, signal },
`assets for tag ${tag}`
)
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
* Pagination follows the required server-provided `has_more` flag.
*
* Uses keyset (cursor) pagination: each page is fetched with the prior
* response's `next_cursor`, which is stable under concurrent inserts/deletes
* and avoids the duplicate/skip drift that offset paging exhibits when the
* underlying set changes mid-walk. Falls back to terminating on `has_more`
* when the server omits `next_cursor`.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
@@ -520,18 +547,21 @@ function createAssetService() {
async function getAllAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
{
limit = DEFAULT_LIMIT,
signal
}: Pick<AssetPaginationOptions, 'limit' | 'signal'> = {}
): Promise<AssetItem[]> {
const assets: AssetItem[] = []
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
let offset = 0
let after: string | undefined
while (true) {
if (signal?.aborted) throw createAbortError()
const data = await getAssetsPageByTag(tag, includePublic, {
limit: pageSize,
offset,
after,
signal
})
const batch = data.assets
@@ -541,11 +571,12 @@ function createAssetService() {
assets.push(...batch)
if (!data.has_more) {
// A server that returns a non-advancing cursor would loop forever.
if (!data.has_more || !data.next_cursor || data.next_cursor === after) {
return assets
}
offset += batch.length
after = data.next_cursor
}
}

View File

@@ -1136,6 +1136,31 @@ export const CORE_SETTINGS: SettingParams[] = [
versionAdded: '1.20.4',
versionModified: '1.20.5'
},
{
id: 'Comfy.Canvas.BackgroundPattern',
category: ['Appearance', 'Canvas', 'BackgroundPattern'],
name: 'Canvas background pattern',
type: 'combo',
options: [
{ value: 'dots', text: 'Dots' },
{ value: 'grid', text: 'Grid' },
{ value: 'none', text: 'None' }
],
tooltip:
'Pattern drawn on the canvas background. Not shown while a custom background image is set.',
defaultValue: 'dots',
versionAdded: '1.47.1'
},
{
id: 'Comfy.Canvas.BackgroundColor',
category: ['Appearance', 'Canvas', 'BackgroundColor'],
name: 'Canvas background color',
type: 'color',
tooltip:
'Custom canvas background color. Leave empty to follow the active theme.',
defaultValue: '',
versionAdded: '1.47.1'
},
// Release data stored in settings
{
id: 'Comfy.Release.Version',

View File

@@ -53,7 +53,8 @@ describe('useReleaseService', () => {
project: 'comfyui',
current_version: '1.0.0'
},
signal: undefined
signal: undefined,
headers: undefined
})
expect(result).toEqual(mockReleases)
@@ -76,7 +77,8 @@ describe('useReleaseService', () => {
current_version: '1.0.0',
form_factor: 'desktop-windows'
},
signal: undefined
signal: undefined,
headers: undefined
})
expect(result).toEqual(mockReleases)
@@ -86,11 +88,30 @@ describe('useReleaseService', () => {
const abortController = new AbortController()
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases({ project: 'comfyui' }, abortController.signal)
await service.getReleases(
{ project: 'comfyui' },
{ signal: abortController.signal }
)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: abortController.signal
signal: abortController.signal,
headers: undefined
})
})
it('should send Comfy-Env header when deployEnvironment is provided', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases(
{ project: 'comfyui' },
{ deployEnvironment: 'local-desktop' }
)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: undefined,
headers: { 'Comfy-Env': 'local-desktop' }
})
})

View File

@@ -98,8 +98,9 @@ export const useReleaseService = () => {
// Fetch release notes from API
const getReleases = async (
params: GetReleasesParams,
signal?: AbortSignal
options: { signal?: AbortSignal; deployEnvironment?: string } = {}
): Promise<ReleaseNote[] | null> => {
const { signal, deployEnvironment } = options
const endpoint = '/releases'
const errorContext = 'Failed to get releases'
const routeSpecificErrors = {
@@ -110,7 +111,10 @@ export const useReleaseService = () => {
() =>
releaseApiClient.get<ReleaseNote[]>(endpoint, {
params,
signal
signal,
headers: deployEnvironment
? { 'Comfy-Env': deployEnvironment }
: undefined
}),
errorContext,
routeSpecificErrors

View File

@@ -228,12 +228,15 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
})
})
@@ -300,12 +303,15 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
expect(store.releases).toEqual([mockRelease])
})
@@ -318,12 +324,30 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
})
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
},
{ deployEnvironment: undefined }
)
})
it('should pass deploy_environment from system stats', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.deploy_environment = 'local-desktop'
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
expect.anything(),
{ deployEnvironment: 'local-desktop' }
)
})
it('should skip fetching when --disable-api-nodes is present', async () => {

View File

@@ -266,12 +266,18 @@ export const useReleaseStore = defineStore('release', () => {
await until(systemStatsStore.isInitialized)
}
const fetchedReleases = await releaseService.getReleases({
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})
const fetchedReleases = await releaseService.getReleases(
{
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
},
{
deployEnvironment:
systemStatsStore.systemStats?.system?.deploy_environment
}
)
if (fetchedReleases !== null) {
releases.value = fetchedReleases

View File

@@ -252,6 +252,7 @@ const zSystemStats = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),
@@ -307,6 +308,8 @@ const zSettings = z.object({
'Comfy.ColorPalette': z.string(),
'Comfy.CustomColorPalettes': colorPalettesSchema,
'Comfy.Canvas.BackgroundImage': z.string().optional(),
'Comfy.Canvas.BackgroundPattern': z.enum(['dots', 'grid', 'none']),
'Comfy.Canvas.BackgroundColor': z.string(),
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),
'Comfy.Appearance.DisableAnimations': z.boolean(),

View File

@@ -108,6 +108,11 @@ interface QueuePromptRequestBody {
* ```
*/
api_key_comfy_org?: string
/**
* Identifies the client submitting the prompt. Forwarded by the backend
* to API nodes' upstream requests via the Comfy-Usage-Source header.
*/
comfy_usage_source?: string
/**
* Override the preview method for this prompt execution.
* 'default' uses the server's CLI setting.
@@ -867,6 +872,7 @@ export class ComfyApi extends EventTarget {
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,
comfy_usage_source: 'comfyui-frontend',
extra_pnginfo: { workflow },
...(options?.previewMethod &&
options.previewMethod !== 'default' && {

View File

@@ -0,0 +1,90 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as canvasPatternUtil from '@/utils/canvasPatternUtil'
const mockSettings = vi.hoisted(() => ({
values: {} as Record<string, unknown>
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
background_image: 'initial',
clear_background_color: 'initial',
_pattern: 'initial',
node_title_color: '',
default_link_color: '',
default_connection_color_byType: {},
setDirty: vi.fn()
}
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => mockSettings.values[key],
set: vi.fn()
})
}))
vi.mock('@/utils/canvasPatternUtil', async (importOriginal) => ({
...(await importOriginal<typeof canvasPatternUtil>()),
generateCanvasPatternImage: vi.fn(
(pattern: string, color: string) => `tile:${pattern}:${color}`
)
}))
import { app } from '@/scripts/app'
import { useColorPaletteService } from '@/services/colorPaletteService'
describe('colorPaletteService canvas background', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockSettings.values = {
'Comfy.Canvas.BackgroundImage': '',
'Comfy.Canvas.BackgroundPattern': 'dots',
'Comfy.Canvas.BackgroundColor': ''
}
app.canvas.background_image = 'initial'
app.canvas.clear_background_color = 'initial'
app.canvas._pattern = 'initial' as unknown as undefined
})
it('renders the generated pattern over the palette color by default', async () => {
await useColorPaletteService().loadColorPalette('dark')
expect(app.canvas.background_image).toBe('tile:dots:#222222')
expect(app.canvas.clear_background_color).toBe('#222222')
expect(app.canvas._pattern).toBeUndefined()
})
it('uses the user background color when set', async () => {
mockSettings.values['Comfy.Canvas.BackgroundColor'] = 'aabbcc'
mockSettings.values['Comfy.Canvas.BackgroundPattern'] = 'grid'
await useColorPaletteService().loadColorPalette('dark')
expect(app.canvas.background_image).toBe('tile:grid:#aabbcc')
expect(app.canvas.clear_background_color).toBe('#aabbcc')
})
it('renders a solid tile when the pattern is none', async () => {
mockSettings.values['Comfy.Canvas.BackgroundPattern'] = 'none'
await useColorPaletteService().loadColorPalette('dark')
expect(app.canvas.background_image).toBe('tile:none:#222222')
})
it('lets a custom background image take precedence over patterns', async () => {
mockSettings.values['Comfy.Canvas.BackgroundImage'] = 'https://bg.png'
await useColorPaletteService().loadColorPalette('dark')
expect(app.canvas.background_image).toBe('')
expect(app.canvas.clear_background_color).toBe('transparent')
expect(app.canvas._pattern).toBeUndefined()
})
})

View File

@@ -11,6 +11,10 @@ import type { Colors, Palette } from '@/schemas/colorPaletteSchema'
import { app } from '@/scripts/app'
import { uploadFile } from '@/scripts/utils'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
generateCanvasPatternImage,
getEffectiveCanvasBackgroundColor
} from '@/utils/canvasPatternUtil'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const THEME_PROPERTY_MAP = {
@@ -153,10 +157,18 @@ export const useColorPaletteService = () => {
app.canvas.default_link_color = palette.LINK_COLOR
const backgroundImage = settingStore.get('Comfy.Canvas.BackgroundImage')
if (backgroundImage) {
app.canvas.background_image = ''
app.canvas.clear_background_color = 'transparent'
} else {
app.canvas.background_image = palette.BACKGROUND_IMAGE
app.canvas.clear_background_color = palette.CLEAR_BACKGROUND_COLOR
const backgroundColor = getEffectiveCanvasBackgroundColor(
settingStore.get('Comfy.Canvas.BackgroundColor'),
palette.CLEAR_BACKGROUND_COLOR
)
app.canvas.background_image = generateCanvasPatternImage(
settingStore.get('Comfy.Canvas.BackgroundPattern'),
backgroundColor
)
app.canvas.clear_background_color = backgroundColor
}
app.canvas._pattern = undefined

View File

@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
generateCanvasPatternImage,
getEffectiveCanvasBackgroundColor,
getPatternMarkColor
} from '@/utils/canvasPatternUtil'
interface RecordingContext {
fillStyle: string
strokeStyle: string
lineWidth: number
groundColor: string
fillRect: ReturnType<typeof vi.fn>
beginPath: ReturnType<typeof vi.fn>
arc: ReturnType<typeof vi.fn>
fill: ReturnType<typeof vi.fn>
moveTo: ReturnType<typeof vi.fn>
lineTo: ReturnType<typeof vi.fn>
stroke: ReturnType<typeof vi.fn>
}
function createRecordingContext(): RecordingContext {
const ctx: RecordingContext = {
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
groundColor: '',
fillRect: vi.fn(() => {
ctx.groundColor = ctx.fillStyle
}),
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn()
}
return ctx
}
let contexts: RecordingContext[]
let getContextSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
contexts = []
getContextSpy = vi
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockImplementation(() => {
const ctx = createRecordingContext()
contexts.push(ctx)
return ctx as unknown as GPUCanvasContext
})
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
'data:image/png;base64,sentinel'
)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('getPatternMarkColor', () => {
it('uses faint light marks on dark backgrounds', () => {
expect(getPatternMarkColor('#141414')).toBe('rgba(255, 255, 255, 0.10)')
expect(getPatternMarkColor('#0000ff')).toBe('rgba(255, 255, 255, 0.10)')
})
it('uses faint dark marks on light backgrounds', () => {
expect(getPatternMarkColor('#f0f0f0')).toBe('rgba(0, 0, 0, 0.13)')
expect(getPatternMarkColor('#ffffff')).toBe('rgba(0, 0, 0, 0.13)')
})
})
describe('getEffectiveCanvasBackgroundColor', () => {
it('prefers the user setting over the palette color', () => {
expect(getEffectiveCanvasBackgroundColor('aabbcc', '#222222')).toBe(
'#aabbcc'
)
})
it('falls back to the palette color when the setting is empty', () => {
expect(getEffectiveCanvasBackgroundColor('', '#222222')).toBe('#222222')
})
it('accepts a #-prefixed stored value', () => {
expect(getEffectiveCanvasBackgroundColor('#aabbcc', '#222222')).toBe(
'#aabbcc'
)
})
})
describe('generateCanvasPatternImage', () => {
it('fills the ground with the background color and draws no marks for none', () => {
generateCanvasPatternImage('none', '#101010')
const ctx = contexts.at(-1)!
expect(ctx.fillRect).toHaveBeenCalledExactlyOnceWith(0, 0, 100, 100)
expect(ctx.groundColor).toBe('#101010')
expect(ctx.arc).not.toHaveBeenCalled()
expect(ctx.stroke).not.toHaveBeenCalled()
})
it('draws a 5x5 grid of dots for the dots pattern', () => {
generateCanvasPatternImage('dots', '#111111')
const ctx = contexts.at(-1)!
expect(ctx.arc).toHaveBeenCalledTimes(25)
expect(ctx.fill).toHaveBeenCalledTimes(25)
expect(ctx.stroke).not.toHaveBeenCalled()
})
it('draws 5 vertical and 5 horizontal lines for the grid pattern', () => {
generateCanvasPatternImage('grid', '#121212')
const ctx = contexts.at(-1)!
expect(ctx.stroke).toHaveBeenCalledTimes(10)
expect(ctx.arc).not.toHaveBeenCalled()
})
it('returns a data URI', () => {
expect(generateCanvasPatternImage('dots', '#131313')).toBe(
'data:image/png;base64,sentinel'
)
})
it('strips alpha from 8-digit hex backgrounds', () => {
generateCanvasPatternImage('dots', '#101010ff')
expect(contexts.at(-1)!.groundColor).toBe('#101010')
})
it('memoizes tiles per pattern and color', () => {
generateCanvasPatternImage('grid', '#151515')
const callsAfterFirst = getContextSpy.mock.calls.length
generateCanvasPatternImage('grid', '#151515')
expect(getContextSpy.mock.calls.length).toBe(callsAfterFirst)
})
})

View File

@@ -0,0 +1,120 @@
import { memoize } from 'es-toolkit/compat'
import { isLightColor, parseToRgb, rgbToHex } from '@/utils/colorUtil'
export type CanvasBackgroundPattern = 'dots' | 'grid' | 'none'
const TILE_SIZE = 100
const SPACING = 20
const DOT_RADIUS = 1.25
const GRID_LINE_WIDTH = 1
const LIGHT_MARK_COLOR = 'rgba(255, 255, 255, 0.10)'
const DARK_MARK_COLOR = 'rgba(0, 0, 0, 0.13)'
let sharedContext: CanvasRenderingContext2D | null = null
function getSharedContext(): CanvasRenderingContext2D {
sharedContext ??= document.createElement('canvas').getContext('2d')
if (!sharedContext) throw new Error('2D canvas context unavailable')
return sharedContext
}
/**
* Normalizes any CSS color (hex, rgb()/hsl(), named colors like `lightgray`)
* to opaque lowercase `#rrggbb`.
*/
function normalizeToHexColor(color: string): string {
const trimmed = color.trim()
if (trimmed.startsWith('#')) {
return rgbToHex(parseToRgb(trimmed)).toLowerCase()
}
const ctx = getSharedContext()
ctx.fillStyle = '#000000'
ctx.fillStyle = trimmed
const parsed = ctx.fillStyle
return parsed.startsWith('#')
? parsed.toLowerCase()
: rgbToHex(parseToRgb(parsed)).toLowerCase()
}
/**
* Resolves the canvas background color: a user-set value (stored as hex
* without `#`) wins over the active palette's color.
*/
export function getEffectiveCanvasBackgroundColor(
settingValue: string,
paletteColor: string
): string {
return normalizeToHexColor(
settingValue ? `#${settingValue.replace(/^#/, '')}` : paletteColor
)
}
/** Faint mark color auto-contrasted against the background. */
export function getPatternMarkColor(backgroundColor: string): string {
return isLightColor(normalizeToHexColor(backgroundColor))
? DARK_MARK_COLOR
: LIGHT_MARK_COLOR
}
function drawDots(ctx: CanvasRenderingContext2D) {
for (let x = SPACING / 2; x < TILE_SIZE; x += SPACING) {
for (let y = SPACING / 2; y < TILE_SIZE; y += SPACING) {
ctx.beginPath()
ctx.arc(x, y, DOT_RADIUS, 0, Math.PI * 2)
ctx.fill()
}
}
}
function drawGrid(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = GRID_LINE_WIDTH
for (let offset = 0; offset < TILE_SIZE; offset += SPACING) {
ctx.beginPath()
ctx.moveTo(offset + 0.5, 0)
ctx.lineTo(offset + 0.5, TILE_SIZE)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(0, offset + 0.5)
ctx.lineTo(TILE_SIZE, offset + 0.5)
ctx.stroke()
}
}
function renderPatternImage(
pattern: CanvasBackgroundPattern,
backgroundColor: string
): string {
const canvas = document.createElement('canvas')
canvas.width = TILE_SIZE
canvas.height = TILE_SIZE
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D canvas context unavailable')
const ground = normalizeToHexColor(backgroundColor)
ctx.fillStyle = ground
ctx.fillRect(0, 0, TILE_SIZE, TILE_SIZE)
const markColor = getPatternMarkColor(ground)
ctx.fillStyle = markColor
ctx.strokeStyle = markColor
if (pattern === 'dots') drawDots(ctx)
if (pattern === 'grid') drawGrid(ctx)
return canvas.toDataURL('image/png')
}
/**
* Generates an opaque repeating background tile as a data URI. The tile is
* always opaque (including `none`) because LGraphCanvas paints only the tile
* at zoom levels >= 1.5.
*/
export const generateCanvasPatternImage: (
pattern: CanvasBackgroundPattern,
backgroundColor: string
) => string = memoize(
renderPatternImage,
(pattern: CanvasBackgroundPattern, backgroundColor: string) =>
`${pattern}:${backgroundColor}`
)

View File

@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from 'vitest'
import type { ColorAdjustOptions } from '@/utils/colorUtil'
import {
adjustColor,
getRelativeLuminance,
hexToHsva,
hexToInt,
hexToRgb,
hsbToRgb,
hsvaToHex,
isLightColor,
isTransparent,
parseToRgb,
rgbToHex,
@@ -410,3 +412,28 @@ describe('colorUtil - adjustColor', () => {
})
})
})
describe('colorUtil - luminance', () => {
describe('getRelativeLuminance', () => {
it('returns 0 for black and 1 for white', () => {
expect(getRelativeLuminance('#000000')).toBe(0)
expect(getRelativeLuminance('#ffffff')).toBeCloseTo(1)
})
it('returns the WCAG luminance for mid gray', () => {
expect(getRelativeLuminance('#808080')).toBeCloseTo(0.2159, 3)
})
})
describe('isLightColor', () => {
it('classifies dark backgrounds as dark', () => {
expect(isLightColor('#141414')).toBe(false)
expect(isLightColor('#0000ff')).toBe(false)
})
it('classifies light backgrounds as light', () => {
expect(isLightColor('#f0f0f0')).toBe(true)
expect(isLightColor('#808080')).toBe(true)
})
})
})

View File

@@ -404,6 +404,26 @@ export function hsvaToHex(hsva: HSVA): string {
return `${hex}${alphaHex}`.toLowerCase()
}
function linearizeSrgbChannel(channel: number): number {
const c = channel / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
/** WCAG relative luminance (0..1) of a parseable CSS color. */
export function getRelativeLuminance(color: string): number {
const { r, g, b } = parseToRgb(color)
return (
0.2126 * linearizeSrgbChannel(r) +
0.7152 * linearizeSrgbChannel(g) +
0.0722 * linearizeSrgbChannel(b)
)
}
/** True when dark foreground marks should be used on this background. */
export function isLightColor(color: string): boolean {
return getRelativeLuminance(color) > 0.179
}
const applyColorAdjustments = (
color: string,
options: ColorAdjustOptions