diff --git a/browser_tests/ComfyPage.ts b/browser_tests/ComfyPage.ts index 55d973b82..324133eb5 100644 --- a/browser_tests/ComfyPage.ts +++ b/browser_tests/ComfyPage.ts @@ -1,6 +1,7 @@ import type { Page, Locator } from '@playwright/test' import { test as base } from '@playwright/test' import { expect } from '@playwright/test' +import { ComfyAppMenu } from './helpers/appMenu' import dotenv from 'dotenv' dotenv.config() import * as fs from 'fs' @@ -237,6 +238,7 @@ export class ComfyPage { // Components public readonly searchBox: ComfyNodeSearchBox public readonly menu: ComfyMenu + public readonly appMenu: ComfyAppMenu constructor(public readonly page: Page) { this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188' @@ -247,6 +249,7 @@ export class ComfyPage { this.workflowUploadInput = page.locator('#comfy-file-input') this.searchBox = new ComfyNodeSearchBox(page) this.menu = new ComfyMenu(page) + this.appMenu = new ComfyAppMenu(page) } async getGraphNodesCount(): Promise { diff --git a/browser_tests/appMenu.spec.ts b/browser_tests/appMenu.spec.ts new file mode 100644 index 000000000..61eb18512 --- /dev/null +++ b/browser_tests/appMenu.spec.ts @@ -0,0 +1,120 @@ +import type { Response } from '@playwright/test' +import type { StatusWsMessage } from '../src/types/apiTypes.ts' +import { expect, mergeTests } from '@playwright/test' +import { comfyPageFixture } from './ComfyPage' +import { webSocketFixture } from './fixtures/ws.ts' + +const test = mergeTests(comfyPageFixture, webSocketFixture) + +test.describe('AppMenu', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating') + }) + + test.afterEach(async ({ comfyPage }) => { + const currentThemeId = await comfyPage.menu.getThemeId() + if (currentThemeId !== 'dark') { + await comfyPage.menu.toggleTheme() + } + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') + }) + + /** + * This test ensures that the autoqueue change mode can only queue one change at a time + */ + test('Does not auto-queue multiple changes at a time', async ({ + comfyPage, + ws + }) => { + // Enable change auto-queue mode + let queueOpts = await comfyPage.appMenu.queueButton.toggleOptions() + expect(await queueOpts.getMode()).toBe('disabled') + await queueOpts.setMode('change') + await comfyPage.nextFrame() + expect(await queueOpts.getMode()).toBe('change') + await comfyPage.appMenu.queueButton.toggleOptions() + + // Intercept the prompt queue endpoint + let promptNumber = 0 + comfyPage.page.route('**/api/prompt', async (route, req) => { + await new Promise((r) => setTimeout(r, 100)) + route.fulfill({ + status: 200, + body: JSON.stringify({ + prompt_id: promptNumber, + number: ++promptNumber, + node_errors: {}, + // Include the request data to validate which prompt was queued so we can validate the width + __request: req.postDataJSON() + }) + }) + }) + + // Start watching for a message to prompt + const requestPromise = comfyPage.page.waitForResponse('**/api/prompt') + + // Find and set the width on the latent node + const triggerChange = async (value: number) => { + return await comfyPage.page.evaluate((value) => { + const node = window['app'].graph._nodes.find( + (n) => n.type === 'EmptyLatentImage' + ) + node.widgets[0].value = value + window['app'].workflowManager.activeWorkflow.changeTracker.checkState() + }, value) + } + + // Trigger a status websocket message + const triggerStatus = async (queueSize: number) => { + await ws.trigger({ + type: 'status', + data: { + status: { + exec_info: { + queue_remaining: queueSize + } + } + } + } as StatusWsMessage) + } + + // Extract the width from the queue response + const getQueuedWidth = async (resp: Promise) => { + const obj = await (await resp).json() + return obj['__request']['prompt']['5']['inputs']['width'] + } + + // Trigger a bunch of changes + const START = 32 + const END = 64 + for (let i = START; i <= END; i += 8) { + await triggerChange(i) + } + + // Ensure the queued width is the first value + expect( + await getQueuedWidth(requestPromise), + 'the first queued prompt should be the first change width' + ).toBe(START) + + // Ensure that no other changes are queued + await expect( + comfyPage.page.waitForResponse('**/api/prompt', { timeout: 250 }) + ).rejects.toThrow() + expect( + promptNumber, + 'only 1 prompt should have been queued even though there were multiple changes' + ).toBe(1) + + // Trigger a status update so auto-queue re-runs + await triggerStatus(1) + await triggerStatus(0) + + // Ensure the queued width is the last queued value + expect( + await getQueuedWidth(comfyPage.page.waitForResponse('**/api/prompt')), + 'last queued prompt width should be the last change' + ).toBe(END) + expect(promptNumber, 'queued prompt count should be 2').toBe(2) + }) +}) diff --git a/browser_tests/fixtures/ws.ts b/browser_tests/fixtures/ws.ts new file mode 100644 index 000000000..e12c53465 --- /dev/null +++ b/browser_tests/fixtures/ws.ts @@ -0,0 +1,51 @@ +import { test as base } from '@playwright/test' + +export const webSocketFixture = base.extend<{ + ws: { trigger(data: any, url?: string): Promise } +}>({ + ws: [ + async ({ page }, use) => { + // Each time a page loads, to catch navigations + page.on('load', async () => { + await page.evaluate(function () { + // Create a wrapper for WebSocket that stores them globally + // so we can look it up to trigger messages + const store: Record = ((window as any).__ws__ = {}) + window.WebSocket = class extends window.WebSocket { + constructor() { + // @ts-expect-error + super(...arguments) + store[this.url] = this + } + } + }) + }) + + await use({ + async trigger(data, url) { + // Trigger a websocket event on the page + await page.evaluate( + function ([data, url]) { + if (!url) { + // If no URL specified, use page URL + const u = new URL(window.location.toString()) + u.protocol = 'ws:' + u.pathname = '/' + url = u.toString() + 'ws' + } + const ws: WebSocket = (window as any).__ws__[url] + ws.dispatchEvent( + new MessageEvent('message', { + data + }) + ) + }, + [JSON.stringify(data), url] + ) + } + }) + }, + // We need this to run automatically as the first thing so it adds handlers as soon as the page loads + { auto: true } + ] +}) diff --git a/browser_tests/helpers/appMenu.ts b/browser_tests/helpers/appMenu.ts new file mode 100644 index 000000000..3bf1070e2 --- /dev/null +++ b/browser_tests/helpers/appMenu.ts @@ -0,0 +1,65 @@ +import type { Page, Locator } from '@playwright/test' + +export class ComfyAppMenu { + public readonly root: Locator + public readonly queueButton: ComfyQueueButton + + constructor(public readonly page: Page) { + this.root = page.locator('.app-menu') + this.queueButton = new ComfyQueueButton(this) + } +} + +class ComfyQueueButton { + public readonly root: Locator + public readonly primaryButton: Locator + public readonly dropdownButton: Locator + constructor(public readonly appMenu: ComfyAppMenu) { + this.root = appMenu.root.getByTestId('queue-button') + this.primaryButton = this.root.locator('.p-splitbutton-button') + this.dropdownButton = this.root.locator('.p-splitbutton-dropdown') + } + + public async toggleOptions() { + await this.dropdownButton.click() + return new ComfyQueueButtonOptions(this.appMenu.page) + } +} + +class ComfyQueueButtonOptions { + public readonly popup: Locator + public readonly modes: { + disabled: { input: Locator; wrapper: Locator } + instant: { input: Locator; wrapper: Locator } + change: { input: Locator; wrapper: Locator } + } + + constructor(public readonly page: Page) { + this.popup = page.getByTestId('queue-options') + this.modes = (['disabled', 'instant', 'change'] as const).reduce( + (modes, mode) => { + modes[mode] = { + input: page.locator(`#autoqueue-${mode}`), + wrapper: page.getByTestId(`autoqueue-${mode}`) + } + return modes + }, + {} as ComfyQueueButtonOptions['modes'] + ) + } + + public async setMode(mode: keyof ComfyQueueButtonOptions['modes']) { + await this.modes[mode].input.click() + } + + public async getMode() { + return ( + await Promise.all( + Object.entries(this.modes).map(async ([mode, opt]) => [ + mode, + await opt.wrapper.getAttribute('data-p-checked') + ]) + ) + ).find(([, checked]) => checked === 'true')?.[0] + } +} diff --git a/src/App.vue b/src/App.vue index 02558a85a..f6761f3cb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,7 @@ + + + diff --git a/src/i18n.ts b/src/i18n.ts index 6bd2fa3be..9e2cfb07d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -57,6 +57,26 @@ const messages = { coverImagePreview: 'Fit Image Preview', clearPendingTasks: 'Clear Pending Tasks' } + }, + menu: { + batchCount: 'Batch Count', + batchCountTooltip: + 'The number of times the workflow generation should be queued', + autoQueue: 'Auto Queue', + disabled: 'Disabled', + disabledTooltip: 'The workflow will not be automatically queued', + instant: 'Instant', + instantTooltip: + 'The workflow will be queued instantly after a generation finishes', + change: 'On Change', + changeTooltip: 'The workflow will be queued once a change is made', + queueWorkflow: 'Queue workflow', + generate: 'Generate', + interrupt: 'Cancel current run', + refresh: 'Refresh node definitions', + clipspace: 'Open Clipspace', + resetView: 'Reset canvas view', + clear: 'Clear workflow' } }, zh: { diff --git a/src/scripts/ui/menu/index.ts b/src/scripts/ui/menu/index.ts index b753ccc26..7181cbd52 100644 --- a/src/scripts/ui/menu/index.ts +++ b/src/scripts/ui/menu/index.ts @@ -11,7 +11,7 @@ import { getInterruptButton } from './interruptButton' import './menu.css' import type { ComfySettingsDialog } from '../settings' -type MenuPosition = 'Disabled' | 'Top' | 'Bottom' +type MenuPosition = 'Disabled' | 'Top' | 'Bottom' | 'Floating' const collapseOnMobile = (t) => { ;(t.element ?? t).classList.add('comfyui-menu-mobile-collapse') @@ -89,7 +89,7 @@ export class ComfyAppMenu { }) ) - this.actionsGroup = new ComfyButtonGroup( + const actionButtons = [ new ComfyButton({ icon: 'refresh', content: 'Refresh', @@ -123,13 +123,14 @@ export class ComfyAppMenu { } } }) - ) + ] + this.actionsGroup = new ComfyButtonGroup(...actionButtons) + // Keep the settings group as there are custom scripts attaching extra // elements to it. this.settingsGroup = new ComfyButtonGroup() - this.viewGroup = new ComfyButtonGroup( - getInterruptButton('nlg-hide').element - ) + const interruptButton = getInterruptButton('nlg-hide').element + this.viewGroup = new ComfyButtonGroup(interruptButton) this.mobileMenuButton = new ComfyButton({ icon: 'menu', action: (_, btn) => { @@ -165,15 +166,36 @@ export class ComfyAppMenu { experimental: true, tooltip: 'On small screens the menu will always be at the top.', type: 'combo', - options: ['Disabled', 'Top', 'Bottom'], + options: ['Disabled', 'Floating', 'Top', 'Bottom'], onChange: async (v: MenuPosition) => { if (v && v !== 'Disabled') { - if (!resizeHandler) { - resizeHandler = () => { - this.calculateSizeBreak() + const floating = v === 'Floating' + if (floating) { + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler) + resizeHandler = null + } + this.element.classList.add('floating') + document.body.classList.add('comfyui-floating-menu') + } else { + this.element.classList.remove('floating') + document.body.classList.remove('comfyui-floating-menu') + if (!resizeHandler) { + resizeHandler = () => { + this.calculateSizeBreak() + } + window.addEventListener('resize', resizeHandler) } - window.addEventListener('resize', resizeHandler) } + + for (const b of [ + ...actionButtons.map((b) => b.element), + interruptButton, + this.queueButton.element + ]) { + b.style.display = floating ? 'none' : null + } + this.updatePosition(v) } else { if (resizeHandler) { @@ -204,7 +226,11 @@ export class ComfyAppMenu { } else { this.app.bodyTop.prepend(this.element) } - this.calculateSizeBreak() + if (v === 'Floating') { + this.updateSizeBreak(0, this.#sizeBreaks.indexOf(this.#sizeBreak), -999) + } else { + this.calculateSizeBreak() + } } updateSizeBreak(idx: number, prevIdx: number, direction: number) { diff --git a/src/scripts/ui/menu/menu.css b/src/scripts/ui/menu/menu.css index 7090eb5b0..b62a6b092 100644 --- a/src/scripts/ui/menu/menu.css +++ b/src/scripts/ui/menu/menu.css @@ -1,3 +1,6 @@ +:root { + --comfy-floating-menu-height: 45px; +} .relative { position: relative; } @@ -118,6 +121,9 @@ overflow: hidden; } +.comfyui-button-group:empty { + display: none; +} .comfyui-button-group > .comfyui-button, .comfyui-button-group > .comfyui-button-wrapper > .comfyui-button { padding: 4px 10px; @@ -142,6 +148,22 @@ overflow: auto; max-height: 90vh; } +.comfyui-menu.floating { + width: max-content; + padding: 8px 0 8px 12px; + overflow: hidden; + border-bottom-right-radius: 12px; + height: var(--comfy-floating-menu-height); + position: absolute; +} + +.comfyui-menu.floating .comfyui-logo { + padding-right: 8px; +} + +.comfyui-floating-menu .comfyui-body-left { + margin-top: var(--comfy-floating-menu-height); +} .comfyui-menu>* { flex-shrink: 0; @@ -708,4 +730,5 @@ } .comfyui-body-bottom .lt-sm.comfyui-menu > .comfyui-menu-button{ bottom: 41px; -} \ No newline at end of file +} + diff --git a/src/scripts/ui/menu/workflows.ts b/src/scripts/ui/menu/workflows.ts index f3d044c81..6a091c9d0 100644 --- a/src/scripts/ui/menu/workflows.ts +++ b/src/scripts/ui/menu/workflows.ts @@ -232,7 +232,7 @@ export class ComfyWorkflowsMenu { const r = getExtraMenuOptions?.apply?.(this, arguments) const setting = app.ui.settings.getSettingValue( 'Comfy.UseNewMenu', - false + 'Disabled' ) if (setting && setting != 'Disabled') { const t = this diff --git a/src/services/autoQueueService.ts b/src/services/autoQueueService.ts new file mode 100644 index 000000000..cbd48cbae --- /dev/null +++ b/src/services/autoQueueService.ts @@ -0,0 +1,41 @@ +import { + useQueueSettingsStore, + useQueuePendingTaskCountStore +} from '@/stores/queueStore' +import { app } from '@/scripts/app' +import { api } from '@/scripts/api' + +export function setupAutoQueueHandler() { + const queueCountStore = useQueuePendingTaskCountStore() + const queueSettingsStore = useQueueSettingsStore() + + let graphHasChanged = false + let internalCount = 0 // Use an internal counter here so it is instantly updated when re-queuing + api.addEventListener('graphChanged', () => { + if (queueSettingsStore.mode === 'change') { + if (internalCount) { + graphHasChanged = true + } else { + graphHasChanged = false + app.queuePrompt(0, queueSettingsStore.batchCount) + internalCount++ + } + } + }) + + queueCountStore.$subscribe( + () => { + internalCount = queueCountStore.count + if (!internalCount && !app.lastExecutionError) { + if ( + queueSettingsStore.mode === 'instant' || + (queueSettingsStore.mode === 'change' && graphHasChanged) + ) { + graphHasChanged = false + app.queuePrompt(0, queueSettingsStore.batchCount) + } + } + }, + { detached: true } + ) +} diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index 6370ae0ed..dd0931818 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -355,3 +355,12 @@ export const useQueuePendingTaskCountStore = defineStore( } } ) + +export type AutoQueueMode = 'disabled' | 'instant' | 'change' + +export const useQueueSettingsStore = defineStore('queueSettingsStore', { + state: () => ({ + mode: 'disabled' as AutoQueueMode, + batchCount: 1 + }) +}) diff --git a/src/types/apiTypes.ts b/src/types/apiTypes.ts index 420b90295..73da75a37 100644 --- a/src/types/apiTypes.ts +++ b/src/types/apiTypes.ts @@ -493,8 +493,8 @@ const zSettings = z.record(z.any()).and( 'Comfy.SnapToGrid.GridSize': z.number(), 'Comfy.TextareaWidget.FontSize': z.number(), 'Comfy.TextareaWidget.Spellcheck': z.boolean(), + 'Comfy.UseNewMenu': z.enum(['Disabled', 'Floating', 'Top', 'Bottom']), 'Comfy.TreeExplorer.ItemPadding': z.number(), - 'Comfy.UseNewMenu': z.any(), 'Comfy.Validation.Workflows': z.boolean(), 'Comfy.Workflow.SortNodeIdOnSave': z.boolean(), 'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),