Apply new code format standard (#217)

This commit is contained in:
Chenlei Hu
2024-07-25 10:10:18 -04:00
committed by GitHub
parent 19c70d95d3
commit e179f75387
121 changed files with 11898 additions and 11983 deletions

View File

@@ -1,4 +1,6 @@
{
"semi": true,
"trailingComma": "es5"
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none"
}

View File

@@ -1,310 +1,310 @@
import type { Page, Locator } from "@playwright/test";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
dotenv.config();
import type { Page, Locator } from '@playwright/test'
import { test as base } from '@playwright/test'
import dotenv from 'dotenv'
dotenv.config()
interface Position {
x: number;
y: number;
x: number
y: number
}
interface Size {
width: number;
height: number;
width: number
height: number
}
class ComfyNodeSearchBox {
public readonly input: Locator;
public readonly dropdown: Locator;
public readonly input: Locator
public readonly dropdown: Locator
constructor(public readonly page: Page) {
this.input = page.locator(
'.comfy-vue-node-search-container input[type="text"]'
);
)
this.dropdown = page.locator(
".comfy-vue-node-search-container .p-autocomplete-list"
);
'.comfy-vue-node-search-container .p-autocomplete-list'
)
}
async fillAndSelectFirstNode(nodeName: string) {
await this.input.waitFor({ state: "visible" });
await this.input.fill(nodeName);
await this.dropdown.waitFor({ state: "visible" });
await this.input.waitFor({ state: 'visible' })
await this.input.fill(nodeName)
await this.dropdown.waitFor({ state: 'visible' })
// Wait for some time for the auto complete list to update.
// The auto complete list is debounced and may take some time to update.
await this.page.waitForTimeout(500);
await this.dropdown.locator("li").nth(0).click();
await this.page.waitForTimeout(500)
await this.dropdown.locator('li').nth(0).click()
}
}
class ComfyMenu {
public readonly themeToggleButton: Locator;
public readonly themeToggleButton: Locator
constructor(public readonly page: Page) {
this.themeToggleButton = page.locator(".comfy-vue-theme-toggle");
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
}
async toggleTheme() {
await this.themeToggleButton.click();
await this.themeToggleButton.click()
await this.page.evaluate(() => {
return new Promise((resolve) => {
window["app"].ui.settings.addEventListener(
"Comfy.ColorPalette.change",
window['app'].ui.settings.addEventListener(
'Comfy.ColorPalette.change',
resolve,
{ once: true }
);
)
setTimeout(resolve, 5000);
});
});
setTimeout(resolve, 5000)
})
})
}
async getThemeId() {
return await this.page.evaluate(async () => {
return await window["app"].ui.settings.getSettingValue(
"Comfy.ColorPalette"
);
});
return await window['app'].ui.settings.getSettingValue(
'Comfy.ColorPalette'
)
})
}
}
export class ComfyPage {
public readonly url: string;
public readonly url: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator;
public readonly widgetTextBox: Locator;
public readonly canvas: Locator
public readonly widgetTextBox: Locator
// Buttons
public readonly resetViewButton: Locator;
public readonly resetViewButton: Locator
// Inputs
public readonly workflowUploadInput: Locator;
public readonly workflowUploadInput: Locator
// Components
public readonly searchBox: ComfyNodeSearchBox;
public readonly menu: ComfyMenu;
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
constructor(public readonly page: Page) {
this.url = process.env.PLAYWRIGHT_TEST_URL || "http://localhost:8188";
this.canvas = page.locator("#graph-canvas");
this.widgetTextBox = page.getByPlaceholder("text").nth(1);
this.resetViewButton = page.getByRole("button", { name: "Reset View" });
this.workflowUploadInput = page.locator("#comfy-file-input");
this.searchBox = new ComfyNodeSearchBox(page);
this.menu = new ComfyMenu(page);
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.canvas = page.locator('#graph-canvas')
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
}
async setup() {
await this.goto();
await this.goto()
// Unify font for consistent screenshots.
await this.page.addStyleTag({
url: "https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap",
});
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
})
await this.page.addStyleTag({
url: "https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap",
});
url: 'https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
})
await this.page.addStyleTag({
content: `
* {
font-family: 'Roboto Mono', 'Noto Color Emoji';
}`,
});
await this.page.waitForFunction(() => document.fonts.ready);
}`
})
await this.page.waitForFunction(() => document.fonts.ready)
await this.page.waitForFunction(
() => window["app"] !== undefined && window["app"].vueAppReady
);
() => window['app'] !== undefined && window['app'].vueAppReady
)
await this.page.evaluate(() => {
window["app"]["canvas"].show_info = false;
});
await this.nextFrame();
window['app']['canvas'].show_info = false
})
await this.nextFrame()
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await this.resetView();
await this.resetView()
}
async realod() {
await this.page.reload({ timeout: 15000 });
await this.setup();
await this.page.reload({ timeout: 15000 })
await this.setup()
}
async goto() {
await this.page.goto(this.url);
await this.page.goto(this.url)
}
async nextFrame() {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame);
});
return new Promise<number>(requestAnimationFrame)
})
}
async loadWorkflow(workflowName: string) {
await this.workflowUploadInput.setInputFiles(
`./browser_tests/assets/${workflowName}.json`
);
await this.nextFrame();
)
await this.nextFrame()
}
async resetView() {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click();
await this.resetViewButton.click()
}
// Avoid "Reset View" button highlight.
await this.page.mouse.move(10, 10);
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async clickTextEncodeNode1() {
await this.canvas.click({
position: {
x: 618,
y: 191,
},
});
await this.nextFrame();
y: 191
}
})
await this.nextFrame()
}
async clickTextEncodeNode2() {
await this.canvas.click({
position: {
x: 622,
y: 400,
},
});
await this.nextFrame();
y: 400
}
})
await this.nextFrame()
}
async clickEmptySpace() {
await this.canvas.click({
position: {
x: 35,
y: 31,
},
});
await this.nextFrame();
y: 31
}
})
await this.nextFrame()
}
async dragAndDrop(source: Position, target: Position) {
await this.page.mouse.move(source.x, source.y);
await this.page.mouse.down();
await this.page.mouse.move(target.x, target.y);
await this.page.mouse.up();
await this.nextFrame();
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y)
await this.page.mouse.up()
await this.nextFrame()
}
async dragNode2() {
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 });
await this.nextFrame();
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 })
await this.nextFrame()
}
async disconnectEdge() {
// CLIP input anchor
await this.page.mouse.move(427, 198);
await this.page.mouse.down();
await this.page.mouse.move(427, 98);
await this.page.mouse.up();
await this.page.mouse.move(427, 198)
await this.page.mouse.down()
await this.page.mouse.move(427, 98)
await this.page.mouse.up()
// Move out the way to avoid highlight of menu item.
await this.page.mouse.move(10, 10);
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async connectEdge() {
// CLIP output anchor on Load Checkpoint Node.
await this.page.mouse.move(332, 509);
await this.page.mouse.down();
await this.page.mouse.move(332, 509)
await this.page.mouse.down()
// CLIP input anchor on CLIP Text Encode Node.
await this.page.mouse.move(427, 198);
await this.page.mouse.up();
await this.nextFrame();
await this.page.mouse.move(427, 198)
await this.page.mouse.up()
await this.nextFrame()
}
async adjustWidgetValue() {
// Adjust Empty Latent Image's width input.
const page = this.page;
await page.locator("#graph-canvas").click({
const page = this.page
await page.locator('#graph-canvas').click({
position: {
x: 724,
y: 645,
},
});
await page.locator('input[type="text"]').click();
await page.locator('input[type="text"]').fill("128");
await page.locator('input[type="text"]').press("Enter");
await this.nextFrame();
y: 645
}
})
await page.locator('input[type="text"]').click()
await page.locator('input[type="text"]').fill('128')
await page.locator('input[type="text"]').press('Enter')
await this.nextFrame()
}
async zoom(deltaY: number) {
await this.page.mouse.move(10, 10);
await this.page.mouse.wheel(0, deltaY);
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.page.mouse.wheel(0, deltaY)
await this.nextFrame()
}
async pan(offset: Position) {
await this.page.mouse.move(10, 10);
await this.page.mouse.down();
await this.page.mouse.move(offset.x, offset.y);
await this.page.mouse.up();
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.page.mouse.down()
await this.page.mouse.move(offset.x, offset.y)
await this.page.mouse.up()
await this.nextFrame()
}
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: "right" });
await this.nextFrame();
await this.page.mouse.click(10, 10, { button: 'right' })
await this.nextFrame()
}
async doubleClickCanvas() {
await this.page.mouse.dblclick(10, 10);
await this.nextFrame();
await this.page.mouse.dblclick(10, 10)
await this.nextFrame()
}
async clickEmptyLatentNode() {
await this.canvas.click({
position: {
x: 724,
y: 625,
},
});
this.page.mouse.move(10, 10);
await this.nextFrame();
y: 625
}
})
this.page.mouse.move(10, 10)
await this.nextFrame()
}
async rightClickEmptyLatentNode() {
await this.canvas.click({
position: {
x: 724,
y: 645,
y: 645
},
button: "right",
});
this.page.mouse.move(10, 10);
await this.nextFrame();
button: 'right'
})
this.page.mouse.move(10, 10)
await this.nextFrame()
}
async select2Nodes() {
// Select 2 CLIP nodes.
await this.page.keyboard.down("Control");
await this.clickTextEncodeNode1();
await this.clickTextEncodeNode2();
await this.page.keyboard.up("Control");
await this.nextFrame();
await this.page.keyboard.down('Control')
await this.clickTextEncodeNode1()
await this.clickTextEncodeNode2()
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlC() {
await this.page.keyboard.down("Control");
await this.page.keyboard.press("KeyC");
await this.page.keyboard.up("Control");
await this.nextFrame();
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyC')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlV() {
await this.page.keyboard.down("Control");
await this.page.keyboard.press("KeyV");
await this.page.keyboard.up("Control");
await this.nextFrame();
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyV')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async closeMenu() {
await this.page.click("button.comfy-close-menu-btn");
await this.nextFrame();
await this.page.click('button.comfy-close-menu-btn')
await this.nextFrame()
}
async resizeNode(
@@ -316,17 +316,17 @@ export class ComfyPage {
) {
const bottomRight = {
x: nodePos.x + nodeSize.width,
y: nodePos.y + nodeSize.height,
};
y: nodePos.y + nodeSize.height
}
const target = {
x: nodePos.x + nodeSize.width * ratioX,
y: nodePos.y + nodeSize.height * ratioY,
};
await this.dragAndDrop(bottomRight, target);
await this.nextFrame();
y: nodePos.y + nodeSize.height * ratioY
}
await this.dragAndDrop(bottomRight, target)
await this.nextFrame()
if (revertAfter) {
await this.dragAndDrop(target, bottomRight);
await this.nextFrame();
await this.dragAndDrop(target, bottomRight)
await this.nextFrame()
}
}
@@ -337,13 +337,13 @@ export class ComfyPage {
) {
const ksamplerPos = {
x: 864,
y: 157,
};
y: 157
}
const ksamplerSize = {
width: 315,
height: 292,
};
this.resizeNode(ksamplerPos, ksamplerSize, percentX, percentY, revertAfter);
height: 292
}
this.resizeNode(ksamplerPos, ksamplerSize, percentX, percentY, revertAfter)
}
async resizeLoadCheckpointNode(
@@ -353,19 +353,19 @@ export class ComfyPage {
) {
const loadCheckpointPos = {
x: 25,
y: 440,
};
y: 440
}
const loadCheckpointSize = {
width: 320,
height: 120,
};
height: 120
}
this.resizeNode(
loadCheckpointPos,
loadCheckpointSize,
percentX,
percentY,
revertAfter
);
)
}
async resizeEmptyLatentNode(
@@ -375,26 +375,26 @@ export class ComfyPage {
) {
const emptyLatentPos = {
x: 475,
y: 580,
};
y: 580
}
const emptyLatentSize = {
width: 303,
height: 132,
};
height: 132
}
this.resizeNode(
emptyLatentPos,
emptyLatentSize,
percentX,
percentY,
revertAfter
);
)
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ page }, use) => {
const comfyPage = new ComfyPage(page);
await comfyPage.setup();
await use(comfyPage);
},
});
const comfyPage = new ComfyPage(page)
await comfyPage.setup()
await use(comfyPage)
}
})

View File

@@ -1,56 +1,56 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe("Copy Paste", () => {
test("Can copy and paste node", async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode();
await comfyPage.page.mouse.move(10, 10);
await comfyPage.ctrlC();
await comfyPage.ctrlV();
await expect(comfyPage.canvas).toHaveScreenshot("copied-node.png");
});
test.describe('Copy Paste', () => {
test('Can copy and paste node', async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot('copied-node.png')
})
test("Can copy and paste text", async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox;
await textBox.click();
const originalString = await textBox.inputValue();
await textBox.selectText();
await comfyPage.ctrlC();
await comfyPage.ctrlV();
await comfyPage.ctrlV();
const resultString = await textBox.inputValue();
expect(resultString).toBe(originalString + originalString);
});
test('Can copy and paste text', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox
await textBox.click()
const originalString = await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.ctrlV()
const resultString = await textBox.inputValue()
expect(resultString).toBe(originalString + originalString)
})
/**
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/98
*/
test("Paste in text area with node previously copied", async ({
comfyPage,
test('Paste in text area with node previously copied', async ({
comfyPage
}) => {
await comfyPage.clickEmptyLatentNode();
await comfyPage.ctrlC();
const textBox = comfyPage.widgetTextBox;
await textBox.click();
await textBox.inputValue();
await textBox.selectText();
await comfyPage.ctrlC();
await comfyPage.ctrlV();
await comfyPage.ctrlV();
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC()
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot(
"paste-in-text-area-with-node-previously-copied.png"
);
});
'paste-in-text-area-with-node-previously-copied.png'
)
})
test("Copy text area does not copy node", async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox;
await textBox.click();
await textBox.inputValue();
await textBox.selectText();
await comfyPage.ctrlC();
test('Copy text area does not copy node', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
// Unfocus textbox.
await comfyPage.page.mouse.click(10, 10);
await comfyPage.ctrlV();
await expect(comfyPage.canvas).toHaveScreenshot("no-node-copied.png");
});
});
await comfyPage.page.mouse.click(10, 10)
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
})
})

View File

@@ -1,77 +1,75 @@
import { expect } from "@playwright/test";
import { ComfyPage, comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from './ComfyPage'
test.describe("Node Interaction", () => {
test("Can enter prompt", async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox;
await textBox.click();
await textBox.fill("Hello World");
await expect(textBox).toHaveValue("Hello World");
await textBox.fill("Hello World 2");
await expect(textBox).toHaveValue("Hello World 2");
});
test.describe('Node Interaction', () => {
test('Can enter prompt', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('Hello World')
await expect(textBox).toHaveValue('Hello World')
await textBox.fill('Hello World 2')
await expect(textBox).toHaveValue('Hello World 2')
})
test("Can highlight selected", async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot("default.png");
await comfyPage.clickTextEncodeNode1();
await expect(comfyPage.canvas).toHaveScreenshot("selected-node1.png");
await comfyPage.clickTextEncodeNode2();
await expect(comfyPage.canvas).toHaveScreenshot("selected-node2.png");
});
test('Can highlight selected', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNode1()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.clickTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
// Flaky. See https://github.com/comfyanonymous/ComfyUI/issues/3866
test.skip("Can drag node", async ({ comfyPage }) => {
await comfyPage.dragNode2();
await expect(comfyPage.canvas).toHaveScreenshot("dragged-node1.png");
});
test.skip('Can drag node', async ({ comfyPage }) => {
await comfyPage.dragNode2()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test("Can disconnect/connect edge", async ({ comfyPage }) => {
await comfyPage.disconnectEdge();
test('Can disconnect/connect edge', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot(
"disconnected-edge-with-menu.png"
);
await comfyPage.connectEdge();
'disconnected-edge-with-menu.png'
)
await comfyPage.connectEdge()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot("default.png", {
maxDiffPixels: 50,
});
});
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
})
})
test("Can adjust widget value", async ({ comfyPage }) => {
await comfyPage.adjustWidgetValue();
await expect(comfyPage.canvas).toHaveScreenshot(
"adjusted-widget-value.png"
);
});
test('Can adjust widget value', async ({ comfyPage }) => {
await comfyPage.adjustWidgetValue()
await expect(comfyPage.canvas).toHaveScreenshot('adjusted-widget-value.png')
})
test("Link snap to slot", async ({ comfyPage }) => {
await comfyPage.loadWorkflow("snap_to_slot");
await expect(comfyPage.canvas).toHaveScreenshot("snap_to_slot.png");
test('Link snap to slot', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('snap_to_slot')
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot.png')
const outputSlotPos = {
x: 406,
y: 333,
};
y: 333
}
const samplerNodeCenterPos = {
x: 748,
y: 77,
};
await comfyPage.dragAndDrop(outputSlotPos, samplerNodeCenterPos);
y: 77
}
await comfyPage.dragAndDrop(outputSlotPos, samplerNodeCenterPos)
await expect(comfyPage.canvas).toHaveScreenshot("snap_to_slot_linked.png");
});
});
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot_linked.png')
})
})
test.describe("Canvas Interaction", () => {
test("Can zoom in/out", async ({ comfyPage }) => {
await comfyPage.zoom(-100);
await expect(comfyPage.canvas).toHaveScreenshot("zoomed-in.png");
await comfyPage.zoom(200);
await expect(comfyPage.canvas).toHaveScreenshot("zoomed-out.png");
});
test.describe('Canvas Interaction', () => {
test('Can zoom in/out', async ({ comfyPage }) => {
await comfyPage.zoom(-100)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png')
await comfyPage.zoom(200)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png')
})
test("Can pan", async ({ comfyPage }) => {
await comfyPage.pan({ x: 200, y: 200 });
await expect(comfyPage.canvas).toHaveScreenshot("panned.png");
});
});
test('Can pan', async ({ comfyPage }) => {
await comfyPage.pan({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned.png')
})
})

View File

@@ -1,36 +1,36 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
function listenForEvent(): Promise<Event> {
return new Promise<Event>((resolve) => {
document.addEventListener("litegraph:canvas", (e) => resolve(e), {
once: true,
});
});
document.addEventListener('litegraph:canvas', (e) => resolve(e), {
once: true
})
})
}
test.describe("Canvas Event", () => {
test("Emit litegraph:canvas empty-release", async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent);
const disconnectPromise = comfyPage.disconnectEdge();
const event = await eventPromise;
await disconnectPromise;
test.describe('Canvas Event', () => {
test('Emit litegraph:canvas empty-release', async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent)
const disconnectPromise = comfyPage.disconnectEdge()
const event = await eventPromise
await disconnectPromise
expect(event).not.toBeNull();
expect(event).not.toBeNull()
// No further check on event content as the content is dropped by
// playwright for some reason.
// See https://github.com/microsoft/playwright/issues/31580
});
})
test("Emit litegraph:canvas empty-double-click", async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent);
const doubleClickPromise = comfyPage.doubleClickCanvas();
const event = await eventPromise;
await doubleClickPromise;
test('Emit litegraph:canvas empty-double-click', async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent)
const doubleClickPromise = comfyPage.doubleClickCanvas()
const event = await eventPromise
await doubleClickPromise
expect(event).not.toBeNull();
expect(event).not.toBeNull()
// No further check on event content as the content is dropped by
// playwright for some reason.
// See https://github.com/microsoft/playwright/issues/31580
});
});
})
})

View File

@@ -1,45 +1,45 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe("Menu", () => {
test.describe('Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(async () => {
await window["app"].ui.settings.setSettingValueAsync(
"Comfy.UseNewMenu",
"Top"
);
});
});
await window['app'].ui.settings.setSettingValueAsync(
'Comfy.UseNewMenu',
'Top'
)
})
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId();
if (currentThemeId !== "dark") {
await comfyPage.menu.toggleTheme();
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
await comfyPage.page.evaluate(async () => {
await window["app"].ui.settings.setSettingValueAsync(
"Comfy.UseNewMenu",
"Disabled"
);
});
});
await window['app'].ui.settings.setSettingValueAsync(
'Comfy.UseNewMenu',
'Disabled'
)
})
})
test("Toggle theme", async ({ comfyPage }) => {
test.setTimeout(30000);
test('Toggle theme', async ({ comfyPage }) => {
test.setTimeout(30000)
expect(await comfyPage.menu.getThemeId()).toBe("dark");
expect(await comfyPage.menu.getThemeId()).toBe('dark')
await comfyPage.menu.toggleTheme();
await comfyPage.menu.toggleTheme()
expect(await comfyPage.menu.getThemeId()).toBe("light");
expect(await comfyPage.menu.getThemeId()).toBe('light')
// Theme id should persist after reload.
await comfyPage.page.reload();
await comfyPage.setup();
expect(await comfyPage.menu.getThemeId()).toBe("light");
await comfyPage.page.reload()
await comfyPage.setup()
expect(await comfyPage.menu.getThemeId()).toBe('light')
await comfyPage.menu.toggleTheme();
await comfyPage.menu.toggleTheme()
expect(await comfyPage.menu.getThemeId()).toBe("dark");
});
});
expect(await comfyPage.menu.getThemeId()).toBe('dark')
})
})

View File

@@ -1,35 +1,35 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe("Node search box", () => {
test("Can trigger on empty canvas double click", async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas();
await expect(comfyPage.searchBox.input).toHaveCount(1);
});
test.describe('Node search box', () => {
test('Can trigger on empty canvas double click', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test("Can trigger on link release", async ({ comfyPage }) => {
await comfyPage.page.keyboard.down("Shift");
await comfyPage.disconnectEdge();
await expect(comfyPage.searchBox.input).toHaveCount(1);
});
test('Can trigger on link release', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('Shift')
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test("Does not trigger on link release (no shift)", async ({ comfyPage }) => {
await comfyPage.disconnectEdge();
await expect(comfyPage.searchBox.input).toHaveCount(0);
});
test('Does not trigger on link release (no shift)', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(0)
})
test("Can add node", async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas();
await expect(comfyPage.searchBox.input).toHaveCount(1);
await comfyPage.searchBox.fillAndSelectFirstNode("KSampler");
await expect(comfyPage.canvas).toHaveScreenshot("added-node.png");
});
test('Can add node', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await expect(comfyPage.canvas).toHaveScreenshot('added-node.png')
})
test("Can auto link node", async ({ comfyPage }) => {
await comfyPage.page.keyboard.down("Shift");
await comfyPage.disconnectEdge();
await comfyPage.page.keyboard.up("Shift");
await comfyPage.searchBox.fillAndSelectFirstNode("CLIPTextEncode");
await expect(comfyPage.canvas).toHaveScreenshot("auto-linked-node.png");
});
});
test('Can auto link node', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('Shift')
await comfyPage.disconnectEdge()
await comfyPage.page.keyboard.up('Shift')
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode')
await expect(comfyPage.canvas).toHaveScreenshot('auto-linked-node.png')
})
})

View File

@@ -1,92 +1,88 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe("Canvas Right Click Menu", () => {
test.describe('Canvas Right Click Menu', () => {
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip("Can add node", async ({ comfyPage }) => {
await comfyPage.rightClickCanvas();
await expect(comfyPage.canvas).toHaveScreenshot("right-click-menu.png");
await comfyPage.page.getByText("Add Node").click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot("add-node-menu.png");
await comfyPage.page.getByText("loaders").click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot(
"add-node-menu-loaders.png"
);
await comfyPage.page.getByText("Load VAE").click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot("add-node-node-added.png");
});
test.skip('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Node').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu.png')
await comfyPage.page.getByText('loaders').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu-loaders.png')
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
})
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip("Can add group", async ({ comfyPage }) => {
await comfyPage.rightClickCanvas();
await expect(comfyPage.canvas).toHaveScreenshot("right-click-menu.png");
await comfyPage.page.getByText("Add Group", { exact: true }).click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot(
"add-group-group-added.png"
);
});
test.skip('Can add group', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test("Can convert to group node", async ({ comfyPage }) => {
await comfyPage.select2Nodes();
await expect(comfyPage.canvas).toHaveScreenshot("selected-2-nodes.png");
comfyPage.page.on("dialog", async (dialog) => {
await dialog.accept("GroupNode2CLIP");
});
await comfyPage.rightClickCanvas();
await comfyPage.page.getByText("Convert to Group Node").click();
await comfyPage.nextFrame();
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
comfyPage.page.on('dialog', async (dialog) => {
await dialog.accept('GroupNode2CLIP')
})
await comfyPage.rightClickCanvas()
await comfyPage.page.getByText('Convert to Group Node').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
"right-click-node-group-node.png"
);
});
});
'right-click-node-group-node.png'
)
})
})
test.describe("Node Right Click Menu", () => {
test("Can open properties panel", async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot("right-click-node.png");
await comfyPage.page.getByText("Properties Panel").click();
await comfyPage.nextFrame();
test.describe('Node Right Click Menu', () => {
test('Can open properties panel', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
"right-click-node-properties-panel.png"
);
});
'right-click-node-properties-panel.png'
)
})
test("Can collapse", async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot("right-click-node.png");
await comfyPage.page.getByText("Collapse").click();
await comfyPage.nextFrame();
test('Can collapse', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Collapse').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
"right-click-node-collapsed.png"
);
});
'right-click-node-collapsed.png'
)
})
test("Can bypass", async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot("right-click-node.png");
await comfyPage.page.getByText("Bypass").click();
await comfyPage.nextFrame();
test('Can bypass', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Bypass').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
"right-click-node-bypassed.png"
);
});
'right-click-node-bypassed.png'
)
})
test("Can convert widget to input", async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot("right-click-node.png");
await comfyPage.page.getByText("Convert Widget to Input").click();
await comfyPage.nextFrame();
await comfyPage.page.getByText("Convert width to input").click();
await comfyPage.nextFrame();
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert Widget to Input').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('Convert width to input').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
"right-click-node-widget-converted.png"
);
});
});
'right-click-node-widget-converted.png'
)
})
})

View File

@@ -1,28 +1,28 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe("Combo text widget", () => {
test("Truncates text when resized", async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.2, 1);
test.describe('Combo text widget', () => {
test('Truncates text when resized', async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.2, 1)
await expect(comfyPage.canvas).toHaveScreenshot(
"load-checkpoint-resized-min-width.png"
);
await comfyPage.closeMenu();
await comfyPage.resizeKsamplerNode(0.2, 1);
'load-checkpoint-resized-min-width.png'
)
await comfyPage.closeMenu()
await comfyPage.resizeKsamplerNode(0.2, 1)
await expect(comfyPage.canvas).toHaveScreenshot(
`ksampler-resized-min-width.png`
);
});
)
})
test("Doesn't truncate when space still available", async ({ comfyPage }) => {
await comfyPage.resizeEmptyLatentNode(0.8, 0.8);
await comfyPage.resizeEmptyLatentNode(0.8, 0.8)
await expect(comfyPage.canvas).toHaveScreenshot(
"empty-latent-resized-80-percent.png"
);
});
'empty-latent-resized-80-percent.png'
)
})
test("Can revert to full text", async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.8, 1, true);
await expect(comfyPage.canvas).toHaveScreenshot("resized-to-original.png");
});
});
test('Can revert to full text', async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.8, 1, true)
await expect(comfyPage.canvas).toHaveScreenshot('resized-to-original.png')
})
})

View File

@@ -1,26 +1,26 @@
import type { JestConfigWithTsJest } from "ts-jest";
import type { JestConfigWithTsJest } from 'ts-jest'
const jestConfig: JestConfigWithTsJest = {
testMatch: ["**/tests-ui/**/*.test.ts"],
testEnvironment: "jsdom",
testMatch: ['**/tests-ui/**/*.test.ts'],
testEnvironment: 'jsdom',
transform: {
"^.+\\.m?[tj]sx?$": [
"ts-jest",
'^.+\\.m?[tj]sx?$': [
'ts-jest',
{
tsconfig: "./tsconfig.json",
babelConfig: "./babel.config.json",
tsconfig: './tsconfig.json',
babelConfig: './babel.config.json'
}
]
},
],
},
setupFiles: ["./tests-ui/globalSetup.ts"],
setupFilesAfterEnv: ["./tests-ui/afterSetup.ts"],
setupFiles: ['./tests-ui/globalSetup.ts'],
setupFilesAfterEnv: ['./tests-ui/afterSetup.ts'],
clearMocks: true,
resetModules: true,
testTimeout: 10000,
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
},
};
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
}
}
export default jestConfig;
export default jestConfig

View File

@@ -1,4 +1,4 @@
import { defineConfig, devices } from "@playwright/test";
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
@@ -11,7 +11,7 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./browser_tests",
testDir: './browser_tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -21,23 +21,23 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
trace: 'on-first-retry'
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
timeout: 5000,
},
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 5000
}
// {
// name: 'firefox',
@@ -68,7 +68,7 @@ export default defineConfig({
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
]
/* Run your local dev server before starting the tests */
// webServer: {
@@ -76,4 +76,4 @@ export default defineConfig({
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});
})

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
autoprefixer: {}
}
}

View File

@@ -1,14 +1,14 @@
import { copy } from "fs-extra";
import { config } from "dotenv";
config();
import { copy } from 'fs-extra'
import { config } from 'dotenv'
config()
const sourceDir = "./dist";
const targetDir = process.env.DEPLOY_COMFYUI_DIR;
const sourceDir = './dist'
const targetDir = process.env.DEPLOY_COMFYUI_DIR
copy(sourceDir, targetDir)
.then(() => {
console.log(`Directory copied successfully! ${sourceDir} -> ${targetDir}`);
console.log(`Directory copied successfully! ${sourceDir} -> ${targetDir}`)
})
.catch((err) => {
console.error("Error copying directory:", err);
});
console.error('Error copying directory:', err)
})

View File

@@ -1,9 +1,9 @@
import zipdir from "zip-dir";
import zipdir from 'zip-dir'
zipdir("./dist", { saveTo: "./dist.zip" }, function (err, buffer) {
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
if (err) {
console.error('Error zipping "dist" directory:', err);
console.error('Error zipping "dist" directory:', err)
} else {
console.log('Successfully zipped "dist" directory.');
console.log('Successfully zipped "dist" directory.')
}
});
})

View File

@@ -13,65 +13,65 @@
</template>
<script setup lang="ts">
import { computed, markRaw, onMounted, ref, watch } from "vue";
import NodeSearchboxPopover from "@/components/NodeSearchBoxPopover.vue";
import SideToolBar from "@/components/sidebar/SideToolBar.vue";
import LiteGraphCanvasSplitterOverlay from "@/components/LiteGraphCanvasSplitterOverlay.vue";
import QueueSideBarTab from "@/components/sidebar/tabs/QueueSideBarTab.vue";
import ProgressSpinner from "primevue/progressspinner";
import { app } from "./scripts/app";
import { useSettingStore } from "./stores/settingStore";
import { useNodeDefStore } from "./stores/nodeDefStore";
import { ExtensionManagerImpl } from "./scripts/extensionManager";
import { useI18n } from "vue-i18n";
import { computed, markRaw, onMounted, ref, watch } from 'vue'
import NodeSearchboxPopover from '@/components/NodeSearchBoxPopover.vue'
import SideToolBar from '@/components/sidebar/SideToolBar.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import QueueSideBarTab from '@/components/sidebar/tabs/QueueSideBarTab.vue'
import ProgressSpinner from 'primevue/progressspinner'
import { app } from './scripts/app'
import { useSettingStore } from './stores/settingStore'
import { useNodeDefStore } from './stores/nodeDefStore'
import { ExtensionManagerImpl } from './scripts/extensionManager'
import { useI18n } from 'vue-i18n'
const isLoading = ref(true);
const isLoading = ref(true)
const nodeSearchEnabled = computed<boolean>(
() => useSettingStore().get("Comfy.NodeSearchBoxImpl") === "default"
);
() => useSettingStore().get('Comfy.NodeSearchBoxImpl') === 'default'
)
const theme = computed<string>(() =>
useSettingStore().get("Comfy.ColorPalette")
);
useSettingStore().get('Comfy.ColorPalette')
)
watch(
theme,
(newTheme) => {
const DARK_THEME_CLASS = "dark-theme";
const isDarkTheme = newTheme !== "light";
const DARK_THEME_CLASS = 'dark-theme'
const isDarkTheme = newTheme !== 'light'
if (isDarkTheme) {
document.body.classList.add(DARK_THEME_CLASS);
document.body.classList.add(DARK_THEME_CLASS)
} else {
document.body.classList.remove(DARK_THEME_CLASS);
document.body.classList.remove(DARK_THEME_CLASS)
}
},
{ immediate: true }
);
)
const { t } = useI18n();
const { t } = useI18n()
const init = () => {
useNodeDefStore().addNodeDefs(Object.values(app.nodeDefs));
useSettingStore().addSettings(app.ui.settings);
app.vueAppReady = true;
useNodeDefStore().addNodeDefs(Object.values(app.nodeDefs))
useSettingStore().addSettings(app.ui.settings)
app.vueAppReady = true
// Late init as extension manager needs to access pinia store.
app.extensionManager = new ExtensionManagerImpl();
app.extensionManager = new ExtensionManagerImpl()
app.extensionManager.registerSidebarTab({
id: "queue",
icon: "pi pi-history",
title: t("sideToolBar.queue"),
tooltip: t("sideToolBar.queue"),
id: 'queue',
icon: 'pi pi-history',
title: t('sideToolBar.queue'),
tooltip: t('sideToolBar.queue'),
component: markRaw(QueueSideBarTab),
type: "vue",
});
};
type: 'vue'
})
}
onMounted(() => {
try {
init();
init()
} catch (e) {
console.error("Failed to init Vue app", e);
console.error('Failed to init Vue app', e)
} finally {
isLoading.value = false;
isLoading.value = false
}
});
})
</script>
<style scoped>

View File

@@ -15,17 +15,17 @@
</template>
<script setup lang="ts">
import { useWorkspaceStore } from "@/stores/workspaceStateStore";
import Splitter from "primevue/splitter";
import SplitterPanel from "primevue/splitterpanel";
import { computed } from "vue";
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
const sideBarPanelVisible = computed(
() => useWorkspaceStore().activeSidebarTab !== null
);
)
const gutterClass = computed(() => {
return sideBarPanelVisible.value ? "" : "gutter-hidden";
});
return sideBarPanelVisible.value ? '' : 'gutter-hidden'
})
</script>
<style>

View File

@@ -19,10 +19,10 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div class="_sb_col">
<div v-if="slotInput" :class="['_sb_dot', slotInput.type]"></div>
</div>
<div class="_sb_col">{{ slotInput ? slotInput.name : "" }}</div>
<div class="_sb_col">{{ slotInput ? slotInput.name : '' }}</div>
<div class="_sb_col middle-column"></div>
<div class="_sb_col _sb_inherit">
{{ slotOutput ? slotOutput.name : "" }}
{{ slotOutput ? slotOutput.name : '' }}
</div>
<div class="_sb_col">
<div v-if="slotOutput" :class="['_sb_dot', slotOutput.type]"></div>
@@ -45,40 +45,40 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
</template>
<script setup lang="ts">
import { app } from "@/scripts/app";
import { type ComfyNodeDef } from "@/types/apiTypes";
import _ from "lodash";
import { PropType } from "vue";
import { app } from '@/scripts/app'
import { type ComfyNodeDef } from '@/types/apiTypes'
import _ from 'lodash'
import { PropType } from 'vue'
const props = defineProps({
nodeDef: {
type: Object as PropType<ComfyNodeDef>,
required: true,
},
});
required: true
}
})
const nodeDef = props.nodeDef as ComfyNodeDef;
const nodeDef = props.nodeDef as ComfyNodeDef
// --------------------------------------------------
// TODO: Move out to separate file
interface IComfyNodeInputDef {
name: string;
type: string;
widgetType: string | null;
defaultValue: any;
name: string
type: string
widgetType: string | null
defaultValue: any
}
interface IComfyNodeOutputDef {
name: string | null;
type: string;
isList: boolean;
name: string | null
type: string
isList: boolean
}
const allInputs = Object.assign(
{},
nodeDef.input.required || {},
nodeDef.input.optional || {}
);
)
const allInputDefs: IComfyNodeInputDef[] = Object.entries(allInputs).map(
([inputName, inputData]) => {
return {
@@ -87,10 +87,10 @@ const allInputDefs: IComfyNodeInputDef[] = Object.entries(allInputs).map(
widgetType: app.getWidgetType(inputData, inputName),
defaultValue:
inputData[1]?.default ||
(inputData[0] instanceof Array ? inputData[0][0] : ""),
};
(inputData[0] instanceof Array ? inputData[0][0] : '')
}
);
}
)
const allOutputDefs: IComfyNodeOutputDef[] = _.zip(
nodeDef.output,
@@ -99,13 +99,13 @@ const allOutputDefs: IComfyNodeOutputDef[] = _.zip(
).map(([outputType, outputName, isList]) => {
return {
name: outputName,
type: outputType instanceof Array ? "COMBO" : outputType,
isList: isList,
};
});
type: outputType instanceof Array ? 'COMBO' : outputType,
isList: isList
}
})
const slotInputDefs = allInputDefs.filter((input) => !input.widgetType);
const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
const slotInputDefs = allInputDefs.filter((input) => !input.widgetType)
const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType)
</script>
<style scoped>
@@ -174,7 +174,7 @@ const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: "Open Sans", sans-serif;
font-family: 'Open Sans', sans-serif;
font-size: small;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);

View File

@@ -53,73 +53,73 @@
</template>
<script setup lang="ts">
import { computed, inject, onMounted, Ref, ref } from "vue";
import AutoCompletePlus from "./primevueOverride/AutoCompletePlus.vue";
import Chip from "primevue/chip";
import Badge from "primevue/badge";
import NodeSearchFilter from "@/components/NodeSearchFilter.vue";
import NodeSourceChip from "@/components/NodeSourceChip.vue";
import { ComfyNodeDef } from "@/types/apiTypes";
import { type FilterAndValue } from "@/services/nodeSearchService";
import NodePreview from "./NodePreview.vue";
import { useNodeDefStore } from "@/stores/nodeDefStore";
import { computed, inject, onMounted, Ref, ref } from 'vue'
import AutoCompletePlus from './primevueOverride/AutoCompletePlus.vue'
import Chip from 'primevue/chip'
import Badge from 'primevue/badge'
import NodeSearchFilter from '@/components/NodeSearchFilter.vue'
import NodeSourceChip from '@/components/NodeSourceChip.vue'
import { ComfyNodeDef } from '@/types/apiTypes'
import { type FilterAndValue } from '@/services/nodeSearchService'
import NodePreview from './NodePreview.vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const props = defineProps({
filters: {
type: Array<FilterAndValue>,
type: Array<FilterAndValue>
},
searchLimit: {
type: Number,
default: 64,
},
});
default: 64
}
})
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`;
const suggestions = ref<ComfyNodeDef[]>([]);
const hoveredSuggestion = ref<ComfyNodeDef | null>(null);
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
const suggestions = ref<ComfyNodeDef[]>([])
const hoveredSuggestion = ref<ComfyNodeDef | null>(null)
const placeholder = computed(() => {
return props.filters.length === 0 ? "Search for nodes" : "";
});
return props.filters.length === 0 ? 'Search for nodes' : ''
})
const search = (query: string) => {
suggestions.value = useNodeDefStore().nodeSearchService.searchNode(
query,
props.filters,
{
limit: props.searchLimit,
limit: props.searchLimit
}
);
};
)
}
const emit = defineEmits(["addFilter", "removeFilter", "addNode"]);
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
const reFocusInput = () => {
const inputElement = document.getElementById(inputId) as HTMLInputElement;
const inputElement = document.getElementById(inputId) as HTMLInputElement
if (inputElement) {
inputElement.blur();
inputElement.focus();
inputElement.blur()
inputElement.focus()
}
};
}
onMounted(reFocusInput);
onMounted(reFocusInput)
const onAddFilter = (filterAndValue: FilterAndValue) => {
emit("addFilter", filterAndValue);
reFocusInput();
};
emit('addFilter', filterAndValue)
reFocusInput()
}
const onRemoveFilter = (event: Event, filterAndValue: FilterAndValue) => {
event.stopPropagation();
event.preventDefault();
emit("removeFilter", filterAndValue);
reFocusInput();
};
event.stopPropagation()
event.preventDefault()
emit('removeFilter', filterAndValue)
reFocusInput()
}
const setHoverSuggestion = (index: number) => {
if (index === -1) {
hoveredSuggestion.value = null;
return;
hoveredSuggestion.value = null
return
}
const value = suggestions.value[index];
hoveredSuggestion.value = value;
};
const value = suggestions.value[index]
hoveredSuggestion.value = value
}
</script>
<style scoped>

View File

@@ -21,142 +21,142 @@
</template>
<script setup lang="ts">
import { app } from "@/scripts/app";
import { inject, onMounted, onUnmounted, reactive, Ref, ref } from "vue";
import NodeSearchBox from "./NodeSearchBox.vue";
import Dialog from "primevue/dialog";
import { app } from '@/scripts/app'
import { inject, onMounted, onUnmounted, reactive, Ref, ref } from 'vue'
import NodeSearchBox from './NodeSearchBox.vue'
import Dialog from 'primevue/dialog'
import {
INodeSlot,
LiteGraph,
LiteGraphCanvasEvent,
LGraphNode,
LinkReleaseContext,
} from "@comfyorg/litegraph";
import { FilterAndValue } from "@/services/nodeSearchService";
import { ComfyNodeDef } from "@/types/apiTypes";
import { useNodeDefStore } from "@/stores/nodeDefStore";
LinkReleaseContext
} from '@comfyorg/litegraph'
import { FilterAndValue } from '@/services/nodeSearchService'
import { ComfyNodeDef } from '@/types/apiTypes'
import { useNodeDefStore } from '@/stores/nodeDefStore'
interface LiteGraphPointerEvent extends Event {
canvasX: number;
canvasY: number;
canvasX: number
canvasY: number
}
const visible = ref(false);
const dismissable = ref(true);
const triggerEvent = ref<LiteGraphCanvasEvent | null>(null);
const visible = ref(false)
const dismissable = ref(true)
const triggerEvent = ref<LiteGraphCanvasEvent | null>(null)
const getNewNodeLocation = (): [number, number] => {
if (triggerEvent.value === null) {
return [100, 100];
return [100, 100]
}
const originalEvent = triggerEvent.value.detail
.originalEvent as LiteGraphPointerEvent;
return [originalEvent.canvasX, originalEvent.canvasY];
};
const nodeFilters = reactive([]);
.originalEvent as LiteGraphPointerEvent
return [originalEvent.canvasX, originalEvent.canvasY]
}
const nodeFilters = reactive([])
const addFilter = (filter: FilterAndValue) => {
nodeFilters.push(filter);
};
nodeFilters.push(filter)
}
const removeFilter = (filter: FilterAndValue) => {
const index = nodeFilters.findIndex((f) => f === filter);
const index = nodeFilters.findIndex((f) => f === filter)
if (index !== -1) {
nodeFilters.splice(index, 1);
nodeFilters.splice(index, 1)
}
};
}
const clearFilters = () => {
nodeFilters.splice(0, nodeFilters.length);
};
nodeFilters.splice(0, nodeFilters.length)
}
const closeDialog = () => {
visible.value = false;
};
visible.value = false
}
const connectNodeOnLinkRelease = (
node: LGraphNode,
context: LinkReleaseContext
) => {
const destIsInput = context.node_from !== undefined;
const destIsInput = context.node_from !== undefined
const srcNode = (
destIsInput ? context.node_from : context.node_to
) as LGraphNode;
const srcSlotIndex: number = context.slot_from.slot_index;
) as LGraphNode
const srcSlotIndex: number = context.slot_from.slot_index
const linkDataType = destIsInput
? context.type_filter_in
: context.type_filter_out;
const destSlots = destIsInput ? node.inputs : node.outputs;
: context.type_filter_out
const destSlots = destIsInput ? node.inputs : node.outputs
const destSlotIndex = destSlots.findIndex(
(slot: INodeSlot) => slot.type === linkDataType
);
)
if (destSlotIndex === -1) {
console.warn(
`Could not find slot with type ${linkDataType} on node ${node.title}`
);
return;
)
return
}
if (destIsInput) {
srcNode.connect(srcSlotIndex, node, destSlotIndex);
srcNode.connect(srcSlotIndex, node, destSlotIndex)
} else {
node.connect(destSlotIndex, srcNode, srcSlotIndex);
node.connect(destSlotIndex, srcNode, srcSlotIndex)
}
};
}
const addNode = (nodeDef: ComfyNodeDef) => {
closeDialog();
const node = LiteGraph.createNode(nodeDef.name, nodeDef.display_name, {});
closeDialog()
const node = LiteGraph.createNode(nodeDef.name, nodeDef.display_name, {})
if (node) {
node.pos = getNewNodeLocation();
app.graph.add(node);
node.pos = getNewNodeLocation()
app.graph.add(node)
const eventDetail = triggerEvent.value.detail;
if (eventDetail.subType === "empty-release") {
connectNodeOnLinkRelease(node, eventDetail.linkReleaseContext);
const eventDetail = triggerEvent.value.detail
if (eventDetail.subType === 'empty-release') {
connectNodeOnLinkRelease(node, eventDetail.linkReleaseContext)
}
}
};
}
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
const shiftPressed = (e.detail.originalEvent as KeyboardEvent).shiftKey;
const shiftPressed = (e.detail.originalEvent as KeyboardEvent).shiftKey
// Ignore empty releases unless shift is pressed
// Empty release without shift is trigger right click menu
if (e.detail.subType === "empty-release" && !shiftPressed) {
return;
if (e.detail.subType === 'empty-release' && !shiftPressed) {
return
}
if (e.detail.subType === "empty-release") {
const destIsInput = e.detail.linkReleaseContext.node_from !== undefined;
if (e.detail.subType === 'empty-release') {
const destIsInput = e.detail.linkReleaseContext.node_from !== undefined
const filter = useNodeDefStore().nodeSearchService.getFilterById(
destIsInput ? "input" : "output"
);
destIsInput ? 'input' : 'output'
)
const value = destIsInput
? e.detail.linkReleaseContext.type_filter_in
: e.detail.linkReleaseContext.type_filter_out;
: e.detail.linkReleaseContext.type_filter_out
addFilter([filter, value]);
addFilter([filter, value])
}
triggerEvent.value = e;
visible.value = true;
triggerEvent.value = e
visible.value = true
// Prevent the dialog from being dismissed immediately
dismissable.value = false;
dismissable.value = false
setTimeout(() => {
dismissable.value = true;
}, 300);
};
dismissable.value = true
}, 300)
}
const handleEscapeKeyPress = (event) => {
if (event.key === "Escape") {
closeDialog();
if (event.key === 'Escape') {
closeDialog()
}
};
}
onMounted(() => {
document.addEventListener("litegraph:canvas", canvasEventHandler);
document.addEventListener("keydown", handleEscapeKeyPress);
});
document.addEventListener('litegraph:canvas', canvasEventHandler)
document.addEventListener('keydown', handleEscapeKeyPress)
})
onUnmounted(() => {
document.removeEventListener("litegraph:canvas", canvasEventHandler);
document.removeEventListener("keydown", handleEscapeKeyPress);
});
document.removeEventListener('litegraph:canvas', canvasEventHandler)
document.removeEventListener('keydown', handleEscapeKeyPress)
})
</script>
<style>

View File

@@ -34,52 +34,52 @@
</template>
<script setup lang="ts">
import { NodeFilter, type FilterAndValue } from "@/services/nodeSearchService";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import SelectButton from "primevue/selectbutton";
import AutoComplete from "primevue/autocomplete";
import { ref, onMounted } from "vue";
import { useNodeDefStore } from "@/stores/nodeDefStore";
import { NodeFilter, type FilterAndValue } from '@/services/nodeSearchService'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import SelectButton from 'primevue/selectbutton'
import AutoComplete from 'primevue/autocomplete'
import { ref, onMounted } from 'vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const visible = ref<boolean>(false);
const filters = ref<NodeFilter[]>([]);
const selectedFilter = ref<NodeFilter>();
const filterValues = ref<string[]>([]);
const selectedFilterValue = ref<string>("");
const visible = ref<boolean>(false)
const filters = ref<NodeFilter[]>([])
const selectedFilter = ref<NodeFilter>()
const filterValues = ref<string[]>([])
const selectedFilterValue = ref<string>('')
onMounted(() => {
const nodeSearchService = useNodeDefStore().nodeSearchService;
filters.value = nodeSearchService.nodeFilters;
selectedFilter.value = nodeSearchService.nodeFilters[0];
});
const nodeSearchService = useNodeDefStore().nodeSearchService
filters.value = nodeSearchService.nodeFilters
selectedFilter.value = nodeSearchService.nodeFilters[0]
})
const emit = defineEmits(["addFilter"]);
const emit = defineEmits(['addFilter'])
const updateSelectedFilterValue = () => {
updateFilterValues("");
updateFilterValues('')
if (filterValues.value.includes(selectedFilterValue.value)) {
return;
return
}
selectedFilterValue.value = filterValues.value[0];
};
selectedFilterValue.value = filterValues.value[0]
}
const updateFilterValues = (query: string) => {
filterValues.value = selectedFilter.value.fuseSearch.search(query);
};
filterValues.value = selectedFilter.value.fuseSearch.search(query)
}
const submit = () => {
visible.value = false;
emit("addFilter", [
visible.value = false
emit('addFilter', [
selectedFilter.value,
selectedFilterValue.value,
] as FilterAndValue);
};
selectedFilterValue.value
] as FilterAndValue)
}
const showModal = () => {
updateSelectedFilterValue();
visible.value = true;
};
updateSelectedFilterValue()
visible.value = true
}
</script>
<style scoped>

View File

@@ -5,18 +5,18 @@
</template>
<script setup lang="ts">
import { getNodeSource } from "@/types/nodeSource";
import Chip from "primevue/chip";
import { computed } from "vue";
import { getNodeSource } from '@/types/nodeSource'
import Chip from 'primevue/chip'
import { computed } from 'vue'
const props = defineProps({
python_module: {
type: String,
required: true,
},
});
required: true
}
})
const nodeSource = computed(() => getNodeSource(props.python_module));
const nodeSource = computed(() => getNodeSource(props.python_module))
</script>
<style scoped>

View File

@@ -1,14 +1,14 @@
<!-- Auto complete with extra event "focused-option-changed" -->
<script>
import AutoComplete from "primevue/autocomplete";
import AutoComplete from 'primevue/autocomplete'
export default {
name: "AutoCompletePlus",
name: 'AutoCompletePlus',
extends: AutoComplete,
emits: ["focused-option-changed"],
emits: ['focused-option-changed'],
mounted() {
if (typeof AutoComplete.mounted === "function") {
AutoComplete.mounted.call(this);
if (typeof AutoComplete.mounted === 'function') {
AutoComplete.mounted.call(this)
}
// Add a watcher on the focusedOptionIndex property
@@ -16,9 +16,9 @@ export default {
() => this.focusedOptionIndex,
(newVal, oldVal) => {
// Emit a custom event when focusedOptionIndex changes
this.$emit("focused-option-changed", newVal);
this.$emit('focused-option-changed', newVal)
}
);
},
};
)
}
}
</script>

View File

@@ -9,7 +9,7 @@
? 'p-button-primary side-bar-button-selected'
: 'p-button-secondary'
}`,
icon: 'side-bar-button-icon',
icon: 'side-bar-button-icon'
}"
@click="emit('click', $event)"
v-tooltip="{ value: props.tooltip, showDelay: 300, hideDelay: 300 }"
@@ -17,22 +17,22 @@
</template>
<script setup lang="ts">
import Button from "primevue/button";
import Button from 'primevue/button'
const props = defineProps({
icon: String,
selected: Boolean,
tooltip: {
type: String,
default: "",
default: ''
},
class: {
type: String,
default: "",
},
});
default: ''
}
})
const emit = defineEmits(["click"]);
const emit = defineEmits(['click'])
</script>
<style>

View File

@@ -7,10 +7,10 @@
</template>
<script setup lang="ts">
import { app } from "@/scripts/app";
import SideBarIcon from "./SideBarIcon.vue";
import { app } from '@/scripts/app'
import SideBarIcon from './SideBarIcon.vue'
const showSetting = () => {
app.ui.settings.show();
};
app.ui.settings.show()
}
</script>

View File

@@ -8,23 +8,23 @@
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import SideBarIcon from "./SideBarIcon.vue";
import { useSettingStore } from "@/stores/settingStore";
import { computed, ref } from 'vue'
import SideBarIcon from './SideBarIcon.vue'
import { useSettingStore } from '@/stores/settingStore'
const previousDarkTheme = ref("dark");
const previousDarkTheme = ref('dark')
const currentTheme = computed(() =>
useSettingStore().get("Comfy.ColorPalette", "dark")
);
const isDarkMode = computed(() => currentTheme.value !== "light");
const icon = computed(() => (isDarkMode.value ? "pi pi-moon" : "pi pi-sun"));
useSettingStore().get('Comfy.ColorPalette', 'dark')
)
const isDarkMode = computed(() => currentTheme.value !== 'light')
const icon = computed(() => (isDarkMode.value ? 'pi pi-moon' : 'pi pi-sun'))
const toggleTheme = () => {
if (isDarkMode.value) {
previousDarkTheme.value = currentTheme.value;
useSettingStore().set("Comfy.ColorPalette", "light");
previousDarkTheme.value = currentTheme.value
useSettingStore().set('Comfy.ColorPalette', 'light')
} else {
useSettingStore().set("Comfy.ColorPalette", previousDarkTheme.value);
useSettingStore().set('Comfy.ColorPalette', previousDarkTheme.value)
}
};
}
</script>

View File

@@ -28,55 +28,55 @@
mountCustomTab(
selectedTab as CustomSidebarTabExtension,
el as HTMLElement
);
)
}
"
></div>
</template>
<script setup lang="ts">
import SideBarIcon from "./SideBarIcon.vue";
import SideBarThemeToggleIcon from "./SideBarThemeToggleIcon.vue";
import SideBarSettingsToggleIcon from "./SideBarSettingsToggleIcon.vue";
import { computed, onBeforeUnmount, watch } from "vue";
import { useSettingStore } from "@/stores/settingStore";
import { app } from "@/scripts/app";
import { useWorkspaceStore } from "@/stores/workspaceStateStore";
import SideBarIcon from './SideBarIcon.vue'
import SideBarThemeToggleIcon from './SideBarThemeToggleIcon.vue'
import SideBarSettingsToggleIcon from './SideBarSettingsToggleIcon.vue'
import { computed, onBeforeUnmount, watch } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import {
CustomSidebarTabExtension,
SidebarTabExtension,
} from "@/types/extensionTypes";
SidebarTabExtension
} from '@/types/extensionTypes'
const workspaceStateStore = useWorkspaceStore();
const tabs = computed(() => app.extensionManager.getSidebarTabs());
const workspaceStateStore = useWorkspaceStore()
const tabs = computed(() => app.extensionManager.getSidebarTabs())
const selectedTab = computed<SidebarTabExtension | null>(() => {
const tabId = workspaceStateStore.activeSidebarTab;
return tabs.value.find((tab) => tab.id === tabId) || null;
});
const tabId = workspaceStateStore.activeSidebarTab
return tabs.value.find((tab) => tab.id === tabId) || null
})
const mountCustomTab = (tab: CustomSidebarTabExtension, el: HTMLElement) => {
tab.render(el);
};
tab.render(el)
}
const onTabClick = (item: SidebarTabExtension) => {
workspaceStateStore.updateActiveSidebarTab(
workspaceStateStore.activeSidebarTab === item.id ? null : item.id
);
};
)
}
const betaMenuEnabled = computed(
() => useSettingStore().get("Comfy.UseNewMenu") !== "Disabled"
);
() => useSettingStore().get('Comfy.UseNewMenu') !== 'Disabled'
)
watch(betaMenuEnabled, (newValue) => {
if (!newValue) {
workspaceStateStore.updateActiveSidebarTab(null);
workspaceStateStore.updateActiveSidebarTab(null)
}
});
})
onBeforeUnmount(() => {
tabs.value.forEach((tab) => {
if (tab.type === "custom" && tab.destroy) {
tab.destroy();
if (tab.type === 'custom' && tab.destroy) {
tab.destroy()
}
});
});
})
})
</script>
<style>

View File

@@ -26,11 +26,11 @@
<Column
:pt="{
headerCell: {
class: 'queue-tool-header-cell',
class: 'queue-tool-header-cell'
},
bodyCell: {
class: 'queue-tool-body-cell',
},
class: 'queue-tool-body-cell'
}
}"
>
<template #header>
@@ -67,89 +67,89 @@
</template>
<script setup lang="ts">
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Tag from "primevue/tag";
import Button from "primevue/button";
import ConfirmPopup from "primevue/confirmpopup";
import Toast from "primevue/toast";
import Message from "primevue/message";
import { useConfirm } from "primevue/useconfirm";
import { useToast } from "primevue/usetoast";
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import Toast from 'primevue/toast'
import Message from 'primevue/message'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import {
TaskItemDisplayStatus,
TaskItemImpl,
useQueueStore,
} from "@/stores/queueStore";
import { computed, onMounted } from "vue";
import { api } from "@/scripts/api";
useQueueStore
} from '@/stores/queueStore'
import { computed, onMounted } from 'vue'
import { api } from '@/scripts/api'
const confirm = useConfirm();
const toast = useToast();
const queueStore = useQueueStore();
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
const tasks = computed(() => queueStore.tasks);
const tasks = computed(() => queueStore.tasks)
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return "secondary";
return 'secondary'
case TaskItemDisplayStatus.Running:
return "info";
return 'info'
case TaskItemDisplayStatus.Completed:
return "success";
return 'success'
case TaskItemDisplayStatus.Failed:
return "danger";
return 'danger'
case TaskItemDisplayStatus.Cancelled:
return "warning";
return 'warning'
}
};
}
const formatTime = (time?: number) => {
if (time === undefined) {
return "";
return ''
}
return `${time.toFixed(2)}s`;
};
return `${time.toFixed(2)}s`
}
const removeTask = (task: TaskItemImpl) => {
if (task.isRunning) {
api.interrupt();
api.interrupt()
}
queueStore.delete(task);
};
queueStore.delete(task)
}
const removeAllTasks = async () => {
await queueStore.clear();
};
await queueStore.clear()
}
const confirmRemoveAll = (event) => {
confirm.require({
target: event.currentTarget,
message: "Do you want to delete all tasks?",
icon: "pi pi-info-circle",
message: 'Do you want to delete all tasks?',
icon: 'pi pi-info-circle',
rejectProps: {
label: "Cancel",
severity: "secondary",
outlined: true,
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: "Delete",
severity: "danger",
label: 'Delete',
severity: 'danger'
},
accept: async () => {
await removeAllTasks();
await removeAllTasks()
toast.add({
severity: "info",
summary: "Confirmed",
detail: "Tasks deleted",
life: 3000,
});
},
});
};
severity: 'info',
summary: 'Confirmed',
detail: 'Tasks deleted',
life: 3000
})
}
})
}
onMounted(() => {
api.addEventListener("status", () => {
queueStore.update();
});
api.addEventListener('status', () => {
queueStore.update()
})
queueStore.update();
});
queueStore.update()
})
</script>
<style>

View File

@@ -1,20 +1,20 @@
import { app } from "../../scripts/app";
import { ComfyDialog, $el } from "../../scripts/ui";
import { ComfyApp } from "../../scripts/app";
import { app } from '../../scripts/app'
import { ComfyDialog, $el } from '../../scripts/ui'
import { ComfyApp } from '../../scripts/app'
export class ClipspaceDialog extends ComfyDialog {
static items = [];
static instance = null;
static items = []
static instance = null
static registerButton(name, contextPredicate, callback) {
const item = $el("button", {
type: "button",
const item = $el('button', {
type: 'button',
textContent: name,
contextPredicate: contextPredicate,
onclick: callback,
});
onclick: callback
})
ClipspaceDialog.items.push(item);
ClipspaceDialog.items.push(item)
}
static invalidatePreview() {
@@ -24,161 +24,161 @@ export class ClipspaceDialog extends ComfyDialog {
ComfyApp.clipspace.imgs.length > 0
) {
const img_preview = document.getElementById(
"clipspace_preview"
) as HTMLImageElement;
'clipspace_preview'
) as HTMLImageElement
if (img_preview) {
img_preview.src =
ComfyApp.clipspace.imgs[ComfyApp.clipspace["selectedIndex"]].src;
img_preview.style.maxHeight = "100%";
img_preview.style.maxWidth = "100%";
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src
img_preview.style.maxHeight = '100%'
img_preview.style.maxWidth = '100%'
}
}
}
static invalidate() {
if (ClipspaceDialog.instance) {
const self = ClipspaceDialog.instance;
const self = ClipspaceDialog.instance
// allow reconstruct controls when copying from non-image to image content.
const children = $el("div.comfy-modal-content", [
const children = $el('div.comfy-modal-content', [
self.createImgSettings(),
...self.createButtons(),
]);
...self.createButtons()
])
if (self.element) {
// update
self.element.removeChild(self.element.firstChild);
self.element.appendChild(children);
self.element.removeChild(self.element.firstChild)
self.element.appendChild(children)
} else {
// new
self.element = $el("div.comfy-modal", { parent: document.body }, [
children,
]);
self.element = $el('div.comfy-modal', { parent: document.body }, [
children
])
}
if (self.element.children[0].children.length <= 1) {
self.element.children[0].appendChild(
$el("p", {}, [
"Unable to find the features to edit content of a format stored in the current Clipspace.",
$el('p', {}, [
'Unable to find the features to edit content of a format stored in the current Clipspace.'
])
);
)
}
ClipspaceDialog.invalidatePreview();
ClipspaceDialog.invalidatePreview()
}
}
constructor() {
super();
super()
}
createButtons() {
const buttons = [];
const buttons = []
for (let idx in ClipspaceDialog.items) {
const item = ClipspaceDialog.items[idx];
const item = ClipspaceDialog.items[idx]
if (!item.contextPredicate || item.contextPredicate())
buttons.push(ClipspaceDialog.items[idx]);
buttons.push(ClipspaceDialog.items[idx])
}
buttons.push(
$el("button", {
type: "button",
textContent: "Close",
$el('button', {
type: 'button',
textContent: 'Close',
onclick: () => {
this.close();
},
this.close()
}
})
);
)
return buttons;
return buttons
}
createImgSettings() {
if (ComfyApp.clipspace.imgs) {
const combo_items = [];
const imgs = ComfyApp.clipspace.imgs;
const combo_items = []
const imgs = ComfyApp.clipspace.imgs
for (let i = 0; i < imgs.length; i++) {
combo_items.push($el("option", { value: i }, [`${i}`]));
combo_items.push($el('option', { value: i }, [`${i}`]))
}
const combo1 = $el(
"select",
'select',
{
id: "clipspace_img_selector",
id: 'clipspace_img_selector',
onchange: (event) => {
ComfyApp.clipspace["selectedIndex"] = event.target.selectedIndex;
ClipspaceDialog.invalidatePreview();
},
ComfyApp.clipspace['selectedIndex'] = event.target.selectedIndex
ClipspaceDialog.invalidatePreview()
}
},
combo_items
);
)
const row1 = $el("tr", {}, [
$el("td", {}, [$el("font", { color: "white" }, ["Select Image"])]),
$el("td", {}, [combo1]),
]);
const row1 = $el('tr', {}, [
$el('td', {}, [$el('font', { color: 'white' }, ['Select Image'])]),
$el('td', {}, [combo1])
])
const combo2 = $el(
"select",
'select',
{
id: "clipspace_img_paste_mode",
id: 'clipspace_img_paste_mode',
onchange: (event) => {
ComfyApp.clipspace["img_paste_mode"] = event.target.value;
},
ComfyApp.clipspace['img_paste_mode'] = event.target.value
}
},
[
$el("option", { value: "selected" }, "selected"),
$el("option", { value: "all" }, "all"),
$el('option', { value: 'selected' }, 'selected'),
$el('option', { value: 'all' }, 'all')
]
) as HTMLSelectElement;
combo2.value = ComfyApp.clipspace["img_paste_mode"];
) as HTMLSelectElement
combo2.value = ComfyApp.clipspace['img_paste_mode']
const row2 = $el("tr", {}, [
$el("td", {}, [$el("font", { color: "white" }, ["Paste Mode"])]),
$el("td", {}, [combo2]),
]);
const row2 = $el('tr', {}, [
$el('td', {}, [$el('font', { color: 'white' }, ['Paste Mode'])]),
$el('td', {}, [combo2])
])
const td = $el(
"td",
{ align: "center", width: "100px", height: "100px", colSpan: "2" },
[$el("img", { id: "clipspace_preview", ondragstart: () => false }, [])]
);
'td',
{ align: 'center', width: '100px', height: '100px', colSpan: '2' },
[$el('img', { id: 'clipspace_preview', ondragstart: () => false }, [])]
)
const row3 = $el("tr", {}, [td]);
const row3 = $el('tr', {}, [td])
return $el("table", {}, [row1, row2, row3]);
return $el('table', {}, [row1, row2, row3])
} else {
return [];
return []
}
}
createImgPreview() {
if (ComfyApp.clipspace.imgs) {
return $el("img", { id: "clipspace_preview", ondragstart: () => false });
} else return [];
return $el('img', { id: 'clipspace_preview', ondragstart: () => false })
} else return []
}
show() {
const img_preview = document.getElementById("clipspace_preview");
ClipspaceDialog.invalidate();
const img_preview = document.getElementById('clipspace_preview')
ClipspaceDialog.invalidate()
this.element.style.display = "block";
this.element.style.display = 'block'
}
}
app.registerExtension({
name: "Comfy.Clipspace",
name: 'Comfy.Clipspace',
init(app) {
app.openClipspace = function () {
if (!ClipspaceDialog.instance) {
ClipspaceDialog.instance = new ClipspaceDialog();
ComfyApp.clipspace_invalidate_handler = ClipspaceDialog.invalidate;
ClipspaceDialog.instance = new ClipspaceDialog()
ComfyApp.clipspace_invalidate_handler = ClipspaceDialog.invalidate
}
if (ComfyApp.clipspace) {
ClipspaceDialog.instance.show();
} else app.ui.dialog.show("Clipspace is Empty!");
};
},
});
ClipspaceDialog.instance.show()
} else app.ui.dialog.show('Clipspace is Empty!')
}
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,172 +1,172 @@
import { LiteGraph, LGraphCanvas } from "@comfyorg/litegraph";
import { app } from "../../scripts/app";
import { LiteGraph, LGraphCanvas } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
// Adds filtering to combo context menus
const ext = {
name: "Comfy.ContextMenuFilter",
name: 'Comfy.ContextMenuFilter',
init() {
const ctxMenu = LiteGraph.ContextMenu;
const ctxMenu = LiteGraph.ContextMenu
// @ts-ignore
// TODO Very hacky way to modify Litegraph behaviour. Fix this later.
LiteGraph.ContextMenu = function (values, options) {
const ctx = ctxMenu.call(this, values, options);
const ctx = ctxMenu.call(this, values, options)
// If we are a dark menu (only used for combo boxes) then add a filter input
if (options?.className === "dark" && values?.length > 10) {
const filter = document.createElement("input");
filter.classList.add("comfy-context-menu-filter");
filter.placeholder = "Filter list";
this.root.prepend(filter);
if (options?.className === 'dark' && values?.length > 10) {
const filter = document.createElement('input')
filter.classList.add('comfy-context-menu-filter')
filter.placeholder = 'Filter list'
this.root.prepend(filter)
const items = Array.from(
this.root.querySelectorAll(".litemenu-entry")
) as HTMLElement[];
let displayedItems = [...items];
let itemCount = displayedItems.length;
this.root.querySelectorAll('.litemenu-entry')
) as HTMLElement[]
let displayedItems = [...items]
let itemCount = displayedItems.length
// We must request an animation frame for the current node of the active canvas to update.
requestAnimationFrame(() => {
// @ts-ignore
const currentNode = LGraphCanvas.active_canvas.current_node;
const currentNode = LGraphCanvas.active_canvas.current_node
const clickedComboValue = currentNode.widgets
?.filter(
(w) =>
w.type === "combo" && w.options.values.length === values.length
w.type === 'combo' && w.options.values.length === values.length
)
.find((w) =>
w.options.values.every((v, i) => v === values[i])
)?.value;
)?.value
let selectedIndex = clickedComboValue
? values.findIndex((v) => v === clickedComboValue)
: 0;
: 0
if (selectedIndex < 0) {
selectedIndex = 0;
selectedIndex = 0
}
let selectedItem = displayedItems[selectedIndex];
updateSelected();
let selectedItem = displayedItems[selectedIndex]
updateSelected()
// Apply highlighting to the selected item
function updateSelected() {
selectedItem?.style.setProperty("background-color", "");
selectedItem?.style.setProperty("color", "");
selectedItem = displayedItems[selectedIndex];
selectedItem?.style.setProperty('background-color', '')
selectedItem?.style.setProperty('color', '')
selectedItem = displayedItems[selectedIndex]
selectedItem?.style.setProperty(
"background-color",
"#ccc",
"important"
);
selectedItem?.style.setProperty("color", "#000", "important");
'background-color',
'#ccc',
'important'
)
selectedItem?.style.setProperty('color', '#000', 'important')
}
const positionList = () => {
const rect = this.root.getBoundingClientRect();
const rect = this.root.getBoundingClientRect()
// If the top is off-screen then shift the element with scaling applied
if (rect.top < 0) {
const scale =
1 -
this.root.getBoundingClientRect().height /
this.root.clientHeight;
const shift = (this.root.clientHeight * scale) / 2;
this.root.style.top = -shift + "px";
this.root.clientHeight
const shift = (this.root.clientHeight * scale) / 2
this.root.style.top = -shift + 'px'
}
}
};
// Arrow up/down to select items
filter.addEventListener("keydown", (event) => {
filter.addEventListener('keydown', (event) => {
switch (event.key) {
case "ArrowUp":
event.preventDefault();
case 'ArrowUp':
event.preventDefault()
if (selectedIndex === 0) {
selectedIndex = itemCount - 1;
selectedIndex = itemCount - 1
} else {
selectedIndex--;
selectedIndex--
}
updateSelected();
break;
case "ArrowRight":
event.preventDefault();
selectedIndex = itemCount - 1;
updateSelected();
break;
case "ArrowDown":
event.preventDefault();
updateSelected()
break
case 'ArrowRight':
event.preventDefault()
selectedIndex = itemCount - 1
updateSelected()
break
case 'ArrowDown':
event.preventDefault()
if (selectedIndex === itemCount - 1) {
selectedIndex = 0;
selectedIndex = 0
} else {
selectedIndex++;
selectedIndex++
}
updateSelected();
break;
case "ArrowLeft":
event.preventDefault();
selectedIndex = 0;
updateSelected();
break;
case "Enter":
selectedItem?.click();
break;
case "Escape":
this.close();
break;
updateSelected()
break
case 'ArrowLeft':
event.preventDefault()
selectedIndex = 0
updateSelected()
break
case 'Enter':
selectedItem?.click()
break
case 'Escape':
this.close()
break
}
});
})
filter.addEventListener("input", () => {
filter.addEventListener('input', () => {
// Hide all items that don't match our filter
const term = filter.value.toLocaleLowerCase();
const term = filter.value.toLocaleLowerCase()
// When filtering, recompute which items are visible for arrow up/down and maintain selection.
displayedItems = items.filter((item) => {
const isVisible =
!term || item.textContent.toLocaleLowerCase().includes(term);
item.style.display = isVisible ? "block" : "none";
return isVisible;
});
!term || item.textContent.toLocaleLowerCase().includes(term)
item.style.display = isVisible ? 'block' : 'none'
return isVisible
})
selectedIndex = 0;
selectedIndex = 0
if (displayedItems.includes(selectedItem)) {
selectedIndex = displayedItems.findIndex(
(d) => d === selectedItem
);
)
}
itemCount = displayedItems.length;
itemCount = displayedItems.length
updateSelected();
updateSelected()
// If we have an event then we can try and position the list under the source
if (options.event) {
let top = options.event.clientY - 10;
let top = options.event.clientY - 10
const bodyRect = document.body.getBoundingClientRect();
const rootRect = this.root.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect()
const rootRect = this.root.getBoundingClientRect()
if (
bodyRect.height &&
top > bodyRect.height - rootRect.height - 10
) {
top = Math.max(0, bodyRect.height - rootRect.height - 10);
top = Math.max(0, bodyRect.height - rootRect.height - 10)
}
this.root.style.top = top + "px";
positionList();
this.root.style.top = top + 'px'
positionList()
}
});
})
requestAnimationFrame(() => {
// Focus the filter box when opening
filter.focus();
filter.focus()
positionList();
});
});
positionList()
})
})
}
return ctx;
};
return ctx
}
LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
},
};
LiteGraph.ContextMenu.prototype = ctxMenu.prototype
}
}
app.registerExtension(ext);
app.registerExtension(ext)

View File

@@ -1,4 +1,4 @@
import { app } from "../../scripts/app";
import { app } from '../../scripts/app'
// Allows for simple dynamic prompt replacement
// Inputs in the format {a|b} will have a random value of a or b chosen when the prompt is queued.
@@ -7,46 +7,46 @@ import { app } from "../../scripts/app";
* Strips C-style line and block comments from a string
*/
function stripComments(str) {
return str.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, "");
return str.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
}
app.registerExtension({
name: "Comfy.DynamicPrompts",
name: 'Comfy.DynamicPrompts',
nodeCreated(node) {
if (node.widgets) {
// Locate dynamic prompt text widgets
// Include any widgets with dynamicPrompts set to true, and customtext
const widgets = node.widgets.filter((n) => n.dynamicPrompts);
const widgets = node.widgets.filter((n) => n.dynamicPrompts)
for (const widget of widgets) {
// Override the serialization of the value to resolve dynamic prompts for all widgets supporting it in this node
widget.serializeValue = (workflowNode, widgetIndex) => {
let prompt = stripComments(widget.value);
let prompt = stripComments(widget.value)
while (
prompt.replace("\\{", "").includes("{") &&
prompt.replace("\\}", "").includes("}")
prompt.replace('\\{', '').includes('{') &&
prompt.replace('\\}', '').includes('}')
) {
const startIndex = prompt.replace("\\{", "00").indexOf("{");
const endIndex = prompt.replace("\\}", "00").indexOf("}");
const startIndex = prompt.replace('\\{', '00').indexOf('{')
const endIndex = prompt.replace('\\}', '00').indexOf('}')
const optionsString = prompt.substring(startIndex + 1, endIndex);
const options = optionsString.split("|");
const optionsString = prompt.substring(startIndex + 1, endIndex)
const options = optionsString.split('|')
const randomIndex = Math.floor(Math.random() * options.length);
const randomOption = options[randomIndex];
const randomIndex = Math.floor(Math.random() * options.length)
const randomOption = options[randomIndex]
prompt =
prompt.substring(0, startIndex) +
randomOption +
prompt.substring(endIndex + 1);
prompt.substring(endIndex + 1)
}
// Overwrite the value in the serialized workflow pnginfo
if (workflowNode?.widgets_values)
workflowNode.widgets_values[widgetIndex] = prompt;
workflowNode.widgets_values[widgetIndex] = prompt
return prompt;
};
return prompt
}
}
},
});
}
}
})

View File

@@ -1,161 +1,161 @@
import { app } from "../../scripts/app";
import { app } from '../../scripts/app'
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
app.registerExtension({
name: "Comfy.EditAttention",
name: 'Comfy.EditAttention',
init() {
const editAttentionDelta = app.ui.settings.addSetting({
id: "Comfy.EditAttention.Delta",
name: "Ctrl+up/down precision",
type: "slider",
id: 'Comfy.EditAttention.Delta',
name: 'Ctrl+up/down precision',
type: 'slider',
attrs: {
min: 0.01,
max: 0.5,
step: 0.01,
step: 0.01
},
defaultValue: 0.05,
});
defaultValue: 0.05
})
function incrementWeight(weight, delta) {
const floatWeight = parseFloat(weight);
if (isNaN(floatWeight)) return weight;
const newWeight = floatWeight + delta;
if (newWeight < 0) return "0";
return String(Number(newWeight.toFixed(10)));
const floatWeight = parseFloat(weight)
if (isNaN(floatWeight)) return weight
const newWeight = floatWeight + delta
if (newWeight < 0) return '0'
return String(Number(newWeight.toFixed(10)))
}
function findNearestEnclosure(text, cursorPos) {
let start = cursorPos,
end = cursorPos;
end = cursorPos
let openCount = 0,
closeCount = 0;
closeCount = 0
// Find opening parenthesis before cursor
while (start >= 0) {
start--;
if (text[start] === "(" && openCount === closeCount) break;
if (text[start] === "(") openCount++;
if (text[start] === ")") closeCount++;
start--
if (text[start] === '(' && openCount === closeCount) break
if (text[start] === '(') openCount++
if (text[start] === ')') closeCount++
}
if (start < 0) return false;
if (start < 0) return false
openCount = 0;
closeCount = 0;
openCount = 0
closeCount = 0
// Find closing parenthesis after cursor
while (end < text.length) {
if (text[end] === ")" && openCount === closeCount) break;
if (text[end] === "(") openCount++;
if (text[end] === ")") closeCount++;
end++;
if (text[end] === ')' && openCount === closeCount) break
if (text[end] === '(') openCount++
if (text[end] === ')') closeCount++
end++
}
if (end === text.length) return false;
if (end === text.length) return false
return { start: start + 1, end: end };
return { start: start + 1, end: end }
}
function addWeightToParentheses(text) {
const parenRegex = /^\((.*)\)$/;
const parenMatch = text.match(parenRegex);
const parenRegex = /^\((.*)\)$/
const parenMatch = text.match(parenRegex)
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/;
const floatMatch = text.match(floatRegex);
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/
const floatMatch = text.match(floatRegex)
if (parenMatch && !floatMatch) {
return `(${parenMatch[1]}:1.0)`;
return `(${parenMatch[1]}:1.0)`
} else {
return text;
return text
}
}
function editAttention(event) {
const inputField = event.composedPath()[0];
const delta = parseFloat(editAttentionDelta.value);
const inputField = event.composedPath()[0]
const delta = parseFloat(editAttentionDelta.value)
if (inputField.tagName !== "TEXTAREA") return;
if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) return;
if (!event.ctrlKey && !event.metaKey) return;
if (inputField.tagName !== 'TEXTAREA') return
if (!(event.key === 'ArrowUp' || event.key === 'ArrowDown')) return
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault();
event.preventDefault()
let start = inputField.selectionStart;
let end = inputField.selectionEnd;
let selectedText = inputField.value.substring(start, end);
let start = inputField.selectionStart
let end = inputField.selectionEnd
let selectedText = inputField.value.substring(start, end)
// If there is no selection, attempt to find the nearest enclosure, or select the current word
if (!selectedText) {
const nearestEnclosure = findNearestEnclosure(inputField.value, start);
const nearestEnclosure = findNearestEnclosure(inputField.value, start)
if (nearestEnclosure) {
start = nearestEnclosure.start;
end = nearestEnclosure.end;
selectedText = inputField.value.substring(start, end);
start = nearestEnclosure.start
end = nearestEnclosure.end
selectedText = inputField.value.substring(start, end)
} else {
// Select the current word, find the start and end of the word
const delimiters = " .,\\/!?%^*;:{}=-_`~()\r\n\t";
const delimiters = ' .,\\/!?%^*;:{}=-_`~()\r\n\t'
while (
!delimiters.includes(inputField.value[start - 1]) &&
start > 0
) {
start--;
start--
}
while (
!delimiters.includes(inputField.value[end]) &&
end < inputField.value.length
) {
end++;
end++
}
selectedText = inputField.value.substring(start, end);
if (!selectedText) return;
selectedText = inputField.value.substring(start, end)
if (!selectedText) return
}
}
// If the selection ends with a space, remove it
if (selectedText[selectedText.length - 1] === " ") {
selectedText = selectedText.substring(0, selectedText.length - 1);
end -= 1;
if (selectedText[selectedText.length - 1] === ' ') {
selectedText = selectedText.substring(0, selectedText.length - 1)
end -= 1
}
// If there are parentheses left and right of the selection, select them
if (
inputField.value[start - 1] === "(" &&
inputField.value[end] === ")"
inputField.value[start - 1] === '(' &&
inputField.value[end] === ')'
) {
start -= 1;
end += 1;
selectedText = inputField.value.substring(start, end);
start -= 1
end += 1
selectedText = inputField.value.substring(start, end)
}
// If the selection is not enclosed in parentheses, add them
if (
selectedText[0] !== "(" ||
selectedText[selectedText.length - 1] !== ")"
selectedText[0] !== '(' ||
selectedText[selectedText.length - 1] !== ')'
) {
selectedText = `(${selectedText})`;
selectedText = `(${selectedText})`
}
// If the selection does not have a weight, add a weight of 1.0
selectedText = addWeightToParentheses(selectedText);
selectedText = addWeightToParentheses(selectedText)
// Increment the weight
const weightDelta = event.key === "ArrowUp" ? delta : -delta;
const weightDelta = event.key === 'ArrowUp' ? delta : -delta
const updatedText = selectedText.replace(
/\((.*):(\d+(?:\.\d+)?)\)/,
(match, text, weight) => {
weight = incrementWeight(weight, weightDelta);
weight = incrementWeight(weight, weightDelta)
if (weight == 1) {
return text;
return text
} else {
return `(${text}:${weight})`;
return `(${text}:${weight})`
}
}
);
)
inputField.setRangeText(updatedText, start, end, "select");
inputField.setRangeText(updatedText, start, end, 'select')
}
window.addEventListener("keydown", editAttention);
},
});
window.addEventListener('keydown', editAttention)
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,41 @@
import { $el, ComfyDialog } from "../../scripts/ui";
import { DraggableList } from "../../scripts/ui/draggableList";
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode";
import "./groupNodeManage.css";
import { app, type ComfyApp } from "../../scripts/app";
import { $el, ComfyDialog } from '../../scripts/ui'
import { DraggableList } from '../../scripts/ui/draggableList'
import { GroupNodeConfig, GroupNodeHandler } from './groupNode'
import './groupNodeManage.css'
import { app, type ComfyApp } from '../../scripts/app'
import {
LiteGraph,
type LGraphNode,
type LGraphNodeConstructor,
} from "@comfyorg/litegraph";
type LGraphNodeConstructor
} from '@comfyorg/litegraph'
const ORDER: symbol = Symbol();
const ORDER: symbol = Symbol()
function merge(target, source) {
if (typeof target === "object" && typeof source === "object") {
if (typeof target === 'object' && typeof source === 'object') {
for (const key in source) {
const sv = source[key];
if (typeof sv === "object") {
let tv = target[key];
if (!tv) tv = target[key] = {};
merge(tv, source[key]);
const sv = source[key]
if (typeof sv === 'object') {
let tv = target[key]
if (!tv) tv = target[key] = {}
merge(tv, source[key])
} else {
target[key] = sv;
target[key] = sv
}
}
}
return target;
return target
}
export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
tabs: Record<
"Inputs" | "Outputs" | "Widgets",
'Inputs' | 'Outputs' | 'Widgets',
{ tab: HTMLAnchorElement; page: HTMLElement }
>;
selectedNodeIndex: number | null | undefined;
selectedTab: keyof ManageGroupDialog["tabs"] = "Inputs";
selectedGroup: string | undefined;
>
selectedNodeIndex: number | null | undefined
selectedTab: keyof ManageGroupDialog['tabs'] = 'Inputs'
selectedGroup: string | undefined
modifications: Record<
string,
Record<
@@ -45,474 +45,472 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
{ name?: string | undefined; visible?: boolean | undefined }
>
>
> = {};
nodeItems: any[];
app: ComfyApp;
groupNodeType: LGraphNodeConstructor<LGraphNode>;
groupNodeDef: any;
groupData: any;
> = {}
nodeItems: any[]
app: ComfyApp
groupNodeType: LGraphNodeConstructor<LGraphNode>
groupNodeDef: any
groupData: any
innerNodesList: HTMLUListElement;
widgetsPage: HTMLElement;
inputsPage: HTMLElement;
outputsPage: HTMLElement;
draggable: any;
innerNodesList: HTMLUListElement
widgetsPage: HTMLElement
inputsPage: HTMLElement
outputsPage: HTMLElement
draggable: any
get selectedNodeInnerIndex() {
return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex;
return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex
}
constructor(app) {
super();
this.app = app;
this.element = $el("dialog.comfy-group-manage", {
parent: document.body,
}) as HTMLDialogElement;
super()
this.app = app
this.element = $el('dialog.comfy-group-manage', {
parent: document.body
}) as HTMLDialogElement
}
changeTab(tab) {
this.tabs[this.selectedTab].tab.classList.remove("active");
this.tabs[this.selectedTab].page.classList.remove("active");
this.tabs[tab].tab.classList.add("active");
this.tabs[tab].page.classList.add("active");
this.selectedTab = tab;
this.tabs[this.selectedTab].tab.classList.remove('active')
this.tabs[this.selectedTab].page.classList.remove('active')
this.tabs[tab].tab.classList.add('active')
this.tabs[tab].page.classList.add('active')
this.selectedTab = tab
}
changeNode(index, force?) {
if (!force && this.selectedNodeIndex === index) return;
if (!force && this.selectedNodeIndex === index) return
if (this.selectedNodeIndex != null) {
this.nodeItems[this.selectedNodeIndex].classList.remove("selected");
this.nodeItems[this.selectedNodeIndex].classList.remove('selected')
}
this.nodeItems[index].classList.add("selected");
this.selectedNodeIndex = index;
this.nodeItems[index].classList.add('selected')
this.selectedNodeIndex = index
if (!this.buildInputsPage() && this.selectedTab === "Inputs") {
this.changeTab("Widgets");
if (!this.buildInputsPage() && this.selectedTab === 'Inputs') {
this.changeTab('Widgets')
}
if (!this.buildWidgetsPage() && this.selectedTab === "Widgets") {
this.changeTab("Outputs");
if (!this.buildWidgetsPage() && this.selectedTab === 'Widgets') {
this.changeTab('Outputs')
}
if (!this.buildOutputsPage() && this.selectedTab === "Outputs") {
this.changeTab("Inputs");
if (!this.buildOutputsPage() && this.selectedTab === 'Outputs') {
this.changeTab('Inputs')
}
this.changeTab(this.selectedTab);
this.changeTab(this.selectedTab)
}
getGroupData() {
this.groupNodeType =
LiteGraph.registered_node_types["workflow/" + this.selectedGroup];
this.groupNodeDef = this.groupNodeType.nodeData;
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType);
LiteGraph.registered_node_types['workflow/' + this.selectedGroup]
this.groupNodeDef = this.groupNodeType.nodeData
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)
}
changeGroup(group, reset = true) {
this.selectedGroup = group;
this.getGroupData();
this.selectedGroup = group
this.getGroupData()
const nodes = this.groupData.nodeData.nodes;
const nodes = this.groupData.nodeData.nodes
this.nodeItems = nodes.map((n, i) =>
$el(
"li.draggable-item",
'li.draggable-item',
{
dataset: {
nodeindex: n.index + "",
nodeindex: n.index + ''
},
onclick: () => {
this.changeNode(i);
},
this.changeNode(i)
}
},
[
$el("span.drag-handle"),
$el('span.drag-handle'),
$el(
"div",
'div',
{
textContent: n.title ?? n.type,
textContent: n.title ?? n.type
},
n.title
? $el("span", {
textContent: n.type,
? $el('span', {
textContent: n.type
})
: []
),
)
]
)
);
)
this.innerNodesList.replaceChildren(...this.nodeItems);
this.innerNodesList.replaceChildren(...this.nodeItems)
if (reset) {
this.selectedNodeIndex = null;
this.changeNode(0);
this.selectedNodeIndex = null
this.changeNode(0)
} else {
const items = this.draggable.getAllItems();
let index = items.findIndex((item) =>
item.classList.contains("selected")
);
if (index === -1) index = this.selectedNodeIndex;
this.changeNode(index, true);
const items = this.draggable.getAllItems()
let index = items.findIndex((item) => item.classList.contains('selected'))
if (index === -1) index = this.selectedNodeIndex
this.changeNode(index, true)
}
const ordered = [...nodes];
this.draggable?.dispose();
this.draggable = new DraggableList(this.innerNodesList, "li");
const ordered = [...nodes]
this.draggable?.dispose()
this.draggable = new DraggableList(this.innerNodesList, 'li')
this.draggable.addEventListener(
"dragend",
'dragend',
({ detail: { oldPosition, newPosition } }) => {
if (oldPosition === newPosition) return;
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]);
if (oldPosition === newPosition) return
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0])
for (let i = 0; i < ordered.length; i++) {
this.storeModification({
nodeIndex: ordered[i].index,
section: ORDER,
prop: "order",
value: i,
});
prop: 'order',
value: i
})
}
}
);
)
}
storeModification(props: {
nodeIndex?: number;
section: symbol;
prop: string;
value: any;
nodeIndex?: number
section: symbol
prop: string
value: any
}) {
const { nodeIndex, section, prop, value } = props;
const groupMod = (this.modifications[this.selectedGroup] ??= {});
const nodesMod = (groupMod.nodes ??= {});
const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {});
const typeMod = (nodeMod[section] ??= {});
if (typeof value === "object") {
const objMod = (typeMod[prop] ??= {});
Object.assign(objMod, value);
const { nodeIndex, section, prop, value } = props
const groupMod = (this.modifications[this.selectedGroup] ??= {})
const nodesMod = (groupMod.nodes ??= {})
const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {})
const typeMod = (nodeMod[section] ??= {})
if (typeof value === 'object') {
const objMod = (typeMod[prop] ??= {})
Object.assign(objMod, value)
} else {
typeMod[prop] = value;
typeMod[prop] = value
}
}
getEditElement(section, prop, value, placeholder, checked, checkable = true) {
if (value === placeholder) value = "";
if (value === placeholder) value = ''
const mods =
this.modifications[this.selectedGroup]?.nodes?.[
this.selectedNodeInnerIndex
]?.[section]?.[prop];
]?.[section]?.[prop]
if (mods) {
if (mods.name != null) {
value = mods.name;
value = mods.name
}
if (mods.visible != null) {
checked = mods.visible;
checked = mods.visible
}
}
return $el("div", [
$el("input", {
return $el('div', [
$el('input', {
value,
placeholder,
type: "text",
type: 'text',
onchange: (e) => {
this.storeModification({
section,
prop,
value: { name: e.target.value },
});
},
value: { name: e.target.value }
})
}
}),
$el("label", { textContent: "Visible" }, [
$el("input", {
type: "checkbox",
$el('label', { textContent: 'Visible' }, [
$el('input', {
type: 'checkbox',
checked,
disabled: !checkable,
onchange: (e) => {
this.storeModification({
section,
prop,
value: { visible: !!e.target.checked },
});
},
}),
]),
]);
value: { visible: !!e.target.checked }
})
}
})
])
])
}
buildWidgetsPage() {
const widgets =
this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex];
const items = Object.keys(widgets ?? {});
const type = app.graph.extra.groupNodes[this.selectedGroup];
const config = type.config?.[this.selectedNodeInnerIndex]?.input;
this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex]
const items = Object.keys(widgets ?? {})
const type = app.graph.extra.groupNodes[this.selectedGroup]
const config = type.config?.[this.selectedNodeInnerIndex]?.input
this.widgetsPage.replaceChildren(
...items.map((oldName) => {
return this.getEditElement(
"input",
'input',
oldName,
widgets[oldName],
oldName,
config?.[oldName]?.visible !== false
);
)
})
);
return !!items.length;
)
return !!items.length
}
buildInputsPage() {
const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex];
const items = Object.keys(inputs ?? {});
const type = app.graph.extra.groupNodes[this.selectedGroup];
const config = type.config?.[this.selectedNodeInnerIndex]?.input;
const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex]
const items = Object.keys(inputs ?? {})
const type = app.graph.extra.groupNodes[this.selectedGroup]
const config = type.config?.[this.selectedNodeInnerIndex]?.input
this.inputsPage.replaceChildren(
...items
.map((oldName) => {
let value = inputs[oldName];
let value = inputs[oldName]
if (!value) {
return;
return
}
return this.getEditElement(
"input",
'input',
oldName,
value,
oldName,
config?.[oldName]?.visible !== false
);
)
})
.filter(Boolean)
);
return !!items.length;
)
return !!items.length
}
buildOutputsPage() {
const nodes = this.groupData.nodeData.nodes;
const nodes = this.groupData.nodeData.nodes
const innerNodeDef = this.groupData.getNodeDef(
nodes[this.selectedNodeInnerIndex]
);
const outputs = innerNodeDef?.output ?? [];
)
const outputs = innerNodeDef?.output ?? []
const groupOutputs =
this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex];
this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex]
const type = app.graph.extra.groupNodes[this.selectedGroup];
const config = type.config?.[this.selectedNodeInnerIndex]?.output;
const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex];
const checkable = node.type !== "PrimitiveNode";
const type = app.graph.extra.groupNodes[this.selectedGroup]
const config = type.config?.[this.selectedNodeInnerIndex]?.output
const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex]
const checkable = node.type !== 'PrimitiveNode'
this.outputsPage.replaceChildren(
...outputs
.map((type, slot) => {
const groupOutputIndex = groupOutputs?.[slot];
const oldName = innerNodeDef.output_name?.[slot] ?? type;
let value = config?.[slot]?.name;
const visible = config?.[slot]?.visible || groupOutputIndex != null;
const groupOutputIndex = groupOutputs?.[slot]
const oldName = innerNodeDef.output_name?.[slot] ?? type
let value = config?.[slot]?.name
const visible = config?.[slot]?.visible || groupOutputIndex != null
if (!value || value === oldName) {
value = "";
value = ''
}
return this.getEditElement(
"output",
'output',
slot,
value,
oldName,
visible,
checkable
);
)
})
.filter(Boolean)
);
return !!outputs.length;
)
return !!outputs.length
}
show(type?) {
const groupNodes = Object.keys(app.graph.extra?.groupNodes ?? {}).sort(
(a, b) => a.localeCompare(b)
);
)
this.innerNodesList = $el(
"ul.comfy-group-manage-list-items"
) as HTMLUListElement;
this.widgetsPage = $el("section.comfy-group-manage-node-page");
this.inputsPage = $el("section.comfy-group-manage-node-page");
this.outputsPage = $el("section.comfy-group-manage-node-page");
const pages = $el("div", [
'ul.comfy-group-manage-list-items'
) as HTMLUListElement
this.widgetsPage = $el('section.comfy-group-manage-node-page')
this.inputsPage = $el('section.comfy-group-manage-node-page')
this.outputsPage = $el('section.comfy-group-manage-node-page')
const pages = $el('div', [
this.widgetsPage,
this.inputsPage,
this.outputsPage,
]);
this.outputsPage
])
this.tabs = [
["Inputs", this.inputsPage],
["Widgets", this.widgetsPage],
["Outputs", this.outputsPage],
['Inputs', this.inputsPage],
['Widgets', this.widgetsPage],
['Outputs', this.outputsPage]
].reduce((p, [name, page]: [string, HTMLElement]) => {
p[name] = {
tab: $el("a", {
tab: $el('a', {
onclick: () => {
this.changeTab(name);
this.changeTab(name)
},
textContent: name,
textContent: name
}),
page,
};
return p;
}, {}) as any;
page
}
return p
}, {}) as any
const outer = $el("div.comfy-group-manage-outer", [
$el("header", [
$el("h2", "Group Nodes"),
const outer = $el('div.comfy-group-manage-outer', [
$el('header', [
$el('h2', 'Group Nodes'),
$el(
"select",
'select',
{
onchange: (e) => {
this.changeGroup(e.target.value);
},
this.changeGroup(e.target.value)
}
},
groupNodes.map((g) =>
$el("option", {
$el('option', {
textContent: g,
selected: "workflow/" + g === type,
value: g,
selected: 'workflow/' + g === type,
value: g
})
)
),
)
]),
$el("main", [
$el("section.comfy-group-manage-list", this.innerNodesList),
$el("section.comfy-group-manage-node", [
$el('main', [
$el('section.comfy-group-manage-list', this.innerNodesList),
$el('section.comfy-group-manage-node', [
$el(
"header",
'header',
Object.values(this.tabs).map((t) => t.tab)
),
pages,
pages
])
]),
]),
$el("footer", [
$el('footer', [
$el(
"button.comfy-btn",
'button.comfy-btn',
{
onclick: (e) => {
// @ts-ignore
const node = app.graph._nodes.find(
(n) => n.type === "workflow/" + this.selectedGroup
);
(n) => n.type === 'workflow/' + this.selectedGroup
)
if (node) {
alert(
"This group node is in use in the current workflow, please first remove these."
);
return;
'This group node is in use in the current workflow, please first remove these.'
)
return
}
if (
confirm(
`Are you sure you want to remove the node: "${this.selectedGroup}"`
)
) {
delete app.graph.extra.groupNodes[this.selectedGroup];
LiteGraph.unregisterNodeType("workflow/" + this.selectedGroup);
delete app.graph.extra.groupNodes[this.selectedGroup]
LiteGraph.unregisterNodeType('workflow/' + this.selectedGroup)
}
this.show()
}
this.show();
},
},
"Delete Group Node"
'Delete Group Node'
),
$el(
"button.comfy-btn",
'button.comfy-btn',
{
onclick: async () => {
let nodesByType;
let recreateNodes = [];
const types = {};
let nodesByType
let recreateNodes = []
const types = {}
for (const g in this.modifications) {
const type = app.graph.extra.groupNodes[g];
let config = (type.config ??= {});
const type = app.graph.extra.groupNodes[g]
let config = (type.config ??= {})
let nodeMods = this.modifications[g]?.nodes;
let nodeMods = this.modifications[g]?.nodes
if (nodeMods) {
const keys = Object.keys(nodeMods);
const keys = Object.keys(nodeMods)
if (nodeMods[keys[0]][ORDER]) {
// If any node is reordered, they will all need sequencing
const orderedNodes = [];
const orderedMods = {};
const orderedConfig = {};
const orderedNodes = []
const orderedMods = {}
const orderedConfig = {}
for (const n of keys) {
const order = nodeMods[n][ORDER].order;
orderedNodes[order] = type.nodes[+n];
orderedMods[order] = nodeMods[n];
orderedNodes[order].index = order;
const order = nodeMods[n][ORDER].order
orderedNodes[order] = type.nodes[+n]
orderedMods[order] = nodeMods[n]
orderedNodes[order].index = order
}
// Rewrite links
for (const l of type.links) {
if (l[0] != null) l[0] = type.nodes[l[0]].index;
if (l[2] != null) l[2] = type.nodes[l[2]].index;
if (l[0] != null) l[0] = type.nodes[l[0]].index
if (l[2] != null) l[2] = type.nodes[l[2]].index
}
// Rewrite externals
if (type.external) {
for (const ext of type.external) {
ext[0] = type.nodes[ext[0]];
ext[0] = type.nodes[ext[0]]
}
}
// Rewrite modifications
for (const id of keys) {
if (config[id]) {
orderedConfig[type.nodes[id].index] = config[id];
orderedConfig[type.nodes[id].index] = config[id]
}
delete config[id];
delete config[id]
}
type.nodes = orderedNodes;
nodeMods = orderedMods;
type.config = config = orderedConfig;
type.nodes = orderedNodes
nodeMods = orderedMods
type.config = config = orderedConfig
}
merge(config, nodeMods);
merge(config, nodeMods)
}
types[g] = type;
types[g] = type
if (!nodesByType) {
// @ts-ignore
nodesByType = app.graph._nodes.reduce((p, n) => {
p[n.type] ??= [];
p[n.type].push(n);
return p;
}, {});
p[n.type] ??= []
p[n.type].push(n)
return p
}, {})
}
const nodes = nodesByType["workflow/" + g];
if (nodes) recreateNodes.push(...nodes);
const nodes = nodesByType['workflow/' + g]
if (nodes) recreateNodes.push(...nodes)
}
await GroupNodeConfig.registerFromWorkflow(types, {});
await GroupNodeConfig.registerFromWorkflow(types, {})
for (const node of recreateNodes) {
node.recreate();
node.recreate()
}
this.modifications = {};
this.app.graph.setDirtyCanvas(true, true);
this.changeGroup(this.selectedGroup, false);
this.modifications = {}
this.app.graph.setDirtyCanvas(true, true)
this.changeGroup(this.selectedGroup, false)
}
},
},
"Save"
'Save'
),
$el(
"button.comfy-btn",
'button.comfy-btn',
{ onclick: () => this.element.close() },
"Close"
),
]),
]);
'Close'
)
])
])
this.element.replaceChildren(outer);
this.element.replaceChildren(outer)
this.changeGroup(
type ? groupNodes.find((g) => "workflow/" + g === type) : groupNodes[0]
);
this.element.showModal();
type ? groupNodes.find((g) => 'workflow/' + g === type) : groupNodes[0]
)
this.element.showModal()
this.element.addEventListener("close", () => {
this.draggable?.dispose();
});
this.element.addEventListener('close', () => {
this.draggable?.dispose()
})
}
}

View File

@@ -1,141 +1,141 @@
import { app } from "../../scripts/app";
import { LGraphCanvas, LiteGraph } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
function setNodeMode(node, mode) {
node.mode = mode;
node.graph.change();
node.mode = mode
node.graph.change()
}
function addNodesToGroup(group, nodes = []) {
var x1, y1, x2, y2;
var nx1, ny1, nx2, ny2;
var node;
var x1, y1, x2, y2
var nx1, ny1, nx2, ny2
var node
x1 = y1 = x2 = y2 = -1;
nx1 = ny1 = nx2 = ny2 = -1;
x1 = y1 = x2 = y2 = -1
nx1 = ny1 = nx2 = ny2 = -1
for (var n of [group._nodes, nodes]) {
for (var i in n) {
node = n[i];
node = n[i]
nx1 = node.pos[0];
ny1 = node.pos[1];
nx2 = node.pos[0] + node.size[0];
ny2 = node.pos[1] + node.size[1];
nx1 = node.pos[0]
ny1 = node.pos[1]
nx2 = node.pos[0] + node.size[0]
ny2 = node.pos[1] + node.size[1]
if (node.type != "Reroute") {
ny1 -= LiteGraph.NODE_TITLE_HEIGHT;
if (node.type != 'Reroute') {
ny1 -= LiteGraph.NODE_TITLE_HEIGHT
}
if (node.flags?.collapsed) {
ny2 = ny1 + LiteGraph.NODE_TITLE_HEIGHT;
ny2 = ny1 + LiteGraph.NODE_TITLE_HEIGHT
if (node?._collapsed_width) {
nx2 = nx1 + Math.round(node._collapsed_width);
nx2 = nx1 + Math.round(node._collapsed_width)
}
}
if (x1 == -1 || nx1 < x1) {
x1 = nx1;
x1 = nx1
}
if (y1 == -1 || ny1 < y1) {
y1 = ny1;
y1 = ny1
}
if (x2 == -1 || nx2 > x2) {
x2 = nx2;
x2 = nx2
}
if (y2 == -1 || ny2 > y2) {
y2 = ny2;
y2 = ny2
}
}
}
var padding = 10;
var padding = 10
y1 = y1 - Math.round(group.font_size * 1.4);
y1 = y1 - Math.round(group.font_size * 1.4)
group.pos = [x1 - padding, y1 - padding];
group.size = [x2 - x1 + padding * 2, y2 - y1 + padding * 2];
group.pos = [x1 - padding, y1 - padding]
group.size = [x2 - x1 + padding * 2, y2 - y1 + padding * 2]
}
app.registerExtension({
name: "Comfy.GroupOptions",
name: 'Comfy.GroupOptions',
setup() {
// @ts-ignore
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
const orig = LGraphCanvas.prototype.getCanvasMenuOptions
// graph_mouse
// @ts-ignore
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
const options = orig.apply(this, arguments)
const group = this.graph.getGroupOnPos(
this.graph_mouse[0],
this.graph_mouse[1]
);
)
if (!group) {
options.push({
content: "Add Group For Selected Nodes",
content: 'Add Group For Selected Nodes',
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
callback: () => {
// @ts-ignore
var group = new LiteGraph.LGraphGroup();
addNodesToGroup(group, this.selected_nodes);
app.canvas.graph.add(group);
this.graph.change();
},
});
var group = new LiteGraph.LGraphGroup()
addNodesToGroup(group, this.selected_nodes)
app.canvas.graph.add(group)
this.graph.change()
}
})
return options;
return options
}
// Group nodes aren't recomputed until the group is moved, this ensures the nodes are up-to-date
group.recomputeInsideNodes();
const nodesInGroup = group._nodes;
group.recomputeInsideNodes()
const nodesInGroup = group._nodes
options.push({
content: "Add Selected Nodes To Group",
content: 'Add Selected Nodes To Group',
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
callback: () => {
addNodesToGroup(group, this.selected_nodes);
this.graph.change();
},
});
addNodesToGroup(group, this.selected_nodes)
this.graph.change()
}
})
// No nodes in group, return default options
if (nodesInGroup.length === 0) {
return options;
return options
} else {
// Add a separator between the default options and the group options
options.push(null);
options.push(null)
}
// Check if all nodes are the same mode
let allNodesAreSameMode = true;
let allNodesAreSameMode = true
for (let i = 1; i < nodesInGroup.length; i++) {
if (nodesInGroup[i].mode !== nodesInGroup[0].mode) {
allNodesAreSameMode = false;
break;
allNodesAreSameMode = false
break
}
}
options.push({
content: "Fit Group To Nodes",
content: 'Fit Group To Nodes',
callback: () => {
addNodesToGroup(group);
this.graph.change();
},
});
addNodesToGroup(group)
this.graph.change()
}
})
options.push({
content: "Select Nodes",
content: 'Select Nodes',
callback: () => {
this.selectNodes(nodesInGroup);
this.graph.change();
this.canvas.focus();
},
});
this.selectNodes(nodesInGroup)
this.graph.change()
this.canvas.focus()
}
})
// Modes
// 0: Always
@@ -145,122 +145,122 @@ app.registerExtension({
// 4: Bypass
// If all nodes are the same mode, add a menu option to change the mode
if (allNodesAreSameMode) {
const mode = nodesInGroup[0].mode;
const mode = nodesInGroup[0].mode
switch (mode) {
case 0:
// All nodes are always, option to disable, and bypass
options.push({
content: "Set Group Nodes to Never",
content: 'Set Group Nodes to Never',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 2);
setNodeMode(node, 2)
}
},
});
}
})
options.push({
content: "Bypass Group Nodes",
content: 'Bypass Group Nodes',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 4);
setNodeMode(node, 4)
}
},
});
break;
}
})
break
case 2:
// All nodes are never, option to enable, and bypass
options.push({
content: "Set Group Nodes to Always",
content: 'Set Group Nodes to Always',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 0);
setNodeMode(node, 0)
}
},
});
}
})
options.push({
content: "Bypass Group Nodes",
content: 'Bypass Group Nodes',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 4);
setNodeMode(node, 4)
}
},
});
break;
}
})
break
case 4:
// All nodes are bypass, option to enable, and disable
options.push({
content: "Set Group Nodes to Always",
content: 'Set Group Nodes to Always',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 0);
setNodeMode(node, 0)
}
},
});
}
})
options.push({
content: "Set Group Nodes to Never",
content: 'Set Group Nodes to Never',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 2);
setNodeMode(node, 2)
}
},
});
break;
}
})
break
default:
// All nodes are On Trigger or On Event(Or other?), option to disable, set to always, or bypass
options.push({
content: "Set Group Nodes to Always",
content: 'Set Group Nodes to Always',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 0);
setNodeMode(node, 0)
}
},
});
}
})
options.push({
content: "Set Group Nodes to Never",
content: 'Set Group Nodes to Never',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 2);
setNodeMode(node, 2)
}
},
});
}
})
options.push({
content: "Bypass Group Nodes",
content: 'Bypass Group Nodes',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 4);
setNodeMode(node, 4)
}
},
});
break;
}
})
break
}
} else {
// Nodes are not all the same mode, add a menu option to change the mode to always, never, or bypass
options.push({
content: "Set Group Nodes to Always",
content: 'Set Group Nodes to Always',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 0);
setNodeMode(node, 0)
}
},
});
}
})
options.push({
content: "Set Group Nodes to Never",
content: 'Set Group Nodes to Never',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 2);
setNodeMode(node, 2)
}
},
});
}
})
options.push({
content: "Bypass Group Nodes",
content: 'Bypass Group Nodes',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 4);
setNodeMode(node, 4)
}
},
});
}
})
}
return options;
};
},
});
return options
}
}
})

View File

@@ -1,23 +1,23 @@
import "./clipspace";
import "./colorPalette";
import "./contextMenuFilter";
import "./dynamicPrompts";
import "./editAttention";
import "./groupNode";
import "./groupNodeManage";
import "./groupOptions";
import "./invertMenuScrolling";
import "./keybinds";
import "./linkRenderMode";
import "./maskeditor";
import "./nodeTemplates";
import "./noteNode";
import "./rerouteNode";
import "./saveImageExtraOutput";
import "./simpleTouchSupport";
import "./slotDefaults";
import "./snapToGrid";
import "./uploadImage";
import "./webcamCapture";
import "./widgetInputs";
import "./uploadAudio";
import './clipspace'
import './colorPalette'
import './contextMenuFilter'
import './dynamicPrompts'
import './editAttention'
import './groupNode'
import './groupNodeManage'
import './groupOptions'
import './invertMenuScrolling'
import './keybinds'
import './linkRenderMode'
import './maskeditor'
import './nodeTemplates'
import './noteNode'
import './rerouteNode'
import './saveImageExtraOutput'
import './simpleTouchSupport'
import './slotDefaults'
import './snapToGrid'
import './uploadImage'
import './webcamCapture'
import './widgetInputs'
import './uploadAudio'

View File

@@ -1,38 +1,38 @@
import { LiteGraph } from "@comfyorg/litegraph";
import { app } from "../../scripts/app";
import { LiteGraph } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
// Inverts the scrolling of context menus
const id = "Comfy.InvertMenuScrolling";
const id = 'Comfy.InvertMenuScrolling'
app.registerExtension({
name: id,
init() {
const ctxMenu = LiteGraph.ContextMenu;
const ctxMenu = LiteGraph.ContextMenu
const replace = () => {
// @ts-ignore
LiteGraph.ContextMenu = function (values, options) {
options = options || {};
options = options || {}
if (options.scroll_speed) {
options.scroll_speed *= -1;
options.scroll_speed *= -1
} else {
options.scroll_speed = -0.1;
options.scroll_speed = -0.1
}
return ctxMenu.call(this, values, options)
}
LiteGraph.ContextMenu.prototype = ctxMenu.prototype
}
return ctxMenu.call(this, values, options);
};
LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
};
app.ui.settings.addSetting({
id,
name: "Invert Menu Scrolling",
type: "boolean",
name: 'Invert Menu Scrolling',
type: 'boolean',
defaultValue: false,
onChange(value) {
if (value) {
replace();
replace()
} else {
LiteGraph.ContextMenu = ctxMenu;
LiteGraph.ContextMenu = ctxMenu
}
},
});
},
});
}
})
}
})

View File

@@ -1,73 +1,73 @@
import { app } from "../../scripts/app";
import { app } from '../../scripts/app'
app.registerExtension({
name: "Comfy.Keybinds",
name: 'Comfy.Keybinds',
init() {
const keybindListener = function (event) {
const modifierPressed = event.ctrlKey || event.metaKey;
const modifierPressed = event.ctrlKey || event.metaKey
// Queue prompt using ctrl or command + enter
if (modifierPressed && event.key === "Enter") {
app.queuePrompt(event.shiftKey ? -1 : 0).then();
return;
if (modifierPressed && event.key === 'Enter') {
app.queuePrompt(event.shiftKey ? -1 : 0).then()
return
}
const target = event.composedPath()[0];
if (["INPUT", "TEXTAREA"].includes(target.tagName)) {
return;
const target = event.composedPath()[0]
if (['INPUT', 'TEXTAREA'].includes(target.tagName)) {
return
}
const modifierKeyIdMap = {
s: "#comfy-save-button",
o: "#comfy-file-input",
Backspace: "#comfy-clear-button",
d: "#comfy-load-default-button",
};
s: '#comfy-save-button',
o: '#comfy-file-input',
Backspace: '#comfy-clear-button',
d: '#comfy-load-default-button'
}
const modifierKeybindId = modifierKeyIdMap[event.key];
const modifierKeybindId = modifierKeyIdMap[event.key]
if (modifierPressed && modifierKeybindId) {
event.preventDefault();
event.preventDefault()
const elem = document.querySelector(modifierKeybindId);
elem.click();
return;
const elem = document.querySelector(modifierKeybindId)
elem.click()
return
}
// Finished Handling all modifier keybinds, now handle the rest
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
return
}
// Close out of modals using escape
if (event.key === "Escape") {
const modals = document.querySelectorAll<HTMLElement>(".comfy-modal");
if (event.key === 'Escape') {
const modals = document.querySelectorAll<HTMLElement>('.comfy-modal')
const modal = Array.from(modals).find(
(modal) =>
window.getComputedStyle(modal).getPropertyValue("display") !==
"none"
);
window.getComputedStyle(modal).getPropertyValue('display') !==
'none'
)
if (modal) {
modal.style.display = "none";
modal.style.display = 'none'
}
[...document.querySelectorAll("dialog")].forEach((d) => {
d.close();
});
;[...document.querySelectorAll('dialog')].forEach((d) => {
d.close()
})
}
const keyIdMap = {
q: "#comfy-view-queue-button",
h: "#comfy-view-history-button",
r: "#comfy-refresh-button",
};
const buttonId = keyIdMap[event.key];
if (buttonId) {
const button = document.querySelector(buttonId);
button.click();
q: '#comfy-view-queue-button',
h: '#comfy-view-history-button',
r: '#comfy-refresh-button'
}
};
window.addEventListener("keydown", keybindListener, true);
},
});
const buttonId = keyIdMap[event.key]
if (buttonId) {
const button = document.querySelector(buttonId)
button.click()
}
}
window.addEventListener('keydown', keybindListener, true)
}
})

View File

@@ -1,26 +1,26 @@
import { app } from "../../scripts/app";
import { LiteGraph } from "@comfyorg/litegraph";
const id = "Comfy.LinkRenderMode";
import { app } from '../../scripts/app'
import { LiteGraph } from '@comfyorg/litegraph'
const id = 'Comfy.LinkRenderMode'
const ext = {
name: id,
async setup(app) {
app.ui.settings.addSetting({
id,
name: "Link Render Mode",
name: 'Link Render Mode',
defaultValue: 2,
type: "combo",
type: 'combo',
// @ts-ignore
options: [...LiteGraph.LINK_RENDER_MODES, "Hidden"].map((m, i) => ({
options: [...LiteGraph.LINK_RENDER_MODES, 'Hidden'].map((m, i) => ({
value: i,
text: m,
selected: i == app.canvas.links_render_mode,
selected: i == app.canvas.links_render_mode
})),
onChange(value) {
app.canvas.links_render_mode = +value;
app.graph.setDirtyCanvas(true);
},
});
},
};
app.canvas.links_render_mode = +value
app.graph.setDirtyCanvas(true)
}
})
}
}
app.registerExtension(ext);
app.registerExtension(ext)

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import { app } from "../../scripts/app";
import { api } from "../../scripts/api";
import { ComfyDialog, $el } from "../../scripts/ui";
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode";
import { LGraphCanvas } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
import { ComfyDialog, $el } from '../../scripts/ui'
import { GroupNodeConfig, GroupNodeHandler } from './groupNode'
import { LGraphCanvas } from '@comfyorg/litegraph'
// Adds the ability to save and add multiple nodes as a template
// To save:
@@ -21,391 +21,391 @@ import { LGraphCanvas } from "@comfyorg/litegraph";
// To rearrange:
// Open the manage dialog and Drag and drop elements using the "Name:" label as handle
const id = "Comfy.NodeTemplates";
const file = "comfy.templates.json";
const id = 'Comfy.NodeTemplates'
const file = 'comfy.templates.json'
class ManageTemplates extends ComfyDialog {
templates: any[];
draggedEl: HTMLElement | null;
saveVisualCue: number | null;
emptyImg: HTMLImageElement;
importInput: HTMLInputElement;
templates: any[]
draggedEl: HTMLElement | null
saveVisualCue: number | null
emptyImg: HTMLImageElement
importInput: HTMLInputElement
constructor() {
super();
super()
this.load().then((v) => {
this.templates = v;
});
this.templates = v
})
this.element.classList.add("comfy-manage-templates");
this.draggedEl = null;
this.saveVisualCue = null;
this.emptyImg = new Image();
this.element.classList.add('comfy-manage-templates')
this.draggedEl = null
this.saveVisualCue = null
this.emptyImg = new Image()
this.emptyImg.src =
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs='
this.importInput = $el("input", {
type: "file",
accept: ".json",
this.importInput = $el('input', {
type: 'file',
accept: '.json',
multiple: true,
style: { display: "none" },
style: { display: 'none' },
parent: document.body,
onchange: () => this.importAll(),
}) as HTMLInputElement;
onchange: () => this.importAll()
}) as HTMLInputElement
}
createButtons() {
const btns = super.createButtons();
btns[0].textContent = "Close";
const btns = super.createButtons()
btns[0].textContent = 'Close'
btns[0].onclick = (e) => {
clearTimeout(this.saveVisualCue);
this.close();
};
clearTimeout(this.saveVisualCue)
this.close()
}
btns.unshift(
$el("button", {
type: "button",
textContent: "Export",
onclick: () => this.exportAll(),
$el('button', {
type: 'button',
textContent: 'Export',
onclick: () => this.exportAll()
})
);
)
btns.unshift(
$el("button", {
type: "button",
textContent: "Import",
$el('button', {
type: 'button',
textContent: 'Import',
onclick: () => {
this.importInput.click();
},
this.importInput.click()
}
})
);
return btns;
)
return btns
}
async load() {
let templates = [];
if (app.storageLocation === "server") {
let templates = []
if (app.storageLocation === 'server') {
if (app.isNewUserSession) {
// New user so migrate existing templates
const json = localStorage.getItem(id);
const json = localStorage.getItem(id)
if (json) {
templates = JSON.parse(json);
templates = JSON.parse(json)
}
await api.storeUserData(file, json, { stringify: false });
await api.storeUserData(file, json, { stringify: false })
} else {
const res = await api.getUserData(file);
const res = await api.getUserData(file)
if (res.status === 200) {
try {
templates = await res.json();
templates = await res.json()
} catch (error) {}
} else if (res.status !== 404) {
console.error(res.status + " " + res.statusText);
console.error(res.status + ' ' + res.statusText)
}
}
} else {
const json = localStorage.getItem(id);
const json = localStorage.getItem(id)
if (json) {
templates = JSON.parse(json);
templates = JSON.parse(json)
}
}
return templates ?? [];
return templates ?? []
}
async store() {
if (app.storageLocation === "server") {
const templates = JSON.stringify(this.templates, undefined, 4);
localStorage.setItem(id, templates); // Backwards compatibility
if (app.storageLocation === 'server') {
const templates = JSON.stringify(this.templates, undefined, 4)
localStorage.setItem(id, templates) // Backwards compatibility
try {
await api.storeUserData(file, templates, { stringify: false });
await api.storeUserData(file, templates, { stringify: false })
} catch (error) {
console.error(error);
alert(error.message);
console.error(error)
alert(error.message)
}
} else {
localStorage.setItem(id, JSON.stringify(this.templates));
localStorage.setItem(id, JSON.stringify(this.templates))
}
}
async importAll() {
for (const file of this.importInput.files) {
if (file.type === "application/json" || file.name.endsWith(".json")) {
const reader = new FileReader();
if (file.type === 'application/json' || file.name.endsWith('.json')) {
const reader = new FileReader()
reader.onload = async () => {
const importFile = JSON.parse(reader.result as string);
const importFile = JSON.parse(reader.result as string)
if (importFile?.templates) {
for (const template of importFile.templates) {
if (template?.name && template?.data) {
this.templates.push(template);
this.templates.push(template)
}
}
await this.store();
await this.store()
}
};
await reader.readAsText(file);
}
await reader.readAsText(file)
}
}
this.importInput.value = null;
this.importInput.value = null
this.close();
this.close()
}
exportAll() {
if (this.templates.length == 0) {
alert("No templates to export.");
return;
alert('No templates to export.')
return
}
const json = JSON.stringify({ templates: this.templates }, null, 2); // convert the data to a JSON string
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = $el("a", {
const json = JSON.stringify({ templates: this.templates }, null, 2) // convert the data to a JSON string
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = $el('a', {
href: url,
download: "node_templates.json",
style: { display: "none" },
parent: document.body,
});
a.click();
download: 'node_templates.json',
style: { display: 'none' },
parent: document.body
})
a.click()
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
a.remove()
window.URL.revokeObjectURL(url)
}, 0)
}
show() {
// Show list of template names + delete button
super.show(
$el(
"div",
'div',
{},
this.templates.flatMap((t, i) => {
let nameInput;
let nameInput
return [
$el(
"div",
'div',
{
dataset: { id: i.toString() },
className: "tempateManagerRow",
className: 'tempateManagerRow',
style: {
display: "grid",
gridTemplateColumns: "1fr auto",
border: "1px dashed transparent",
gap: "5px",
backgroundColor: "var(--comfy-menu-bg)",
display: 'grid',
gridTemplateColumns: '1fr auto',
border: '1px dashed transparent',
gap: '5px',
backgroundColor: 'var(--comfy-menu-bg)'
},
ondragstart: (e) => {
this.draggedEl = e.currentTarget;
e.currentTarget.style.opacity = "0.6";
e.currentTarget.style.border = "1px dashed yellow";
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(this.emptyImg, 0, 0);
this.draggedEl = e.currentTarget
e.currentTarget.style.opacity = '0.6'
e.currentTarget.style.border = '1px dashed yellow'
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setDragImage(this.emptyImg, 0, 0)
},
ondragend: (e) => {
e.target.style.opacity = "1";
e.currentTarget.style.border = "1px dashed transparent";
e.currentTarget.removeAttribute("draggable");
e.target.style.opacity = '1'
e.currentTarget.style.border = '1px dashed transparent'
e.currentTarget.removeAttribute('draggable')
// rearrange the elements
this.element
.querySelectorAll(".tempateManagerRow")
.querySelectorAll('.tempateManagerRow')
.forEach((el: HTMLElement, i) => {
var prev_i = Number.parseInt(el.dataset.id);
var prev_i = Number.parseInt(el.dataset.id)
if (el == this.draggedEl && prev_i != i) {
this.templates.splice(
i,
0,
this.templates.splice(prev_i, 1)[0]
);
)
}
el.dataset.id = i.toString();
});
this.store();
el.dataset.id = i.toString()
})
this.store()
},
ondragover: (e) => {
e.preventDefault();
if (e.currentTarget == this.draggedEl) return;
e.preventDefault()
if (e.currentTarget == this.draggedEl) return
let rect = e.currentTarget.getBoundingClientRect();
let rect = e.currentTarget.getBoundingClientRect()
if (e.clientY > rect.top + rect.height / 2) {
e.currentTarget.parentNode.insertBefore(
this.draggedEl,
e.currentTarget.nextSibling
);
)
} else {
e.currentTarget.parentNode.insertBefore(
this.draggedEl,
e.currentTarget
);
)
}
}
},
},
[
$el(
"label",
'label',
{
textContent: "Name: ",
textContent: 'Name: ',
style: {
cursor: "grab",
cursor: 'grab'
},
onmousedown: (e) => {
// enable dragging only from the label
if (e.target.localName == "label")
e.currentTarget.parentNode.draggable = "true";
},
if (e.target.localName == 'label')
e.currentTarget.parentNode.draggable = 'true'
}
},
[
$el("input", {
$el('input', {
value: t.name,
dataset: { name: t.name },
style: {
transitionProperty: "background-color",
transitionDuration: "0s",
transitionProperty: 'background-color',
transitionDuration: '0s'
},
onchange: (e) => {
clearTimeout(this.saveVisualCue);
var el = e.target;
var row = el.parentNode.parentNode;
clearTimeout(this.saveVisualCue)
var el = e.target
var row = el.parentNode.parentNode
this.templates[row.dataset.id].name =
el.value.trim() || "untitled";
this.store();
el.style.backgroundColor = "rgb(40, 95, 40)";
el.style.transitionDuration = "0s";
el.value.trim() || 'untitled'
this.store()
el.style.backgroundColor = 'rgb(40, 95, 40)'
el.style.transitionDuration = '0s'
// @ts-expect-error
// In browser env the return value is number.
this.saveVisualCue = setTimeout(function () {
el.style.transitionDuration = ".7s";
el.style.backgroundColor = "var(--comfy-input-bg)";
}, 15);
el.style.transitionDuration = '.7s'
el.style.backgroundColor = 'var(--comfy-input-bg)'
}, 15)
},
onkeypress: (e) => {
var el = e.target;
clearTimeout(this.saveVisualCue);
el.style.transitionDuration = "0s";
el.style.backgroundColor = "var(--comfy-input-bg)";
var el = e.target
clearTimeout(this.saveVisualCue)
el.style.transitionDuration = '0s'
el.style.backgroundColor = 'var(--comfy-input-bg)'
},
$: (el) => (nameInput = el),
}),
$: (el) => (nameInput = el)
})
]
),
$el("div", {}, [
$el("button", {
textContent: "Export",
$el('div', {}, [
$el('button', {
textContent: 'Export',
style: {
fontSize: "12px",
fontWeight: "normal",
fontSize: '12px',
fontWeight: 'normal'
},
onclick: (e) => {
const json = JSON.stringify({ templates: [t] }, null, 2); // convert the data to a JSON string
const json = JSON.stringify({ templates: [t] }, null, 2) // convert the data to a JSON string
const blob = new Blob([json], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = $el("a", {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const a = $el('a', {
href: url,
download: (nameInput.value || t.name) + ".json",
style: { display: "none" },
parent: document.body,
});
a.click();
download: (nameInput.value || t.name) + '.json',
style: { display: 'none' },
parent: document.body
})
a.click()
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
},
a.remove()
window.URL.revokeObjectURL(url)
}, 0)
}
}),
$el("button", {
textContent: "Delete",
$el('button', {
textContent: 'Delete',
style: {
fontSize: "12px",
color: "red",
fontWeight: "normal",
fontSize: '12px',
color: 'red',
fontWeight: 'normal'
},
onclick: (e) => {
const item = e.target.parentNode.parentNode;
item.parentNode.removeChild(item);
this.templates.splice(item.dataset.id * 1, 1);
this.store();
const item = e.target.parentNode.parentNode
item.parentNode.removeChild(item)
this.templates.splice(item.dataset.id * 1, 1)
this.store()
// update the rows index, setTimeout ensures that the list is updated
var that = this;
var that = this
setTimeout(function () {
that.element
.querySelectorAll(".tempateManagerRow")
.querySelectorAll('.tempateManagerRow')
.forEach((el: HTMLElement, i) => {
el.dataset.id = i.toString();
});
}, 0);
},
}),
]),
el.dataset.id = i.toString()
})
}, 0)
}
})
])
]
)
]
),
];
})
)
);
)
}
}
app.registerExtension({
name: id,
setup() {
const manage = new ManageTemplates();
const manage = new ManageTemplates()
const clipboardAction = async (cb) => {
// We use the clipboard functions but dont want to overwrite the current user clipboard
// Restore it after we've run our callback
const old = localStorage.getItem("litegrapheditor_clipboard");
await cb();
localStorage.setItem("litegrapheditor_clipboard", old);
};
const old = localStorage.getItem('litegrapheditor_clipboard')
await cb()
localStorage.setItem('litegrapheditor_clipboard', old)
}
// @ts-ignore
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
const orig = LGraphCanvas.prototype.getCanvasMenuOptions
// @ts-ignore
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
const options = orig.apply(this, arguments)
options.push(null);
options.push(null)
options.push({
content: `Save Selected as Template`,
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
callback: () => {
const name = prompt("Enter name");
if (!name?.trim()) return;
const name = prompt('Enter name')
if (!name?.trim()) return
clipboardAction(() => {
app.canvas.copyToClipboard();
let data = localStorage.getItem("litegrapheditor_clipboard");
data = JSON.parse(data);
const nodeIds = Object.keys(app.canvas.selected_nodes);
app.canvas.copyToClipboard()
let data = localStorage.getItem('litegrapheditor_clipboard')
data = JSON.parse(data)
const nodeIds = Object.keys(app.canvas.selected_nodes)
for (let i = 0; i < nodeIds.length; i++) {
const node = app.graph.getNodeById(Number.parseInt(nodeIds[i]));
const node = app.graph.getNodeById(Number.parseInt(nodeIds[i]))
// @ts-ignore
const nodeData = node?.constructor.nodeData;
const nodeData = node?.constructor.nodeData
let groupData = GroupNodeHandler.getGroupData(node);
let groupData = GroupNodeHandler.getGroupData(node)
if (groupData) {
groupData = groupData.nodeData;
groupData = groupData.nodeData
// @ts-ignore
if (!data.groupNodes) {
// @ts-ignore
data.groupNodes = {};
data.groupNodes = {}
}
// @ts-ignore
data.groupNodes[nodeData.name] = groupData;
data.groupNodes[nodeData.name] = groupData
// @ts-ignore
data.nodes[i].type = nodeData.name;
data.nodes[i].type = nodeData.name
}
}
manage.templates.push({
name,
data: JSON.stringify(data),
});
manage.store();
});
},
});
data: JSON.stringify(data)
})
manage.store()
})
}
})
// Map each template to a menu item
const subItems = manage.templates.map((t) => {
@@ -413,28 +413,28 @@ app.registerExtension({
content: t.name,
callback: () => {
clipboardAction(async () => {
const data = JSON.parse(t.data);
await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {});
localStorage.setItem("litegrapheditor_clipboard", t.data);
app.canvas.pasteFromClipboard();
});
},
};
});
const data = JSON.parse(t.data)
await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {})
localStorage.setItem('litegrapheditor_clipboard', t.data)
app.canvas.pasteFromClipboard()
})
}
}
})
subItems.push(null, {
content: "Manage",
callback: () => manage.show(),
});
content: 'Manage',
callback: () => manage.show()
})
options.push({
content: "Node Templates",
content: 'Node Templates',
submenu: {
options: subItems,
},
});
options: subItems
}
})
return options;
};
},
});
return options
}
}
})

View File

@@ -1,53 +1,53 @@
import { LiteGraph, LGraphCanvas } from "@comfyorg/litegraph";
import { app } from "../../scripts/app";
import { ComfyWidgets } from "../../scripts/widgets";
import { LiteGraph, LGraphCanvas } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
import { ComfyWidgets } from '../../scripts/widgets'
// Node that add notes to your project
app.registerExtension({
name: "Comfy.NoteNode",
name: 'Comfy.NoteNode',
registerCustomNodes() {
class NoteNode {
static category: string;
static category: string
color = LGraphCanvas.node_colors.yellow.color;
bgcolor = LGraphCanvas.node_colors.yellow.bgcolor;
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor;
properties: { text: string };
serialize_widgets: boolean;
isVirtualNode: boolean;
collapsable: boolean;
title_mode: number;
color = LGraphCanvas.node_colors.yellow.color
bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
properties: { text: string }
serialize_widgets: boolean
isVirtualNode: boolean
collapsable: boolean
title_mode: number
constructor() {
if (!this.properties) {
this.properties = { text: "" };
this.properties = { text: '' }
}
ComfyWidgets.STRING(
// @ts-ignore
// Should we extends LGraphNode?
this,
"",
["", { default: this.properties.text, multiline: true }],
'',
['', { default: this.properties.text, multiline: true }],
app
);
)
this.serialize_widgets = true;
this.isVirtualNode = true;
this.serialize_widgets = true
this.isVirtualNode = true
}
}
// Load default visibility
LiteGraph.registerNodeType(
"Note",
'Note',
// @ts-ignore
Object.assign(NoteNode, {
title_mode: LiteGraph.NORMAL_TITLE,
title: "Note",
collapsable: true,
title: 'Note',
collapsable: true
})
);
)
NoteNode.category = "utils";
},
});
NoteNode.category = 'utils'
}
})

View File

@@ -1,35 +1,35 @@
import { app } from "../../scripts/app";
import { mergeIfValid, getWidgetConfig, setWidgetConfig } from "./widgetInputs";
import { LiteGraph, LGraphCanvas, LGraphNode } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { mergeIfValid, getWidgetConfig, setWidgetConfig } from './widgetInputs'
import { LiteGraph, LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
// Node that allows you to redirect connections for cleaner graphs
app.registerExtension({
name: "Comfy.RerouteNode",
name: 'Comfy.RerouteNode',
registerCustomNodes(app) {
interface RerouteNode extends LGraphNode {
__outputType?: string;
__outputType?: string
}
class RerouteNode {
static category: string | undefined;
static defaultVisibility = false;
static category: string | undefined
static defaultVisibility = false
constructor() {
if (!this.properties) {
this.properties = {};
this.properties = {}
}
this.properties.showOutputText = RerouteNode.defaultVisibility;
this.properties.horizontal = false;
this.properties.showOutputText = RerouteNode.defaultVisibility
this.properties.horizontal = false
this.addInput("", "*");
this.addOutput(this.properties.showOutputText ? "*" : "", "*");
this.addInput('', '*')
this.addOutput(this.properties.showOutputText ? '*' : '', '*')
this.onAfterGraphConfigured = function () {
requestAnimationFrame(() => {
this.onConnectionsChange(LiteGraph.INPUT, null, true, null);
});
};
this.onConnectionsChange(LiteGraph.INPUT, null, true, null)
})
}
this.onConnectionsChange = function (
type,
@@ -37,7 +37,7 @@ app.registerExtension({
connected,
link_info
) {
this.applyOrientation();
this.applyOrientation()
// Prevent multiple connections to different types when we have no input
if (connected && type === LiteGraph.OUTPUT) {
@@ -45,78 +45,78 @@ app.registerExtension({
const types = new Set(
this.outputs[0].links
.map((l) => app.graph.links[l].type)
.filter((t) => t !== "*")
);
.filter((t) => t !== '*')
)
if (types.size > 1) {
const linksToDisconnect = [];
const linksToDisconnect = []
for (let i = 0; i < this.outputs[0].links.length - 1; i++) {
const linkId = this.outputs[0].links[i];
const link = app.graph.links[linkId];
linksToDisconnect.push(link);
const linkId = this.outputs[0].links[i]
const link = app.graph.links[linkId]
linksToDisconnect.push(link)
}
for (const link of linksToDisconnect) {
const node = app.graph.getNodeById(link.target_id);
node.disconnectInput(link.target_slot);
const node = app.graph.getNodeById(link.target_id)
node.disconnectInput(link.target_slot)
}
}
}
// Find root input
let currentNode = this;
let updateNodes = [];
let inputType = null;
let inputNode = null;
let currentNode = this
let updateNodes = []
let inputType = null
let inputNode = null
while (currentNode) {
updateNodes.unshift(currentNode);
const linkId = currentNode.inputs[0].link;
updateNodes.unshift(currentNode)
const linkId = currentNode.inputs[0].link
if (linkId !== null) {
const link = app.graph.links[linkId];
if (!link) return;
const node = app.graph.getNodeById(link.origin_id);
const type = node.constructor.type;
if (type === "Reroute") {
const link = app.graph.links[linkId]
if (!link) return
const node = app.graph.getNodeById(link.origin_id)
const type = node.constructor.type
if (type === 'Reroute') {
if (node === this) {
// We've found a circle
currentNode.disconnectInput(link.target_slot);
currentNode = null;
currentNode.disconnectInput(link.target_slot)
currentNode = null
} else {
// Move the previous node
currentNode = node;
currentNode = node
}
} else {
// We've found the end
inputNode = currentNode;
inputType = node.outputs[link.origin_slot]?.type ?? null;
break;
inputNode = currentNode
inputType = node.outputs[link.origin_slot]?.type ?? null
break
}
} else {
// This path has no input node
currentNode = null;
break;
currentNode = null
break
}
}
// Find all outputs
const nodes = [this];
let outputType = null;
const nodes = [this]
let outputType = null
while (nodes.length) {
currentNode = nodes.pop();
currentNode = nodes.pop()
const outputs =
(currentNode.outputs ? currentNode.outputs[0].links : []) || [];
(currentNode.outputs ? currentNode.outputs[0].links : []) || []
if (outputs.length) {
for (const linkId of outputs) {
const link = app.graph.links[linkId];
const link = app.graph.links[linkId]
// When disconnecting sometimes the link is still registered
if (!link) continue;
if (!link) continue
const node = app.graph.getNodeById(link.target_id);
const type = node.constructor.type;
const node = app.graph.getNodeById(link.target_id)
const type = node.constructor.type
if (type === "Reroute") {
if (type === 'Reroute') {
// Follow reroute nodes
nodes.push(node);
updateNodes.push(node);
nodes.push(node)
updateNodes.push(node)
} else {
// We've found an output
const nodeOutType =
@@ -124,16 +124,16 @@ app.registerExtension({
node.inputs[link?.target_slot] &&
node.inputs[link.target_slot].type
? node.inputs[link.target_slot].type
: null;
: null
if (
inputType &&
inputType !== "*" &&
inputType !== '*' &&
nodeOutType !== inputType
) {
// The output doesnt match our input so disconnect it
node.disconnectInput(link.target_slot);
node.disconnectInput(link.target_slot)
} else {
outputType = nodeOutType;
outputType = nodeOutType
}
}
}
@@ -142,50 +142,50 @@ app.registerExtension({
}
}
const displayType = inputType || outputType || "*";
const color = LGraphCanvas.link_type_colors[displayType];
const displayType = inputType || outputType || '*'
const color = LGraphCanvas.link_type_colors[displayType]
let widgetConfig;
let targetWidget;
let widgetType;
let widgetConfig
let targetWidget
let widgetType
// Update the types of each node
for (const node of updateNodes) {
// If we dont have an input type we are always wildcard but we'll show the output type
// This lets you change the output link to a different type and all nodes will update
node.outputs[0].type = inputType || "*";
node.__outputType = displayType;
node.outputs[0].type = inputType || '*'
node.__outputType = displayType
node.outputs[0].name = node.properties.showOutputText
? displayType
: "";
node.size = node.computeSize();
node.applyOrientation();
: ''
node.size = node.computeSize()
node.applyOrientation()
for (const l of node.outputs[0].links || []) {
const link = app.graph.links[l];
const link = app.graph.links[l]
if (link) {
link.color = color;
link.color = color
if (app.configuringGraph) continue;
const targetNode = app.graph.getNodeById(link.target_id);
const targetInput = targetNode.inputs?.[link.target_slot];
if (app.configuringGraph) continue
const targetNode = app.graph.getNodeById(link.target_id)
const targetInput = targetNode.inputs?.[link.target_slot]
if (targetInput?.widget) {
const config = getWidgetConfig(targetInput);
const config = getWidgetConfig(targetInput)
if (!widgetConfig) {
widgetConfig = config[1] ?? {};
widgetType = config[0];
widgetConfig = config[1] ?? {}
widgetType = config[0]
}
if (!targetWidget) {
targetWidget = targetNode.widgets?.find(
(w) => w.name === targetInput.widget.name
);
)
}
const merged = mergeIfValid(targetInput, [
config[0],
widgetConfig,
]);
widgetConfig
])
if (merged.customConfig) {
widgetConfig = merged.customConfig;
widgetConfig = merged.customConfig
}
}
}
@@ -194,64 +194,64 @@ app.registerExtension({
for (const node of updateNodes) {
if (widgetConfig && outputType) {
node.inputs[0].widget = { name: "value" };
node.inputs[0].widget = { name: 'value' }
setWidgetConfig(
node.inputs[0],
[widgetType ?? displayType, widgetConfig],
targetWidget
);
)
} else {
setWidgetConfig(node.inputs[0], null);
setWidgetConfig(node.inputs[0], null)
}
}
if (inputNode) {
const link = app.graph.links[inputNode.inputs[0].link];
const link = app.graph.links[inputNode.inputs[0].link]
if (link) {
link.color = color;
link.color = color
}
}
}
};
this.clone = function () {
const cloned = RerouteNode.prototype.clone.apply(this);
cloned.removeOutput(0);
cloned.addOutput(this.properties.showOutputText ? "*" : "", "*");
cloned.size = cloned.computeSize();
return cloned;
};
const cloned = RerouteNode.prototype.clone.apply(this)
cloned.removeOutput(0)
cloned.addOutput(this.properties.showOutputText ? '*' : '', '*')
cloned.size = cloned.computeSize()
return cloned
}
// This node is purely frontend and does not impact the resulting prompt so should not be serialized
this.isVirtualNode = true;
this.isVirtualNode = true
}
getExtraMenuOptions(_, options) {
options.unshift(
{
content:
(this.properties.showOutputText ? "Hide" : "Show") + " Type",
(this.properties.showOutputText ? 'Hide' : 'Show') + ' Type',
callback: () => {
this.properties.showOutputText = !this.properties.showOutputText;
this.properties.showOutputText = !this.properties.showOutputText
if (this.properties.showOutputText) {
this.outputs[0].name =
this.__outputType || (this.outputs[0].type as string);
this.__outputType || (this.outputs[0].type as string)
} else {
this.outputs[0].name = "";
this.outputs[0].name = ''
}
this.size = this.computeSize()
this.applyOrientation()
app.graph.setDirtyCanvas(true, true)
}
this.size = this.computeSize();
this.applyOrientation();
app.graph.setDirtyCanvas(true, true);
},
},
{
content:
(RerouteNode.defaultVisibility ? "Hide" : "Show") +
" Type By Default",
(RerouteNode.defaultVisibility ? 'Hide' : 'Show') +
' Type By Default',
callback: () => {
RerouteNode.setDefaultTextVisibility(
!RerouteNode.defaultVisibility
);
},
)
}
},
{
// naming is inverted with respect to LiteGraphNode.horizontal
@@ -259,25 +259,25 @@ app.registerExtension({
// each slot in the inputs and outputs are layed out horizontally,
// which is the opposite of the visual orientation of the inputs and outputs as a node
content:
"Set " + (this.properties.horizontal ? "Horizontal" : "Vertical"),
'Set ' + (this.properties.horizontal ? 'Horizontal' : 'Vertical'),
callback: () => {
this.properties.horizontal = !this.properties.horizontal;
this.applyOrientation();
},
this.properties.horizontal = !this.properties.horizontal
this.applyOrientation()
}
);
}
)
}
applyOrientation() {
this.horizontal = this.properties.horizontal;
this.horizontal = this.properties.horizontal
if (this.horizontal) {
// we correct the input position, because LiteGraphNode.horizontal
// doesn't account for title presence
// which reroute nodes don't have
this.inputs[0].pos = [this.size[0] / 2, 0];
this.inputs[0].pos = [this.size[0] / 2, 0]
} else {
delete this.inputs[0].pos;
delete this.inputs[0].pos
}
app.graph.setDirtyCanvas(true, true);
app.graph.setDirtyCanvas(true, true)
}
computeSize(): [number, number] {
@@ -289,34 +289,34 @@ app.registerExtension({
40
)
: 75,
26,
];
26
]
}
static setDefaultTextVisibility(visible) {
RerouteNode.defaultVisibility = visible;
RerouteNode.defaultVisibility = visible
if (visible) {
localStorage["Comfy.RerouteNode.DefaultVisibility"] = "true";
localStorage['Comfy.RerouteNode.DefaultVisibility'] = 'true'
} else {
delete localStorage["Comfy.RerouteNode.DefaultVisibility"];
delete localStorage['Comfy.RerouteNode.DefaultVisibility']
}
}
}
// Load default visibility
RerouteNode.setDefaultTextVisibility(
!!localStorage["Comfy.RerouteNode.DefaultVisibility"]
);
!!localStorage['Comfy.RerouteNode.DefaultVisibility']
)
LiteGraph.registerNodeType(
"Reroute",
'Reroute',
Object.assign(RerouteNode, {
title_mode: LiteGraph.NO_TITLE,
title: "Reroute",
collapsable: false,
title: 'Reroute',
collapsable: false
})
);
)
RerouteNode.category = "utils";
},
});
RerouteNode.category = 'utils'
}
})

View File

@@ -1,43 +1,39 @@
import { app } from "../../scripts/app";
import { applyTextReplacements } from "../../scripts/utils";
import { app } from '../../scripts/app'
import { applyTextReplacements } from '../../scripts/utils'
// Use widget values and dates in output filenames
app.registerExtension({
name: "Comfy.SaveImageExtraOutput",
name: 'Comfy.SaveImageExtraOutput',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SaveImage") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
if (nodeData.name === 'SaveImage') {
const onNodeCreated = nodeType.prototype.onNodeCreated
// When the SaveImage node is created we want to override the serialization of the output name widget to run our S&R
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated
? onNodeCreated.apply(this, arguments)
: undefined;
: undefined
const widget = this.widgets.find((w) => w.name === "filename_prefix");
const widget = this.widgets.find((w) => w.name === 'filename_prefix')
widget.serializeValue = () => {
return applyTextReplacements(app, widget.value);
};
return applyTextReplacements(app, widget.value)
}
return r;
};
return r
}
} else {
// When any other node is created add a property to alias the node
const onNodeCreated = nodeType.prototype.onNodeCreated;
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated
? onNodeCreated.apply(this, arguments)
: undefined;
: undefined
if (!this.properties || !("Node name for S&R" in this.properties)) {
this.addProperty(
"Node name for S&R",
this.constructor.type,
"string"
);
if (!this.properties || !('Node name for S&R' in this.properties)) {
this.addProperty('Node name for S&R', this.constructor.type, 'string')
}
return r;
};
return r
}
},
});
}
}
})

View File

@@ -1,115 +1,115 @@
import { app } from "../../scripts/app";
import { LGraphCanvas, LiteGraph } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
let touchZooming;
let touchCount = 0;
let touchZooming
let touchCount = 0
app.registerExtension({
name: "Comfy.SimpleTouchSupport",
name: 'Comfy.SimpleTouchSupport',
setup() {
let zoomPos;
let touchTime;
let lastTouch;
let zoomPos
let touchTime
let lastTouch
function getMultiTouchPos(e) {
return Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
)
}
app.canvasEl.addEventListener(
"touchstart",
'touchstart',
(e) => {
touchCount++;
lastTouch = null;
touchCount++
lastTouch = null
if (e.touches?.length === 1) {
// Store start time for press+hold for context menu
touchTime = new Date();
lastTouch = e.touches[0];
touchTime = new Date()
lastTouch = e.touches[0]
} else {
touchTime = null;
touchTime = null
if (e.touches?.length === 2) {
// Store center pos for zoom
zoomPos = getMultiTouchPos(e);
app.canvas.pointer_is_down = false;
zoomPos = getMultiTouchPos(e)
app.canvas.pointer_is_down = false
}
}
},
true
);
)
app.canvasEl.addEventListener("touchend", (e: TouchEvent) => {
touchZooming = false;
touchCount = e.touches?.length ?? touchCount - 1;
app.canvasEl.addEventListener('touchend', (e: TouchEvent) => {
touchZooming = false
touchCount = e.touches?.length ?? touchCount - 1
if (touchTime && !e.touches?.length) {
if (new Date().getTime() - touchTime > 600) {
try {
// hack to get litegraph to use this event
e.constructor = CustomEvent;
e.constructor = CustomEvent
} catch (error) {}
// @ts-ignore
e.clientX = lastTouch.clientX;
e.clientX = lastTouch.clientX
// @ts-ignore
e.clientY = lastTouch.clientY;
e.clientY = lastTouch.clientY
app.canvas.pointer_is_down = true;
app.canvas.pointer_is_down = true
// @ts-ignore
app.canvas._mousedown_callback(e);
app.canvas._mousedown_callback(e)
}
touchTime = null;
touchTime = null
}
});
})
app.canvasEl.addEventListener(
"touchmove",
'touchmove',
(e) => {
touchTime = null;
touchTime = null
if (e.touches?.length === 2) {
app.canvas.pointer_is_down = false;
touchZooming = true;
app.canvas.pointer_is_down = false
touchZooming = true
// @ts-ignore
LiteGraph.closeAllContextMenus();
LiteGraph.closeAllContextMenus()
// @ts-ignore
app.canvas.search_box?.close();
const newZoomPos = getMultiTouchPos(e);
app.canvas.search_box?.close()
const newZoomPos = getMultiTouchPos(e)
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2
let scale = app.canvas.ds.scale;
const diff = zoomPos - newZoomPos;
let scale = app.canvas.ds.scale
const diff = zoomPos - newZoomPos
if (diff > 0.5) {
scale *= 1 / 1.07;
scale *= 1 / 1.07
} else if (diff < -0.5) {
scale *= 1.07;
scale *= 1.07
}
app.canvas.ds.changeScale(scale, [midX, midY]);
app.canvas.setDirty(true, true);
zoomPos = newZoomPos;
app.canvas.ds.changeScale(scale, [midX, midY])
app.canvas.setDirty(true, true)
zoomPos = newZoomPos
}
},
true
);
},
});
)
}
})
// @ts-ignore
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
const processMouseDown = LGraphCanvas.prototype.processMouseDown
// @ts-ignore
LGraphCanvas.prototype.processMouseDown = function (e) {
if (touchZooming || touchCount) {
return;
return
}
return processMouseDown.apply(this, arguments);
};
return processMouseDown.apply(this, arguments)
}
// @ts-ignore
const processMouseMove = LGraphCanvas.prototype.processMouseMove;
const processMouseMove = LGraphCanvas.prototype.processMouseMove
// @ts-ignore
LGraphCanvas.prototype.processMouseMove = function (e) {
if (touchZooming || touchCount > 1) {
return;
return
}
return processMouseMove.apply(this, arguments);
};
return processMouseMove.apply(this, arguments)
}

View File

@@ -1,97 +1,97 @@
import { app } from "../../scripts/app";
import { ComfyWidgets } from "../../scripts/widgets";
import { LiteGraph } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { ComfyWidgets } from '../../scripts/widgets'
import { LiteGraph } from '@comfyorg/litegraph'
// Adds defaults for quickly adding nodes with middle click on the input/output
app.registerExtension({
name: "Comfy.SlotDefaults",
name: 'Comfy.SlotDefaults',
suggestionsNumber: null,
init() {
LiteGraph.search_filter_enabled = true;
LiteGraph.middle_click_slot_add_default_node = true;
LiteGraph.search_filter_enabled = true
LiteGraph.middle_click_slot_add_default_node = true
this.suggestionsNumber = app.ui.settings.addSetting({
id: "Comfy.NodeSuggestions.number",
name: "Number of nodes suggestions",
type: "slider",
id: 'Comfy.NodeSuggestions.number',
name: 'Number of nodes suggestions',
type: 'slider',
attrs: {
min: 1,
max: 100,
step: 1,
step: 1
},
defaultValue: 5,
onChange: (newVal, oldVal) => {
this.setDefaults(newVal);
},
});
this.setDefaults(newVal)
}
})
},
slot_types_default_out: {},
slot_types_default_in: {},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
var nodeId = nodeData.name;
var inputs = [];
inputs = nodeData["input"]["required"]; //only show required inputs to reduce the mess also not logical to create node with optional inputs
var nodeId = nodeData.name
var inputs = []
inputs = nodeData['input']['required'] //only show required inputs to reduce the mess also not logical to create node with optional inputs
for (const inputKey in inputs) {
var input = inputs[inputKey];
if (typeof input[0] !== "string") continue;
var input = inputs[inputKey]
if (typeof input[0] !== 'string') continue
var type = input[0];
var type = input[0]
if (type in ComfyWidgets) {
var customProperties = input[1];
if (!customProperties?.forceInput) continue; //ignore widgets that don't force input
var customProperties = input[1]
if (!customProperties?.forceInput) continue //ignore widgets that don't force input
}
if (!(type in this.slot_types_default_out)) {
this.slot_types_default_out[type] = ["Reroute"];
this.slot_types_default_out[type] = ['Reroute']
}
if (this.slot_types_default_out[type].includes(nodeId)) continue;
this.slot_types_default_out[type].push(nodeId);
if (this.slot_types_default_out[type].includes(nodeId)) continue
this.slot_types_default_out[type].push(nodeId)
// Input types have to be stored as lower case
// Store each node that can handle this input type
const lowerType = type.toLocaleLowerCase();
const lowerType = type.toLocaleLowerCase()
if (!(lowerType in LiteGraph.registered_slot_in_types)) {
LiteGraph.registered_slot_in_types[lowerType] = { nodes: [] };
LiteGraph.registered_slot_in_types[lowerType] = { nodes: [] }
}
LiteGraph.registered_slot_in_types[lowerType].nodes.push(
nodeType.comfyClass
);
)
}
var outputs = nodeData["output"];
var outputs = nodeData['output']
for (const key in outputs) {
var type = outputs[key] as string;
var type = outputs[key] as string
if (!(type in this.slot_types_default_in)) {
this.slot_types_default_in[type] = ["Reroute"]; // ["Reroute", "Primitive"]; primitive doesn't always work :'()
this.slot_types_default_in[type] = ['Reroute'] // ["Reroute", "Primitive"]; primitive doesn't always work :'()
}
this.slot_types_default_in[type].push(nodeId);
this.slot_types_default_in[type].push(nodeId)
// Store each node that can handle this output type
if (!(type in LiteGraph.registered_slot_out_types)) {
LiteGraph.registered_slot_out_types[type] = { nodes: [] };
LiteGraph.registered_slot_out_types[type] = { nodes: [] }
}
LiteGraph.registered_slot_out_types[type].nodes.push(nodeType.comfyClass);
LiteGraph.registered_slot_out_types[type].nodes.push(nodeType.comfyClass)
if (!LiteGraph.slot_types_out.includes(type)) {
LiteGraph.slot_types_out.push(type);
LiteGraph.slot_types_out.push(type)
}
}
var maxNum = this.suggestionsNumber.value;
this.setDefaults(maxNum);
var maxNum = this.suggestionsNumber.value
this.setDefaults(maxNum)
},
setDefaults(maxNum) {
LiteGraph.slot_types_default_out = {};
LiteGraph.slot_types_default_in = {};
LiteGraph.slot_types_default_out = {}
LiteGraph.slot_types_default_in = {}
for (const type in this.slot_types_default_out) {
LiteGraph.slot_types_default_out[type] = this.slot_types_default_out[
type
].slice(0, maxNum);
].slice(0, maxNum)
}
for (const type in this.slot_types_default_in) {
LiteGraph.slot_types_default_in[type] = this.slot_types_default_in[
type
].slice(0, maxNum);
].slice(0, maxNum)
}
},
});
}
})

View File

@@ -1,75 +1,73 @@
import { app } from "../../scripts/app";
import { app } from '../../scripts/app'
import {
LGraphCanvas,
LGraphNode,
LGraphGroup,
LiteGraph,
} from "@comfyorg/litegraph";
LiteGraph
} from '@comfyorg/litegraph'
// Shift + drag/resize to snap to grid
/** Rounds a Vector2 in-place to the current CANVAS_GRID_SIZE. */
function roundVectorToGrid(vec) {
vec[0] =
LiteGraph.CANVAS_GRID_SIZE *
Math.round(vec[0] / LiteGraph.CANVAS_GRID_SIZE);
LiteGraph.CANVAS_GRID_SIZE * Math.round(vec[0] / LiteGraph.CANVAS_GRID_SIZE)
vec[1] =
LiteGraph.CANVAS_GRID_SIZE *
Math.round(vec[1] / LiteGraph.CANVAS_GRID_SIZE);
return vec;
LiteGraph.CANVAS_GRID_SIZE * Math.round(vec[1] / LiteGraph.CANVAS_GRID_SIZE)
return vec
}
app.registerExtension({
name: "Comfy.SnapToGrid",
name: 'Comfy.SnapToGrid',
init() {
// Add setting to control grid size
app.ui.settings.addSetting({
id: "Comfy.SnapToGrid.GridSize",
name: "Grid Size",
type: "slider",
id: 'Comfy.SnapToGrid.GridSize',
name: 'Grid Size',
type: 'slider',
attrs: {
min: 1,
max: 500,
max: 500
},
tooltip:
"When dragging and resizing nodes while holding shift they will be aligned to the grid, this controls the size of that grid.",
'When dragging and resizing nodes while holding shift they will be aligned to the grid, this controls the size of that grid.',
defaultValue: LiteGraph.CANVAS_GRID_SIZE,
onChange(value) {
LiteGraph.CANVAS_GRID_SIZE = +value;
},
});
LiteGraph.CANVAS_GRID_SIZE = +value
}
})
// After moving a node, if the shift key is down align it to grid
const onNodeMoved = app.canvas.onNodeMoved;
const onNodeMoved = app.canvas.onNodeMoved
app.canvas.onNodeMoved = function (node) {
const r = onNodeMoved?.apply(this, arguments);
const r = onNodeMoved?.apply(this, arguments)
if (app.shiftDown) {
// Ensure all selected nodes are realigned
for (const id in this.selected_nodes) {
this.selected_nodes[id].alignToGrid();
this.selected_nodes[id].alignToGrid()
}
}
return r;
};
return r
}
// When a node is added, add a resize handler to it so we can fix align the size with the grid
const onNodeAdded = app.graph.onNodeAdded;
const onNodeAdded = app.graph.onNodeAdded
app.graph.onNodeAdded = function (node) {
const onResize = node.onResize;
const onResize = node.onResize
node.onResize = function () {
if (app.shiftDown) {
roundVectorToGrid(node.size);
roundVectorToGrid(node.size)
}
return onResize?.apply(this, arguments)
}
return onNodeAdded?.apply(this, arguments)
}
return onResize?.apply(this, arguments);
};
return onNodeAdded?.apply(this, arguments);
};
// Draw a preview of where the node will go if holding shift and the node is selected
// @ts-ignore
const origDrawNode = LGraphCanvas.prototype.drawNode;
const origDrawNode = LGraphCanvas.prototype.drawNode
// @ts-ignore
LGraphCanvas.prototype.drawNode = function (node, ctx) {
if (
@@ -77,53 +75,53 @@ app.registerExtension({
this.node_dragged &&
node.id in this.selected_nodes
) {
const [x, y] = roundVectorToGrid([...node.pos]);
const shiftX = x - node.pos[0];
let shiftY = y - node.pos[1];
const [x, y] = roundVectorToGrid([...node.pos])
const shiftX = x - node.pos[0]
let shiftY = y - node.pos[1]
let w, h;
let w, h
if (node.flags.collapsed) {
// @ts-ignore
w = node._collapsed_width;
h = LiteGraph.NODE_TITLE_HEIGHT;
shiftY -= LiteGraph.NODE_TITLE_HEIGHT;
w = node._collapsed_width
h = LiteGraph.NODE_TITLE_HEIGHT
shiftY -= LiteGraph.NODE_TITLE_HEIGHT
} else {
w = node.size[0];
h = node.size[1];
w = node.size[0]
h = node.size[1]
// @ts-ignore
let titleMode = node.constructor.title_mode;
let titleMode = node.constructor.title_mode
if (
titleMode !== LiteGraph.TRANSPARENT_TITLE &&
titleMode !== LiteGraph.NO_TITLE
) {
h += LiteGraph.NODE_TITLE_HEIGHT;
shiftY -= LiteGraph.NODE_TITLE_HEIGHT;
h += LiteGraph.NODE_TITLE_HEIGHT
shiftY -= LiteGraph.NODE_TITLE_HEIGHT
}
}
const f = ctx.fillStyle;
ctx.fillStyle = "rgba(100, 100, 100, 0.5)";
ctx.fillRect(shiftX, shiftY, w, h);
ctx.fillStyle = f;
const f = ctx.fillStyle
ctx.fillStyle = 'rgba(100, 100, 100, 0.5)'
ctx.fillRect(shiftX, shiftY, w, h)
ctx.fillStyle = f
}
return origDrawNode.apply(this, arguments);
};
return origDrawNode.apply(this, arguments)
}
/**
* The currently moving, selected group only. Set after the `selected_group` has actually started
* moving.
*/
let selectedAndMovingGroup: LGraphGroup | null = null;
let selectedAndMovingGroup: LGraphGroup | null = null
/**
* Handles moving a group; tracking when a group has been moved (to show the ghost in `drawGroups`
* below) as well as handle the last move call from LiteGraph's `processMouseUp`.
*/
// @ts-ignore
const groupMove = LGraphGroup.prototype.move;
const groupMove = LGraphGroup.prototype.move
// @ts-ignore
LGraphGroup.prototype.move = function (deltax, deltay, ignore_nodes) {
const v = groupMove.apply(this, arguments);
const v = groupMove.apply(this, arguments)
// When we've started moving, set `selectedAndMovingGroup` as LiteGraph sets `selected_group`
// too eagerly and we don't want to behave like we're moving until we get a delta.
if (
@@ -131,7 +129,7 @@ app.registerExtension({
app.canvas.selected_group === this &&
(deltax || deltay)
) {
selectedAndMovingGroup = this;
selectedAndMovingGroup = this
}
// LiteGraph will call group.move both on mouse-move as well as mouse-up though we only want
@@ -141,15 +139,15 @@ app.registerExtension({
if (app.canvas.last_mouse_dragging === false && app.shiftDown) {
// After moving a group (while app.shiftDown), snap all the child nodes and, finally,
// align the group itself.
this.recomputeInsideNodes();
this.recomputeInsideNodes()
for (const node of this._nodes) {
node.alignToGrid();
node.alignToGrid()
}
// @ts-ignore
LGraphNode.prototype.alignToGrid.apply(this);
LGraphNode.prototype.alignToGrid.apply(this)
}
return v
}
return v;
};
/**
* Handles drawing a group when, snapping the size when one is actively being resized tracking and/or
@@ -157,50 +155,50 @@ app.registerExtension({
* both.
*/
// @ts-ignore
const drawGroups = LGraphCanvas.prototype.drawGroups;
const drawGroups = LGraphCanvas.prototype.drawGroups
// @ts-ignore
LGraphCanvas.prototype.drawGroups = function (canvas, ctx) {
if (this.selected_group && app.shiftDown) {
if (this.selected_group_resizing) {
// @ts-ignore
roundVectorToGrid(this.selected_group.size);
roundVectorToGrid(this.selected_group.size)
} else if (selectedAndMovingGroup) {
// @ts-ignore
const [x, y] = roundVectorToGrid([...selectedAndMovingGroup.pos]);
const f = ctx.fillStyle;
const s = ctx.strokeStyle;
ctx.fillStyle = "rgba(100, 100, 100, 0.33)";
ctx.strokeStyle = "rgba(100, 100, 100, 0.66)";
const [x, y] = roundVectorToGrid([...selectedAndMovingGroup.pos])
const f = ctx.fillStyle
const s = ctx.strokeStyle
ctx.fillStyle = 'rgba(100, 100, 100, 0.33)'
ctx.strokeStyle = 'rgba(100, 100, 100, 0.66)'
// @ts-ignore
ctx.rect(x, y, ...selectedAndMovingGroup.size);
ctx.fill();
ctx.stroke();
ctx.fillStyle = f;
ctx.strokeStyle = s;
ctx.rect(x, y, ...selectedAndMovingGroup.size)
ctx.fill()
ctx.stroke()
ctx.fillStyle = f
ctx.strokeStyle = s
}
} else if (!this.selected_group) {
selectedAndMovingGroup = null;
selectedAndMovingGroup = null
}
return drawGroups.apply(this, arguments)
}
return drawGroups.apply(this, arguments);
};
/** Handles adding a group in a snapping-enabled state. */
// @ts-ignore
const onGroupAdd = LGraphCanvas.onGroupAdd;
const onGroupAdd = LGraphCanvas.onGroupAdd
// @ts-ignore
LGraphCanvas.onGroupAdd = function () {
const v = onGroupAdd.apply(app.canvas, arguments);
const v = onGroupAdd.apply(app.canvas, arguments)
if (app.shiftDown) {
// @ts-ignore
const lastGroup = app.graph._groups[app.graph._groups.length - 1];
const lastGroup = app.graph._groups[app.graph._groups.length - 1]
if (lastGroup) {
// @ts-ignore
roundVectorToGrid(lastGroup.pos);
roundVectorToGrid(lastGroup.pos)
// @ts-ignore
roundVectorToGrid(lastGroup.size);
roundVectorToGrid(lastGroup.size)
}
}
return v;
};
},
});
return v
}
}
})

View File

@@ -1,35 +1,35 @@
import { app } from "../../scripts/app";
import { api } from "../../scripts/api";
import type { IWidget } from "@comfyorg/litegraph";
import type { DOMWidget } from "@/scripts/domWidget";
import { ComfyNodeDef } from "@/types/apiTypes";
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
import type { IWidget } from '@comfyorg/litegraph'
import type { DOMWidget } from '@/scripts/domWidget'
import { ComfyNodeDef } from '@/types/apiTypes'
type FolderType = "input" | "output" | "temp";
type FolderType = 'input' | 'output' | 'temp'
function splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf("/");
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
return ["", path];
return ['', path]
}
return [
path.substring(0, folder_separator),
path.substring(folder_separator + 1),
];
path.substring(folder_separator + 1)
]
}
function getResourceURL(
subfolder: string,
filename: string,
type: FolderType = "input"
type: FolderType = 'input'
): string {
const params = [
"filename=" + encodeURIComponent(filename),
"type=" + type,
"subfolder=" + subfolder,
app.getRandParam().substring(1),
].join("&");
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`;
return `/view?${params}`
}
async function uploadFile(
@@ -41,109 +41,109 @@ async function uploadFile(
) {
try {
// Wrap file in formdata so it includes filename
const body = new FormData();
body.append("image", file);
if (pasted) body.append("subfolder", "pasted");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
const body = new FormData()
body.append('image', file)
if (pasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json();
const data = await resp.json()
// Add the file to the dropdown list and update the widget value
let path = data.name;
if (data.subfolder) path = data.subfolder + "/" + path;
let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path
if (!audioWidget.options.values.includes(path)) {
audioWidget.options.values.push(path);
audioWidget.options.values.push(path)
}
if (updateNode) {
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(path))
);
audioWidget.value = path;
)
audioWidget.value = path
}
} else {
alert(resp.status + " - " + resp.statusText);
alert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
alert(error);
alert(error)
}
}
// AudioWidget MUST be registered first, as AUDIOUPLOAD depends on AUDIO_UI to be
// present.
app.registerExtension({
name: "Comfy.AudioWidget",
name: 'Comfy.AudioWidget',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (
["LoadAudio", "SaveAudio", "PreviewAudio"].includes(nodeType.comfyClass)
['LoadAudio', 'SaveAudio', 'PreviewAudio'].includes(nodeType.comfyClass)
) {
nodeData.input.required.audioUI = ["AUDIO_UI"];
nodeData.input.required.audioUI = ['AUDIO_UI']
}
},
getCustomWidgets() {
return {
AUDIO_UI(node, inputName: string) {
const audio = document.createElement("audio");
audio.controls = true;
audio.classList.add("comfy-audio");
audio.setAttribute("name", "media");
const audio = document.createElement('audio')
audio.controls = true
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement> = node.addDOMWidget(
inputName,
/* name=*/ "audioUI",
/* name=*/ 'audioUI',
audio
);
)
// @ts-ignore
// TODO: Sort out the DOMWidget type.
audioUIWidget.serialize = false;
audioUIWidget.serialize = false
const isOutputNode = node.constructor.nodeData.output_node;
const isOutputNode = node.constructor.nodeData.output_node
if (isOutputNode) {
// Hide the audio widget when there is no audio initially.
audioUIWidget.element.classList.add("empty-audio-widget");
audioUIWidget.element.classList.add('empty-audio-widget')
// Populate the audio widget UI on node execution.
const onExecuted = node.onExecuted;
const onExecuted = node.onExecuted
node.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
const audios = message.audio;
if (!audios) return;
const audio = audios[0];
onExecuted?.apply(this, arguments)
const audios = message.audio
if (!audios) return
const audio = audios[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
);
audioUIWidget.element.classList.remove("empty-audio-widget");
};
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
return { widget: audioUIWidget }
}
}
return { widget: audioUIWidget };
},
};
},
onNodeOutputsUpdated(nodeOutputs: Record<number, any>) {
for (const [nodeId, output] of Object.entries(nodeOutputs)) {
const node = app.graph.getNodeById(Number.parseInt(nodeId));
if ("audio" in output) {
const node = app.graph.getNodeById(Number.parseInt(nodeId))
if ('audio' in output) {
const audioUIWidget = node.widgets.find(
(w) => w.name === "audioUI"
) as unknown as DOMWidget<HTMLAudioElement>;
const audio = output.audio[0];
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement>
const audio = output.audio[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
);
audioUIWidget.element.classList.remove("empty-audio-widget");
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
},
});
}
})
app.registerExtension({
name: "Comfy.UploadAudio",
name: 'Comfy.UploadAudio',
async beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef) {
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
nodeData.input.required.upload = ["AUDIOUPLOAD"];
nodeData.input.required.upload = ['AUDIOUPLOAD']
}
},
getCustomWidgets() {
@@ -151,55 +151,55 @@ app.registerExtension({
AUDIOUPLOAD(node, inputName: string) {
// The widget that allows user to select file.
const audioWidget: IWidget = node.widgets.find(
(w: IWidget) => w.name === "audio"
);
(w: IWidget) => w.name === 'audio'
)
const audioUIWidget: DOMWidget<HTMLAudioElement> = node.widgets.find(
(w: IWidget) => w.name === "audioUI"
);
(w: IWidget) => w.name === 'audioUI'
)
const onAudioWidgetUpdate = () => {
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(audioWidget.value))
);
};
)
}
// Initially load default audio file to audioUIWidget.
if (audioWidget.value) {
onAudioWidgetUpdate();
onAudioWidgetUpdate()
}
audioWidget.callback = onAudioWidgetUpdate;
audioWidget.callback = onAudioWidgetUpdate
// Load saved audio file widget values if restoring from workflow
const onGraphConfigured = node.onGraphConfigured;
const onGraphConfigured = node.onGraphConfigured
node.onGraphConfigured = function () {
onGraphConfigured?.apply(this, arguments);
onGraphConfigured?.apply(this, arguments)
if (audioWidget.value) {
onAudioWidgetUpdate();
onAudioWidgetUpdate()
}
}
};
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "audio/*";
fileInput.style.display = "none";
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'audio/*'
fileInput.style.display = 'none'
fileInput.onchange = () => {
if (fileInput.files.length) {
uploadFile(audioWidget, audioUIWidget, fileInput.files[0], true);
uploadFile(audioWidget, audioUIWidget, fileInput.files[0], true)
}
}
};
// The widget to pop up the upload dialog.
const uploadWidget = node.addWidget(
"button",
'button',
inputName,
/* value=*/ "",
/* value=*/ '',
() => {
fileInput.click();
fileInput.click()
}
);
uploadWidget.label = "choose file to upload";
uploadWidget.serialize = false;
)
uploadWidget.label = 'choose file to upload'
uploadWidget.serialize = false
return { widget: uploadWidget };
},
};
},
});
return { widget: uploadWidget }
}
}
}
})

View File

@@ -1,13 +1,13 @@
import { app } from "../../scripts/app";
import { ComfyNodeDef } from "@/types/apiTypes";
import { app } from '../../scripts/app'
import { ComfyNodeDef } from '@/types/apiTypes'
// Adds an upload button to the nodes
app.registerExtension({
name: "Comfy.UploadImage",
name: 'Comfy.UploadImage',
async beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef, app) {
if (nodeData?.input?.required?.image?.[1]?.image_upload === true) {
nodeData.input.required.upload = ["IMAGEUPLOAD"];
nodeData.input.required.upload = ['IMAGEUPLOAD']
}
},
});
}
})

View File

@@ -1,140 +1,140 @@
import { app } from "../../scripts/app";
import { api } from "../../scripts/api";
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
const WEBCAM_READY = Symbol();
const WEBCAM_READY = Symbol()
app.registerExtension({
name: "Comfy.WebcamCapture",
name: 'Comfy.WebcamCapture',
getCustomWidgets(app) {
return {
WEBCAM(node, inputName) {
let res;
node[WEBCAM_READY] = new Promise((resolve) => (res = resolve));
let res
node[WEBCAM_READY] = new Promise((resolve) => (res = resolve))
const container = document.createElement("div");
container.style.background = "rgba(0,0,0,0.25)";
container.style.textAlign = "center";
const container = document.createElement('div')
container.style.background = 'rgba(0,0,0,0.25)'
container.style.textAlign = 'center'
const video = document.createElement("video");
video.style.height = video.style.width = "100%";
const video = document.createElement('video')
video.style.height = video.style.width = '100%'
const loadVideo = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
container.replaceChildren(video);
audio: false
})
container.replaceChildren(video)
setTimeout(() => res(video), 500); // Fallback as loadedmetadata doesnt fire sometimes?
video.addEventListener("loadedmetadata", () => res(video), false);
video.srcObject = stream;
video.play();
setTimeout(() => res(video), 500) // Fallback as loadedmetadata doesnt fire sometimes?
video.addEventListener('loadedmetadata', () => res(video), false)
video.srcObject = stream
video.play()
} catch (error) {
const label = document.createElement("div");
label.style.color = "red";
label.style.overflow = "auto";
label.style.maxHeight = "100%";
label.style.whiteSpace = "pre-wrap";
const label = document.createElement('div')
label.style.color = 'red'
label.style.overflow = 'auto'
label.style.maxHeight = '100%'
label.style.whiteSpace = 'pre-wrap'
if (window.isSecureContext) {
label.textContent =
"Unable to load webcam, please ensure access is granted:\n" +
error.message;
'Unable to load webcam, please ensure access is granted:\n' +
error.message
} else {
label.textContent =
"Unable to load webcam. A secure context is required, if you are not accessing ComfyUI on localhost (127.0.0.1) you will have to enable TLS (https)\n\n" +
error.message;
'Unable to load webcam. A secure context is required, if you are not accessing ComfyUI on localhost (127.0.0.1) you will have to enable TLS (https)\n\n' +
error.message
}
container.replaceChildren(label);
container.replaceChildren(label)
}
}
};
loadVideo();
loadVideo()
return { widget: node.addDOMWidget(inputName, "WEBCAM", container) };
},
};
return { widget: node.addDOMWidget(inputName, 'WEBCAM', container) }
}
}
},
nodeCreated(node) {
if ((node.type, node.constructor.comfyClass !== "WebcamCapture")) return;
if ((node.type, node.constructor.comfyClass !== 'WebcamCapture')) return
let video;
const camera = node.widgets.find((w) => w.name === "image");
const w = node.widgets.find((w) => w.name === "width");
const h = node.widgets.find((w) => w.name === "height");
let video
const camera = node.widgets.find((w) => w.name === 'image')
const w = node.widgets.find((w) => w.name === 'width')
const h = node.widgets.find((w) => w.name === 'height')
const captureOnQueue = node.widgets.find(
(w) => w.name === "capture_on_queue"
);
(w) => w.name === 'capture_on_queue'
)
const canvas = document.createElement("canvas");
const canvas = document.createElement('canvas')
const capture = () => {
canvas.width = w.value;
canvas.height = h.value;
const ctx = canvas.getContext("2d");
ctx.drawImage(video, 0, 0, w.value, h.value);
const data = canvas.toDataURL("image/png");
canvas.width = w.value
canvas.height = h.value
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0, w.value, h.value)
const data = canvas.toDataURL('image/png')
const img = new Image();
const img = new Image()
img.onload = () => {
node.imgs = [img];
app.graph.setDirtyCanvas(true);
node.imgs = [img]
app.graph.setDirtyCanvas(true)
requestAnimationFrame(() => {
node.setSizeForImage?.();
});
};
img.src = data;
};
node.setSizeForImage?.()
})
}
img.src = data
}
const btn = node.addWidget(
"button",
"waiting for camera...",
"capture",
'button',
'waiting for camera...',
'capture',
capture
);
btn.disabled = true;
btn.serializeValue = () => undefined;
)
btn.disabled = true
btn.serializeValue = () => undefined
camera.serializeValue = async () => {
if (captureOnQueue.value) {
capture();
capture()
} else if (!node.imgs?.length) {
const err = `No webcam image captured`;
alert(err);
throw new Error(err);
const err = `No webcam image captured`
alert(err)
throw new Error(err)
}
// Upload image to temp storage
const blob = await new Promise<Blob>((r) => canvas.toBlob(r));
const name = `${+new Date()}.png`;
const file = new File([blob], name);
const body = new FormData();
body.append("image", file);
body.append("subfolder", "webcam");
body.append("type", "temp");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
const blob = await new Promise<Blob>((r) => canvas.toBlob(r))
const name = `${+new Date()}.png`
const file = new File([blob], name)
const body = new FormData()
body.append('image', file)
body.append('subfolder', 'webcam')
body.append('type', 'temp')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
const err = `Error uploading camera image: ${resp.status} - ${resp.statusText}`;
alert(err);
throw new Error(err);
const err = `Error uploading camera image: ${resp.status} - ${resp.statusText}`
alert(err)
throw new Error(err)
}
return `webcam/${name} [temp]`
}
return `webcam/${name} [temp]`;
};
node[WEBCAM_READY].then((v) => {
video = v;
video = v
// If width isnt specified then use video output resolution
if (!w.value) {
w.value = video.videoWidth || 640;
h.value = video.videoHeight || 480;
w.value = video.videoWidth || 640
h.value = video.videoHeight || 480
}
btn.disabled = false;
btn.label = "capture";
});
},
});
btn.disabled = false
btn.label = 'capture'
})
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,27 @@
import { createI18n } from "vue-i18n";
import { createI18n } from 'vue-i18n'
const messages = {
en: {
sideToolBar: {
settings: "Settings",
themeToggle: "Toggle Theme",
queue: "Queue",
},
settings: 'Settings',
themeToggle: 'Toggle Theme',
queue: 'Queue'
}
},
zh: {
sideToolBar: {
settings: "设置",
themeToggle: "主题切换",
queue: "队列",
},
},
settings: '设置',
themeToggle: '主题切换',
queue: '队列'
}
}
// TODO: Add more languages
};
}
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: navigator.language.split("-")[0] || "en",
fallbackLocale: "en",
messages,
});
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages
})

View File

@@ -1,48 +1,48 @@
import { createApp } from "vue";
import PrimeVue from "primevue/config";
import Aura from "@primevue/themes/aura";
import { definePreset } from "@primevue/themes";
import ConfirmationService from "primevue/confirmationservice";
import ToastService from "primevue/toastservice";
import Tooltip from "primevue/tooltip";
import "primeicons/primeicons.css";
import { createApp } from 'vue'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import { definePreset } from '@primevue/themes'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import 'primeicons/primeicons.css'
import App from "./App.vue";
import { app as comfyApp } from "@/scripts/app";
import { createPinia } from "pinia";
import { i18n } from "./i18n";
import App from './App.vue'
import { app as comfyApp } from '@/scripts/app'
import { createPinia } from 'pinia'
import { i18n } from './i18n'
const ComfyUIPreset = definePreset(Aura, {
semantic: {
// @ts-ignore
primary: Aura.primitive.blue,
},
});
primary: Aura.primitive.blue
}
})
const app = createApp(App);
const pinia = createPinia();
const app = createApp(App)
const pinia = createPinia()
comfyApp.setup().then(() => {
window["app"] = comfyApp;
window["graph"] = comfyApp.graph;
window['app'] = comfyApp
window['graph'] = comfyApp.graph
app.directive("tooltip", Tooltip);
app.directive('tooltip', Tooltip)
app
.use(PrimeVue, {
theme: {
preset: ComfyUIPreset,
options: {
prefix: "p",
prefix: 'p',
cssLayer: false,
// This is a workaround for the issue with the dark mode selector
// https://github.com/primefaces/primevue/issues/5515
darkModeSelector: ".dark-theme, :root:has(.dark-theme)",
},
},
darkModeSelector: '.dark-theme, :root:has(.dark-theme)'
}
}
})
.use(ConfirmationService)
.use(ToastService)
.use(pinia)
.use(i18n)
.mount("#vue-app");
});
.mount('#vue-app')
})

View File

@@ -1,64 +1,64 @@
import { ComfyWorkflowJSON } from "@/types/comfyWorkflow";
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import {
HistoryTaskItem,
PendingTaskItem,
RunningTaskItem,
ComfyNodeDef,
validateComfyNodeDef,
} from "@/types/apiTypes";
validateComfyNodeDef
} from '@/types/apiTypes'
interface QueuePromptRequestBody {
client_id: string;
client_id: string
// Mapping from node id to node info + input values
// TODO: Type this.
prompt: Record<number, any>;
prompt: Record<number, any>
extra_data: {
extra_pnginfo: {
workflow: ComfyWorkflowJSON;
};
};
front?: boolean;
number?: number;
workflow: ComfyWorkflowJSON
}
}
front?: boolean
number?: number
}
class ComfyApi extends EventTarget {
#registered = new Set();
api_host: string;
api_base: string;
initialClientId: string;
user: string;
socket?: WebSocket;
clientId?: string;
#registered = new Set()
api_host: string
api_base: string
initialClientId: string
user: string
socket?: WebSocket
clientId?: string
constructor() {
super();
this.api_host = location.host;
this.api_base = location.pathname.split("/").slice(0, -1).join("/");
this.initialClientId = sessionStorage.getItem("clientId");
super()
this.api_host = location.host
this.api_base = location.pathname.split('/').slice(0, -1).join('/')
this.initialClientId = sessionStorage.getItem('clientId')
}
apiURL(route: string): string {
return this.api_base + "/api" + route;
return this.api_base + '/api' + route
}
fileURL(route: string): string {
return this.api_base + route;
return this.api_base + route
}
fetchApi(route, options?) {
if (!options) {
options = {};
options = {}
}
if (!options.headers) {
options.headers = {};
options.headers = {}
}
options.headers["Comfy-User"] = this.user;
return fetch(this.apiURL(route), options);
options.headers['Comfy-User'] = this.user
return fetch(this.apiURL(route), options)
}
addEventListener(type, callback, options?) {
super.addEventListener(type, callback, options);
this.#registered.add(type);
super.addEventListener(type, callback, options)
this.#registered.add(type)
}
/**
@@ -67,13 +67,13 @@ class ComfyApi extends EventTarget {
#pollQueue() {
setInterval(async () => {
try {
const resp = await this.fetchApi("/prompt");
const status = await resp.json();
this.dispatchEvent(new CustomEvent("status", { detail: status }));
const resp = await this.fetchApi('/prompt')
const status = await resp.json()
this.dispatchEvent(new CustomEvent('status', { detail: status }))
} catch (error) {
this.dispatchEvent(new CustomEvent("status", { detail: null }));
this.dispatchEvent(new CustomEvent('status', { detail: null }))
}
}, 1000);
}, 1000)
}
/**
@@ -82,144 +82,144 @@ class ComfyApi extends EventTarget {
*/
#createSocket(isReconnect?) {
if (this.socket) {
return;
return
}
let opened = false;
let existingSession = window.name;
let opened = false
let existingSession = window.name
if (existingSession) {
existingSession = "?clientId=" + existingSession;
existingSession = '?clientId=' + existingSession
}
this.socket = new WebSocket(
`ws${window.location.protocol === "https:" ? "s" : ""}://${this.api_host}${this.api_base}/ws${existingSession}`
);
this.socket.binaryType = "arraybuffer";
`ws${window.location.protocol === 'https:' ? 's' : ''}://${this.api_host}${this.api_base}/ws${existingSession}`
)
this.socket.binaryType = 'arraybuffer'
this.socket.addEventListener("open", () => {
opened = true;
this.socket.addEventListener('open', () => {
opened = true
if (isReconnect) {
this.dispatchEvent(new CustomEvent("reconnected"));
this.dispatchEvent(new CustomEvent('reconnected'))
}
});
})
this.socket.addEventListener("error", () => {
if (this.socket) this.socket.close();
this.socket.addEventListener('error', () => {
if (this.socket) this.socket.close()
if (!isReconnect && !opened) {
this.#pollQueue();
this.#pollQueue()
}
});
})
this.socket.addEventListener("close", () => {
this.socket.addEventListener('close', () => {
setTimeout(() => {
this.socket = null;
this.#createSocket(true);
}, 300);
this.socket = null
this.#createSocket(true)
}, 300)
if (opened) {
this.dispatchEvent(new CustomEvent("status", { detail: null }));
this.dispatchEvent(new CustomEvent("reconnecting"));
this.dispatchEvent(new CustomEvent('status', { detail: null }))
this.dispatchEvent(new CustomEvent('reconnecting'))
}
});
})
this.socket.addEventListener("message", (event) => {
this.socket.addEventListener('message', (event) => {
try {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
const eventType = view.getUint32(0);
const buffer = event.data.slice(4);
const view = new DataView(event.data)
const eventType = view.getUint32(0)
const buffer = event.data.slice(4)
switch (eventType) {
case 1:
const view2 = new DataView(event.data);
const imageType = view2.getUint32(0);
let imageMime;
const view2 = new DataView(event.data)
const imageType = view2.getUint32(0)
let imageMime
switch (imageType) {
case 1:
default:
imageMime = "image/jpeg";
break;
imageMime = 'image/jpeg'
break
case 2:
imageMime = "image/png";
imageMime = 'image/png'
}
const imageBlob = new Blob([buffer.slice(4)], {
type: imageMime,
});
type: imageMime
})
this.dispatchEvent(
new CustomEvent("b_preview", { detail: imageBlob })
);
break;
new CustomEvent('b_preview', { detail: imageBlob })
)
break
default:
throw new Error(
`Unknown binary websocket message of type ${eventType}`
);
)
}
} else {
const msg = JSON.parse(event.data);
const msg = JSON.parse(event.data)
switch (msg.type) {
case "status":
case 'status':
if (msg.data.sid) {
this.clientId = msg.data.sid;
window.name = this.clientId; // use window name so it isnt reused when duplicating tabs
sessionStorage.setItem("clientId", this.clientId); // store in session storage so duplicate tab can load correct workflow
this.clientId = msg.data.sid
window.name = this.clientId // use window name so it isnt reused when duplicating tabs
sessionStorage.setItem('clientId', this.clientId) // store in session storage so duplicate tab can load correct workflow
}
this.dispatchEvent(
new CustomEvent("status", { detail: msg.data.status })
);
break;
case "progress":
new CustomEvent('status', { detail: msg.data.status })
)
break
case 'progress':
this.dispatchEvent(
new CustomEvent("progress", { detail: msg.data })
);
break;
case "executing":
new CustomEvent('progress', { detail: msg.data })
)
break
case 'executing':
this.dispatchEvent(
new CustomEvent("executing", { detail: msg.data.node })
);
break;
case "executed":
new CustomEvent('executing', { detail: msg.data.node })
)
break
case 'executed':
this.dispatchEvent(
new CustomEvent("executed", { detail: msg.data })
);
break;
case "execution_start":
new CustomEvent('executed', { detail: msg.data })
)
break
case 'execution_start':
this.dispatchEvent(
new CustomEvent("execution_start", { detail: msg.data })
);
break;
case "execution_success":
new CustomEvent('execution_start', { detail: msg.data })
)
break
case 'execution_success':
this.dispatchEvent(
new CustomEvent("execution_success", { detail: msg.data })
);
break;
case "execution_error":
new CustomEvent('execution_success', { detail: msg.data })
)
break
case 'execution_error':
this.dispatchEvent(
new CustomEvent("execution_error", { detail: msg.data })
);
break;
case "execution_cached":
new CustomEvent('execution_error', { detail: msg.data })
)
break
case 'execution_cached':
this.dispatchEvent(
new CustomEvent("execution_cached", { detail: msg.data })
);
break;
new CustomEvent('execution_cached', { detail: msg.data })
)
break
default:
if (this.#registered.has(msg.type)) {
this.dispatchEvent(
new CustomEvent(msg.type, { detail: msg.data })
);
)
} else {
throw new Error(`Unknown message type ${msg.type}`);
throw new Error(`Unknown message type ${msg.type}`)
}
}
}
} catch (error) {
console.warn("Unhandled message:", event.data, error);
console.warn('Unhandled message:', event.data, error)
}
});
})
}
/**
* Initialises sockets and realtime updates
*/
init() {
this.#createSocket();
this.#createSocket()
}
/**
@@ -227,8 +227,8 @@ class ComfyApi extends EventTarget {
* @returns An array of script urls to import
*/
async getExtensions() {
const resp = await this.fetchApi("/extensions", { cache: "no-store" });
return await resp.json();
const resp = await this.fetchApi('/extensions', { cache: 'no-store' })
return await resp.json()
}
/**
@@ -236,8 +236,8 @@ class ComfyApi extends EventTarget {
* @returns An array of script urls to import
*/
async getEmbeddings() {
const resp = await this.fetchApi("/embeddings", { cache: "no-store" });
return await resp.json();
const resp = await this.fetchApi('/embeddings', { cache: 'no-store' })
return await resp.json()
}
/**
@@ -245,18 +245,18 @@ class ComfyApi extends EventTarget {
* @returns The node definitions
*/
async getNodeDefs(): Promise<Record<string, ComfyNodeDef>> {
const resp = await this.fetchApi("/object_info", { cache: "no-store" });
const objectInfoUnsafe = await resp.json();
const objectInfo: Record<string, ComfyNodeDef> = {};
const resp = await this.fetchApi('/object_info', { cache: 'no-store' })
const objectInfoUnsafe = await resp.json()
const objectInfo: Record<string, ComfyNodeDef> = {}
for (const key in objectInfoUnsafe) {
try {
objectInfo[key] = validateComfyNodeDef(objectInfoUnsafe[key]);
objectInfo[key] = validateComfyNodeDef(objectInfoUnsafe[key])
} catch (e) {
console.warn("Ignore node definition: ", key);
console.error(e);
console.warn('Ignore node definition: ', key)
console.error(e)
}
}
return objectInfo;
return objectInfo
}
/**
@@ -268,30 +268,30 @@ class ComfyApi extends EventTarget {
const body: QueuePromptRequestBody = {
client_id: this.clientId,
prompt: output,
extra_data: { extra_pnginfo: { workflow } },
};
if (number === -1) {
body.front = true;
} else if (number != 0) {
body.number = number;
extra_data: { extra_pnginfo: { workflow } }
}
const res = await this.fetchApi("/prompt", {
method: "POST",
if (number === -1) {
body.front = true
} else if (number != 0) {
body.number = number
}
const res = await this.fetchApi('/prompt', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
});
body: JSON.stringify(body)
})
if (res.status !== 200) {
throw {
response: await res.json(),
};
response: await res.json()
}
}
return await res.json();
return await res.json()
}
/**
@@ -300,10 +300,10 @@ class ComfyApi extends EventTarget {
* @returns The items of the specified type grouped by their status
*/
async getItems(type) {
if (type === "queue") {
return this.getQueue();
if (type === 'queue') {
return this.getQueue()
}
return this.getHistory();
return this.getHistory()
}
/**
@@ -311,27 +311,27 @@ class ComfyApi extends EventTarget {
* @returns The currently running and queued items
*/
async getQueue(): Promise<{
Running: RunningTaskItem[];
Pending: PendingTaskItem[];
Running: RunningTaskItem[]
Pending: PendingTaskItem[]
}> {
try {
const res = await this.fetchApi("/queue");
const data = await res.json();
const res = await this.fetchApi('/queue')
const data = await res.json()
return {
// Running action uses a different endpoint for cancelling
Running: data.queue_running.map((prompt) => ({
taskType: "Running",
taskType: 'Running',
prompt,
remove: { name: "Cancel", cb: () => api.interrupt() },
remove: { name: 'Cancel', cb: () => api.interrupt() }
})),
Pending: data.queue_pending.map((prompt) => ({
taskType: "Pending",
prompt,
})),
};
taskType: 'Pending',
prompt
}))
}
} catch (error) {
console.error(error);
return { Running: [], Pending: [] };
console.error(error)
return { Running: [], Pending: [] }
}
}
@@ -343,15 +343,15 @@ class ComfyApi extends EventTarget {
max_items: number = 200
): Promise<{ History: HistoryTaskItem[] }> {
try {
const res = await this.fetchApi(`/history?max_items=${max_items}`);
const res = await this.fetchApi(`/history?max_items=${max_items}`)
return {
History: Object.values(await res.json()).map(
(item: HistoryTaskItem) => ({ ...item, taskType: "History" })
),
};
(item: HistoryTaskItem) => ({ ...item, taskType: 'History' })
)
}
} catch (error) {
console.error(error);
return { History: [] };
console.error(error)
return { History: [] }
}
}
@@ -360,8 +360,8 @@ class ComfyApi extends EventTarget {
* @returns System stats such as python version, OS, per device info
*/
async getSystemStats() {
const res = await this.fetchApi("/system_stats");
return await res.json();
const res = await this.fetchApi('/system_stats')
return await res.json()
}
/**
@@ -371,15 +371,15 @@ class ComfyApi extends EventTarget {
*/
async #postItem(type, body) {
try {
await this.fetchApi("/" + type, {
method: "POST",
await this.fetchApi('/' + type, {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined,
});
body: body ? JSON.stringify(body) : undefined
})
} catch (error) {
console.error(error);
console.error(error)
}
}
@@ -389,7 +389,7 @@ class ComfyApi extends EventTarget {
* @param {number} id The id of the item to delete
*/
async deleteItem(type, id) {
await this.#postItem(type, { delete: [id] });
await this.#postItem(type, { delete: [id] })
}
/**
@@ -397,14 +397,14 @@ class ComfyApi extends EventTarget {
* @param {string} type The type of list to clear, queue or history
*/
async clearItems(type) {
await this.#postItem(type, { clear: true });
await this.#postItem(type, { clear: true })
}
/**
* Interrupts the execution of the running prompt
*/
async interrupt() {
await this.#postItem("interrupt", null);
await this.#postItem('interrupt', null)
}
/**
@@ -412,7 +412,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<{ storage: "server" | "browser", users?: Promise<string, unknown>, migrated?: boolean }> }
*/
async getUserConfig() {
return (await this.fetchApi("/users")).json();
return (await this.fetchApi('/users')).json()
}
/**
@@ -421,13 +421,13 @@ class ComfyApi extends EventTarget {
* @returns The fetch response
*/
createUser(username) {
return this.fetchApi("/users", {
method: "POST",
return this.fetchApi('/users', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json'
},
body: JSON.stringify({ username }),
});
body: JSON.stringify({ username })
})
}
/**
@@ -435,7 +435,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<string, unknown> } A dictionary of id -> value
*/
async getSettings() {
return (await this.fetchApi("/settings")).json();
return (await this.fetchApi('/settings')).json()
}
/**
@@ -444,7 +444,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<unknown> } The setting value
*/
async getSetting(id) {
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json();
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json()
}
/**
@@ -454,9 +454,9 @@ class ComfyApi extends EventTarget {
*/
async storeSettings(settings) {
return this.fetchApi(`/settings`, {
method: "POST",
body: JSON.stringify(settings),
});
method: 'POST',
body: JSON.stringify(settings)
})
}
/**
@@ -467,9 +467,9 @@ class ComfyApi extends EventTarget {
*/
async storeSetting(id, value) {
return this.fetchApi(`/settings/${encodeURIComponent(id)}`, {
method: "POST",
body: JSON.stringify(value),
});
method: 'POST',
body: JSON.stringify(value)
})
}
/**
@@ -479,7 +479,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<unknown> } The fetch response object
*/
async getUserData(file, options?) {
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options);
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options)
}
/**
@@ -493,26 +493,26 @@ class ComfyApi extends EventTarget {
file: string,
data: unknown,
options: RequestInit & {
overwrite?: boolean;
stringify?: boolean;
throwOnError?: boolean;
overwrite?: boolean
stringify?: boolean
throwOnError?: boolean
} = { overwrite: true, stringify: true, throwOnError: true }
): Promise<Response> {
const resp = await this.fetchApi(
`/userdata/${encodeURIComponent(file)}?overwrite=${options.overwrite}`,
{
method: "POST",
method: 'POST',
body: options?.stringify ? JSON.stringify(data) : data,
...options,
...options
}
);
)
if (resp.status !== 200 && options.throwOnError !== false) {
throw new Error(
`Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}`
);
)
}
return resp;
return resp
}
/**
@@ -521,12 +521,12 @@ class ComfyApi extends EventTarget {
*/
async deleteUserData(file) {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
method: "DELETE",
});
method: 'DELETE'
})
if (resp.status !== 204) {
throw new Error(
`Error removing user data file '${file}': ${resp.status} ${resp.statusText}`
);
)
}
}
@@ -539,10 +539,10 @@ class ComfyApi extends EventTarget {
const resp = await this.fetchApi(
`/userdata/${encodeURIComponent(source)}/move/${encodeURIComponent(dest)}?overwrite=${options?.overwrite}`,
{
method: "POST",
method: 'POST'
}
);
return resp;
)
return resp
}
/**
@@ -566,17 +566,17 @@ class ComfyApi extends EventTarget {
`/userdata?${new URLSearchParams({
recurse,
dir,
split,
split
})}`
);
if (resp.status === 404) return [];
)
if (resp.status === 404) return []
if (resp.status !== 200) {
throw new Error(
`Error getting user data list '${dir}': ${resp.status} ${resp.statusText}`
);
)
}
return resp.json();
return resp.json()
}
}
export const api = new ComfyApi();
export const api = new ComfyApi()

File diff suppressed because it is too large Load Diff

View File

@@ -1,278 +1,278 @@
import type { ComfyApp } from "./app";
import { api } from "./api";
import { clone } from "./utils";
import { LGraphCanvas, LiteGraph } from "@comfyorg/litegraph";
import { ComfyWorkflow } from "./workflows";
import type { ComfyApp } from './app'
import { api } from './api'
import { clone } from './utils'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { ComfyWorkflow } from './workflows'
export class ChangeTracker {
static MAX_HISTORY = 50;
#app: ComfyApp;
undo = [];
redo = [];
activeState = null;
isOurLoad = false;
workflow: ComfyWorkflow | null;
static MAX_HISTORY = 50
#app: ComfyApp
undo = []
redo = []
activeState = null
isOurLoad = false
workflow: ComfyWorkflow | null
ds: { scale: number; offset: [number, number] };
nodeOutputs: any;
ds: { scale: number; offset: [number, number] }
nodeOutputs: any
get app() {
return this.#app ?? this.workflow.manager.app;
return this.#app ?? this.workflow.manager.app
}
constructor(workflow: ComfyWorkflow) {
this.workflow = workflow;
this.workflow = workflow
}
#setApp(app) {
this.#app = app;
this.#app = app
}
store() {
this.ds = {
scale: this.app.canvas.ds.scale,
offset: [...this.app.canvas.ds.offset],
};
offset: [...this.app.canvas.ds.offset]
}
}
restore() {
if (this.ds) {
this.app.canvas.ds.scale = this.ds.scale;
this.app.canvas.ds.offset = this.ds.offset;
this.app.canvas.ds.scale = this.ds.scale
this.app.canvas.ds.offset = this.ds.offset
}
if (this.nodeOutputs) {
this.app.nodeOutputs = this.nodeOutputs;
this.app.nodeOutputs = this.nodeOutputs
}
}
checkState() {
if (!this.app.graph) return;
if (!this.app.graph) return
const currentState = this.app.graph.serialize();
const currentState = this.app.graph.serialize()
if (!this.activeState) {
this.activeState = clone(currentState);
return;
this.activeState = clone(currentState)
return
}
if (!ChangeTracker.graphEqual(this.activeState, currentState)) {
this.undo.push(this.activeState);
this.undo.push(this.activeState)
if (this.undo.length > ChangeTracker.MAX_HISTORY) {
this.undo.shift();
this.undo.shift()
}
this.activeState = clone(currentState);
this.redo.length = 0;
this.workflow.unsaved = true;
this.activeState = clone(currentState)
this.redo.length = 0
this.workflow.unsaved = true
api.dispatchEvent(
new CustomEvent("graphChanged", { detail: this.activeState })
);
new CustomEvent('graphChanged', { detail: this.activeState })
)
}
}
async updateState(source, target) {
const prevState = source.pop();
const prevState = source.pop()
if (prevState) {
target.push(this.activeState);
this.isOurLoad = true;
await this.app.loadGraphData(prevState, false, false, this.workflow);
this.activeState = prevState;
target.push(this.activeState)
this.isOurLoad = true
await this.app.loadGraphData(prevState, false, false, this.workflow)
this.activeState = prevState
}
}
async undoRedo(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === "y") {
this.updateState(this.redo, this.undo);
return true;
} else if (e.key === "z") {
this.updateState(this.undo, this.redo);
return true;
if (e.key === 'y') {
this.updateState(this.redo, this.undo)
return true
} else if (e.key === 'z') {
this.updateState(this.undo, this.redo)
return true
}
}
}
static init(app: ComfyApp) {
const changeTracker = () =>
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker;
globalTracker.#setApp(app);
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker
globalTracker.#setApp(app)
const loadGraphData = app.loadGraphData;
const loadGraphData = app.loadGraphData
app.loadGraphData = async function () {
const v = await loadGraphData.apply(this, arguments);
const ct = changeTracker();
const v = await loadGraphData.apply(this, arguments)
const ct = changeTracker()
if (ct.isOurLoad) {
ct.isOurLoad = false;
ct.isOurLoad = false
} else {
ct.checkState();
ct.checkState()
}
return v
}
return v;
};
let keyIgnored = false;
let keyIgnored = false
window.addEventListener(
"keydown",
'keydown',
(e) => {
requestAnimationFrame(async () => {
let activeEl;
let activeEl
// If we are auto queue in change mode then we do want to trigger on inputs
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") {
activeEl = document.activeElement;
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === 'instant') {
activeEl = document.activeElement
if (
activeEl?.tagName === "INPUT" ||
activeEl?.["type"] === "textarea"
activeEl?.tagName === 'INPUT' ||
activeEl?.['type'] === 'textarea'
) {
// Ignore events on inputs, they have their native history
return;
return
}
}
keyIgnored =
e.key === "Control" ||
e.key === "Shift" ||
e.key === "Alt" ||
e.key === "Meta";
if (keyIgnored) return;
e.key === 'Control' ||
e.key === 'Shift' ||
e.key === 'Alt' ||
e.key === 'Meta'
if (keyIgnored) return
// Check if this is a ctrl+z ctrl+y
if (await changeTracker().undoRedo(e)) return;
if (await changeTracker().undoRedo(e)) return
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(app, activeEl)) return;
changeTracker().checkState();
});
if (ChangeTracker.bindInput(app, activeEl)) return
changeTracker().checkState()
})
},
true
);
)
window.addEventListener("keyup", (e) => {
window.addEventListener('keyup', (e) => {
if (keyIgnored) {
keyIgnored = false;
changeTracker().checkState();
keyIgnored = false
changeTracker().checkState()
}
});
})
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener("mouseup", () => {
changeTracker().checkState();
});
window.addEventListener('mouseup', () => {
changeTracker().checkState()
})
// Handle prompt queue event for dynamic widget changes
api.addEventListener("promptQueued", () => {
changeTracker().checkState();
});
api.addEventListener('promptQueued', () => {
changeTracker().checkState()
})
api.addEventListener("graphCleared", () => {
changeTracker().checkState();
});
api.addEventListener('graphCleared', () => {
changeTracker().checkState()
})
// Handle litegraph clicks
// @ts-ignore
const processMouseUp = LGraphCanvas.prototype.processMouseUp;
const processMouseUp = LGraphCanvas.prototype.processMouseUp
// @ts-ignore
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, arguments);
changeTracker().checkState();
return v;
};
const v = processMouseUp.apply(this, arguments)
changeTracker().checkState()
return v
}
// @ts-ignore
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
const processMouseDown = LGraphCanvas.prototype.processMouseDown
// @ts-ignore
LGraphCanvas.prototype.processMouseDown = function (e) {
const v = processMouseDown.apply(this, arguments);
changeTracker().checkState();
return v;
};
const v = processMouseDown.apply(this, arguments)
changeTracker().checkState()
return v
}
// Handle litegraph context menu for COMBO widgets
const close = LiteGraph.ContextMenu.prototype.close;
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e) {
const v = close.apply(this, arguments);
changeTracker().checkState();
return v;
};
const v = close.apply(this, arguments)
changeTracker().checkState()
return v
}
// Detects nodes being added via the node search dialog
const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded;
const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded
LiteGraph.LGraph.prototype.onNodeAdded = function () {
const v = onNodeAdded?.apply(this, arguments);
const v = onNodeAdded?.apply(this, arguments)
if (!app?.configuringGraph) {
const ct = changeTracker();
const ct = changeTracker()
if (!ct.isOurLoad) {
ct.checkState();
ct.checkState()
}
}
return v;
};
return v
}
// Store node outputs
api.addEventListener("executed", ({ detail }) => {
const prompt = app.workflowManager.queuedPrompts[detail.prompt_id];
if (!prompt?.workflow) return;
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {});
const output = nodeOutputs[detail.node];
api.addEventListener('executed', ({ detail }) => {
const prompt = app.workflowManager.queuedPrompts[detail.prompt_id]
if (!prompt?.workflow) return
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {})
const output = nodeOutputs[detail.node]
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
const v = output[k];
const v = output[k]
if (v instanceof Array) {
output[k] = v.concat(detail.output[k]);
output[k] = v.concat(detail.output[k])
} else {
output[k] = detail.output[k];
output[k] = detail.output[k]
}
}
} else {
nodeOutputs[detail.node] = detail.output;
nodeOutputs[detail.node] = detail.output
}
});
})
}
static bindInput(app, activeEl) {
if (
activeEl &&
activeEl.tagName !== "CANVAS" &&
activeEl.tagName !== "BODY"
activeEl.tagName !== 'CANVAS' &&
activeEl.tagName !== 'BODY'
) {
for (const evt of ["change", "input", "blur"]) {
for (const evt of ['change', 'input', 'blur']) {
if (`on${evt}` in activeEl) {
const listener = () => {
app.workflowManager.activeWorkflow.changeTracker.checkState();
activeEl.removeEventListener(evt, listener);
};
activeEl.addEventListener(evt, listener);
return true;
app.workflowManager.activeWorkflow.changeTracker.checkState()
activeEl.removeEventListener(evt, listener)
}
activeEl.addEventListener(evt, listener)
return true
}
}
}
}
static graphEqual(a, b, path = "") {
if (a === b) return true;
static graphEqual(a, b, path = '') {
if (a === b) return true
if (typeof a == "object" && a && typeof b == "object" && b) {
const keys = Object.getOwnPropertyNames(a);
if (typeof a == 'object' && a && typeof b == 'object' && b) {
const keys = Object.getOwnPropertyNames(a)
if (keys.length != Object.getOwnPropertyNames(b).length) {
return false;
return false
}
for (const key of keys) {
let av = a[key];
let bv = b[key];
if (!path && key === "nodes") {
let av = a[key]
let bv = b[key]
if (!path && key === 'nodes') {
// Nodes need to be sorted as the order changes when selecting nodes
av = [...av].sort((a, b) => a.id - b.id);
bv = [...bv].sort((a, b) => a.id - b.id);
} else if (path === "extra.ds") {
av = [...av].sort((a, b) => a.id - b.id)
bv = [...bv].sort((a, b) => a.id - b.id)
} else if (path === 'extra.ds') {
// Ignore view changes
continue;
continue
}
if (!ChangeTracker.graphEqual(av, bv, path + (path ? "." : "") + key)) {
return false;
if (!ChangeTracker.graphEqual(av, bv, path + (path ? '.' : '') + key)) {
return false
}
}
return true;
return true
}
return false;
return false
}
}
const globalTracker = new ChangeTracker({} as ComfyWorkflow);
const globalTracker = new ChangeTracker({} as ComfyWorkflow)

View File

@@ -1,4 +1,4 @@
import type { ComfyWorkflowJSON } from "@/types/comfyWorkflow";
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
export const defaultGraph: ComfyWorkflowJSON = {
last_node_id: 9,
@@ -6,132 +6,132 @@ export const defaultGraph: ComfyWorkflowJSON = {
nodes: [
{
id: 7,
type: "CLIPTextEncode",
type: 'CLIPTextEncode',
pos: [413, 389],
size: [425.27801513671875, 180.6060791015625],
flags: {},
order: 3,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 5 }],
inputs: [{ name: 'clip', type: 'CLIP', link: 5 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [6],
slot_index: 0,
},
slot_index: 0
}
],
properties: {},
widgets_values: ["text, watermark"],
widgets_values: ['text, watermark']
},
{
id: 6,
type: "CLIPTextEncode",
type: 'CLIPTextEncode',
pos: [415, 186],
size: [422.84503173828125, 164.31304931640625],
flags: {},
order: 2,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 3 }],
inputs: [{ name: 'clip', type: 'CLIP', link: 3 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [4],
slot_index: 0,
},
slot_index: 0
}
],
properties: {},
widgets_values: [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
],
'beautiful scenery nature glass bottle landscape, , purple galaxy bottle,'
]
},
{
id: 5,
type: "EmptyLatentImage",
type: 'EmptyLatentImage',
pos: [473, 609],
size: [315, 106],
flags: {},
order: 1,
mode: 0,
outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [2], slot_index: 0 }],
properties: {},
widgets_values: [512, 512, 1],
widgets_values: [512, 512, 1]
},
{
id: 3,
type: "KSampler",
type: 'KSampler',
pos: [863, 186],
size: [315, 262],
flags: {},
order: 4,
mode: 0,
inputs: [
{ name: "model", type: "MODEL", link: 1 },
{ name: "positive", type: "CONDITIONING", link: 4 },
{ name: "negative", type: "CONDITIONING", link: 6 },
{ name: "latent_image", type: "LATENT", link: 2 },
{ name: 'model', type: 'MODEL', link: 1 },
{ name: 'positive', type: 'CONDITIONING', link: 4 },
{ name: 'negative', type: 'CONDITIONING', link: 6 },
{ name: 'latent_image', type: 'LATENT', link: 2 }
],
outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [7], slot_index: 0 }],
properties: {},
widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
widgets_values: [156680208700286, true, 20, 8, 'euler', 'normal', 1]
},
{
id: 8,
type: "VAEDecode",
type: 'VAEDecode',
pos: [1209, 188],
size: [210, 46],
flags: {},
order: 5,
mode: 0,
inputs: [
{ name: "samples", type: "LATENT", link: 7 },
{ name: "vae", type: "VAE", link: 8 },
{ name: 'samples', type: 'LATENT', link: 7 },
{ name: 'vae', type: 'VAE', link: 8 }
],
outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
properties: {},
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [9], slot_index: 0 }],
properties: {}
},
{
id: 9,
type: "SaveImage",
type: 'SaveImage',
pos: [1451, 189],
size: [210, 26],
flags: {},
order: 6,
mode: 0,
inputs: [{ name: "images", type: "IMAGE", link: 9 }],
properties: {},
inputs: [{ name: 'images', type: 'IMAGE', link: 9 }],
properties: {}
},
{
id: 4,
type: "CheckpointLoaderSimple",
type: 'CheckpointLoaderSimple',
pos: [26, 474],
size: [315, 98],
flags: {},
order: 0,
mode: 0,
outputs: [
{ name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
{ name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
{ name: "VAE", type: "VAE", links: [8], slot_index: 2 },
{ name: 'MODEL', type: 'MODEL', links: [1], slot_index: 0 },
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
],
properties: {},
widgets_values: ["v1-5-pruned-emaonly.ckpt"],
},
widgets_values: ['v1-5-pruned-emaonly.ckpt']
}
],
links: [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[1, 4, 0, 3, 0, 'MODEL'],
[2, 5, 0, 3, 3, 'LATENT'],
[3, 4, 1, 6, 0, 'CLIP'],
[4, 6, 0, 3, 1, 'CONDITIONING'],
[5, 4, 1, 7, 0, 'CLIP'],
[6, 7, 0, 3, 2, 'CONDITIONING'],
[7, 3, 0, 8, 0, 'LATENT'],
[8, 4, 2, 8, 1, 'VAE'],
[9, 8, 0, 9, 0, 'IMAGE']
],
groups: [],
config: {},
extra: {},
version: 0.4,
};
version: 0.4
}

View File

@@ -1,60 +1,60 @@
import { app, ANIM_PREVIEW_WIDGET } from "./app";
import { LGraphCanvas, LGraphNode, LiteGraph } from "@comfyorg/litegraph";
import type { Vector4 } from "@comfyorg/litegraph";
import { app, ANIM_PREVIEW_WIDGET } from './app'
import { LGraphCanvas, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type { Vector4 } from '@comfyorg/litegraph'
const SIZE = Symbol();
const SIZE = Symbol()
interface Rect {
height: number;
width: number;
x: number;
y: number;
height: number
width: number
x: number
y: number
}
export interface DOMWidget<T = HTMLElement> {
type: string;
name: string;
computedHeight?: number;
element?: T;
options: any;
value?: any;
y?: number;
callback?: (value: any) => void;
type: string
name: string
computedHeight?: number
element?: T
options: any
value?: any
y?: number
callback?: (value: any) => void
draw?: (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number,
widgetHeight: number
) => void;
onRemove?: () => void;
) => void
onRemove?: () => void
}
function intersect(a: Rect, b: Rect): Vector4 | null {
const x = Math.max(a.x, b.x);
const num1 = Math.min(a.x + a.width, b.x + b.width);
const y = Math.max(a.y, b.y);
const num2 = Math.min(a.y + a.height, b.y + b.height);
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y];
else return null;
const x = Math.max(a.x, b.x)
const num1 = Math.min(a.x + a.width, b.x + b.width)
const y = Math.max(a.y, b.y)
const num2 = Math.min(a.y + a.height, b.y + b.height)
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
else return null
}
function getClipPath(node: LGraphNode, element: HTMLElement): string {
const selectedNode: LGraphNode = Object.values(
app.canvas.selected_nodes
)[0] as LGraphNode;
)[0] as LGraphNode
if (selectedNode && selectedNode !== node) {
const elRect = element.getBoundingClientRect();
const MARGIN = 7;
const scale = app.canvas.ds.scale;
const elRect = element.getBoundingClientRect()
const MARGIN = 7
const scale = app.canvas.ds.scale
const bounding = selectedNode.getBounding();
const bounding = selectedNode.getBounding()
const intersection = intersect(
{
x: elRect.x / scale,
y: elRect.y / scale,
width: elRect.width / scale,
height: elRect.height / scale,
height: elRect.height / scale
},
{
x: selectedNode.pos[0] + app.canvas.ds.offset[0] - MARGIN,
@@ -64,197 +64,197 @@ function getClipPath(node: LGraphNode, element: HTMLElement): string {
LiteGraph.NODE_TITLE_HEIGHT -
MARGIN,
width: bounding[2] + MARGIN + MARGIN,
height: bounding[3] + MARGIN + MARGIN,
height: bounding[3] + MARGIN + MARGIN
}
);
)
if (!intersection) {
return "";
return ''
}
const widgetRect = element.getBoundingClientRect();
const clipX = elRect.left + intersection[0] - widgetRect.x / scale + "px";
const clipY = elRect.top + intersection[1] - widgetRect.y / scale + "px";
const clipWidth = intersection[2] + "px";
const clipHeight = intersection[3] + "px";
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`;
return path;
const widgetRect = element.getBoundingClientRect()
const clipX = elRect.left + intersection[0] - widgetRect.x / scale + 'px'
const clipY = elRect.top + intersection[1] - widgetRect.y / scale + 'px'
const clipWidth = intersection[2] + 'px'
const clipHeight = intersection[3] + 'px'
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
return path
}
return "";
return ''
}
function computeSize(size: [number, number]): void {
if (this.widgets?.[0]?.last_y == null) return;
if (this.widgets?.[0]?.last_y == null) return
let y = this.widgets[0].last_y;
let freeSpace = size[1] - y;
let y = this.widgets[0].last_y
let freeSpace = size[1] - y
let widgetHeight = 0;
let dom = [];
let widgetHeight = 0
let dom = []
for (const w of this.widgets) {
if (w.type === "converted-widget") {
if (w.type === 'converted-widget') {
// Ignore
delete w.computedHeight;
delete w.computedHeight
} else if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
widgetHeight += w.computeSize()[1] + 4
} else if (w.element) {
// Extract DOM widget size info
const styles = getComputedStyle(w.element);
const styles = getComputedStyle(w.element)
let minHeight =
w.options.getMinHeight?.() ??
parseInt(styles.getPropertyValue("--comfy-widget-min-height"));
parseInt(styles.getPropertyValue('--comfy-widget-min-height'))
let maxHeight =
w.options.getMaxHeight?.() ??
parseInt(styles.getPropertyValue("--comfy-widget-max-height"));
parseInt(styles.getPropertyValue('--comfy-widget-max-height'))
let prefHeight =
w.options.getHeight?.() ??
styles.getPropertyValue("--comfy-widget-height");
if (prefHeight.endsWith?.("%")) {
styles.getPropertyValue('--comfy-widget-height')
if (prefHeight.endsWith?.('%')) {
prefHeight =
size[1] *
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100);
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100)
} else {
prefHeight = parseInt(prefHeight);
prefHeight = parseInt(prefHeight)
if (isNaN(minHeight)) {
minHeight = prefHeight;
minHeight = prefHeight
}
}
if (isNaN(minHeight)) {
minHeight = 50;
minHeight = 50
}
if (!isNaN(maxHeight)) {
if (!isNaN(prefHeight)) {
prefHeight = Math.min(prefHeight, maxHeight);
prefHeight = Math.min(prefHeight, maxHeight)
} else {
prefHeight = maxHeight;
prefHeight = maxHeight
}
}
dom.push({
minHeight,
prefHeight,
w,
});
w
})
} else {
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4;
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4
}
}
freeSpace -= widgetHeight;
freeSpace -= widgetHeight
// Calculate sizes with all widgets at their min height
const prefGrow = []; // Nodes that want to grow to their prefd size
const canGrow = []; // Nodes that can grow to auto size
let growBy = 0;
const prefGrow = [] // Nodes that want to grow to their prefd size
const canGrow = [] // Nodes that can grow to auto size
let growBy = 0
for (const d of dom) {
freeSpace -= d.minHeight;
freeSpace -= d.minHeight
if (isNaN(d.prefHeight)) {
canGrow.push(d);
d.w.computedHeight = d.minHeight;
canGrow.push(d)
d.w.computedHeight = d.minHeight
} else {
const diff = d.prefHeight - d.minHeight;
const diff = d.prefHeight - d.minHeight
if (diff > 0) {
prefGrow.push(d);
growBy += diff;
d.diff = diff;
prefGrow.push(d)
growBy += diff
d.diff = diff
} else {
d.w.computedHeight = d.minHeight;
d.w.computedHeight = d.minHeight
}
}
}
if (this.imgs && !this.widgets.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
// Allocate space for image
freeSpace -= 220;
freeSpace -= 220
}
this.freeWidgetSpace = freeSpace;
this.freeWidgetSpace = freeSpace
if (freeSpace < 0) {
// Not enough space for all widgets so we need to grow
size[1] -= freeSpace;
this.graph.setDirtyCanvas(true);
size[1] -= freeSpace
this.graph.setDirtyCanvas(true)
} else {
// Share the space between each
const growDiff = freeSpace - growBy;
const growDiff = freeSpace - growBy
if (growDiff > 0) {
// All pref sizes can be fulfilled
freeSpace = growDiff;
freeSpace = growDiff
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight;
d.w.computedHeight = d.prefHeight
}
} else {
// We need to grow evenly
const shared = -growDiff / prefGrow.length;
const shared = -growDiff / prefGrow.length
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight - shared;
d.w.computedHeight = d.prefHeight - shared
}
freeSpace = 0;
freeSpace = 0
}
if (freeSpace > 0 && canGrow.length) {
// Grow any that are auto height
const shared = freeSpace / canGrow.length;
const shared = freeSpace / canGrow.length
for (const d of canGrow) {
d.w.computedHeight += shared;
d.w.computedHeight += shared
}
}
}
// Position each of the widgets
for (const w of this.widgets) {
w.y = y;
w.y = y
if (w.computedHeight) {
y += w.computedHeight;
y += w.computedHeight
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
y += w.computeSize()[1] + 4
} else {
y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
y += LiteGraph.NODE_WIDGET_HEIGHT + 4
}
}
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set();
const elementWidgets = new Set()
//@ts-ignore
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes;
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
//@ts-ignore
LGraphCanvas.prototype.computeVisibleNodes = function (): LGraphNode[] {
const visibleNodes = computeVisibleNodes.apply(this, arguments);
const visibleNodes = computeVisibleNodes.apply(this, arguments)
// @ts-ignore
for (const node of app.graph._nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1;
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets) {
// @ts-ignore
if (w.element) {
// @ts-ignore
w.element.hidden = hidden;
w.element.hidden = hidden
// @ts-ignore
w.element.style.display = hidden ? "none" : undefined;
w.element.style.display = hidden ? 'none' : undefined
if (hidden) {
w.options.onHide?.(w);
w.options.onHide?.(w)
}
}
}
}
}
return visibleNodes;
};
return visibleNodes
}
let enableDomClipping = true;
let enableDomClipping = true
export function addDomClippingSetting(): void {
app.ui.settings.addSetting({
id: "Comfy.DOMClippingEnabled",
name: "Enable DOM element clipping (enabling may reduce performance)",
type: "boolean",
id: 'Comfy.DOMClippingEnabled',
name: 'Enable DOM element clipping (enabling may reduce performance)',
type: 'boolean',
defaultValue: enableDomClipping,
onChange(value) {
enableDomClipping = !!value;
},
});
enableDomClipping = !!value
}
})
}
//@ts-ignore
@@ -264,33 +264,33 @@ LGraphNode.prototype.addDOMWidget = function (
element: HTMLElement,
options: Record<string, any>
): DOMWidget {
options = { hideOnZoom: true, selectOn: ["focus", "click"], ...options };
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
if (!element.parentElement) {
document.body.append(element);
document.body.append(element)
}
element.hidden = true;
element.style.display = "none";
element.hidden = true
element.style.display = 'none'
let mouseDownHandler;
let mouseDownHandler
if (element.blur) {
mouseDownHandler = (event) => {
if (!element.contains(event.target)) {
element.blur();
element.blur()
}
};
document.addEventListener("mousedown", mouseDownHandler);
}
document.addEventListener('mousedown', mouseDownHandler)
}
const widget: DOMWidget = {
type,
name,
get value() {
return options.getValue?.() ?? undefined;
return options.getValue?.() ?? undefined
},
set value(v) {
options.setValue?.(v);
widget.callback?.(widget.value);
options.setValue?.(v)
widget.callback?.(widget.value)
},
draw: function (
ctx: CanvasRenderingContext2D,
@@ -300,99 +300,99 @@ LGraphNode.prototype.addDOMWidget = function (
widgetHeight: number
) {
if (widget.computedHeight == null) {
computeSize.call(node, node.size);
computeSize.call(node, node.size)
}
const hidden =
node.flags?.collapsed ||
(!!options.hideOnZoom && app.canvas.ds.scale < 0.5) ||
widget.computedHeight <= 0 ||
widget.type === "converted-widget" ||
widget.type === "hidden";
element.hidden = hidden;
element.style.display = hidden ? "none" : null;
widget.type === 'converted-widget' ||
widget.type === 'hidden'
element.hidden = hidden
element.style.display = hidden ? 'none' : null
if (hidden) {
widget.options.onHide?.(widget);
return;
widget.options.onHide?.(widget)
return
}
const margin = 10;
const elRect = ctx.canvas.getBoundingClientRect();
const margin = 10
const elRect = ctx.canvas.getBoundingClientRect()
const transform = new DOMMatrix()
.scaleSelf(
elRect.width / ctx.canvas.width,
elRect.height / ctx.canvas.height
)
.multiplySelf(ctx.getTransform())
.translateSelf(margin, margin + y);
.translateSelf(margin, margin + y)
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d)
Object.assign(element.style, {
transformOrigin: "0 0",
transformOrigin: '0 0',
transform: scale,
left: `${transform.a + transform.e + elRect.left}px`,
top: `${transform.d + transform.f + elRect.top}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
position: "absolute",
position: 'absolute',
// @ts-ignore
zIndex: app.graph._nodes.indexOf(node),
});
zIndex: app.graph._nodes.indexOf(node)
})
if (enableDomClipping) {
element.style.clipPath = getClipPath(node, element);
element.style.willChange = "clip-path";
element.style.clipPath = getClipPath(node, element)
element.style.willChange = 'clip-path'
}
this.options.onDraw?.(widget);
this.options.onDraw?.(widget)
},
element,
options,
onRemove() {
if (mouseDownHandler) {
document.removeEventListener("mousedown", mouseDownHandler);
document.removeEventListener('mousedown', mouseDownHandler)
}
element.remove()
}
}
element.remove();
},
};
for (const evt of options.selectOn) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this);
app.canvas.bringToFront(this);
});
app.canvas.selectNode(this)
app.canvas.bringToFront(this)
})
}
this.addCustomWidget(widget);
elementWidgets.add(this);
this.addCustomWidget(widget)
elementWidgets.add(this)
const collapse = this.collapse;
const collapse = this.collapse
this.collapse = function () {
collapse.apply(this, arguments);
collapse.apply(this, arguments)
if (this.flags?.collapsed) {
element.hidden = true;
element.style.display = "none";
element.hidden = true
element.style.display = 'none'
}
}
};
const onRemoved = this.onRemoved;
const onRemoved = this.onRemoved
this.onRemoved = function () {
element.remove();
elementWidgets.delete(this);
onRemoved?.apply(this, arguments);
};
element.remove()
elementWidgets.delete(this)
onRemoved?.apply(this, arguments)
}
if (!this[SIZE]) {
this[SIZE] = true;
const onResize = this.onResize;
this[SIZE] = true
const onResize = this.onResize
this.onResize = function (size) {
options.beforeResize?.call(widget, this);
computeSize.call(this, size);
onResize?.apply(this, arguments);
options.afterResize?.call(widget, this);
};
options.beforeResize?.call(widget, this)
computeSize.call(this, size)
onResize?.apply(this, arguments)
options.afterResize?.call(widget, this)
}
}
return widget;
};
return widget
}

View File

@@ -1,43 +1,43 @@
import { useWorkspaceStore } from "@/stores/workspaceStateStore";
import { ExtensionManager, SidebarTabExtension } from "@/types/extensionTypes";
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { ExtensionManager, SidebarTabExtension } from '@/types/extensionTypes'
export class ExtensionManagerImpl implements ExtensionManager {
private sidebarTabs: SidebarTabExtension[] = [];
private workspaceStore = useWorkspaceStore();
private sidebarTabs: SidebarTabExtension[] = []
private workspaceStore = useWorkspaceStore()
registerSidebarTab(tab: SidebarTabExtension) {
this.sidebarTabs.push(tab);
this.updateSidebarOrder();
this.sidebarTabs.push(tab)
this.updateSidebarOrder()
}
unregisterSidebarTab(id: string) {
const index = this.sidebarTabs.findIndex((tab) => tab.id === id);
const index = this.sidebarTabs.findIndex((tab) => tab.id === id)
if (index !== -1) {
const tab = this.sidebarTabs[index];
if (tab.type === "custom" && tab.destroy) {
tab.destroy();
const tab = this.sidebarTabs[index]
if (tab.type === 'custom' && tab.destroy) {
tab.destroy()
}
this.sidebarTabs.splice(index, 1);
this.updateSidebarOrder();
this.sidebarTabs.splice(index, 1)
this.updateSidebarOrder()
}
}
getSidebarTabs() {
return this.sidebarTabs.sort((a, b) => {
const orderA = this.workspaceStore.sidebarTabsOrder.indexOf(a.id);
const orderB = this.workspaceStore.sidebarTabsOrder.indexOf(b.id);
return orderA - orderB;
});
const orderA = this.workspaceStore.sidebarTabsOrder.indexOf(a.id)
const orderB = this.workspaceStore.sidebarTabsOrder.indexOf(b.id)
return orderA - orderB
})
}
private updateSidebarOrder() {
const currentOrder = this.workspaceStore.sidebarTabsOrder;
const currentOrder = this.workspaceStore.sidebarTabsOrder
const newTabs = this.sidebarTabs.filter(
(tab) => !currentOrder.includes(tab.id)
);
)
this.workspaceStore.updateSidebarOrder([
...currentOrder,
...newTabs.map((tab) => tab.id),
]);
...newTabs.map((tab) => tab.id)
])
}
}

View File

@@ -1,8 +1,8 @@
import { $el, ComfyDialog } from "./ui";
import { api } from "./api";
import type { ComfyApp } from "./app";
import { $el, ComfyDialog } from './ui'
import { api } from './api'
import type { ComfyApp } from './app'
$el("style", {
$el('style', {
textContent: `
.comfy-logging-logs {
display: grid;
@@ -23,17 +23,17 @@ $el("style", {
padding: 5px;
}
`,
parent: document.body,
});
parent: document.body
})
// Stringify function supporting max depth and removal of circular references
// https://stackoverflow.com/a/57193345
function stringify(val, depth, replacer, space, onGetObjID?) {
depth = isNaN(+depth) ? 1 : depth;
var recursMap = new WeakMap();
depth = isNaN(+depth) ? 1 : depth
var recursMap = new WeakMap()
function _build(val, depth, o?, a?, r?) {
// (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
return !val || typeof val != "object"
return !val || typeof val != 'object'
? val
: ((r = recursMap.has(val)),
recursMap.set(val, true),
@@ -42,201 +42,201 @@ function stringify(val, depth, replacer, space, onGetObjID?) {
? (o = (onGetObjID && onGetObjID(val)) || null)
: JSON.stringify(val, function (k, v) {
if (a || depth > 0) {
if (replacer) v = replacer(k, v);
if (!k) return (a = Array.isArray(v)), (val = v);
!o && (o = a ? [] : {});
o[k] = _build(v, a ? depth : depth - 1);
if (replacer) v = replacer(k, v)
if (!k) return (a = Array.isArray(v)), (val = v)
!o && (o = a ? [] : {})
o[k] = _build(v, a ? depth : depth - 1)
}
}),
o === void 0 ? (a ? [] : {}) : o);
o === void 0 ? (a ? [] : {}) : o)
}
return JSON.stringify(_build(val, depth), null, space);
return JSON.stringify(_build(val, depth), null, space)
}
const jsonReplacer = (k, v, ui) => {
if (v instanceof Array && v.length === 1) {
v = v[0];
v = v[0]
}
if (v instanceof Date) {
v = v.toISOString();
v = v.toISOString()
if (ui) {
v = v.split("T")[1];
v = v.split('T')[1]
}
}
if (v instanceof Error) {
let err = "";
if (v.name) err += v.name + "\n";
if (v.message) err += v.message + "\n";
if (v.stack) err += v.stack + "\n";
let err = ''
if (v.name) err += v.name + '\n'
if (v.message) err += v.message + '\n'
if (v.stack) err += v.stack + '\n'
if (!err) {
err = v.toString();
err = v.toString()
}
v = err;
v = err
}
return v;
};
return v
}
const fileInput: HTMLInputElement = $el("input", {
type: "file",
accept: ".json",
style: { display: "none" },
parent: document.body,
}) as HTMLInputElement;
const fileInput: HTMLInputElement = $el('input', {
type: 'file',
accept: '.json',
style: { display: 'none' },
parent: document.body
}) as HTMLInputElement
class ComfyLoggingDialog extends ComfyDialog {
logging: any;
logging: any
constructor(logging) {
super();
this.logging = logging;
super()
this.logging = logging
}
clear() {
this.logging.clear();
this.show();
this.logging.clear()
this.show()
}
export() {
const blob = new Blob(
[stringify([...this.logging.entries], 20, jsonReplacer, "\t")],
[stringify([...this.logging.entries], 20, jsonReplacer, '\t')],
{
type: "application/json",
type: 'application/json'
}
);
const url = URL.createObjectURL(blob);
const a = $el("a", {
)
const url = URL.createObjectURL(blob)
const a = $el('a', {
href: url,
download: `comfyui-logs-${Date.now()}.json`,
style: { display: "none" },
parent: document.body,
});
a.click();
style: { display: 'none' },
parent: document.body
})
a.click()
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
a.remove()
window.URL.revokeObjectURL(url)
}, 0)
}
import() {
fileInput.onchange = () => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = () => {
fileInput.remove();
fileInput.remove()
try {
const obj = JSON.parse(reader.result as string);
const obj = JSON.parse(reader.result as string)
if (obj instanceof Array) {
this.show(obj);
this.show(obj)
} else {
throw new Error("Invalid file selected.");
throw new Error('Invalid file selected.')
}
} catch (error) {
alert("Unable to load logs: " + error.message);
alert('Unable to load logs: ' + error.message)
}
};
reader.readAsText(fileInput.files[0]);
};
fileInput.click();
}
reader.readAsText(fileInput.files[0])
}
fileInput.click()
}
createButtons() {
return [
$el("button", {
type: "button",
textContent: "Clear",
onclick: () => this.clear(),
$el('button', {
type: 'button',
textContent: 'Clear',
onclick: () => this.clear()
}),
$el("button", {
type: "button",
textContent: "Export logs...",
onclick: () => this.export(),
$el('button', {
type: 'button',
textContent: 'Export logs...',
onclick: () => this.export()
}),
$el("button", {
type: "button",
textContent: "View exported logs...",
onclick: () => this.import(),
$el('button', {
type: 'button',
textContent: 'View exported logs...',
onclick: () => this.import()
}),
...super.createButtons(),
];
...super.createButtons()
]
}
getTypeColor(type) {
switch (type) {
case "error":
return "red";
case "warn":
return "orange";
case "debug":
return "dodgerblue";
case 'error':
return 'red'
case 'warn':
return 'orange'
case 'debug':
return 'dodgerblue'
}
}
show(entries?: any[]) {
if (!entries) entries = this.logging.entries;
this.element.style.width = "100%";
if (!entries) entries = this.logging.entries
this.element.style.width = '100%'
const cols = {
source: "Source",
type: "Type",
timestamp: "Timestamp",
message: "Message",
};
const keys = Object.keys(cols);
source: 'Source',
type: 'Type',
timestamp: 'Timestamp',
message: 'Message'
}
const keys = Object.keys(cols)
const headers = Object.values(cols).map((title) =>
$el("div.comfy-logging-title", {
textContent: title,
$el('div.comfy-logging-title', {
textContent: title
})
);
)
const rows = entries.map((entry, i) => {
return $el(
"div.comfy-logging-log",
'div.comfy-logging-log',
{
$: (el) =>
el.style.setProperty(
"--row-bg",
`var(--tr-${i % 2 ? "even" : "odd"}-bg-color)`
),
'--row-bg',
`var(--tr-${i % 2 ? 'even' : 'odd'}-bg-color)`
)
},
keys.map((key) => {
let v = entry[key];
let color;
if (key === "type") {
color = this.getTypeColor(v);
let v = entry[key]
let color
if (key === 'type') {
color = this.getTypeColor(v)
} else {
v = jsonReplacer(key, v, true);
v = jsonReplacer(key, v, true)
if (typeof v === "object") {
v = stringify(v, 5, jsonReplacer, " ");
if (typeof v === 'object') {
v = stringify(v, 5, jsonReplacer, ' ')
}
}
return $el("div", {
return $el('div', {
style: {
color,
color
},
textContent: v,
});
textContent: v
})
})
)
})
);
});
const grid = $el(
"div.comfy-logging-logs",
'div.comfy-logging-logs',
{
style: {
gridTemplateColumns: `repeat(${headers.length}, 1fr)`,
},
gridTemplateColumns: `repeat(${headers.length}, 1fr)`
}
},
[...headers, ...rows]
);
const els = [grid];
)
const els = [grid]
if (!this.logging.enabled) {
els.unshift(
$el("h3", {
style: { textAlign: "center" },
textContent: "Logging is disabled",
$el('h3', {
style: { textAlign: 'center' },
textContent: 'Logging is disabled'
})
);
)
}
super.show($el("div", els));
super.show($el('div', els))
}
}
@@ -244,118 +244,118 @@ export class ComfyLogging {
/**
* @type Array<{ source: string, type: string, timestamp: Date, message: any }>
*/
entries = [];
entries = []
#enabled;
#console = {};
#enabled
#console = {}
app: ComfyApp;
dialog: ComfyLoggingDialog;
app: ComfyApp
dialog: ComfyLoggingDialog
get enabled() {
return this.#enabled;
return this.#enabled
}
set enabled(value) {
if (value === this.#enabled) return;
if (value === this.#enabled) return
if (value) {
this.patchConsole();
this.patchConsole()
} else {
this.unpatchConsole();
this.unpatchConsole()
}
this.#enabled = value;
this.#enabled = value
}
constructor(app) {
this.app = app;
this.app = app
this.dialog = new ComfyLoggingDialog(this);
this.addSetting();
this.catchUnhandled();
this.addInitData();
this.dialog = new ComfyLoggingDialog(this)
this.addSetting()
this.catchUnhandled()
this.addInitData()
}
addSetting() {
const settingId: string = "Comfy.Logging.Enabled";
const htmlSettingId = settingId.replaceAll(".", "-");
const settingId: string = 'Comfy.Logging.Enabled'
const htmlSettingId = settingId.replaceAll('.', '-')
const setting = this.app.ui.settings.addSetting({
id: settingId,
name: settingId,
defaultValue: true,
onChange: (value) => {
this.enabled = value;
this.enabled = value
},
type: (name, setter, value) => {
return $el("tr", [
$el("td", [
$el("label", {
textContent: "Logging",
for: htmlSettingId,
}),
return $el('tr', [
$el('td', [
$el('label', {
textContent: 'Logging',
for: htmlSettingId
})
]),
$el("td", [
$el("input", {
$el('td', [
$el('input', {
id: htmlSettingId,
type: "checkbox",
type: 'checkbox',
checked: value,
onchange: (event) => {
setter(event.target.checked);
},
setter(event.target.checked)
}
}),
$el("button", {
textContent: "View Logs",
$el('button', {
textContent: 'View Logs',
onclick: () => {
this.app.ui.settings.element.close();
this.dialog.show();
this.app.ui.settings.element.close()
this.dialog.show()
},
style: {
fontSize: "14px",
display: "block",
marginTop: "5px",
},
}),
]),
]);
},
});
this.enabled = setting.value;
fontSize: '14px',
display: 'block',
marginTop: '5px'
}
})
])
])
}
})
this.enabled = setting.value
}
patchConsole() {
// Capture common console outputs
const self = this;
for (const type of ["log", "warn", "error", "debug"]) {
const orig = console[type];
this.#console[type] = orig;
const self = this
for (const type of ['log', 'warn', 'error', 'debug']) {
const orig = console[type]
this.#console[type] = orig
console[type] = function () {
orig.apply(console, arguments);
self.addEntry("console", type, ...arguments);
};
orig.apply(console, arguments)
self.addEntry('console', type, ...arguments)
}
}
}
unpatchConsole() {
// Restore original console functions
for (const type of Object.keys(this.#console)) {
console[type] = this.#console[type];
console[type] = this.#console[type]
}
this.#console = {};
this.#console = {}
}
catchUnhandled() {
// Capture uncaught errors
window.addEventListener("error", (e) => {
this.addEntry("window", "error", e.error ?? "Unknown error");
return false;
});
window.addEventListener('error', (e) => {
this.addEntry('window', 'error', e.error ?? 'Unknown error')
return false
})
window.addEventListener("unhandledrejection", (e) => {
this.addEntry("unhandledrejection", "error", e.reason ?? "Unknown error");
});
window.addEventListener('unhandledrejection', (e) => {
this.addEntry('unhandledrejection', 'error', e.reason ?? 'Unknown error')
})
}
clear() {
this.entries = [];
this.entries = []
}
addEntry(source, type, ...args) {
@@ -364,20 +364,20 @@ export class ComfyLogging {
source,
type,
timestamp: new Date(),
message: args,
});
message: args
})
}
}
log(source, ...args) {
this.addEntry(source, "log", ...args);
this.addEntry(source, 'log', ...args)
}
async addInitData() {
if (!this.enabled) return;
const source = "ComfyUI.Logging";
this.addEntry(source, "debug", { UserAgent: navigator.userAgent });
const systemStats = await api.getSystemStats();
this.addEntry(source, "debug", systemStats);
if (!this.enabled) return
const source = 'ComfyUI.Logging'
this.addEntry(source, 'debug', { UserAgent: navigator.userAgent })
const systemStats = await api.getSystemStats()
this.addEntry(source, 'debug', systemStats)
}
}

View File

@@ -1,76 +1,76 @@
export function getFromFlacBuffer(buffer: ArrayBuffer): Record<string, string> {
const dataView = new DataView(buffer);
const dataView = new DataView(buffer)
// Verify the FLAC signature
const signature = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
if (signature !== "fLaC") {
console.error("Not a valid FLAC file");
return;
const signature = String.fromCharCode(...new Uint8Array(buffer, 0, 4))
if (signature !== 'fLaC') {
console.error('Not a valid FLAC file')
return
}
// Parse metadata blocks
let offset = 4;
let vorbisComment = null;
let offset = 4
let vorbisComment = null
while (offset < dataView.byteLength) {
const isLastBlock = dataView.getUint8(offset) & 0x80;
const blockType = dataView.getUint8(offset) & 0x7f;
const blockSize = dataView.getUint32(offset, false) & 0xffffff;
offset += 4;
const isLastBlock = dataView.getUint8(offset) & 0x80
const blockType = dataView.getUint8(offset) & 0x7f
const blockSize = dataView.getUint32(offset, false) & 0xffffff
offset += 4
if (blockType === 4) {
// Vorbis Comment block type
vorbisComment = parseVorbisComment(
new DataView(buffer, offset, blockSize)
);
)
}
offset += blockSize;
if (isLastBlock) break;
offset += blockSize
if (isLastBlock) break
}
return vorbisComment;
return vorbisComment
}
export function getFromFlacFile(file: File): Promise<Record<string, string>> {
return new Promise((r) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = function (event) {
const arrayBuffer = event.target.result as ArrayBuffer;
r(getFromFlacBuffer(arrayBuffer));
};
reader.readAsArrayBuffer(file);
});
const arrayBuffer = event.target.result as ArrayBuffer
r(getFromFlacBuffer(arrayBuffer))
}
reader.readAsArrayBuffer(file)
})
}
// Function to parse the Vorbis Comment block
function parseVorbisComment(dataView: DataView): Record<string, string> {
let offset = 0;
const vendorLength = dataView.getUint32(offset, true);
offset += 4;
const vendorString = getString(dataView, offset, vendorLength);
offset += vendorLength;
let offset = 0
const vendorLength = dataView.getUint32(offset, true)
offset += 4
const vendorString = getString(dataView, offset, vendorLength)
offset += vendorLength
const userCommentListLength = dataView.getUint32(offset, true);
offset += 4;
const comments = {};
const userCommentListLength = dataView.getUint32(offset, true)
offset += 4
const comments = {}
for (let i = 0; i < userCommentListLength; i++) {
const commentLength = dataView.getUint32(offset, true);
offset += 4;
const comment = getString(dataView, offset, commentLength);
offset += commentLength;
const commentLength = dataView.getUint32(offset, true)
offset += 4
const comment = getString(dataView, offset, commentLength)
offset += commentLength
const [key, value] = comment.split("=");
const [key, value] = comment.split('=')
comments[key] = value;
comments[key] = value
}
return comments;
return comments
}
function getString(dataView: DataView, offset: number, length: number): string {
let string = "";
let string = ''
for (let i = 0; i < length; i++) {
string += String.fromCharCode(dataView.getUint8(offset + i));
string += String.fromCharCode(dataView.getUint8(offset + i))
}
return string;
return string
}

View File

@@ -1,53 +1,53 @@
export function getFromPngBuffer(buffer: ArrayBuffer) {
// Get the PNG data as a Uint8Array
const pngData = new Uint8Array(buffer);
const dataView = new DataView(pngData.buffer);
const pngData = new Uint8Array(buffer)
const dataView = new DataView(pngData.buffer)
// Check that the PNG signature is present
if (dataView.getUint32(0) !== 0x89504e47) {
console.error("Not a valid PNG file");
return;
console.error('Not a valid PNG file')
return
}
// Start searching for chunks after the PNG signature
let offset = 8;
let txt_chunks: Record<string, string> = {};
let offset = 8
let txt_chunks: Record<string, string> = {}
// Loop through the chunks in the PNG file
while (offset < pngData.length) {
// Get the length of the chunk
const length = dataView.getUint32(offset);
const length = dataView.getUint32(offset)
// Get the chunk type
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8));
if (type === "tEXt" || type == "comf" || type === "iTXt") {
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8))
if (type === 'tEXt' || type == 'comf' || type === 'iTXt') {
// Get the keyword
let keyword_end = offset + 8;
let keyword_end = offset + 8
while (pngData[keyword_end] !== 0) {
keyword_end++;
keyword_end++
}
const keyword = String.fromCharCode(
...pngData.slice(offset + 8, keyword_end)
);
)
// Get the text
const contentArraySegment = pngData.slice(
keyword_end + 1,
offset + 8 + length
);
const contentJson = new TextDecoder("utf-8").decode(contentArraySegment);
txt_chunks[keyword] = contentJson;
)
const contentJson = new TextDecoder('utf-8').decode(contentArraySegment)
txt_chunks[keyword] = contentJson
}
offset += 12 + length;
offset += 12 + length
}
return txt_chunks;
return txt_chunks
}
export function getFromPngFile(file: File) {
return new Promise<Record<string, string>>((r) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (event) => {
r(getFromPngBuffer(event.target.result as ArrayBuffer));
};
r(getFromPngBuffer(event.target.result as ArrayBuffer))
}
reader.readAsArrayBuffer(file);
});
reader.readAsArrayBuffer(file)
})
}

View File

@@ -1,438 +1,436 @@
import { LiteGraph } from "@comfyorg/litegraph";
import { api } from "./api";
import { getFromPngFile } from "./metadata/png";
import { getFromFlacFile } from "./metadata/flac";
import { LiteGraph } from '@comfyorg/litegraph'
import { api } from './api'
import { getFromPngFile } from './metadata/png'
import { getFromFlacFile } from './metadata/flac'
// Original functions left in for backwards compatibility
export function getPngMetadata(file: File): Promise<Record<string, string>> {
return getFromPngFile(file);
return getFromPngFile(file)
}
export function getFlacMetadata(file: File): Promise<Record<string, string>> {
return getFromFlacFile(file);
return getFromFlacFile(file)
}
function parseExifData(exifData) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === "II";
const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === 'II'
// Function to read 16-bit and 32-bit integers from binary data
function readInt(offset, isLittleEndian, length) {
let arr = exifData.slice(offset, offset + length);
let arr = exifData.slice(offset, offset + length)
if (length === 2) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(
0,
isLittleEndian
);
)
} else if (length === 4) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(
0,
isLittleEndian
);
)
}
}
// Read the offset to the first IFD (Image File Directory)
const ifdOffset = readInt(4, isLittleEndian, 4);
const ifdOffset = readInt(4, isLittleEndian, 4)
function parseIFD(offset) {
const numEntries = readInt(offset, isLittleEndian, 2);
const result = {};
const numEntries = readInt(offset, isLittleEndian, 2)
const result = {}
for (let i = 0; i < numEntries; i++) {
const entryOffset = offset + 2 + i * 12;
const tag = readInt(entryOffset, isLittleEndian, 2);
const type = readInt(entryOffset + 2, isLittleEndian, 2);
const numValues = readInt(entryOffset + 4, isLittleEndian, 4);
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4);
const entryOffset = offset + 2 + i * 12
const tag = readInt(entryOffset, isLittleEndian, 2)
const type = readInt(entryOffset + 2, isLittleEndian, 2)
const numValues = readInt(entryOffset + 4, isLittleEndian, 4)
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4)
// Read the value(s) based on the data type
let value;
let value
if (type === 2) {
// ASCII string
value = String.fromCharCode(
...exifData.slice(valueOffset, valueOffset + numValues - 1)
);
)
}
result[tag] = value;
result[tag] = value
}
return result;
return result
}
// Parse the first IFD
const ifdData = parseIFD(ifdOffset);
return ifdData;
const ifdData = parseIFD(ifdOffset)
return ifdData
}
function splitValues(input) {
var output = {};
var output = {}
for (var key in input) {
var value = input[key];
var splitValues = value.split(":", 2);
output[splitValues[0]] = splitValues[1];
var value = input[key]
var splitValues = value.split(':', 2)
output[splitValues[0]] = splitValues[1]
}
return output;
return output
}
export function getWebpMetadata(file) {
return new Promise<Record<string, string>>((r) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (event) => {
const webp = new Uint8Array(event.target.result as ArrayBuffer);
const dataView = new DataView(webp.buffer);
const webp = new Uint8Array(event.target.result as ArrayBuffer)
const dataView = new DataView(webp.buffer)
// Check that the WEBP signature is present
if (
dataView.getUint32(0) !== 0x52494646 ||
dataView.getUint32(8) !== 0x57454250
) {
console.error("Not a valid WEBP file");
r({});
return;
console.error('Not a valid WEBP file')
r({})
return
}
// Start searching for chunks after the WEBP signature
let offset = 12;
let txt_chunks = {};
let offset = 12
let txt_chunks = {}
// Loop through the chunks in the WEBP file
while (offset < webp.length) {
const chunk_length = dataView.getUint32(offset + 4, true);
const chunk_length = dataView.getUint32(offset + 4, true)
const chunk_type = String.fromCharCode(
...webp.slice(offset, offset + 4)
);
if (chunk_type === "EXIF") {
)
if (chunk_type === 'EXIF') {
if (
String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) ==
"Exif\0\0"
'Exif\0\0'
) {
offset += 6;
offset += 6
}
let data = parseExifData(
webp.slice(offset + 8, offset + 8 + chunk_length)
);
)
for (var key in data) {
var value = data[key] as string;
let index = value.indexOf(":");
txt_chunks[value.slice(0, index)] = value.slice(index + 1);
var value = data[key] as string
let index = value.indexOf(':')
txt_chunks[value.slice(0, index)] = value.slice(index + 1)
}
break;
break
}
offset += 8 + chunk_length;
offset += 8 + chunk_length
}
r(txt_chunks);
};
r(txt_chunks)
}
reader.readAsArrayBuffer(file);
});
reader.readAsArrayBuffer(file)
})
}
export function getLatentMetadata(file) {
return new Promise((r) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (event) => {
const safetensorsData = new Uint8Array(
event.target.result as ArrayBuffer
);
const dataView = new DataView(safetensorsData.buffer);
let header_size = dataView.getUint32(0, true);
let offset = 8;
const safetensorsData = new Uint8Array(event.target.result as ArrayBuffer)
const dataView = new DataView(safetensorsData.buffer)
let header_size = dataView.getUint32(0, true)
let offset = 8
let header = JSON.parse(
new TextDecoder().decode(
safetensorsData.slice(offset, offset + header_size)
)
);
r(header.__metadata__);
};
)
r(header.__metadata__)
}
var slice = file.slice(0, 1024 * 1024 * 4);
reader.readAsArrayBuffer(slice);
});
var slice = file.slice(0, 1024 * 1024 * 4)
reader.readAsArrayBuffer(slice)
})
}
export async function importA1111(graph, parameters) {
const p = parameters.lastIndexOf("\nSteps:");
const p = parameters.lastIndexOf('\nSteps:')
if (p > -1) {
const embeddings = await api.getEmbeddings();
const embeddings = await api.getEmbeddings()
const opts = parameters
.substr(p)
.split("\n")[1]
.split('\n')[1]
.match(
new RegExp('\\s*([^:]+:\\s*([^"\\{].*?|".*?"|\\{.*?\\}))\\s*(,|$)', "g")
new RegExp('\\s*([^:]+:\\s*([^"\\{].*?|".*?"|\\{.*?\\}))\\s*(,|$)', 'g')
)
.reduce((p, n) => {
const s = n.split(":");
if (s[1].endsWith(",")) {
s[1] = s[1].substr(0, s[1].length - 1);
const s = n.split(':')
if (s[1].endsWith(',')) {
s[1] = s[1].substr(0, s[1].length - 1)
}
p[s[0].trim().toLowerCase()] = s[1].trim();
return p;
}, {});
const p2 = parameters.lastIndexOf("\nNegative prompt:", p);
p[s[0].trim().toLowerCase()] = s[1].trim()
return p
}, {})
const p2 = parameters.lastIndexOf('\nNegative prompt:', p)
if (p2 > -1) {
let positive = parameters.substr(0, p2).trim();
let negative = parameters.substring(p2 + 18, p).trim();
let positive = parameters.substr(0, p2).trim()
let negative = parameters.substring(p2 + 18, p).trim()
const ckptNode = LiteGraph.createNode("CheckpointLoaderSimple");
const clipSkipNode = LiteGraph.createNode("CLIPSetLastLayer");
const positiveNode = LiteGraph.createNode("CLIPTextEncode");
const negativeNode = LiteGraph.createNode("CLIPTextEncode");
const samplerNode = LiteGraph.createNode("KSampler");
const imageNode = LiteGraph.createNode("EmptyLatentImage");
const vaeNode = LiteGraph.createNode("VAEDecode");
const vaeLoaderNode = LiteGraph.createNode("VAELoader");
const saveNode = LiteGraph.createNode("SaveImage");
let hrSamplerNode = null;
let hrSteps = null;
const ckptNode = LiteGraph.createNode('CheckpointLoaderSimple')
const clipSkipNode = LiteGraph.createNode('CLIPSetLastLayer')
const positiveNode = LiteGraph.createNode('CLIPTextEncode')
const negativeNode = LiteGraph.createNode('CLIPTextEncode')
const samplerNode = LiteGraph.createNode('KSampler')
const imageNode = LiteGraph.createNode('EmptyLatentImage')
const vaeNode = LiteGraph.createNode('VAEDecode')
const vaeLoaderNode = LiteGraph.createNode('VAELoader')
const saveNode = LiteGraph.createNode('SaveImage')
let hrSamplerNode = null
let hrSteps = null
const ceil64 = (v) => Math.ceil(v / 64) * 64;
const ceil64 = (v) => Math.ceil(v / 64) * 64
const getWidget = (node, name) => {
return node.widgets.find((w) => w.name === name);
};
return node.widgets.find((w) => w.name === name)
}
const setWidgetValue = (node, name, value, isOptionPrefix?) => {
const w = getWidget(node, name);
const w = getWidget(node, name)
if (isOptionPrefix) {
const o = w.options.values.find((w) => w.startsWith(value));
const o = w.options.values.find((w) => w.startsWith(value))
if (o) {
w.value = o;
w.value = o
} else {
console.warn(`Unknown value '${value}' for widget '${name}'`, node);
w.value = value;
console.warn(`Unknown value '${value}' for widget '${name}'`, node)
w.value = value
}
} else {
w.value = value;
w.value = value
}
}
};
const createLoraNodes = (clipNode, text, prevClip, prevModel) => {
const loras = [];
const loras = []
text = text.replace(/<lora:([^:]+:[^>]+)>/g, function (m, c) {
const s = c.split(":");
const weight = parseFloat(s[1]);
const s = c.split(':')
const weight = parseFloat(s[1])
if (isNaN(weight)) {
console.warn("Invalid LORA", m);
console.warn('Invalid LORA', m)
} else {
loras.push({ name: s[0], weight });
loras.push({ name: s[0], weight })
}
return "";
});
return ''
})
for (const l of loras) {
const loraNode = LiteGraph.createNode("LoraLoader");
graph.add(loraNode);
setWidgetValue(loraNode, "lora_name", l.name, true);
setWidgetValue(loraNode, "strength_model", l.weight);
setWidgetValue(loraNode, "strength_clip", l.weight);
prevModel.node.connect(prevModel.index, loraNode, 0);
prevClip.node.connect(prevClip.index, loraNode, 1);
prevModel = { node: loraNode, index: 0 };
prevClip = { node: loraNode, index: 1 };
const loraNode = LiteGraph.createNode('LoraLoader')
graph.add(loraNode)
setWidgetValue(loraNode, 'lora_name', l.name, true)
setWidgetValue(loraNode, 'strength_model', l.weight)
setWidgetValue(loraNode, 'strength_clip', l.weight)
prevModel.node.connect(prevModel.index, loraNode, 0)
prevClip.node.connect(prevClip.index, loraNode, 1)
prevModel = { node: loraNode, index: 0 }
prevClip = { node: loraNode, index: 1 }
}
prevClip.node.connect(1, clipNode, 0);
prevModel.node.connect(0, samplerNode, 0);
prevClip.node.connect(1, clipNode, 0)
prevModel.node.connect(0, samplerNode, 0)
if (hrSamplerNode) {
prevModel.node.connect(0, hrSamplerNode, 0);
prevModel.node.connect(0, hrSamplerNode, 0)
}
return { text, prevModel, prevClip };
};
return { text, prevModel, prevClip }
}
const replaceEmbeddings = (text) => {
if (!embeddings.length) return text;
if (!embeddings.length) return text
return text.replaceAll(
new RegExp(
"\\b(" +
'\\b(' +
embeddings
.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\b|\\b") +
")\\b",
"ig"
.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\b|\\b') +
')\\b',
'ig'
),
"embedding:$1"
);
};
'embedding:$1'
)
}
const popOpt = (name) => {
const v = opts[name];
delete opts[name];
return v;
};
const v = opts[name]
delete opts[name]
return v
}
graph.clear();
graph.add(ckptNode);
graph.add(clipSkipNode);
graph.add(positiveNode);
graph.add(negativeNode);
graph.add(samplerNode);
graph.add(imageNode);
graph.add(vaeNode);
graph.add(vaeLoaderNode);
graph.add(saveNode);
graph.clear()
graph.add(ckptNode)
graph.add(clipSkipNode)
graph.add(positiveNode)
graph.add(negativeNode)
graph.add(samplerNode)
graph.add(imageNode)
graph.add(vaeNode)
graph.add(vaeLoaderNode)
graph.add(saveNode)
ckptNode.connect(1, clipSkipNode, 0);
clipSkipNode.connect(0, positiveNode, 0);
clipSkipNode.connect(0, negativeNode, 0);
ckptNode.connect(0, samplerNode, 0);
positiveNode.connect(0, samplerNode, 1);
negativeNode.connect(0, samplerNode, 2);
imageNode.connect(0, samplerNode, 3);
vaeNode.connect(0, saveNode, 0);
samplerNode.connect(0, vaeNode, 0);
vaeLoaderNode.connect(0, vaeNode, 1);
ckptNode.connect(1, clipSkipNode, 0)
clipSkipNode.connect(0, positiveNode, 0)
clipSkipNode.connect(0, negativeNode, 0)
ckptNode.connect(0, samplerNode, 0)
positiveNode.connect(0, samplerNode, 1)
negativeNode.connect(0, samplerNode, 2)
imageNode.connect(0, samplerNode, 3)
vaeNode.connect(0, saveNode, 0)
samplerNode.connect(0, vaeNode, 0)
vaeLoaderNode.connect(0, vaeNode, 1)
const handlers = {
model(v) {
setWidgetValue(ckptNode, "ckpt_name", v, true);
setWidgetValue(ckptNode, 'ckpt_name', v, true)
},
vae(v) {
setWidgetValue(vaeLoaderNode, "vae_name", v, true);
setWidgetValue(vaeLoaderNode, 'vae_name', v, true)
},
"cfg scale"(v) {
setWidgetValue(samplerNode, "cfg", +v);
'cfg scale'(v) {
setWidgetValue(samplerNode, 'cfg', +v)
},
"clip skip"(v) {
setWidgetValue(clipSkipNode, "stop_at_clip_layer", -v);
'clip skip'(v) {
setWidgetValue(clipSkipNode, 'stop_at_clip_layer', -v)
},
sampler(v) {
let name = v.toLowerCase().replace("++", "pp").replaceAll(" ", "_");
if (name.includes("karras")) {
name = name.replace("karras", "").replace(/_+$/, "");
setWidgetValue(samplerNode, "scheduler", "karras");
let name = v.toLowerCase().replace('++', 'pp').replaceAll(' ', '_')
if (name.includes('karras')) {
name = name.replace('karras', '').replace(/_+$/, '')
setWidgetValue(samplerNode, 'scheduler', 'karras')
} else {
setWidgetValue(samplerNode, "scheduler", "normal");
setWidgetValue(samplerNode, 'scheduler', 'normal')
}
const w = getWidget(samplerNode, "sampler_name");
const w = getWidget(samplerNode, 'sampler_name')
const o = w.options.values.find(
(w) => w === name || w === "sample_" + name
);
(w) => w === name || w === 'sample_' + name
)
if (o) {
setWidgetValue(samplerNode, "sampler_name", o);
setWidgetValue(samplerNode, 'sampler_name', o)
}
},
size(v) {
const wxh = v.split("x");
const w = ceil64(+wxh[0]);
const h = ceil64(+wxh[1]);
const hrUp = popOpt("hires upscale");
const hrSz = popOpt("hires resize");
hrSteps = popOpt("hires steps");
let hrMethod = popOpt("hires upscaler");
const wxh = v.split('x')
const w = ceil64(+wxh[0])
const h = ceil64(+wxh[1])
const hrUp = popOpt('hires upscale')
const hrSz = popOpt('hires resize')
hrSteps = popOpt('hires steps')
let hrMethod = popOpt('hires upscaler')
setWidgetValue(imageNode, "width", w);
setWidgetValue(imageNode, "height", h);
setWidgetValue(imageNode, 'width', w)
setWidgetValue(imageNode, 'height', h)
if (hrUp || hrSz) {
let uw, uh;
let uw, uh
if (hrUp) {
uw = w * hrUp;
uh = h * hrUp;
uw = w * hrUp
uh = h * hrUp
} else {
const s = hrSz.split("x");
uw = +s[0];
uh = +s[1];
const s = hrSz.split('x')
uw = +s[0]
uh = +s[1]
}
let upscaleNode;
let latentNode;
let upscaleNode
let latentNode
if (hrMethod.startsWith("Latent")) {
latentNode = upscaleNode = LiteGraph.createNode("LatentUpscale");
graph.add(upscaleNode);
samplerNode.connect(0, upscaleNode, 0);
if (hrMethod.startsWith('Latent')) {
latentNode = upscaleNode = LiteGraph.createNode('LatentUpscale')
graph.add(upscaleNode)
samplerNode.connect(0, upscaleNode, 0)
switch (hrMethod) {
case "Latent (nearest-exact)":
hrMethod = "nearest-exact";
break;
case 'Latent (nearest-exact)':
hrMethod = 'nearest-exact'
break
}
setWidgetValue(upscaleNode, "upscale_method", hrMethod, true);
setWidgetValue(upscaleNode, 'upscale_method', hrMethod, true)
} else {
const decode = LiteGraph.createNode("VAEDecodeTiled");
graph.add(decode);
samplerNode.connect(0, decode, 0);
vaeLoaderNode.connect(0, decode, 1);
const decode = LiteGraph.createNode('VAEDecodeTiled')
graph.add(decode)
samplerNode.connect(0, decode, 0)
vaeLoaderNode.connect(0, decode, 1)
const upscaleLoaderNode =
LiteGraph.createNode("UpscaleModelLoader");
graph.add(upscaleLoaderNode);
setWidgetValue(upscaleLoaderNode, "model_name", hrMethod, true);
LiteGraph.createNode('UpscaleModelLoader')
graph.add(upscaleLoaderNode)
setWidgetValue(upscaleLoaderNode, 'model_name', hrMethod, true)
const modelUpscaleNode = LiteGraph.createNode(
"ImageUpscaleWithModel"
);
graph.add(modelUpscaleNode);
decode.connect(0, modelUpscaleNode, 1);
upscaleLoaderNode.connect(0, modelUpscaleNode, 0);
'ImageUpscaleWithModel'
)
graph.add(modelUpscaleNode)
decode.connect(0, modelUpscaleNode, 1)
upscaleLoaderNode.connect(0, modelUpscaleNode, 0)
upscaleNode = LiteGraph.createNode("ImageScale");
graph.add(upscaleNode);
modelUpscaleNode.connect(0, upscaleNode, 0);
upscaleNode = LiteGraph.createNode('ImageScale')
graph.add(upscaleNode)
modelUpscaleNode.connect(0, upscaleNode, 0)
const vaeEncodeNode = (latentNode =
LiteGraph.createNode("VAEEncodeTiled"));
graph.add(vaeEncodeNode);
upscaleNode.connect(0, vaeEncodeNode, 0);
vaeLoaderNode.connect(0, vaeEncodeNode, 1);
LiteGraph.createNode('VAEEncodeTiled'))
graph.add(vaeEncodeNode)
upscaleNode.connect(0, vaeEncodeNode, 0)
vaeLoaderNode.connect(0, vaeEncodeNode, 1)
}
setWidgetValue(upscaleNode, "width", ceil64(uw));
setWidgetValue(upscaleNode, "height", ceil64(uh));
setWidgetValue(upscaleNode, 'width', ceil64(uw))
setWidgetValue(upscaleNode, 'height', ceil64(uh))
hrSamplerNode = LiteGraph.createNode("KSampler");
graph.add(hrSamplerNode);
ckptNode.connect(0, hrSamplerNode, 0);
positiveNode.connect(0, hrSamplerNode, 1);
negativeNode.connect(0, hrSamplerNode, 2);
latentNode.connect(0, hrSamplerNode, 3);
hrSamplerNode.connect(0, vaeNode, 0);
hrSamplerNode = LiteGraph.createNode('KSampler')
graph.add(hrSamplerNode)
ckptNode.connect(0, hrSamplerNode, 0)
positiveNode.connect(0, hrSamplerNode, 1)
negativeNode.connect(0, hrSamplerNode, 2)
latentNode.connect(0, hrSamplerNode, 3)
hrSamplerNode.connect(0, vaeNode, 0)
}
},
steps(v) {
setWidgetValue(samplerNode, "steps", +v);
setWidgetValue(samplerNode, 'steps', +v)
},
seed(v) {
setWidgetValue(samplerNode, "seed", +v);
},
};
setWidgetValue(samplerNode, 'seed', +v)
}
}
for (const opt in opts) {
if (opt in handlers) {
handlers[opt](popOpt(opt));
handlers[opt](popOpt(opt))
}
}
if (hrSamplerNode) {
setWidgetValue(
hrSamplerNode,
"steps",
hrSteps ? +hrSteps : getWidget(samplerNode, "steps").value
);
'steps',
hrSteps ? +hrSteps : getWidget(samplerNode, 'steps').value
)
setWidgetValue(
hrSamplerNode,
"cfg",
getWidget(samplerNode, "cfg").value
);
'cfg',
getWidget(samplerNode, 'cfg').value
)
setWidgetValue(
hrSamplerNode,
"scheduler",
getWidget(samplerNode, "scheduler").value
);
'scheduler',
getWidget(samplerNode, 'scheduler').value
)
setWidgetValue(
hrSamplerNode,
"sampler_name",
getWidget(samplerNode, "sampler_name").value
);
'sampler_name',
getWidget(samplerNode, 'sampler_name').value
)
setWidgetValue(
hrSamplerNode,
"denoise",
+(popOpt("denoising strength") || "1")
);
'denoise',
+(popOpt('denoising strength') || '1')
)
}
let n = createLoraNodes(
@@ -440,29 +438,29 @@ export async function importA1111(graph, parameters) {
positive,
{ node: clipSkipNode, index: 0 },
{ node: ckptNode, index: 0 }
);
positive = n.text;
n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel);
negative = n.text;
)
positive = n.text
n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel)
negative = n.text
setWidgetValue(positiveNode, "text", replaceEmbeddings(positive));
setWidgetValue(negativeNode, "text", replaceEmbeddings(negative));
setWidgetValue(positiveNode, 'text', replaceEmbeddings(positive))
setWidgetValue(negativeNode, 'text', replaceEmbeddings(negative))
graph.arrange();
graph.arrange()
for (const opt of [
"model hash",
"ensd",
"version",
"vae hash",
"ti hashes",
"lora hashes",
"hashes",
'model hash',
'ensd',
'version',
'vae hash',
'ti hashes',
'lora hashes',
'hashes'
]) {
delete opts[opt];
delete opts[opt]
}
console.warn("Unhandled parameters:", opts);
console.warn('Unhandled parameters:', opts)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,64 +1,64 @@
import { ComfyDialog } from "../dialog";
import { $el } from "../../ui";
import { ComfyDialog } from '../dialog'
import { $el } from '../../ui'
export class ComfyAsyncDialog extends ComfyDialog<HTMLDialogElement> {
#resolve: (value: any) => void;
#resolve: (value: any) => void
constructor(actions?: Array<string | { value?: any; text: string }>) {
super(
"dialog.comfy-dialog.comfyui-dialog",
'dialog.comfy-dialog.comfyui-dialog',
actions?.map((opt) => {
if (typeof opt === "string") {
opt = { text: opt };
if (typeof opt === 'string') {
opt = { text: opt }
}
return $el("button.comfyui-button", {
type: "button",
return $el('button.comfyui-button', {
type: 'button',
textContent: opt.text,
onclick: () => this.close(opt.value ?? opt.text),
});
onclick: () => this.close(opt.value ?? opt.text)
})
);
})
)
}
show(html: string | HTMLElement | HTMLElement[]) {
this.element.addEventListener("close", () => {
this.close();
});
this.element.addEventListener('close', () => {
this.close()
})
super.show(html);
super.show(html)
return new Promise((resolve) => {
this.#resolve = resolve;
});
this.#resolve = resolve
})
}
showModal(html: string | HTMLElement | HTMLElement[]) {
this.element.addEventListener("close", () => {
this.close();
});
this.element.addEventListener('close', () => {
this.close()
})
super.show(html);
this.element.showModal();
super.show(html)
this.element.showModal()
return new Promise((resolve) => {
this.#resolve = resolve;
});
this.#resolve = resolve
})
}
close(result = null) {
this.#resolve(result);
this.element.close();
super.close();
this.#resolve(result)
this.element.close()
super.close()
}
static async prompt({ title = null, message, actions }) {
const dialog = new ComfyAsyncDialog(actions);
const content = [$el("span", message)];
const dialog = new ComfyAsyncDialog(actions)
const content = [$el('span', message)]
if (title) {
content.unshift($el("h3", title));
content.unshift($el('h3', title))
}
const res = await dialog.showModal(content);
dialog.element.remove();
return res;
const res = await dialog.showModal(content)
dialog.element.remove()
return res
}
}

View File

@@ -1,40 +1,40 @@
import { $el } from "../../ui";
import { applyClasses, ClassList, toggleElement } from "../utils";
import { prop } from "../../utils";
import type { ComfyPopup } from "./popup";
import type { ComfyComponent } from ".";
import type { ComfyApp } from "@/scripts/app";
import { $el } from '../../ui'
import { applyClasses, ClassList, toggleElement } from '../utils'
import { prop } from '../../utils'
import type { ComfyPopup } from './popup'
import type { ComfyComponent } from '.'
import type { ComfyApp } from '@/scripts/app'
type ComfyButtonProps = {
icon?: string;
overIcon?: string;
iconSize?: number;
content?: string | HTMLElement;
tooltip?: string;
enabled?: boolean;
action?: (e: Event, btn: ComfyButton) => void;
classList?: ClassList;
visibilitySetting?: { id: string; showValue: any };
app?: ComfyApp;
};
icon?: string
overIcon?: string
iconSize?: number
content?: string | HTMLElement
tooltip?: string
enabled?: boolean
action?: (e: Event, btn: ComfyButton) => void
classList?: ClassList
visibilitySetting?: { id: string; showValue: any }
app?: ComfyApp
}
export class ComfyButton implements ComfyComponent<HTMLElement> {
#over = 0;
#popupOpen = false;
isOver = false;
iconElement = $el("i.mdi");
contentElement = $el("span");
popup: ComfyPopup;
element: HTMLElement;
overIcon: string;
iconSize: number;
content: string | HTMLElement;
icon: string;
tooltip: string;
classList: ClassList;
hidden: boolean;
enabled: boolean;
action: (e: Event, btn: ComfyButton) => void;
#over = 0
#popupOpen = false
isOver = false
iconElement = $el('i.mdi')
contentElement = $el('span')
popup: ComfyPopup
element: HTMLElement
overIcon: string
iconSize: number
content: string | HTMLElement
icon: string
tooltip: string
classList: ClassList
hidden: boolean
enabled: boolean
action: (e: Event, btn: ComfyButton) => void
constructor({
icon,
@@ -43,134 +43,134 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
content,
tooltip,
action,
classList = "comfyui-button",
classList = 'comfyui-button',
visibilitySetting,
app,
enabled = true,
enabled = true
}: ComfyButtonProps) {
this.element = $el(
"button",
'button',
{
onmouseenter: () => {
this.isOver = true;
this.isOver = true
if (this.overIcon) {
this.updateIcon();
this.updateIcon()
}
},
onmouseleave: () => {
this.isOver = false;
this.isOver = false
if (this.overIcon) {
this.updateIcon();
this.updateIcon()
}
}
},
},
[this.iconElement, this.contentElement]
);
)
this.icon = prop(
this,
"icon",
'icon',
icon,
toggleElement(this.iconElement, { onShow: this.updateIcon })
);
this.overIcon = prop(this, "overIcon", overIcon, () => {
)
this.overIcon = prop(this, 'overIcon', overIcon, () => {
if (this.isOver) {
this.updateIcon();
this.updateIcon()
}
});
this.iconSize = prop(this, "iconSize", iconSize, this.updateIcon);
})
this.iconSize = prop(this, 'iconSize', iconSize, this.updateIcon)
this.content = prop(
this,
"content",
'content',
content,
toggleElement(this.contentElement, {
onShow: (el, v) => {
if (typeof v === "string") {
el.textContent = v;
if (typeof v === 'string') {
el.textContent = v
} else {
el.replaceChildren(v);
el.replaceChildren(v)
}
}
},
})
);
)
this.tooltip = prop(this, "tooltip", tooltip, (v) => {
this.tooltip = prop(this, 'tooltip', tooltip, (v) => {
if (v) {
this.element.title = v;
this.element.title = v
} else {
this.element.removeAttribute("title");
this.element.removeAttribute('title')
}
});
this.classList = prop(this, "classList", classList, this.updateClasses);
this.hidden = prop(this, "hidden", false, this.updateClasses);
this.enabled = prop(this, "enabled", enabled, () => {
this.updateClasses();
(this.element as HTMLButtonElement).disabled = !this.enabled;
});
this.action = prop(this, "action", action);
this.element.addEventListener("click", (e) => {
})
this.classList = prop(this, 'classList', classList, this.updateClasses)
this.hidden = prop(this, 'hidden', false, this.updateClasses)
this.enabled = prop(this, 'enabled', enabled, () => {
this.updateClasses()
;(this.element as HTMLButtonElement).disabled = !this.enabled
})
this.action = prop(this, 'action', action)
this.element.addEventListener('click', (e) => {
if (this.popup) {
// we are either a touch device or triggered by click not hover
if (!this.#over) {
this.popup.toggle();
this.popup.toggle()
}
}
this.action?.(e, this);
});
this.action?.(e, this)
})
if (visibilitySetting?.id) {
const settingUpdated = () => {
this.hidden =
app.ui.settings.getSettingValue(visibilitySetting.id) !==
visibilitySetting.showValue;
};
visibilitySetting.showValue
}
app.ui.settings.addEventListener(
visibilitySetting.id + ".change",
visibilitySetting.id + '.change',
settingUpdated
);
settingUpdated();
)
settingUpdated()
}
}
updateIcon = () =>
(this.iconElement.className = `mdi mdi-${(this.isOver && this.overIcon) || this.icon}${this.iconSize ? " mdi-" + this.iconSize + "px" : ""}`);
(this.iconElement.className = `mdi mdi-${(this.isOver && this.overIcon) || this.icon}${this.iconSize ? ' mdi-' + this.iconSize + 'px' : ''}`)
updateClasses = () => {
const internalClasses = [];
const internalClasses = []
if (this.hidden) {
internalClasses.push("hidden");
internalClasses.push('hidden')
}
if (!this.enabled) {
internalClasses.push("disabled");
internalClasses.push('disabled')
}
if (this.popup) {
if (this.#popupOpen) {
internalClasses.push("popup-open");
internalClasses.push('popup-open')
} else {
internalClasses.push("popup-closed");
internalClasses.push('popup-closed')
}
}
applyClasses(this.element, this.classList, ...internalClasses);
};
applyClasses(this.element, this.classList, ...internalClasses)
}
withPopup(popup: ComfyPopup, mode: "click" | "hover" = "click") {
this.popup = popup;
withPopup(popup: ComfyPopup, mode: 'click' | 'hover' = 'click') {
this.popup = popup
if (mode === "hover") {
if (mode === 'hover') {
for (const el of [this.element, this.popup.element]) {
el.addEventListener("mouseenter", () => {
this.popup.open = !!++this.#over;
});
el.addEventListener("mouseleave", () => {
this.popup.open = !!--this.#over;
});
el.addEventListener('mouseenter', () => {
this.popup.open = !!++this.#over
})
el.addEventListener('mouseleave', () => {
this.popup.open = !!--this.#over
})
}
}
popup.addEventListener("change", () => {
this.#popupOpen = popup.open;
this.updateClasses();
});
popup.addEventListener('change', () => {
this.#popupOpen = popup.open
this.updateClasses()
})
return this;
return this
}
}

View File

@@ -1,37 +1,37 @@
import { $el } from "../../ui";
import { ComfyButton } from "./button";
import { prop } from "../../utils";
import { $el } from '../../ui'
import { ComfyButton } from './button'
import { prop } from '../../utils'
export class ComfyButtonGroup {
element = $el("div.comfyui-button-group");
buttons: (HTMLElement | ComfyButton)[];
element = $el('div.comfyui-button-group')
buttons: (HTMLElement | ComfyButton)[]
constructor(...buttons: (HTMLElement | ComfyButton)[]) {
this.buttons = prop(this, "buttons", buttons, () => this.update());
this.buttons = prop(this, 'buttons', buttons, () => this.update())
}
insert(button: ComfyButton, index: number) {
this.buttons.splice(index, 0, button);
this.update();
this.buttons.splice(index, 0, button)
this.update()
}
append(button: ComfyButton) {
this.buttons.push(button);
this.update();
this.buttons.push(button)
this.update()
}
remove(indexOrButton: ComfyButton | number) {
if (typeof indexOrButton !== "number") {
indexOrButton = this.buttons.indexOf(indexOrButton);
if (typeof indexOrButton !== 'number') {
indexOrButton = this.buttons.indexOf(indexOrButton)
}
if (indexOrButton > -1) {
const r = this.buttons.splice(indexOrButton, 1);
this.update();
return r;
const r = this.buttons.splice(indexOrButton, 1)
this.update()
return r
}
}
update() {
this.element.replaceChildren(...this.buttons.map((b) => b["element"] ?? b));
this.element.replaceChildren(...this.buttons.map((b) => b['element'] ?? b))
}
}

View File

@@ -1,3 +1,3 @@
export interface ComfyComponent<T extends HTMLElement = HTMLElement> {
element: T;
element: T
}

View File

@@ -1,140 +1,140 @@
import { prop } from "../../utils";
import { $el } from "../../ui";
import { applyClasses, ClassList } from "../utils";
import { prop } from '../../utils'
import { $el } from '../../ui'
import { applyClasses, ClassList } from '../utils'
export class ComfyPopup extends EventTarget {
element = $el("div.comfyui-popup");
open: boolean;
children: HTMLElement[];
target: HTMLElement;
ignoreTarget: boolean;
container: HTMLElement;
position: string;
closeOnEscape: boolean;
horizontal: string;
classList: ClassList;
element = $el('div.comfyui-popup')
open: boolean
children: HTMLElement[]
target: HTMLElement
ignoreTarget: boolean
container: HTMLElement
position: string
closeOnEscape: boolean
horizontal: string
classList: ClassList
constructor(
{
target,
container = document.body,
classList = "",
classList = '',
ignoreTarget = true,
closeOnEscape = true,
position = "absolute",
horizontal = "left",
position = 'absolute',
horizontal = 'left'
}: {
target: HTMLElement;
container?: HTMLElement;
classList?: ClassList;
ignoreTarget?: boolean;
closeOnEscape?: boolean;
position?: "absolute" | "relative";
horizontal?: "left" | "right";
target: HTMLElement
container?: HTMLElement
classList?: ClassList
ignoreTarget?: boolean
closeOnEscape?: boolean
position?: 'absolute' | 'relative'
horizontal?: 'left' | 'right'
},
...children: HTMLElement[]
) {
super();
this.target = target;
this.ignoreTarget = ignoreTarget;
this.container = container;
this.position = position;
this.closeOnEscape = closeOnEscape;
this.horizontal = horizontal;
super()
this.target = target
this.ignoreTarget = ignoreTarget
this.container = container
this.position = position
this.closeOnEscape = closeOnEscape
this.horizontal = horizontal
container.append(this.element);
container.append(this.element)
this.children = prop(this, "children", children, () => {
this.element.replaceChildren(...this.children);
this.update();
});
this.classList = prop(this, "classList", classList, () =>
applyClasses(this.element, this.classList, "comfyui-popup", horizontal)
);
this.open = prop(this, "open", false, (v, o) => {
if (v === o) return;
this.children = prop(this, 'children', children, () => {
this.element.replaceChildren(...this.children)
this.update()
})
this.classList = prop(this, 'classList', classList, () =>
applyClasses(this.element, this.classList, 'comfyui-popup', horizontal)
)
this.open = prop(this, 'open', false, (v, o) => {
if (v === o) return
if (v) {
this.#show();
this.#show()
} else {
this.#hide();
this.#hide()
}
});
})
}
toggle() {
this.open = !this.open;
this.open = !this.open
}
#hide() {
this.element.classList.remove("open");
window.removeEventListener("resize", this.update);
window.removeEventListener("click", this.#clickHandler, { capture: true });
window.removeEventListener("keydown", this.#escHandler, { capture: true });
this.element.classList.remove('open')
window.removeEventListener('resize', this.update)
window.removeEventListener('click', this.#clickHandler, { capture: true })
window.removeEventListener('keydown', this.#escHandler, { capture: true })
this.dispatchEvent(new CustomEvent("close"));
this.dispatchEvent(new CustomEvent("change"));
this.dispatchEvent(new CustomEvent('close'))
this.dispatchEvent(new CustomEvent('change'))
}
#show() {
this.element.classList.add("open");
this.update();
this.element.classList.add('open')
this.update()
window.addEventListener("resize", this.update);
window.addEventListener("click", this.#clickHandler, { capture: true });
window.addEventListener('resize', this.update)
window.addEventListener('click', this.#clickHandler, { capture: true })
if (this.closeOnEscape) {
window.addEventListener("keydown", this.#escHandler, { capture: true });
window.addEventListener('keydown', this.#escHandler, { capture: true })
}
this.dispatchEvent(new CustomEvent("open"));
this.dispatchEvent(new CustomEvent("change"));
this.dispatchEvent(new CustomEvent('open'))
this.dispatchEvent(new CustomEvent('change'))
}
#escHandler = (e) => {
if (e.key === "Escape") {
this.open = false;
e.preventDefault();
e.stopImmediatePropagation();
if (e.key === 'Escape') {
this.open = false
e.preventDefault()
e.stopImmediatePropagation()
}
}
};
#clickHandler = (e) => {
/** @type {any} */
const target = e.target;
const target = e.target
if (
!this.element.contains(target) &&
this.ignoreTarget &&
!this.target.contains(target)
) {
this.open = false;
this.open = false
}
}
};
update = () => {
const rect = this.target.getBoundingClientRect();
this.element.style.setProperty("--bottom", "unset");
if (this.position === "absolute") {
if (this.horizontal === "left") {
this.element.style.setProperty("--left", rect.left + "px");
const rect = this.target.getBoundingClientRect()
this.element.style.setProperty('--bottom', 'unset')
if (this.position === 'absolute') {
if (this.horizontal === 'left') {
this.element.style.setProperty('--left', rect.left + 'px')
} else {
this.element.style.setProperty(
"--left",
rect.right - this.element.clientWidth + "px"
);
'--left',
rect.right - this.element.clientWidth + 'px'
)
}
this.element.style.setProperty("--top", rect.bottom + "px");
this.element.style.setProperty("--limit", rect.bottom + "px");
this.element.style.setProperty('--top', rect.bottom + 'px')
this.element.style.setProperty('--limit', rect.bottom + 'px')
} else {
this.element.style.setProperty("--left", 0 + "px");
this.element.style.setProperty("--top", rect.height + "px");
this.element.style.setProperty("--limit", rect.height + "px");
this.element.style.setProperty('--left', 0 + 'px')
this.element.style.setProperty('--top', rect.height + 'px')
this.element.style.setProperty('--limit', rect.height + 'px')
}
const thisRect = this.element.getBoundingClientRect();
const thisRect = this.element.getBoundingClientRect()
if (thisRect.height < 30) {
// Move up instead
this.element.style.setProperty("--top", "unset");
this.element.style.setProperty("--bottom", rect.height + 5 + "px");
this.element.style.setProperty("--limit", rect.height + 5 + "px");
this.element.style.setProperty('--top', 'unset')
this.element.style.setProperty('--bottom', rect.height + 5 + 'px')
this.element.style.setProperty('--limit', rect.height + 5 + 'px')
}
}
};
}

View File

@@ -1,56 +1,56 @@
import { $el } from "../../ui";
import { ComfyButton } from "./button";
import { prop } from "../../utils";
import { ComfyPopup } from "./popup";
import { $el } from '../../ui'
import { ComfyButton } from './button'
import { prop } from '../../utils'
import { ComfyPopup } from './popup'
export class ComfySplitButton {
arrow: ComfyButton;
element: HTMLElement;
popup: ComfyPopup;
items: Array<HTMLElement | ComfyButton>;
arrow: ComfyButton
element: HTMLElement
popup: ComfyPopup
items: Array<HTMLElement | ComfyButton>
constructor(
{
primary,
mode,
horizontal = "left",
position = "relative",
horizontal = 'left',
position = 'relative'
}: {
primary: ComfyButton;
mode?: "hover" | "click";
horizontal?: "left" | "right";
position?: "relative" | "absolute";
primary: ComfyButton
mode?: 'hover' | 'click'
horizontal?: 'left' | 'right'
position?: 'relative' | 'absolute'
},
...items: Array<HTMLElement | ComfyButton>
) {
this.arrow = new ComfyButton({
icon: "chevron-down",
});
icon: 'chevron-down'
})
this.element = $el(
"div.comfyui-split-button" + (mode === "hover" ? ".hover" : ""),
'div.comfyui-split-button' + (mode === 'hover' ? '.hover' : ''),
[
$el("div.comfyui-split-primary", primary.element),
$el("div.comfyui-split-arrow", this.arrow.element),
$el('div.comfyui-split-primary', primary.element),
$el('div.comfyui-split-arrow', this.arrow.element)
]
);
)
this.popup = new ComfyPopup({
target: this.element,
container: position === "relative" ? this.element : document.body,
container: position === 'relative' ? this.element : document.body,
classList:
"comfyui-split-button-popup" + (mode === "hover" ? " hover" : ""),
closeOnEscape: mode === "click",
'comfyui-split-button-popup' + (mode === 'hover' ? ' hover' : ''),
closeOnEscape: mode === 'click',
position,
horizontal,
});
horizontal
})
this.arrow.withPopup(this.popup, mode);
this.arrow.withPopup(this.popup, mode)
this.items = prop(this, "items", items, () => this.update());
this.items = prop(this, 'items', items, () => this.update())
}
update() {
this.popup.element.replaceChildren(
...this.items.map((b) => ("element" in b ? b.element : b))
);
...this.items.map((b) => ('element' in b ? b.element : b))
)
}
}

View File

@@ -1,47 +1,47 @@
import { $el } from "../ui";
import { $el } from '../ui'
export class ComfyDialog<
T extends HTMLElement = HTMLElement,
T extends HTMLElement = HTMLElement
> extends EventTarget {
element: T;
textElement: HTMLElement;
#buttons: HTMLButtonElement[] | null;
element: T
textElement: HTMLElement
#buttons: HTMLButtonElement[] | null
constructor(type = "div", buttons = null) {
super();
this.#buttons = buttons;
this.element = $el(type + ".comfy-modal", { parent: document.body }, [
$el("div.comfy-modal-content", [
$el("p", { $: (p) => (this.textElement = p) }),
...this.createButtons(),
]),
]) as T;
constructor(type = 'div', buttons = null) {
super()
this.#buttons = buttons
this.element = $el(type + '.comfy-modal', { parent: document.body }, [
$el('div.comfy-modal-content', [
$el('p', { $: (p) => (this.textElement = p) }),
...this.createButtons()
])
]) as T
}
createButtons() {
return (
this.#buttons ?? [
$el("button", {
type: "button",
textContent: "Close",
onclick: () => this.close(),
}),
$el('button', {
type: 'button',
textContent: 'Close',
onclick: () => this.close()
})
]
);
)
}
close() {
this.element.style.display = "none";
this.element.style.display = 'none'
}
show(html) {
if (typeof html === "string") {
this.textElement.innerHTML = html;
if (typeof html === 'string') {
this.textElement.innerHTML = html
} else {
this.textElement.replaceChildren(
...(html instanceof Array ? html : [html])
);
)
}
this.element.style.display = "flex";
this.element.style.display = 'flex'
}
}

View File

@@ -24,9 +24,9 @@
SOFTWARE.
*/
import { $el } from "../ui";
import { $el } from '../ui'
$el("style", {
$el('style', {
parent: document.head,
textContent: `
.draggable-item {
@@ -40,261 +40,261 @@ $el("style", {
.draggable-item.is-draggable {
z-index: 10;
}
`,
});
`
})
export class DraggableList extends EventTarget {
listContainer;
draggableItem;
pointerStartX;
pointerStartY;
scrollYMax;
itemsGap = 0;
items = [];
itemSelector;
handleClass = "drag-handle";
off = [];
offDrag = [];
listContainer
draggableItem
pointerStartX
pointerStartY
scrollYMax
itemsGap = 0
items = []
itemSelector
handleClass = 'drag-handle'
off = []
offDrag = []
constructor(element, itemSelector) {
super();
this.listContainer = element;
this.itemSelector = itemSelector;
super()
this.listContainer = element
this.itemSelector = itemSelector
if (!this.listContainer) return;
if (!this.listContainer) return
this.off.push(this.on(this.listContainer, "mousedown", this.dragStart));
this.off.push(this.on(this.listContainer, "touchstart", this.dragStart));
this.off.push(this.on(document, "mouseup", this.dragEnd));
this.off.push(this.on(document, "touchend", this.dragEnd));
this.off.push(this.on(this.listContainer, 'mousedown', this.dragStart))
this.off.push(this.on(this.listContainer, 'touchstart', this.dragStart))
this.off.push(this.on(document, 'mouseup', this.dragEnd))
this.off.push(this.on(document, 'touchend', this.dragEnd))
}
getAllItems() {
if (!this.items?.length) {
this.items = Array.from(
this.listContainer.querySelectorAll(this.itemSelector)
);
)
this.items.forEach((element) => {
element.classList.add("is-idle");
});
element.classList.add('is-idle')
})
}
return this.items;
return this.items
}
getIdleItems() {
return this.getAllItems().filter((item) =>
item.classList.contains("is-idle")
);
item.classList.contains('is-idle')
)
}
isItemAbove(item) {
return item.hasAttribute("data-is-above");
return item.hasAttribute('data-is-above')
}
isItemToggled(item) {
return item.hasAttribute("data-is-toggled");
return item.hasAttribute('data-is-toggled')
}
on(source, event, listener, options?) {
listener = listener.bind(this);
source.addEventListener(event, listener, options);
return () => source.removeEventListener(event, listener);
listener = listener.bind(this)
source.addEventListener(event, listener, options)
return () => source.removeEventListener(event, listener)
}
dragStart(e) {
if (e.target.classList.contains(this.handleClass)) {
this.draggableItem = e.target.closest(this.itemSelector);
this.draggableItem = e.target.closest(this.itemSelector)
}
if (!this.draggableItem) return;
if (!this.draggableItem) return
this.pointerStartX = e.clientX || e.touches[0].clientX;
this.pointerStartY = e.clientY || e.touches[0].clientY;
this.pointerStartX = e.clientX || e.touches[0].clientX
this.pointerStartY = e.clientY || e.touches[0].clientY
this.scrollYMax =
this.listContainer.scrollHeight - this.listContainer.clientHeight;
this.listContainer.scrollHeight - this.listContainer.clientHeight
this.setItemsGap();
this.initDraggableItem();
this.initItemsState();
this.setItemsGap()
this.initDraggableItem()
this.initItemsState()
this.offDrag.push(this.on(document, "mousemove", this.drag));
this.offDrag.push(this.on(document, 'mousemove', this.drag))
this.offDrag.push(
this.on(document, "touchmove", this.drag, { passive: false })
);
this.on(document, 'touchmove', this.drag, { passive: false })
)
this.dispatchEvent(
new CustomEvent("dragstart", {
new CustomEvent('dragstart', {
detail: {
element: this.draggableItem,
position: this.getAllItems().indexOf(this.draggableItem),
},
position: this.getAllItems().indexOf(this.draggableItem)
}
})
);
)
}
setItemsGap() {
if (this.getIdleItems().length <= 1) {
this.itemsGap = 0;
return;
this.itemsGap = 0
return
}
const item1 = this.getIdleItems()[0];
const item2 = this.getIdleItems()[1];
const item1 = this.getIdleItems()[0]
const item2 = this.getIdleItems()[1]
const item1Rect = item1.getBoundingClientRect();
const item2Rect = item2.getBoundingClientRect();
const item1Rect = item1.getBoundingClientRect()
const item2Rect = item2.getBoundingClientRect()
this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top);
this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top)
}
initItemsState() {
this.getIdleItems().forEach((item, i) => {
if (this.getAllItems().indexOf(this.draggableItem) > i) {
item.dataset.isAbove = "";
item.dataset.isAbove = ''
}
});
})
}
initDraggableItem() {
this.draggableItem.classList.remove("is-idle");
this.draggableItem.classList.add("is-draggable");
this.draggableItem.classList.remove('is-idle')
this.draggableItem.classList.add('is-draggable')
}
drag(e) {
if (!this.draggableItem) return;
if (!this.draggableItem) return
e.preventDefault();
e.preventDefault()
const clientX = e.clientX || e.touches[0].clientX;
const clientY = e.clientY || e.touches[0].clientY;
const clientX = e.clientX || e.touches[0].clientX
const clientY = e.clientY || e.touches[0].clientY
const listRect = this.listContainer.getBoundingClientRect();
const listRect = this.listContainer.getBoundingClientRect()
if (clientY > listRect.bottom) {
if (this.listContainer.scrollTop < this.scrollYMax) {
this.listContainer.scrollBy(0, 10);
this.pointerStartY -= 10;
this.listContainer.scrollBy(0, 10)
this.pointerStartY -= 10
}
} else if (clientY < listRect.top && this.listContainer.scrollTop > 0) {
this.pointerStartY += 10;
this.listContainer.scrollBy(0, -10);
this.pointerStartY += 10
this.listContainer.scrollBy(0, -10)
}
const pointerOffsetX = clientX - this.pointerStartX;
const pointerOffsetY = clientY - this.pointerStartY;
const pointerOffsetX = clientX - this.pointerStartX
const pointerOffsetY = clientY - this.pointerStartY
this.updateIdleItemsStateAndPosition();
this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`;
this.updateIdleItemsStateAndPosition()
this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`
}
updateIdleItemsStateAndPosition() {
const draggableItemRect = this.draggableItem.getBoundingClientRect();
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2;
const draggableItemRect = this.draggableItem.getBoundingClientRect()
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2
// Update state
this.getIdleItems().forEach((item) => {
const itemRect = item.getBoundingClientRect();
const itemY = itemRect.top + itemRect.height / 2;
const itemRect = item.getBoundingClientRect()
const itemY = itemRect.top + itemRect.height / 2
if (this.isItemAbove(item)) {
if (draggableItemY <= itemY) {
item.dataset.isToggled = "";
item.dataset.isToggled = ''
} else {
delete item.dataset.isToggled;
delete item.dataset.isToggled
}
} else {
if (draggableItemY >= itemY) {
item.dataset.isToggled = "";
item.dataset.isToggled = ''
} else {
delete item.dataset.isToggled;
delete item.dataset.isToggled
}
}
});
})
// Update position
this.getIdleItems().forEach((item) => {
if (this.isItemToggled(item)) {
const direction = this.isItemAbove(item) ? 1 : -1;
item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`;
const direction = this.isItemAbove(item) ? 1 : -1
item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`
} else {
item.style.transform = "";
item.style.transform = ''
}
});
})
}
dragEnd() {
if (!this.draggableItem) return;
if (!this.draggableItem) return
this.applyNewItemsOrder();
this.cleanup();
this.applyNewItemsOrder()
this.cleanup()
}
applyNewItemsOrder() {
const reorderedItems = [];
const reorderedItems = []
let oldPosition = -1;
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index;
return;
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item;
return;
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1;
reorderedItems[newIndex] = item;
});
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index];
if (typeof item === "undefined") {
reorderedItems[index] = this.draggableItem;
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
reorderedItems.forEach((item) => {
this.listContainer.appendChild(item);
});
this.listContainer.appendChild(item)
})
this.items = reorderedItems;
this.items = reorderedItems
this.dispatchEvent(
new CustomEvent("dragend", {
new CustomEvent('dragend', {
detail: {
element: this.draggableItem,
oldPosition,
newPosition: reorderedItems.indexOf(this.draggableItem),
},
newPosition: reorderedItems.indexOf(this.draggableItem)
}
})
);
)
}
cleanup() {
this.itemsGap = 0;
this.items = [];
this.unsetDraggableItem();
this.unsetItemState();
this.itemsGap = 0
this.items = []
this.unsetDraggableItem()
this.unsetItemState()
this.offDrag.forEach((f) => f());
this.offDrag = [];
this.offDrag.forEach((f) => f())
this.offDrag = []
}
unsetDraggableItem() {
this.draggableItem.style = null;
this.draggableItem.classList.remove("is-draggable");
this.draggableItem.classList.add("is-idle");
this.draggableItem = null;
this.draggableItem.style = null
this.draggableItem.classList.remove('is-draggable')
this.draggableItem.classList.add('is-idle')
this.draggableItem = null
}
unsetItemState() {
this.getIdleItems().forEach((item, i) => {
delete item.dataset.isAbove;
delete item.dataset.isToggled;
item.style.transform = "";
});
delete item.dataset.isAbove
delete item.dataset.isToggled
item.style.transform = ''
})
}
dispose() {
this.off.forEach((f) => f());
this.off.forEach((f) => f())
}
}

View File

@@ -1,72 +1,72 @@
import { app } from "../app";
import { $el } from "../ui";
import { app } from '../app'
import { $el } from '../ui'
export function calculateImageGrid(imgs, dw, dh) {
let best = 0;
let w = imgs[0].naturalWidth;
let h = imgs[0].naturalHeight;
const numImages = imgs.length;
let best = 0
let w = imgs[0].naturalWidth
let h = imgs[0].naturalHeight
const numImages = imgs.length
let cellWidth, cellHeight, cols, rows, shiftX;
let cellWidth, cellHeight, cols, rows, shiftX
// compact style
for (let c = 1; c <= numImages; c++) {
const r = Math.ceil(numImages / c);
const cW = dw / c;
const cH = dh / r;
const scaleX = cW / w;
const scaleY = cH / h;
const r = Math.ceil(numImages / c)
const cW = dw / c
const cH = dh / r
const scaleX = cW / w
const scaleY = cH / h
const scale = Math.min(scaleX, scaleY, 1);
const imageW = w * scale;
const imageH = h * scale;
const area = imageW * imageH * numImages;
const scale = Math.min(scaleX, scaleY, 1)
const imageW = w * scale
const imageH = h * scale
const area = imageW * imageH * numImages
if (area > best) {
best = area;
cellWidth = imageW;
cellHeight = imageH;
cols = c;
rows = r;
shiftX = c * ((cW - imageW) / 2);
best = area
cellWidth = imageW
cellHeight = imageH
cols = c
rows = r
shiftX = c * ((cW - imageW) / 2)
}
}
return { cellWidth, cellHeight, cols, rows, shiftX };
return { cellWidth, cellHeight, cols, rows, shiftX }
}
export function createImageHost(node) {
const el = $el("div.comfy-img-preview");
let currentImgs;
let first = true;
const el = $el('div.comfy-img-preview')
let currentImgs
let first = true
function updateSize() {
let w = null;
let h = null;
let w = null
let h = null
if (currentImgs) {
let elH = el.clientHeight;
let elH = el.clientHeight
if (first) {
first = false;
first = false
// On first run, if we are small then grow a bit
if (elH < 190) {
elH = 190;
elH = 190
}
el.style.setProperty("--comfy-widget-min-height", elH.toString());
el.style.setProperty('--comfy-widget-min-height', elH.toString())
} else {
el.style.setProperty("--comfy-widget-min-height", null);
el.style.setProperty('--comfy-widget-min-height', null)
}
const nw = node.size[0];
({ cellWidth: w, cellHeight: h } = calculateImageGrid(
const nw = node.size[0]
;({ cellWidth: w, cellHeight: h } = calculateImageGrid(
currentImgs,
nw - 20,
elH
));
w += "px";
h += "px";
))
w += 'px'
h += 'px'
el.style.setProperty("--comfy-img-preview-width", w);
el.style.setProperty("--comfy-img-preview-height", h);
el.style.setProperty('--comfy-img-preview-width', w)
el.style.setProperty('--comfy-img-preview-height', h)
}
}
return {
@@ -75,31 +75,31 @@ export function createImageHost(node) {
if (imgs !== currentImgs) {
if (currentImgs == null) {
requestAnimationFrame(() => {
updateSize();
});
updateSize()
})
}
el.replaceChildren(...imgs);
currentImgs = imgs;
node.onResize(node.size);
node.graph.setDirtyCanvas(true, true);
el.replaceChildren(...imgs)
currentImgs = imgs
node.onResize(node.size)
node.graph.setDirtyCanvas(true, true)
}
},
getHeight() {
updateSize();
updateSize()
},
onDraw() {
// Element from point uses a hittest find elements so we need to toggle pointer events
el.style.pointerEvents = "all";
el.style.pointerEvents = 'all'
const over = document.elementFromPoint(
app.canvas.mouse[0],
app.canvas.mouse[1]
);
el.style.pointerEvents = "none";
)
el.style.pointerEvents = 'none'
if (!over) return;
if (!over) return
// Set the overIndex so Open Image etc work
const idx = currentImgs.indexOf(over);
node.overIndex = idx;
},
};
const idx = currentImgs.indexOf(over)
node.overIndex = idx
}
}
}

View File

@@ -1,320 +1,308 @@
import type { ComfyApp } from "@/scripts/app";
import { api } from "../../api";
import { $el } from "../../ui";
import { downloadBlob } from "../../utils";
import { ComfyButton } from "../components/button";
import { ComfyButtonGroup } from "../components/buttonGroup";
import { ComfySplitButton } from "../components/splitButton";
import { ComfyQueueButton } from "./queueButton";
import { ComfyWorkflowsMenu } from "./workflows";
import { getInteruptButton } from "./interruptButton";
import "./menu.css";
import type { ComfySettingsDialog } from "../settings";
import type { ComfyApp } from '@/scripts/app'
import { api } from '../../api'
import { $el } from '../../ui'
import { downloadBlob } from '../../utils'
import { ComfyButton } from '../components/button'
import { ComfyButtonGroup } from '../components/buttonGroup'
import { ComfySplitButton } from '../components/splitButton'
import { ComfyQueueButton } from './queueButton'
import { ComfyWorkflowsMenu } from './workflows'
import { getInteruptButton } from './interruptButton'
import './menu.css'
import type { ComfySettingsDialog } from '../settings'
type MenuPosition = "Disabled" | "Top" | "Bottom";
type MenuPosition = 'Disabled' | 'Top' | 'Bottom'
const collapseOnMobile = (t) => {
(t.element ?? t).classList.add("comfyui-menu-mobile-collapse");
return t;
};
;(t.element ?? t).classList.add('comfyui-menu-mobile-collapse')
return t
}
const showOnMobile = (t) => {
(t.element ?? t).classList.add("lt-lg-show");
return t;
};
;(t.element ?? t).classList.add('lt-lg-show')
return t
}
export class ComfyAppMenu {
#sizeBreak = "lg";
#sizeBreak = 'lg'
#lastSizeBreaks = {
lg: null,
md: null,
sm: null,
xs: null,
};
#sizeBreaks = Object.keys(this.#lastSizeBreaks);
#cachedInnerSize = null;
#cacheTimeout = null;
app: ComfyApp;
workflows: ComfyWorkflowsMenu;
logo: HTMLElement;
saveButton: ComfySplitButton;
actionsGroup: ComfyButtonGroup;
settingsGroup: ComfyButtonGroup;
viewGroup: ComfyButtonGroup;
mobileMenuButton: ComfyButton;
element: HTMLElement;
menuPositionSetting: ReturnType<ComfySettingsDialog["addSetting"]>;
position: MenuPosition;
xs: null
}
#sizeBreaks = Object.keys(this.#lastSizeBreaks)
#cachedInnerSize = null
#cacheTimeout = null
app: ComfyApp
workflows: ComfyWorkflowsMenu
logo: HTMLElement
saveButton: ComfySplitButton
actionsGroup: ComfyButtonGroup
settingsGroup: ComfyButtonGroup
viewGroup: ComfyButtonGroup
mobileMenuButton: ComfyButton
element: HTMLElement
menuPositionSetting: ReturnType<ComfySettingsDialog['addSetting']>
position: MenuPosition
constructor(app: ComfyApp) {
this.app = app;
this.app = app
this.workflows = new ComfyWorkflowsMenu(app);
this.workflows = new ComfyWorkflowsMenu(app)
const getSaveButton = (t?: string) =>
new ComfyButton({
icon: "content-save",
tooltip: "Save the current workflow",
icon: 'content-save',
tooltip: 'Save the current workflow',
action: () => app.workflowManager.activeWorkflow.save(),
content: t,
});
content: t
})
this.logo = $el(
"h1.comfyui-logo.nlg-hide",
{ title: "ComfyUI" },
"ComfyUI"
);
this.logo = $el('h1.comfyui-logo.nlg-hide', { title: 'ComfyUI' }, 'ComfyUI')
this.saveButton = new ComfySplitButton(
{
primary: getSaveButton(),
mode: "hover",
position: "absolute",
mode: 'hover',
position: 'absolute'
},
getSaveButton("Save"),
getSaveButton('Save'),
new ComfyButton({
icon: "content-save-edit",
content: "Save As",
tooltip: "Save the current graph as a new workflow",
action: () => app.workflowManager.activeWorkflow.save(true),
icon: 'content-save-edit',
content: 'Save As',
tooltip: 'Save the current graph as a new workflow',
action: () => app.workflowManager.activeWorkflow.save(true)
}),
new ComfyButton({
icon: "download",
content: "Export",
tooltip: "Export the current workflow as JSON",
action: () => this.exportWorkflow("workflow", "workflow"),
icon: 'download',
content: 'Export',
tooltip: 'Export the current workflow as JSON',
action: () => this.exportWorkflow('workflow', 'workflow')
}),
new ComfyButton({
icon: "api",
content: "Export (API Format)",
icon: 'api',
content: 'Export (API Format)',
tooltip:
"Export the current workflow as JSON for use with the ComfyUI API",
action: () => this.exportWorkflow("workflow_api", "output"),
visibilitySetting: { id: "Comfy.DevMode", showValue: true },
app,
'Export the current workflow as JSON for use with the ComfyUI API',
action: () => this.exportWorkflow('workflow_api', 'output'),
visibilitySetting: { id: 'Comfy.DevMode', showValue: true },
app
})
);
)
this.actionsGroup = new ComfyButtonGroup(
new ComfyButton({
icon: "refresh",
content: "Refresh",
tooltip: "Refresh widgets in nodes to find new models or files",
action: () => app.refreshComboInNodes(),
icon: 'refresh',
content: 'Refresh',
tooltip: 'Refresh widgets in nodes to find new models or files',
action: () => app.refreshComboInNodes()
}),
new ComfyButton({
icon: "clipboard-edit-outline",
content: "Clipspace",
tooltip: "Open Clipspace window",
action: () => app["openClipspace"](),
icon: 'clipboard-edit-outline',
content: 'Clipspace',
tooltip: 'Open Clipspace window',
action: () => app['openClipspace']()
}),
new ComfyButton({
icon: "fit-to-page-outline",
content: "Reset View",
tooltip: "Reset the canvas view",
action: () => app.resetView(),
icon: 'fit-to-page-outline',
content: 'Reset View',
tooltip: 'Reset the canvas view',
action: () => app.resetView()
}),
new ComfyButton({
icon: "cancel",
content: "Clear",
tooltip: "Clears current workflow",
icon: 'cancel',
content: 'Clear',
tooltip: 'Clears current workflow',
action: () => {
if (
!app.ui.settings.getSettingValue("Comfy.ConfirmClear", true) ||
confirm("Clear workflow?")
!app.ui.settings.getSettingValue('Comfy.ConfirmClear', true) ||
confirm('Clear workflow?')
) {
app.clean();
app.graph.clear();
api.dispatchEvent(new CustomEvent("graphCleared"));
app.clean()
app.graph.clear()
api.dispatchEvent(new CustomEvent('graphCleared'))
}
}
},
})
);
)
// Keep the settings group as there are custom scripts attaching extra
// elements to it.
this.settingsGroup = new ComfyButtonGroup();
this.viewGroup = new ComfyButtonGroup(
getInteruptButton("nlg-hide").element
);
this.settingsGroup = new ComfyButtonGroup()
this.viewGroup = new ComfyButtonGroup(getInteruptButton('nlg-hide').element)
this.mobileMenuButton = new ComfyButton({
icon: "menu",
icon: 'menu',
action: (_, btn) => {
btn.icon = this.element.classList.toggle("expanded")
? "menu-open"
: "menu";
window.dispatchEvent(new Event("resize"));
btn.icon = this.element.classList.toggle('expanded')
? 'menu-open'
: 'menu'
window.dispatchEvent(new Event('resize'))
},
classList: "comfyui-button comfyui-menu-button",
});
classList: 'comfyui-button comfyui-menu-button'
})
this.element = $el("nav.comfyui-menu.lg", { style: { display: "none" } }, [
this.element = $el('nav.comfyui-menu.lg', { style: { display: 'none' } }, [
this.logo,
this.workflows.element,
this.saveButton.element,
collapseOnMobile(this.actionsGroup).element,
$el("section.comfyui-menu-push"),
$el('section.comfyui-menu-push'),
collapseOnMobile(this.settingsGroup).element,
collapseOnMobile(this.viewGroup).element,
getInteruptButton("lt-lg-show").element,
getInteruptButton('lt-lg-show').element,
new ComfyQueueButton(app).element,
showOnMobile(this.mobileMenuButton).element,
]);
showOnMobile(this.mobileMenuButton).element
])
let resizeHandler: () => void;
let resizeHandler: () => void
this.menuPositionSetting = app.ui.settings.addSetting({
id: "Comfy.UseNewMenu",
defaultValue: "Disabled",
name: "[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.",
type: "combo",
options: ["Disabled", "Top", "Bottom"],
id: 'Comfy.UseNewMenu',
defaultValue: 'Disabled',
name: '[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom'],
onChange: async (v: MenuPosition) => {
if (v && v !== "Disabled") {
if (v && v !== 'Disabled') {
if (!resizeHandler) {
resizeHandler = () => {
this.calculateSizeBreak();
};
window.addEventListener("resize", resizeHandler);
this.calculateSizeBreak()
}
this.updatePosition(v);
window.addEventListener('resize', resizeHandler)
}
this.updatePosition(v)
} else {
if (resizeHandler) {
window.removeEventListener("resize", resizeHandler);
resizeHandler = null;
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
document.body.style.removeProperty("display");
app.ui.menuContainer.style.removeProperty("display");
this.element.style.display = "none";
app.ui.restoreMenuPosition();
document.body.style.removeProperty('display')
app.ui.menuContainer.style.removeProperty('display')
this.element.style.display = 'none'
app.ui.restoreMenuPosition()
}
window.dispatchEvent(new Event("resize"));
},
});
window.dispatchEvent(new Event('resize'))
}
})
}
updatePosition(v: MenuPosition) {
document.body.style.display = "grid";
this.app.ui.menuContainer.style.display = "none";
this.element.style.removeProperty("display");
this.position = v;
if (v === "Bottom") {
this.app.bodyBottom.append(this.element);
document.body.style.display = 'grid'
this.app.ui.menuContainer.style.display = 'none'
this.element.style.removeProperty('display')
this.position = v
if (v === 'Bottom') {
this.app.bodyBottom.append(this.element)
} else {
this.app.bodyTop.prepend(this.element);
this.app.bodyTop.prepend(this.element)
}
this.calculateSizeBreak();
this.calculateSizeBreak()
}
updateSizeBreak(idx: number, prevIdx: number, direction: number) {
const newSize = this.#sizeBreaks[idx];
if (newSize === this.#sizeBreak) return;
this.#cachedInnerSize = null;
clearTimeout(this.#cacheTimeout);
const newSize = this.#sizeBreaks[idx]
if (newSize === this.#sizeBreak) return
this.#cachedInnerSize = null
clearTimeout(this.#cacheTimeout)
this.#sizeBreak = this.#sizeBreaks[idx];
this.#sizeBreak = this.#sizeBreaks[idx]
for (let i = 0; i < this.#sizeBreaks.length; i++) {
const sz = this.#sizeBreaks[i];
const sz = this.#sizeBreaks[i]
if (sz === this.#sizeBreak) {
this.element.classList.add(sz);
this.element.classList.add(sz)
} else {
this.element.classList.remove(sz);
this.element.classList.remove(sz)
}
if (i < idx) {
this.element.classList.add("lt-" + sz);
this.element.classList.add('lt-' + sz)
} else {
this.element.classList.remove("lt-" + sz);
this.element.classList.remove('lt-' + sz)
}
}
if (idx) {
// We're on a small screen, force the menu at the top
if (this.position !== "Top") {
this.updatePosition("Top");
if (this.position !== 'Top') {
this.updatePosition('Top')
}
} else if (this.position != this.menuPositionSetting.value) {
// Restore user position
this.updatePosition(this.menuPositionSetting.value);
this.updatePosition(this.menuPositionSetting.value)
}
// Allow multiple updates, but prevent bouncing
if (!direction) {
direction = prevIdx - idx;
direction = prevIdx - idx
} else if (direction != prevIdx - idx) {
return;
return
}
this.calculateSizeBreak(direction);
this.calculateSizeBreak(direction)
}
calculateSizeBreak(direction = 0) {
let idx = this.#sizeBreaks.indexOf(this.#sizeBreak);
const currIdx = idx;
const innerSize = this.calculateInnerSize(idx);
let idx = this.#sizeBreaks.indexOf(this.#sizeBreak)
const currIdx = idx
const innerSize = this.calculateInnerSize(idx)
if (window.innerWidth >= this.#lastSizeBreaks[this.#sizeBreaks[idx - 1]]) {
if (idx > 0) {
idx--;
idx--
}
} else if (innerSize > this.element.clientWidth) {
this.#lastSizeBreaks[this.#sizeBreak] = Math.max(
window.innerWidth,
innerSize
);
)
// We need to shrink
if (idx < this.#sizeBreaks.length - 1) {
idx++;
idx++
}
}
this.updateSizeBreak(idx, currIdx, direction);
this.updateSizeBreak(idx, currIdx, direction)
}
calculateInnerSize(idx: number) {
// Cache the inner size to prevent too much calculation when resizing the window
clearTimeout(this.#cacheTimeout);
clearTimeout(this.#cacheTimeout)
if (this.#cachedInnerSize) {
// Extend cache time
this.#cacheTimeout = setTimeout(
() => (this.#cachedInnerSize = null),
100
);
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100)
} else {
let innerSize = 0;
let count = 1;
let innerSize = 0
let count = 1
for (const c of this.element.children) {
if (c.classList.contains("comfyui-menu-push")) continue; // ignore right push
if (idx && c.classList.contains("comfyui-menu-mobile-collapse"))
continue; // ignore collapse items
innerSize += c.clientWidth;
count++;
if (c.classList.contains('comfyui-menu-push')) continue // ignore right push
if (idx && c.classList.contains('comfyui-menu-mobile-collapse'))
continue // ignore collapse items
innerSize += c.clientWidth
count++
}
innerSize += 8 * count;
this.#cachedInnerSize = innerSize;
this.#cacheTimeout = setTimeout(
() => (this.#cachedInnerSize = null),
100
);
innerSize += 8 * count
this.#cachedInnerSize = innerSize
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100)
}
return this.#cachedInnerSize;
return this.#cachedInnerSize
}
getFilename(defaultName: string) {
if (this.app.ui.settings.getSettingValue("Comfy.PromptFilename", true)) {
defaultName = prompt("Save workflow as:", defaultName);
if (!defaultName) return;
if (!defaultName.toLowerCase().endsWith(".json")) {
defaultName += ".json";
if (this.app.ui.settings.getSettingValue('Comfy.PromptFilename', true)) {
defaultName = prompt('Save workflow as:', defaultName)
if (!defaultName) return
if (!defaultName.toLowerCase().endsWith('.json')) {
defaultName += '.json'
}
}
return defaultName;
return defaultName
}
async exportWorkflow(
filename: string,
promptProperty: "workflow" | "output"
promptProperty: 'workflow' | 'output'
) {
if (this.app.workflowManager.activeWorkflow?.path) {
filename = this.app.workflowManager.activeWorkflow.name;
filename = this.app.workflowManager.activeWorkflow.name
}
const p = await this.app.graphToPrompt();
const json = JSON.stringify(p[promptProperty], null, 2);
const blob = new Blob([json], { type: "application/json" });
const file = this.getFilename(filename);
if (!file) return;
downloadBlob(file, blob);
const p = await this.app.graphToPrompt()
const json = JSON.stringify(p[promptProperty], null, 2)
const blob = new Blob([json], { type: 'application/json' })
const file = this.getFilename(filename)
if (!file) return
downloadBlob(file, blob)
}
}

View File

@@ -1,21 +1,21 @@
import { api } from "../../api";
import { ComfyButton } from "../components/button";
import { api } from '../../api'
import { ComfyButton } from '../components/button'
export function getInteruptButton(visibility: string) {
const btn = new ComfyButton({
icon: "close",
tooltip: "Cancel current generation",
icon: 'close',
tooltip: 'Cancel current generation',
enabled: false,
action: () => {
api.interrupt();
api.interrupt()
},
classList: ["comfyui-button", "comfyui-interrupt-button", visibility],
});
classList: ['comfyui-button', 'comfyui-interrupt-button', visibility]
})
api.addEventListener("status", ({ detail }) => {
const sz = detail?.exec_info?.queue_remaining;
btn.enabled = sz > 0;
});
api.addEventListener('status', ({ detail }) => {
const sz = detail?.exec_info?.queue_remaining
btn.enabled = sz > 0
})
return btn;
return btn
}

View File

@@ -1,107 +1,107 @@
import { ComfyButton } from "../components/button";
import { $el } from "../../ui";
import { api } from "../../api";
import { ComfySplitButton } from "../components/splitButton";
import { ComfyQueueOptions } from "./queueOptions";
import { prop } from "../../utils";
import type { ComfyApp } from "@/scripts/app";
import { ComfyButton } from '../components/button'
import { $el } from '../../ui'
import { api } from '../../api'
import { ComfySplitButton } from '../components/splitButton'
import { ComfyQueueOptions } from './queueOptions'
import { prop } from '../../utils'
import type { ComfyApp } from '@/scripts/app'
export class ComfyQueueButton {
element = $el("div.comfyui-queue-button");
#internalQueueSize = 0;
element = $el('div.comfyui-queue-button')
#internalQueueSize = 0
queuePrompt = async (e?: MouseEvent) => {
this.#internalQueueSize += this.queueOptions.batchCount;
this.#internalQueueSize += this.queueOptions.batchCount
// Hold shift to queue front, event is undefined when auto-queue is enabled
await this.app.queuePrompt(
e?.shiftKey ? -1 : 0,
this.queueOptions.batchCount
);
};
queueOptions: ComfyQueueOptions;
app: ComfyApp;
queueSizeElement: HTMLElement;
autoQueueMode: string;
graphHasChanged: boolean;
)
}
queueOptions: ComfyQueueOptions
app: ComfyApp
queueSizeElement: HTMLElement
autoQueueMode: string
graphHasChanged: boolean
constructor(app: ComfyApp) {
this.app = app;
this.queueSizeElement = $el("span.comfyui-queue-count", {
textContent: "?",
});
this.app = app
this.queueSizeElement = $el('span.comfyui-queue-count', {
textContent: '?'
})
const queue = new ComfyButton({
content: $el("div", [
$el("span", {
textContent: "Queue",
content: $el('div', [
$el('span', {
textContent: 'Queue'
}),
this.queueSizeElement,
this.queueSizeElement
]),
icon: "play",
classList: "comfyui-button",
action: this.queuePrompt,
});
icon: 'play',
classList: 'comfyui-button',
action: this.queuePrompt
})
this.queueOptions = new ComfyQueueOptions(app);
this.queueOptions = new ComfyQueueOptions(app)
const btn = new ComfySplitButton(
{
primary: queue,
mode: "click",
position: "absolute",
horizontal: "right",
mode: 'click',
position: 'absolute',
horizontal: 'right'
},
this.queueOptions.element
);
btn.element.classList.add("primary");
this.element.append(btn.element);
)
btn.element.classList.add('primary')
this.element.append(btn.element)
this.autoQueueMode = prop(this, "autoQueueMode", "", () => {
this.autoQueueMode = prop(this, 'autoQueueMode', '', () => {
switch (this.autoQueueMode) {
case "instant":
queue.icon = "infinity";
break;
case "change":
queue.icon = "auto-mode";
break;
case 'instant':
queue.icon = 'infinity'
break
case 'change':
queue.icon = 'auto-mode'
break
default:
queue.icon = "play";
break;
queue.icon = 'play'
break
}
});
})
this.queueOptions.addEventListener(
"autoQueueMode",
(e) => (this.autoQueueMode = e["detail"])
);
'autoQueueMode',
(e) => (this.autoQueueMode = e['detail'])
)
api.addEventListener("graphChanged", () => {
if (this.autoQueueMode === "change") {
api.addEventListener('graphChanged', () => {
if (this.autoQueueMode === 'change') {
if (this.#internalQueueSize) {
this.graphHasChanged = true;
this.graphHasChanged = true
} else {
this.graphHasChanged = false;
this.queuePrompt();
this.graphHasChanged = false
this.queuePrompt()
}
}
});
})
api.addEventListener("status", ({ detail }) => {
this.#internalQueueSize = detail?.exec_info?.queue_remaining;
api.addEventListener('status', ({ detail }) => {
this.#internalQueueSize = detail?.exec_info?.queue_remaining
if (this.#internalQueueSize != null) {
this.queueSizeElement.textContent =
this.#internalQueueSize > 99 ? "99+" : this.#internalQueueSize + "";
this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`;
this.#internalQueueSize > 99 ? '99+' : this.#internalQueueSize + ''
this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`
if (!this.#internalQueueSize && !app.lastExecutionError) {
if (
this.autoQueueMode === "instant" ||
(this.autoQueueMode === "change" && this.graphHasChanged)
this.autoQueueMode === 'instant' ||
(this.autoQueueMode === 'change' && this.graphHasChanged)
) {
this.graphHasChanged = false;
this.queuePrompt();
this.graphHasChanged = false
this.queuePrompt()
}
}
}
});
})
}
}

View File

@@ -1,82 +1,82 @@
import type { ComfyApp } from "@/scripts/app";
import { $el } from "../../ui";
import { prop } from "../../utils";
import type { ComfyApp } from '@/scripts/app'
import { $el } from '../../ui'
import { prop } from '../../utils'
export class ComfyQueueOptions extends EventTarget {
element = $el("div.comfyui-queue-options");
app: ComfyApp;
batchCountInput: HTMLInputElement;
batchCount: number;
batchCountRange: HTMLInputElement;
autoQueueMode: string;
autoQueueEl: HTMLElement;
element = $el('div.comfyui-queue-options')
app: ComfyApp
batchCountInput: HTMLInputElement
batchCount: number
batchCountRange: HTMLInputElement
autoQueueMode: string
autoQueueEl: HTMLElement
constructor(app: ComfyApp) {
super();
this.app = app;
super()
this.app = app
this.batchCountInput = $el("input", {
className: "comfyui-queue-batch-value",
type: "number",
min: "1",
value: "1",
oninput: () => (this.batchCount = +this.batchCountInput.value),
});
this.batchCountInput = $el('input', {
className: 'comfyui-queue-batch-value',
type: 'number',
min: '1',
value: '1',
oninput: () => (this.batchCount = +this.batchCountInput.value)
})
this.batchCountRange = $el("input", {
type: "range",
min: "1",
max: "100",
value: "1",
oninput: () => (this.batchCount = +this.batchCountRange.value),
});
this.batchCountRange = $el('input', {
type: 'range',
min: '1',
max: '100',
value: '1',
oninput: () => (this.batchCount = +this.batchCountRange.value)
})
this.element.append(
$el("div.comfyui-queue-batch", [
$el('div.comfyui-queue-batch', [
$el(
"label",
'label',
{
textContent: "Batch count: ",
textContent: 'Batch count: '
},
this.batchCountInput
),
this.batchCountRange,
this.batchCountRange
])
);
)
const createOption = (text, value, checked = false) =>
$el(
"label",
'label',
{ textContent: text },
$el("input", {
type: "radio",
name: "AutoQueueMode",
$el('input', {
type: 'radio',
name: 'AutoQueueMode',
checked,
value,
oninput: (e) => (this.autoQueueMode = e.target["value"]),
oninput: (e) => (this.autoQueueMode = e.target['value'])
})
);
)
this.autoQueueEl = $el("div.comfyui-queue-mode", [
$el("span", "Auto Queue:"),
createOption("Disabled", "", true),
createOption("Instant", "instant"),
createOption("On Change", "change"),
]);
this.autoQueueEl = $el('div.comfyui-queue-mode', [
$el('span', 'Auto Queue:'),
createOption('Disabled', '', true),
createOption('Instant', 'instant'),
createOption('On Change', 'change')
])
this.element.append(this.autoQueueEl);
this.element.append(this.autoQueueEl)
this.batchCount = prop(this, "batchCount", 1, () => {
this.batchCountInput.value = this.batchCount + "";
this.batchCountRange.value = this.batchCount + "";
});
this.batchCount = prop(this, 'batchCount', 1, () => {
this.batchCountInput.value = this.batchCount + ''
this.batchCountRange.value = this.batchCount + ''
})
this.autoQueueMode = prop(this, "autoQueueMode", "Disabled", () => {
this.autoQueueMode = prop(this, 'autoQueueMode', 'Disabled', () => {
this.dispatchEvent(
new CustomEvent("autoQueueMode", {
detail: this.autoQueueMode,
new CustomEvent('autoQueueMode', {
detail: this.autoQueueMode
})
)
})
);
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,60 @@
import { $el } from "../ui";
import { api } from "../api";
import { ComfyDialog } from "./dialog";
import type { ComfyApp } from "../app";
import type { Setting, SettingParams } from "@/types/settingTypes";
import { useSettingStore } from "@/stores/settingStore";
import { $el } from '../ui'
import { api } from '../api'
import { ComfyDialog } from './dialog'
import type { ComfyApp } from '../app'
import type { Setting, SettingParams } from '@/types/settingTypes'
import { useSettingStore } from '@/stores/settingStore'
export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
app: ComfyApp;
settingsValues: any;
settingsLookup: Record<string, Setting>;
settingsParamLookup: Record<string, SettingParams>;
app: ComfyApp
settingsValues: any
settingsLookup: Record<string, Setting>
settingsParamLookup: Record<string, SettingParams>
constructor(app: ComfyApp) {
super();
const frontendVersion = window["__COMFYUI_FRONTEND_VERSION__"];
this.app = app;
this.settingsValues = {};
this.settingsLookup = {};
this.settingsParamLookup = {};
super()
const frontendVersion = window['__COMFYUI_FRONTEND_VERSION__']
this.app = app
this.settingsValues = {}
this.settingsLookup = {}
this.settingsParamLookup = {}
this.element = $el(
"dialog",
'dialog',
{
id: "comfy-settings-dialog",
parent: document.body,
id: 'comfy-settings-dialog',
parent: document.body
},
[
$el("table.comfy-modal-content.comfy-table", [
$el('table.comfy-modal-content.comfy-table', [
$el(
"caption",
'caption',
{ textContent: `Settings (v${frontendVersion})` },
$el("button.comfy-btn", {
type: "button",
textContent: "\u00d7",
$el('button.comfy-btn', {
type: 'button',
textContent: '\u00d7',
onclick: () => {
this.element.close();
},
this.element.close()
}
})
),
$el("tbody", { $: (tbody) => (this.textElement = tbody) }),
$el("button", {
type: "button",
textContent: "Close",
$el('tbody', { $: (tbody) => (this.textElement = tbody) }),
$el('button', {
type: 'button',
textContent: 'Close',
style: {
cursor: "pointer",
cursor: 'pointer'
},
onclick: () => {
this.element.close();
},
}),
]),
this.element.close()
}
})
])
]
) as HTMLDialogElement;
) as HTMLDialogElement
}
get settings() {
return Object.values(this.settingsLookup);
return Object.values(this.settingsLookup)
}
#dispatchChange<T>(id: string, value: T, oldValue?: T) {
@@ -63,84 +63,84 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
// `load` re-dispatch the change for any settings added before load so
// settingStore is always up to date.
if (this.app.vueAppReady) {
useSettingStore().settingValues[id] = value;
useSettingStore().settingValues[id] = value
}
this.dispatchEvent(
new CustomEvent(id + ".change", {
new CustomEvent(id + '.change', {
detail: {
value,
oldValue,
},
oldValue
}
})
);
)
}
async load() {
if (this.app.storageLocation === "browser") {
this.settingsValues = localStorage;
if (this.app.storageLocation === 'browser') {
this.settingsValues = localStorage
} else {
this.settingsValues = await api.getSettings();
this.settingsValues = await api.getSettings()
}
// Trigger onChange for any settings added before load
for (const id in this.settingsLookup) {
const value = this.settingsValues[this.getId(id)];
this.settingsLookup[id].onChange?.(value);
this.#dispatchChange(id, value);
const value = this.settingsValues[this.getId(id)]
this.settingsLookup[id].onChange?.(value)
this.#dispatchChange(id, value)
}
}
getId(id: string) {
if (this.app.storageLocation === "browser") {
id = "Comfy.Settings." + id;
if (this.app.storageLocation === 'browser') {
id = 'Comfy.Settings.' + id
}
return id;
return id
}
getSettingValue<T>(id: string, defaultValue?: T): T {
let value = this.settingsValues[this.getId(id)];
let value = this.settingsValues[this.getId(id)]
if (value != null) {
if (this.app.storageLocation === "browser") {
if (this.app.storageLocation === 'browser') {
try {
value = JSON.parse(value) as T;
value = JSON.parse(value) as T
} catch (error) {}
}
}
return value ?? defaultValue;
return value ?? defaultValue
}
getSettingDefaultValue(id: string) {
const param = this.settingsParamLookup[id];
return param?.defaultValue;
const param = this.settingsParamLookup[id]
return param?.defaultValue
}
async setSettingValueAsync(id: string, value: any) {
const json = JSON.stringify(value);
localStorage["Comfy.Settings." + id] = json; // backwards compatibility for extensions keep setting in storage
const json = JSON.stringify(value)
localStorage['Comfy.Settings.' + id] = json // backwards compatibility for extensions keep setting in storage
let oldValue = this.getSettingValue(id, undefined);
this.settingsValues[this.getId(id)] = value;
let oldValue = this.getSettingValue(id, undefined)
this.settingsValues[this.getId(id)] = value
if (id in this.settingsLookup) {
this.settingsLookup[id].onChange?.(value, oldValue);
this.settingsLookup[id].onChange?.(value, oldValue)
}
this.#dispatchChange(id, value, oldValue);
this.#dispatchChange(id, value, oldValue)
await api.storeSetting(id, value);
await api.storeSetting(id, value)
}
setSettingValue(id: string, value: any) {
this.setSettingValueAsync(id, value).catch((err) => {
alert(`Error saving setting '${id}'`);
console.error(err);
});
alert(`Error saving setting '${id}'`)
console.error(err)
})
}
refreshSetting(id: string) {
const value = this.getSettingValue(id);
this.settingsLookup[id].onChange?.(value);
this.#dispatchChange(id, value);
const value = this.getSettingValue(id)
this.settingsLookup[id].onChange?.(value)
this.#dispatchChange(id, value)
}
addSetting(params: SettingParams) {
@@ -151,232 +151,231 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
defaultValue,
onChange,
attrs = {},
tooltip = "",
options = undefined,
} = params;
tooltip = '',
options = undefined
} = params
if (!id) {
throw new Error("Settings must have an ID");
throw new Error('Settings must have an ID')
}
if (id in this.settingsLookup) {
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`);
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`)
}
let skipOnChange = false;
let value = this.getSettingValue(id);
let skipOnChange = false
let value = this.getSettingValue(id)
if (value == null) {
if (this.app.isNewUserSession) {
// Check if we have a localStorage value but not a setting value and we are a new user
const localValue = localStorage["Comfy.Settings." + id];
const localValue = localStorage['Comfy.Settings.' + id]
if (localValue) {
value = JSON.parse(localValue);
this.setSettingValue(id, value); // Store on the server
value = JSON.parse(localValue)
this.setSettingValue(id, value) // Store on the server
}
}
if (value == null) {
value = defaultValue;
value = defaultValue
}
}
// Trigger initial setting of value
if (!skipOnChange) {
onChange?.(value, undefined);
this.#dispatchChange(id, value);
onChange?.(value, undefined)
this.#dispatchChange(id, value)
}
this.settingsParamLookup[id] = params;
this.settingsParamLookup[id] = params
this.settingsLookup[id] = {
id,
onChange,
name,
render: () => {
if (type === "hidden") return;
if (type === 'hidden') return
const setter = (v) => {
if (onChange) {
onChange(v, value);
onChange(v, value)
}
this.setSettingValue(id, v);
value = v;
};
value = this.getSettingValue(id, defaultValue);
this.setSettingValue(id, v)
value = v
}
value = this.getSettingValue(id, defaultValue)
let element;
const htmlID = id.replaceAll(".", "-");
let element
const htmlID = id.replaceAll('.', '-')
const labelCell = $el("td", [
$el("label", {
const labelCell = $el('td', [
$el('label', {
for: htmlID,
classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""],
textContent: name,
}),
]);
classList: [tooltip !== '' ? 'comfy-tooltip-indicator' : ''],
textContent: name
})
])
if (typeof type === "function") {
element = type(name, setter, value, attrs);
if (typeof type === 'function') {
element = type(name, setter, value, attrs)
} else {
switch (type) {
case "boolean":
element = $el("tr", [
case 'boolean':
element = $el('tr', [
labelCell,
$el("td", [
$el("input", {
$el('td', [
$el('input', {
id: htmlID,
type: "checkbox",
type: 'checkbox',
checked: value,
onchange: (event) => {
const isChecked = event.target.checked;
const isChecked = event.target.checked
if (onChange !== undefined) {
onChange(isChecked);
onChange(isChecked)
}
this.setSettingValue(id, isChecked);
},
}),
]),
]);
break;
case "number":
element = $el("tr", [
this.setSettingValue(id, isChecked)
}
})
])
])
break
case 'number':
element = $el('tr', [
labelCell,
$el("td", [
$el("input", {
$el('td', [
$el('input', {
type,
value,
id: htmlID,
oninput: (e) => {
setter(e.target.value);
setter(e.target.value)
},
...attrs,
}),
]),
]);
break;
case "slider":
element = $el("tr", [
...attrs
})
])
])
break
case 'slider':
element = $el('tr', [
labelCell,
$el("td", [
$el('td', [
$el(
"div",
'div',
{
style: {
display: "grid",
gridAutoFlow: "column",
},
display: 'grid',
gridAutoFlow: 'column'
}
},
[
$el("input", {
$el('input', {
...attrs,
value,
type: "range",
type: 'range',
oninput: (e) => {
setter(e.target.value);
e.target.nextElementSibling.value = e.target.value;
},
setter(e.target.value)
e.target.nextElementSibling.value = e.target.value
}
}),
$el("input", {
$el('input', {
...attrs,
value,
id: htmlID,
type: "number",
style: { maxWidth: "4rem" },
type: 'number',
style: { maxWidth: '4rem' },
oninput: (e) => {
setter(e.target.value);
e.target.previousElementSibling.value =
e.target.value;
},
}),
setter(e.target.value)
e.target.previousElementSibling.value = e.target.value
}
})
]
),
]),
]);
break;
case "combo":
element = $el("tr", [
)
])
])
break
case 'combo':
element = $el('tr', [
labelCell,
$el("td", [
$el('td', [
$el(
"select",
'select',
{
oninput: (e) => {
setter(e.target.value);
setter(e.target.value)
}
},
},
(typeof options === "function"
(typeof options === 'function'
? options(value)
: options || []
).map((opt) => {
if (typeof opt === "string") {
opt = { text: opt };
if (typeof opt === 'string') {
opt = { text: opt }
}
const v = opt.value ?? opt.text;
return $el("option", {
const v = opt.value ?? opt.text
return $el('option', {
value: v,
textContent: opt.text,
selected: value + "" === v + "",
});
selected: value + '' === v + ''
})
),
]),
]);
break;
case "text":
})
)
])
])
break
case 'text':
default:
if (type !== "text") {
if (type !== 'text') {
console.warn(
`Unsupported setting type '${type}, defaulting to text`
);
)
}
element = $el("tr", [
element = $el('tr', [
labelCell,
$el("td", [
$el("input", {
$el('td', [
$el('input', {
value,
id: htmlID,
oninput: (e) => {
setter(e.target.value);
setter(e.target.value)
},
...attrs,
}),
]),
]);
break;
...attrs
})
])
])
break
}
}
if (tooltip) {
element.title = tooltip;
element.title = tooltip
}
return element;
},
} as Setting;
return element
}
} as Setting
const self = this;
const self = this
return {
get value() {
return self.getSettingValue(id, defaultValue);
return self.getSettingValue(id, defaultValue)
},
set value(v) {
self.setSettingValue(id, v);
},
};
self.setSettingValue(id, v)
}
}
}
show() {
this.textElement.replaceChildren(
$el(
"tr",
'tr',
{
style: { display: "none" },
style: { display: 'none' }
},
[$el("th"), $el("th", { style: { width: "33%" } })]
[$el('th'), $el('th', { style: { width: '33%' } })]
),
...this.settings
.sort((a, b) => a.name.localeCompare(b.name))
.map((s) => s.render())
.filter(Boolean)
);
this.element.showModal();
)
this.element.showModal()
}
}

View File

@@ -1,7 +1,7 @@
import "./spinner.css";
import './spinner.css'
export function createSpinner() {
const div = document.createElement("div");
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`;
return div.firstElementChild;
const div = document.createElement('div')
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`
return div.firstElementChild
}

View File

@@ -1,4 +1,4 @@
import { $el } from "../ui";
import { $el } from '../ui'
/**
* @typedef { { text: string, value?: string, tooltip?: string } } ToggleSwitchItem
@@ -11,55 +11,55 @@ import { $el } from "../ui";
* @param { (e: { item: ToggleSwitchItem, prev?: ToggleSwitchItem }) => void } [opts.onChange]
*/
export function toggleSwitch(name, items, e?) {
const onChange = e?.onChange;
const onChange = e?.onChange
let selectedIndex;
let elements;
let selectedIndex
let elements
function updateSelected(index) {
if (selectedIndex != null) {
elements[selectedIndex].classList.remove("comfy-toggle-selected");
elements[selectedIndex].classList.remove('comfy-toggle-selected')
}
onChange?.({
item: items[index],
prev: selectedIndex == null ? undefined : items[selectedIndex],
});
selectedIndex = index;
elements[selectedIndex].classList.add("comfy-toggle-selected");
prev: selectedIndex == null ? undefined : items[selectedIndex]
})
selectedIndex = index
elements[selectedIndex].classList.add('comfy-toggle-selected')
}
elements = items.map((item, i) => {
if (typeof item === "string") item = { text: item };
if (!item.value) item.value = item.text;
if (typeof item === 'string') item = { text: item }
if (!item.value) item.value = item.text
const toggle = $el(
"label",
'label',
{
textContent: item.text,
title: item.tooltip ?? "",
title: item.tooltip ?? ''
},
$el("input", {
$el('input', {
name,
type: "radio",
type: 'radio',
value: item.value ?? item.text,
checked: item.selected,
onchange: () => {
updateSelected(i);
},
})
);
if (item.selected) {
updateSelected(i);
updateSelected(i)
}
return toggle;
});
})
)
if (item.selected) {
updateSelected(i)
}
return toggle
})
const container = $el("div.comfy-toggle-switch", elements);
const container = $el('div.comfy-toggle-switch', elements)
if (selectedIndex == null) {
elements[0].children[0].checked = true;
updateSelected(0);
elements[0].children[0].checked = true
updateSelected(0)
}
return container;
return container
}

View File

@@ -1,57 +1,57 @@
import { api } from "../api";
import { $el } from "../ui";
import { createSpinner } from "./spinner";
import "./userSelection.css";
import { api } from '../api'
import { $el } from '../ui'
import { createSpinner } from './spinner'
import './userSelection.css'
interface SelectedUser {
username: string;
userId: string;
created: boolean;
username: string
userId: string
created: boolean
}
export class UserSelectionScreen {
async show(users, user): Promise<SelectedUser> {
const userSelection = document.getElementById("comfy-user-selection");
userSelection.style.display = "";
const userSelection = document.getElementById('comfy-user-selection')
userSelection.style.display = ''
return new Promise((resolve) => {
const input = userSelection.getElementsByTagName("input")[0];
const select = userSelection.getElementsByTagName("select")[0];
const inputSection = input.closest("section");
const selectSection = select.closest("section");
const form = userSelection.getElementsByTagName("form")[0];
const error = userSelection.getElementsByClassName("comfy-user-error")[0];
const input = userSelection.getElementsByTagName('input')[0]
const select = userSelection.getElementsByTagName('select')[0]
const inputSection = input.closest('section')
const selectSection = select.closest('section')
const form = userSelection.getElementsByTagName('form')[0]
const error = userSelection.getElementsByClassName('comfy-user-error')[0]
const button = userSelection.getElementsByClassName(
"comfy-user-button-next"
)[0];
'comfy-user-button-next'
)[0]
let inputActive = null;
input.addEventListener("focus", () => {
inputSection.classList.add("selected");
selectSection.classList.remove("selected");
inputActive = true;
});
select.addEventListener("focus", () => {
inputSection.classList.remove("selected");
selectSection.classList.add("selected");
inputActive = false;
select.style.color = "";
});
select.addEventListener("blur", () => {
let inputActive = null
input.addEventListener('focus', () => {
inputSection.classList.add('selected')
selectSection.classList.remove('selected')
inputActive = true
})
select.addEventListener('focus', () => {
inputSection.classList.remove('selected')
selectSection.classList.add('selected')
inputActive = false
select.style.color = ''
})
select.addEventListener('blur', () => {
if (!select.value) {
select.style.color = "var(--descrip-text)";
select.style.color = 'var(--descrip-text)'
}
});
})
form.addEventListener("submit", async (e) => {
e.preventDefault();
form.addEventListener('submit', async (e) => {
e.preventDefault()
if (inputActive == null) {
error.textContent =
"Please enter a username or select an existing user.";
'Please enter a username or select an existing user.'
} else if (inputActive) {
const username = input.value.trim();
const username = input.value.trim()
if (!username) {
error.textContent = "Please enter a username.";
return;
error.textContent = 'Please enter a username.'
return
}
// Create new user
@@ -63,31 +63,31 @@ export class UserSelectionScreen {
input.readonly =
// @ts-ignore
select.readonly =
true;
const spinner = createSpinner();
button.prepend(spinner);
true
const spinner = createSpinner()
button.prepend(spinner)
try {
const resp = await api.createUser(username);
const resp = await api.createUser(username)
if (resp.status >= 300) {
let message =
"Error creating user: " + resp.status + " " + resp.statusText;
'Error creating user: ' + resp.status + ' ' + resp.statusText
try {
const res = await resp.json();
const res = await resp.json()
if (res.error) {
message = res.error;
message = res.error
}
} catch (error) {}
throw new Error(message);
throw new Error(message)
}
resolve({ username, userId: await resp.json(), created: true });
resolve({ username, userId: await resp.json(), created: true })
} catch (err) {
spinner.remove();
spinner.remove()
error.textContent =
err.message ??
err.statusText ??
err ??
"An unknown error occurred.";
'An unknown error occurred.'
// Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339)
// Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551)
input.disabled =
@@ -96,50 +96,50 @@ export class UserSelectionScreen {
input.readonly =
// @ts-ignore
select.readonly =
false;
return;
false
return
}
} else if (!select.value) {
error.textContent = "Please select an existing user.";
return;
error.textContent = 'Please select an existing user.'
return
} else {
resolve({
username: users[select.value],
userId: select.value,
created: false,
});
created: false
})
}
});
})
if (user) {
const name = localStorage["Comfy.userName"];
const name = localStorage['Comfy.userName']
if (name) {
input.value = name;
input.value = name
}
}
if (input.value) {
// Focus the input, do this separately as sometimes browsers like to fill in the value
input.focus();
input.focus()
}
const userIds = Object.keys(users ?? {});
const userIds = Object.keys(users ?? {})
if (userIds.length) {
for (const u of userIds) {
$el("option", { textContent: users[u], value: u, parent: select });
$el('option', { textContent: users[u], value: u, parent: select })
}
select.style.color = "var(--descrip-text)";
select.style.color = 'var(--descrip-text)'
if (select.value) {
// Focus the select, do this separately as sometimes browsers like to fill in the value
select.focus();
select.focus()
}
} else {
userSelection.classList.add("no-users");
input.focus();
userSelection.classList.add('no-users')
input.focus()
}
}).then((r: SelectedUser) => {
userSelection.remove();
return r;
});
userSelection.remove()
return r
})
}
}

View File

@@ -1,28 +1,28 @@
export type ClassList = string | string[] | Record<string, boolean>;
export type ClassList = string | string[] | Record<string, boolean>
export function applyClasses(
element: HTMLElement,
classList: ClassList,
...requiredClasses: string[]
) {
classList ??= "";
classList ??= ''
let str: string;
if (typeof classList === "string") {
str = classList;
let str: string
if (typeof classList === 'string') {
str = classList
} else if (classList instanceof Array) {
str = classList.join(" ");
str = classList.join(' ')
} else {
str = Object.entries(classList).reduce((p, c) => {
if (c[1]) {
p += (p.length ? " " : "") + c[0];
p += (p.length ? ' ' : '') + c[0]
}
return p;
}, "");
return p
}, '')
}
element.className = str;
element.className = str
if (requiredClasses) {
element.classList.add(...requiredClasses);
element.classList.add(...requiredClasses)
}
}
@@ -30,28 +30,28 @@ export function toggleElement(
element: HTMLElement,
{
onHide,
onShow,
onShow
}: {
onHide?: (el: HTMLElement) => void;
onShow?: (el: HTMLElement, value) => void;
onHide?: (el: HTMLElement) => void
onShow?: (el: HTMLElement, value) => void
} = {}
) {
let placeholder: HTMLElement | Comment;
let hidden: boolean;
let placeholder: HTMLElement | Comment
let hidden: boolean
return (value) => {
if (value) {
if (hidden) {
hidden = false;
placeholder.replaceWith(element);
hidden = false
placeholder.replaceWith(element)
}
onShow?.(element, value);
onShow?.(element, value)
} else {
if (!placeholder) {
placeholder = document.createComment("");
placeholder = document.createComment('')
}
hidden = true
element.replaceWith(placeholder)
onHide?.(element)
}
hidden = true;
element.replaceWith(placeholder);
onHide?.(element);
}
};
}

View File

@@ -1,6 +1,6 @@
import { api } from "./api";
import type { ComfyApp } from "./app";
import { $el } from "./ui";
import { api } from './api'
import type { ComfyApp } from './app'
import { $el } from './ui'
// Simple date formatter
const parts = {
@@ -8,88 +8,82 @@ const parts = {
M: (d) => d.getMonth() + 1,
h: (d) => d.getHours(),
m: (d) => d.getMinutes(),
s: (d) => d.getSeconds(),
};
s: (d) => d.getSeconds()
}
const format =
Object.keys(parts)
.map((k) => k + k + "?")
.join("|") + "|yyy?y?";
.map((k) => k + k + '?')
.join('|') + '|yyy?y?'
function formatDate(text: string, date: Date) {
return text.replace(new RegExp(format, "g"), (text: string): string => {
if (text === "yy") return (date.getFullYear() + "").substring(2);
if (text === "yyyy") return date.getFullYear().toString();
return text.replace(new RegExp(format, 'g'), (text: string): string => {
if (text === 'yy') return (date.getFullYear() + '').substring(2)
if (text === 'yyyy') return date.getFullYear().toString()
if (text[0] in parts) {
const p = parts[text[0]](date);
return (p + "").padStart(text.length, "0");
const p = parts[text[0]](date)
return (p + '').padStart(text.length, '0')
}
return text;
});
return text
})
}
export function clone(obj) {
try {
if (typeof structuredClone !== "undefined") {
return structuredClone(obj);
if (typeof structuredClone !== 'undefined') {
return structuredClone(obj)
}
} catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
return JSON.parse(JSON.stringify(obj));
return JSON.parse(JSON.stringify(obj))
}
export function applyTextReplacements(app: ComfyApp, value: string): string {
return value.replace(/%([^%]+)%/g, function (match, text) {
const split = text.split(".");
const split = text.split('.')
if (split.length !== 2) {
// Special handling for dates
if (split[0].startsWith("date:")) {
return formatDate(split[0].substring(5), new Date());
if (split[0].startsWith('date:')) {
return formatDate(split[0].substring(5), new Date())
}
if (text !== "width" && text !== "height") {
if (text !== 'width' && text !== 'height') {
// Dont warn on standard replacements
console.warn("Invalid replacement pattern", text);
console.warn('Invalid replacement pattern', text)
}
return match;
return match
}
// Find node with matching S&R property name
// @ts-ignore
let nodes = app.graph._nodes.filter(
(n) => n.properties?.["Node name for S&R"] === split[0]
);
(n) => n.properties?.['Node name for S&R'] === split[0]
)
// If we cant, see if there is a node with that title
if (!nodes.length) {
// @ts-ignore
nodes = app.graph._nodes.filter((n) => n.title === split[0]);
nodes = app.graph._nodes.filter((n) => n.title === split[0])
}
if (!nodes.length) {
console.warn("Unable to find node", split[0]);
return match;
console.warn('Unable to find node', split[0])
return match
}
if (nodes.length > 1) {
console.warn("Multiple nodes matched", split[0], "using first match");
console.warn('Multiple nodes matched', split[0], 'using first match')
}
const node = nodes[0];
const node = nodes[0]
const widget = node.widgets?.find((w) => w.name === split[1]);
const widget = node.widgets?.find((w) => w.name === split[1])
if (!widget) {
console.warn(
"Unable to find widget",
split[1],
"on node",
split[0],
node
);
return match;
console.warn('Unable to find widget', split[1], 'on node', split[0], node)
return match
}
return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_");
});
return ((widget.value ?? '') + '').replaceAll(/\/|\\/g, '_')
})
}
export async function addStylesheet(
@@ -97,24 +91,24 @@ export async function addStylesheet(
relativeTo?: string
): Promise<void> {
return new Promise((res, rej) => {
let url;
if (urlOrFile.endsWith(".js")) {
url = urlOrFile.substr(0, urlOrFile.length - 2) + "css";
let url
if (urlOrFile.endsWith('.js')) {
url = urlOrFile.substr(0, urlOrFile.length - 2) + 'css'
} else {
url = new URL(
urlOrFile,
relativeTo ?? `${window.location.protocol}//${window.location.host}`
).toString();
).toString()
}
$el("link", {
$el('link', {
parent: document.head,
rel: "stylesheet",
type: "text/css",
rel: 'stylesheet',
type: 'text/css',
href: url,
onload: res,
onerror: rej,
});
});
onerror: rej
})
})
}
/**
@@ -122,18 +116,18 @@ export async function addStylesheet(
* @param { Blob } blob
*/
export function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const a = $el("a", {
const url = URL.createObjectURL(blob)
const a = $el('a', {
href: url,
download: filename,
style: { display: "none" },
parent: document.body,
});
a.click();
style: { display: 'none' },
parent: document.body
})
a.click()
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
a.remove()
window.URL.revokeObjectURL(url)
}, 0)
}
export function prop<T>(
@@ -147,32 +141,32 @@ export function prop<T>(
name: string
) => void
): T {
let currentValue;
let currentValue
Object.defineProperty(target, name, {
get() {
return currentValue;
return currentValue
},
set(newValue) {
const prevValue = currentValue;
currentValue = newValue;
onChanged?.(currentValue, prevValue, target, name);
},
});
return defaultValue;
const prevValue = currentValue
currentValue = newValue
onChanged?.(currentValue, prevValue, target, name)
}
})
return defaultValue
}
export function getStorageValue(id) {
const clientId = api.clientId ?? api.initialClientId;
const clientId = api.clientId ?? api.initialClientId
return (
(clientId && sessionStorage.getItem(`${id}:${clientId}`)) ??
localStorage.getItem(id)
);
)
}
export function setStorageValue(id, value) {
const clientId = api.clientId ?? api.initialClientId;
const clientId = api.clientId ?? api.initialClientId
if (clientId) {
sessionStorage.setItem(`${id}:${clientId}`, value);
sessionStorage.setItem(`${id}:${clientId}`, value)
}
localStorage.setItem(id, value);
localStorage.setItem(id, value)
}

View File

@@ -1,8 +1,8 @@
import { api } from "./api";
import "./domWidget";
import type { ComfyApp } from "./app";
import type { IWidget, LGraphNode } from "@comfyorg/litegraph";
import { ComfyNodeDef } from "@/types/apiTypes";
import { api } from './api'
import './domWidget'
import type { ComfyApp } from './app'
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
import { ComfyNodeDef } from '@/types/apiTypes'
export type ComfyWidgetConstructor = (
node: LGraphNode,
@@ -10,20 +10,20 @@ export type ComfyWidgetConstructor = (
inputData: ComfyNodeDef,
app?: ComfyApp,
widgetName?: string
) => { widget: IWidget; minWidth?: number; minHeight?: number };
) => { widget: IWidget; minWidth?: number; minHeight?: number }
let controlValueRunBefore = false;
let controlValueRunBefore = false
export function updateControlWidgetLabel(widget) {
let replacement = "after";
let find = "before";
let replacement = 'after'
let find = 'before'
if (controlValueRunBefore) {
[find, replacement] = [replacement, find];
;[find, replacement] = [replacement, find]
}
widget.label = (widget.label ?? widget.name).replace(find, replacement);
widget.label = (widget.label ?? widget.name).replace(find, replacement)
}
const IS_CONTROL_WIDGET = Symbol();
const HAS_EXECUTED = Symbol();
const IS_CONTROL_WIDGET = Symbol()
const HAS_EXECUTED = Symbol()
function getNumberDefaults(
inputData: ComfyNodeDef,
@@ -31,41 +31,41 @@ function getNumberDefaults(
precision,
enable_rounding
) {
let defaultVal = inputData[1]["default"];
let { min, max, step, round } = inputData[1];
let defaultVal = inputData[1]['default']
let { min, max, step, round } = inputData[1]
if (defaultVal == undefined) defaultVal = 0;
if (min == undefined) min = 0;
if (max == undefined) max = 2048;
if (step == undefined) step = defaultStep;
if (defaultVal == undefined) defaultVal = 0
if (min == undefined) min = 0
if (max == undefined) max = 2048
if (step == undefined) step = defaultStep
// precision is the number of decimal places to show.
// by default, display the the smallest number of decimal places such that changes of size step are visible.
if (precision == undefined) {
precision = Math.max(-Math.floor(Math.log10(step)), 0);
precision = Math.max(-Math.floor(Math.log10(step)), 0)
}
if (enable_rounding && (round == undefined || round === true)) {
// by default, round the value to those decimal places shown.
round = Math.round(1000000 * Math.pow(0.1, precision)) / 1000000;
round = Math.round(1000000 * Math.pow(0.1, precision)) / 1000000
}
return {
val: defaultVal,
config: { min, max, step: 10.0 * step, round, precision },
};
config: { min, max, step: 10.0 * step, round, precision }
}
}
export function addValueControlWidget(
node,
targetWidget,
defaultValue = "randomize",
defaultValue = 'randomize',
values,
widgetName,
inputData: ComfyNodeDef
) {
let name = inputData[1]?.control_after_generate;
if (typeof name !== "string") {
name = widgetName;
let name = inputData[1]?.control_after_generate
if (typeof name !== 'string') {
name = widgetName
}
const widgets = addValueControlWidgets(
node,
@@ -73,200 +73,200 @@ export function addValueControlWidget(
defaultValue,
{
addFilterList: false,
controlAfterGenerateName: name,
controlAfterGenerateName: name
},
inputData
);
return widgets[0];
)
return widgets[0]
}
export function addValueControlWidgets(
node,
targetWidget,
defaultValue = "randomize",
defaultValue = 'randomize',
options,
inputData: ComfyNodeDef
) {
if (!defaultValue) defaultValue = "randomize";
if (!options) options = {};
if (!defaultValue) defaultValue = 'randomize'
if (!options) options = {}
const getName = (defaultName, optionName) => {
let name = defaultName;
let name = defaultName
if (options[optionName]) {
name = options[optionName];
} else if (typeof inputData?.[1]?.[defaultName] === "string") {
name = inputData?.[1]?.[defaultName];
name = options[optionName]
} else if (typeof inputData?.[1]?.[defaultName] === 'string') {
name = inputData?.[1]?.[defaultName]
} else if (inputData?.[1]?.control_prefix) {
name = inputData?.[1]?.control_prefix + " " + name;
name = inputData?.[1]?.control_prefix + ' ' + name
}
return name
}
return name;
};
const widgets = [];
const widgets = []
const valueControl = node.addWidget(
"combo",
getName("control_after_generate", "controlAfterGenerateName"),
'combo',
getName('control_after_generate', 'controlAfterGenerateName'),
defaultValue,
function () {},
{
values: ["fixed", "increment", "decrement", "randomize"],
serialize: false, // Don't include this in prompt.
values: ['fixed', 'increment', 'decrement', 'randomize'],
serialize: false // Don't include this in prompt.
}
);
valueControl[IS_CONTROL_WIDGET] = true;
updateControlWidgetLabel(valueControl);
widgets.push(valueControl);
)
valueControl[IS_CONTROL_WIDGET] = true
updateControlWidgetLabel(valueControl)
widgets.push(valueControl)
const isCombo = targetWidget.type === "combo";
let comboFilter;
const isCombo = targetWidget.type === 'combo'
let comboFilter
if (isCombo) {
valueControl.options.values.push("increment-wrap");
valueControl.options.values.push('increment-wrap')
}
if (isCombo && options.addFilterList !== false) {
comboFilter = node.addWidget(
"string",
getName("control_filter_list", "controlFilterListName"),
"",
'string',
getName('control_filter_list', 'controlFilterListName'),
'',
function () {},
{
serialize: false, // Don't include this in prompt.
serialize: false // Don't include this in prompt.
}
);
updateControlWidgetLabel(comboFilter);
)
updateControlWidgetLabel(comboFilter)
widgets.push(comboFilter);
widgets.push(comboFilter)
}
const applyWidgetControl = () => {
var v = valueControl.value;
var v = valueControl.value
if (isCombo && v !== "fixed") {
let values = targetWidget.options.values;
const filter = comboFilter?.value;
if (isCombo && v !== 'fixed') {
let values = targetWidget.options.values
const filter = comboFilter?.value
if (filter) {
let check;
if (filter.startsWith("/") && filter.endsWith("/")) {
let check
if (filter.startsWith('/') && filter.endsWith('/')) {
try {
const regex = new RegExp(filter.substring(1, filter.length - 1));
check = (item) => regex.test(item);
const regex = new RegExp(filter.substring(1, filter.length - 1))
check = (item) => regex.test(item)
} catch (error) {
console.error(
"Error constructing RegExp filter for node " + node.id,
'Error constructing RegExp filter for node ' + node.id,
filter,
error
);
)
}
}
if (!check) {
const lower = filter.toLocaleLowerCase();
check = (item) => item.toLocaleLowerCase().includes(lower);
const lower = filter.toLocaleLowerCase()
check = (item) => item.toLocaleLowerCase().includes(lower)
}
values = values.filter((item) => check(item));
values = values.filter((item) => check(item))
if (!values.length && targetWidget.options.values.length) {
console.warn(
"Filter for node " + node.id + " has filtered out all items",
'Filter for node ' + node.id + ' has filtered out all items',
filter
);
)
}
}
let current_index = values.indexOf(targetWidget.value);
let current_length = values.length;
let current_index = values.indexOf(targetWidget.value)
let current_length = values.length
switch (v) {
case "increment":
current_index += 1;
break;
case "increment-wrap":
current_index += 1;
case 'increment':
current_index += 1
break
case 'increment-wrap':
current_index += 1
if (current_index >= current_length) {
current_index = 0;
current_index = 0
}
break;
case "decrement":
current_index -= 1;
break;
case "randomize":
current_index = Math.floor(Math.random() * current_length);
break;
break
case 'decrement':
current_index -= 1
break
case 'randomize':
current_index = Math.floor(Math.random() * current_length)
break
default:
break;
break
}
current_index = Math.max(0, current_index);
current_index = Math.min(current_length - 1, current_index);
current_index = Math.max(0, current_index)
current_index = Math.min(current_length - 1, current_index)
if (current_index >= 0) {
let value = values[current_index];
targetWidget.value = value;
targetWidget.callback(value);
let value = values[current_index]
targetWidget.value = value
targetWidget.callback(value)
}
} else {
//number
let min = targetWidget.options.min;
let max = targetWidget.options.max;
let min = targetWidget.options.min
let max = targetWidget.options.max
// limit to something that javascript can handle
max = Math.min(1125899906842624, max);
min = Math.max(-1125899906842624, min);
let range = (max - min) / (targetWidget.options.step / 10);
max = Math.min(1125899906842624, max)
min = Math.max(-1125899906842624, min)
let range = (max - min) / (targetWidget.options.step / 10)
//adjust values based on valueControl Behaviour
switch (v) {
case "fixed":
break;
case "increment":
targetWidget.value += targetWidget.options.step / 10;
break;
case "decrement":
targetWidget.value -= targetWidget.options.step / 10;
break;
case "randomize":
case 'fixed':
break
case 'increment':
targetWidget.value += targetWidget.options.step / 10
break
case 'decrement':
targetWidget.value -= targetWidget.options.step / 10
break
case 'randomize':
targetWidget.value =
Math.floor(Math.random() * range) *
(targetWidget.options.step / 10) +
min;
break;
min
break
default:
break;
break
}
/*check if values are over or under their respective
* ranges and set them to min or max.*/
if (targetWidget.value < min) targetWidget.value = min;
if (targetWidget.value < min) targetWidget.value = min
if (targetWidget.value > max) targetWidget.value = max;
targetWidget.callback(targetWidget.value);
if (targetWidget.value > max) targetWidget.value = max
targetWidget.callback(targetWidget.value)
}
}
};
valueControl.beforeQueued = () => {
if (controlValueRunBefore) {
// Don't run on first execution
if (valueControl[HAS_EXECUTED]) {
applyWidgetControl();
applyWidgetControl()
}
}
valueControl[HAS_EXECUTED] = true;
};
valueControl[HAS_EXECUTED] = true
}
valueControl.afterQueued = () => {
if (!controlValueRunBefore) {
applyWidgetControl();
applyWidgetControl()
}
}
};
return widgets;
return widgets
}
function seedWidget(node, inputName, inputData: ComfyNodeDef, app, widgetName) {
const seed = createIntWidget(node, inputName, inputData, app, true);
const seed = createIntWidget(node, inputName, inputData, app, true)
const seedControl = addValueControlWidget(
node,
seed.widget,
"randomize",
'randomize',
undefined,
widgetName,
inputData
);
)
seed.widget.linkedWidgets = [seedControl];
return seed;
seed.widget.linkedWidgets = [seedControl]
return seed
}
function createIntWidget(
@@ -276,119 +276,116 @@ function createIntWidget(
app,
isSeedInput: boolean = false
) {
const control = inputData[1]?.control_after_generate;
const control = inputData[1]?.control_after_generate
if (!isSeedInput && control) {
return seedWidget(
node,
inputName,
inputData,
app,
typeof control === "string" ? control : undefined
);
typeof control === 'string' ? control : undefined
)
}
let widgetType = isSlider(inputData[1]["display"], app);
const { val, config } = getNumberDefaults(inputData, 1, 0, true);
Object.assign(config, { precision: 0 });
let widgetType = isSlider(inputData[1]['display'], app)
const { val, config } = getNumberDefaults(inputData, 1, 0, true)
Object.assign(config, { precision: 0 })
return {
widget: node.addWidget(
widgetType,
inputName,
val,
function (v) {
const s = this.options.step / 10;
let sh = this.options.min % s;
const s = this.options.step / 10
let sh = this.options.min % s
if (isNaN(sh)) {
sh = 0;
sh = 0
}
this.value = Math.round((v - sh) / s) * s + sh;
this.value = Math.round((v - sh) / s) * s + sh
},
config
),
};
)
}
}
function addMultilineWidget(node, name, opts, app) {
const inputEl = document.createElement("textarea");
inputEl.className = "comfy-multiline-input";
inputEl.value = opts.defaultVal;
inputEl.placeholder = opts.placeholder || name;
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
const widget = node.addDOMWidget(name, "customtext", inputEl, {
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue() {
return inputEl.value;
return inputEl.value
},
setValue(v) {
inputEl.value = v;
},
});
widget.inputEl = inputEl;
inputEl.value = v
}
})
widget.inputEl = inputEl
inputEl.addEventListener("input", () => {
widget.callback?.(widget.value);
});
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
})
return { minWidth: 400, minHeight: 200, widget };
return { minWidth: 400, minHeight: 200, widget }
}
function isSlider(display, app) {
if (app.ui.settings.getSettingValue("Comfy.DisableSliders")) {
return "number";
if (app.ui.settings.getSettingValue('Comfy.DisableSliders')) {
return 'number'
}
return display === "slider" ? "slider" : "number";
return display === 'slider' ? 'slider' : 'number'
}
export function initWidgets(app) {
app.ui.settings.addSetting({
id: "Comfy.WidgetControlMode",
name: "Widget Value Control Mode",
type: "combo",
defaultValue: "after",
options: ["before", "after"],
id: 'Comfy.WidgetControlMode',
name: 'Widget Value Control Mode',
type: 'combo',
defaultValue: 'after',
options: ['before', 'after'],
tooltip:
"Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",
'Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.',
onChange(value) {
controlValueRunBefore = value === "before";
controlValueRunBefore = value === 'before'
for (const n of app.graph._nodes) {
if (!n.widgets) continue;
if (!n.widgets) continue
for (const w of n.widgets) {
if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w);
updateControlWidgetLabel(w)
if (w.linkedWidgets) {
for (const l of w.linkedWidgets) {
updateControlWidgetLabel(l);
updateControlWidgetLabel(l)
}
}
}
}
}
app.graph.setDirtyCanvas(true);
},
});
app.graph.setDirtyCanvas(true)
}
})
}
export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
"INT:seed": seedWidget,
"INT:noise_seed": seedWidget,
'INT:seed': seedWidget,
'INT:noise_seed': seedWidget,
FLOAT(node, inputName, inputData: ComfyNodeDef, app) {
let widgetType: "number" | "slider" = isSlider(
inputData[1]["display"],
app
);
let widgetType: 'number' | 'slider' = isSlider(inputData[1]['display'], app)
let precision = app.ui.settings.getSettingValue(
"Comfy.FloatRoundingPrecision"
);
'Comfy.FloatRoundingPrecision'
)
let disable_rounding = app.ui.settings.getSettingValue(
"Comfy.DisableFloatRounding"
);
if (precision == 0) precision = undefined;
'Comfy.DisableFloatRounding'
)
if (precision == 0) precision = undefined
const { val, config } = getNumberDefaults(
inputData,
0.5,
precision,
!disable_rounding
);
)
return {
widget: node.addWidget(
widgetType,
@@ -397,72 +394,66 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
function (v) {
if (config.round) {
this.value =
Math.round((v + Number.EPSILON) / config.round) * config.round;
if (this.value > config.max) this.value = config.max;
if (this.value < config.min) this.value = config.min;
Math.round((v + Number.EPSILON) / config.round) * config.round
if (this.value > config.max) this.value = config.max
if (this.value < config.min) this.value = config.min
} else {
this.value = v;
this.value = v
}
},
config
),
};
)
}
},
INT(node, inputName, inputData: ComfyNodeDef, app) {
return createIntWidget(node, inputName, inputData, app);
return createIntWidget(node, inputName, inputData, app)
},
BOOLEAN(node, inputName, inputData) {
let defaultVal = false;
let options = {};
let defaultVal = false
let options = {}
if (inputData[1]) {
if (inputData[1].default) defaultVal = inputData[1].default;
if (inputData[1].label_on) options["on"] = inputData[1].label_on;
if (inputData[1].label_off) options["off"] = inputData[1].label_off;
if (inputData[1].default) defaultVal = inputData[1].default
if (inputData[1].label_on) options['on'] = inputData[1].label_on
if (inputData[1].label_off) options['off'] = inputData[1].label_off
}
return {
widget: node.addWidget(
"toggle",
inputName,
defaultVal,
() => {},
options
),
};
widget: node.addWidget('toggle', inputName, defaultVal, () => {}, options)
}
},
STRING(node, inputName, inputData: ComfyNodeDef, app) {
const defaultVal = inputData[1].default || "";
const multiline = !!inputData[1].multiline;
const defaultVal = inputData[1].default || ''
const multiline = !!inputData[1].multiline
let res;
let res
if (multiline) {
res = addMultilineWidget(
node,
inputName,
{ defaultVal, ...inputData[1] },
app
);
)
} else {
res = {
widget: node.addWidget("text", inputName, defaultVal, () => {}, {}),
};
widget: node.addWidget('text', inputName, defaultVal, () => {}, {})
}
}
if (inputData[1].dynamicPrompts != undefined)
res.widget.dynamicPrompts = inputData[1].dynamicPrompts;
res.widget.dynamicPrompts = inputData[1].dynamicPrompts
return res;
return res
},
COMBO(node, inputName, inputData: ComfyNodeDef) {
const type = inputData[0];
let defaultValue = type[0];
const type = inputData[0]
let defaultValue = type[0]
if (inputData[1] && inputData[1].default) {
defaultValue = inputData[1].default;
defaultValue = inputData[1].default
}
const res = {
widget: node.addWidget("combo", inputName, defaultValue, () => {}, {
values: type,
}),
};
widget: node.addWidget('combo', inputName, defaultValue, () => {}, {
values: type
})
}
if (inputData[1]?.control_after_generate) {
// TODO make combo handle a widget node type?
// @ts-ignore
@@ -472,9 +463,9 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
undefined,
undefined,
inputData
);
)
}
return res;
return res
},
IMAGEUPLOAD(
node: LGraphNode,
@@ -485,168 +476,168 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
// TODO make image upload handle a custom node type?
// @ts-ignore
const imageWidget = node.widgets.find(
(w) => w.name === (inputData[1]?.widget ?? "image")
);
let uploadWidget;
(w) => w.name === (inputData[1]?.widget ?? 'image')
)
let uploadWidget
function showImage(name) {
const img = new Image();
const img = new Image()
img.onload = () => {
// @ts-ignore
node.imgs = [img];
app.graph.setDirtyCanvas(true);
};
let folder_separator = name.lastIndexOf("/");
let subfolder = "";
node.imgs = [img]
app.graph.setDirtyCanvas(true)
}
let folder_separator = name.lastIndexOf('/')
let subfolder = ''
if (folder_separator > -1) {
subfolder = name.substring(0, folder_separator);
name = name.substring(folder_separator + 1);
subfolder = name.substring(0, folder_separator)
name = name.substring(folder_separator + 1)
}
img.src = api.apiURL(
`/view?filename=${encodeURIComponent(name)}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}`
);
)
// @ts-ignore
node.setSizeForImage?.();
node.setSizeForImage?.()
}
var default_value = imageWidget.value;
Object.defineProperty(imageWidget, "value", {
var default_value = imageWidget.value
Object.defineProperty(imageWidget, 'value', {
set: function (value) {
this._real_value = value;
this._real_value = value
},
get: function () {
if (!this._real_value) {
return default_value;
return default_value
}
let value = this._real_value;
let value = this._real_value
if (value.filename) {
let real_value = value;
value = "";
let real_value = value
value = ''
if (real_value.subfolder) {
value = real_value.subfolder + "/";
value = real_value.subfolder + '/'
}
value += real_value.filename;
value += real_value.filename
if (real_value.type && real_value.type !== "input")
value += ` [${real_value.type}]`;
if (real_value.type && real_value.type !== 'input')
value += ` [${real_value.type}]`
}
return value;
},
});
return value
}
})
// Add our own callback to the combo widget to render an image when it changes
// TODO: Explain this?
// @ts-ignore
const cb = node.callback;
const cb = node.callback
imageWidget.callback = function () {
showImage(imageWidget.value);
showImage(imageWidget.value)
if (cb) {
return cb.apply(this, arguments);
return cb.apply(this, arguments)
}
}
};
// On load if we have a value then render the image
// The value isnt set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
if (imageWidget.value) {
showImage(imageWidget.value);
showImage(imageWidget.value)
}
});
})
async function uploadFile(file, updateNode, pasted = false) {
try {
// Wrap file in formdata so it includes filename
const body = new FormData();
body.append("image", file);
if (pasted) body.append("subfolder", "pasted");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
const body = new FormData()
body.append('image', file)
if (pasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json();
const data = await resp.json()
// Add the file to the dropdown list and update the widget value
let path = data.name;
if (data.subfolder) path = data.subfolder + "/" + path;
let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path
if (!imageWidget.options.values.includes(path)) {
imageWidget.options.values.push(path);
imageWidget.options.values.push(path)
}
if (updateNode) {
showImage(path);
imageWidget.value = path;
showImage(path)
imageWidget.value = path
}
} else {
alert(resp.status + " - " + resp.statusText);
alert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
alert(error);
alert(error)
}
}
const fileInput = document.createElement("input");
const fileInput = document.createElement('input')
Object.assign(fileInput, {
type: "file",
accept: "image/jpeg,image/png,image/webp",
style: "display: none",
type: 'file',
accept: 'image/jpeg,image/png,image/webp',
style: 'display: none',
onchange: async () => {
if (fileInput.files.length) {
await uploadFile(fileInput.files[0], true);
await uploadFile(fileInput.files[0], true)
}
},
});
document.body.append(fileInput);
}
})
document.body.append(fileInput)
// Create the button widget for selecting the files
uploadWidget = node.addWidget("button", inputName, "image", () => {
fileInput.click();
});
uploadWidget.label = "choose file to upload";
uploadWidget.serialize = false;
uploadWidget = node.addWidget('button', inputName, 'image', () => {
fileInput.click()
})
uploadWidget.label = 'choose file to upload'
uploadWidget.serialize = false
// Add handler to check if an image is being dragged over our node
// @ts-ignore
node.onDragOver = function (e) {
if (e.dataTransfer && e.dataTransfer.items) {
const image = [...e.dataTransfer.items].find((f) => f.kind === "file");
return !!image;
const image = [...e.dataTransfer.items].find((f) => f.kind === 'file')
return !!image
}
return false;
};
return false
}
// On drop upload files
// @ts-ignore
node.onDragDrop = function (e) {
console.log("onDragDrop called");
let handled = false;
console.log('onDragDrop called')
let handled = false
for (const file of e.dataTransfer.files) {
if (file.type.startsWith("image/")) {
uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one
handled = true;
if (file.type.startsWith('image/')) {
uploadFile(file, !handled) // Dont await these, any order is fine, only update on first one
handled = true
}
}
return handled;
};
return handled
}
// @ts-ignore
node.pasteFile = function (file) {
if (file.type.startsWith("image/")) {
if (file.type.startsWith('image/')) {
const is_pasted =
file.name === "image.png" && file.lastModified - Date.now() < 2000;
uploadFile(file, true, is_pasted);
return true;
file.name === 'image.png' && file.lastModified - Date.now() < 2000
uploadFile(file, true, is_pasted)
return true
}
return false
}
return false;
};
return { widget: uploadWidget };
},
};
return { widget: uploadWidget }
}
}

View File

@@ -1,53 +1,53 @@
import type { ComfyApp } from "./app";
import { api } from "./api";
import { ChangeTracker } from "./changeTracker";
import { ComfyAsyncDialog } from "./ui/components/asyncDialog";
import { getStorageValue, setStorageValue } from "./utils";
import { LGraphCanvas, LGraph } from "@comfyorg/litegraph";
import type { ComfyApp } from './app'
import { api } from './api'
import { ChangeTracker } from './changeTracker'
import { ComfyAsyncDialog } from './ui/components/asyncDialog'
import { getStorageValue, setStorageValue } from './utils'
import { LGraphCanvas, LGraph } from '@comfyorg/litegraph'
function appendJsonExt(path: string) {
if (!path.toLowerCase().endsWith(".json")) {
path += ".json";
if (!path.toLowerCase().endsWith('.json')) {
path += '.json'
}
return path;
return path
}
export function trimJsonExt(path: string) {
return path?.replace(/\.json$/, "");
return path?.replace(/\.json$/, '')
}
export class ComfyWorkflowManager extends EventTarget {
#activePromptId: string | null = null;
#unsavedCount = 0;
#activeWorkflow: ComfyWorkflow;
#activePromptId: string | null = null
#unsavedCount = 0
#activeWorkflow: ComfyWorkflow
workflowLookup: Record<string, ComfyWorkflow> = {};
workflows: Array<ComfyWorkflow> = [];
openWorkflows: Array<ComfyWorkflow> = [];
workflowLookup: Record<string, ComfyWorkflow> = {}
workflows: Array<ComfyWorkflow> = []
openWorkflows: Array<ComfyWorkflow> = []
queuedPrompts: Record<
string,
{ workflow?: ComfyWorkflow; nodes?: Record<string, boolean> }
> = {};
app: ComfyApp;
> = {}
app: ComfyApp
get activeWorkflow() {
return this.#activeWorkflow ?? this.openWorkflows[0];
return this.#activeWorkflow ?? this.openWorkflows[0]
}
get activePromptId() {
return this.#activePromptId;
return this.#activePromptId
}
get activePrompt() {
return this.queuedPrompts[this.#activePromptId];
return this.queuedPrompts[this.#activePromptId]
}
constructor(app: ComfyApp) {
super();
this.app = app;
ChangeTracker.init(app);
super()
this.app = app
ChangeTracker.init(app)
this.#bindExecutionEvents();
this.#bindExecutionEvents()
}
#bindExecutionEvents() {
@@ -55,99 +55,99 @@ export class ComfyWorkflowManager extends EventTarget {
const emit = () =>
this.dispatchEvent(
new CustomEvent("execute", { detail: this.activePrompt })
);
let executing = null;
api.addEventListener("execution_start", (e) => {
this.#activePromptId = e.detail.prompt_id;
new CustomEvent('execute', { detail: this.activePrompt })
)
let executing = null
api.addEventListener('execution_start', (e) => {
this.#activePromptId = e.detail.prompt_id
// This event can fire before the event is stored, so put a placeholder
this.queuedPrompts[this.#activePromptId] ??= { nodes: {} };
emit();
});
api.addEventListener("execution_cached", (e) => {
if (!this.activePrompt) return;
this.queuedPrompts[this.#activePromptId] ??= { nodes: {} }
emit()
})
api.addEventListener('execution_cached', (e) => {
if (!this.activePrompt) return
for (const n of e.detail.nodes) {
this.activePrompt.nodes[n] = true;
this.activePrompt.nodes[n] = true
}
emit();
});
api.addEventListener("executed", (e) => {
if (!this.activePrompt) return;
this.activePrompt.nodes[e.detail.node] = true;
emit();
});
api.addEventListener("executing", (e) => {
if (!this.activePrompt) return;
emit()
})
api.addEventListener('executed', (e) => {
if (!this.activePrompt) return
this.activePrompt.nodes[e.detail.node] = true
emit()
})
api.addEventListener('executing', (e) => {
if (!this.activePrompt) return
if (executing) {
// Seems sometimes nodes that are cached fire executing but not executed
this.activePrompt.nodes[executing] = true;
this.activePrompt.nodes[executing] = true
}
executing = e.detail;
executing = e.detail
if (!executing) {
delete this.queuedPrompts[this.#activePromptId];
this.#activePromptId = null;
delete this.queuedPrompts[this.#activePromptId]
this.#activePromptId = null
}
emit();
});
emit()
})
}
async loadWorkflows() {
try {
let favorites;
const resp = await api.getUserData("workflows/.index.json");
let info;
let favorites
const resp = await api.getUserData('workflows/.index.json')
let info
if (resp.status === 200) {
info = await resp.json();
favorites = new Set(info?.favorites ?? []);
info = await resp.json()
favorites = new Set(info?.favorites ?? [])
} else {
favorites = new Set();
favorites = new Set()
}
const workflows = (await api.listUserData("workflows", true, true)).map(
const workflows = (await api.listUserData('workflows', true, true)).map(
(w) => {
let workflow = this.workflowLookup[w[0]];
let workflow = this.workflowLookup[w[0]]
if (!workflow) {
workflow = new ComfyWorkflow(
this,
w[0],
w.slice(1),
favorites.has(w[0])
);
this.workflowLookup[workflow.path] = workflow;
)
this.workflowLookup[workflow.path] = workflow
}
return workflow;
return workflow
}
);
)
this.workflows = workflows;
this.workflows = workflows
} catch (error) {
alert("Error loading workflows: " + (error.message ?? error));
this.workflows = [];
alert('Error loading workflows: ' + (error.message ?? error))
this.workflows = []
}
}
async saveWorkflowMetadata() {
await api.storeUserData("workflows/.index.json", {
await api.storeUserData('workflows/.index.json', {
favorites: [
...this.workflows.filter((w) => w.isFavorite).map((w) => w.path),
],
});
...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)
]
})
}
/**
* @param {string | ComfyWorkflow | null} workflow
*/
setWorkflow(workflow) {
if (workflow && typeof workflow === "string") {
if (workflow && typeof workflow === 'string') {
// Selected by path, i.e. on reload of last workflow
const found = this.workflows.find((w) => w.path === workflow);
const found = this.workflows.find((w) => w.path === workflow)
if (found) {
workflow = found;
workflow = found
workflow.unsaved =
!workflow ||
getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true";
getStorageValue('Comfy.PreviousWorkflowUnsaved') === 'true'
}
}
@@ -156,33 +156,33 @@ export class ComfyWorkflowManager extends EventTarget {
workflow = new ComfyWorkflow(
this,
workflow ||
"Unsaved Workflow" +
(this.#unsavedCount++ ? ` (${this.#unsavedCount})` : "")
);
'Unsaved Workflow' +
(this.#unsavedCount++ ? ` (${this.#unsavedCount})` : '')
)
}
const index = this.openWorkflows.indexOf(workflow);
const index = this.openWorkflows.indexOf(workflow)
if (index === -1) {
// Opening a new workflow
this.openWorkflows.push(workflow);
this.openWorkflows.push(workflow)
}
this.#activeWorkflow = workflow;
this.#activeWorkflow = workflow
setStorageValue("Comfy.PreviousWorkflow", this.activeWorkflow.path ?? "");
this.dispatchEvent(new CustomEvent("changeWorkflow"));
setStorageValue('Comfy.PreviousWorkflow', this.activeWorkflow.path ?? '')
this.dispatchEvent(new CustomEvent('changeWorkflow'))
}
storePrompt({ nodes, id }) {
this.queuedPrompts[id] ??= {};
this.queuedPrompts[id] ??= {}
this.queuedPrompts[id].nodes = {
...nodes.reduce((p, n) => {
p[n] = false;
return p;
p[n] = false
return p
}, {}),
...this.queuedPrompts[id].nodes,
};
this.queuedPrompts[id].workflow = this.activeWorkflow;
...this.queuedPrompts[id].nodes
}
this.queuedPrompts[id].workflow = this.activeWorkflow
}
/**
@@ -190,71 +190,71 @@ export class ComfyWorkflowManager extends EventTarget {
*/
async closeWorkflow(workflow, warnIfUnsaved = true) {
if (!workflow.isOpen) {
return true;
return true
}
if (workflow.unsaved && warnIfUnsaved) {
const res = await ComfyAsyncDialog.prompt({
title: "Save Changes?",
title: 'Save Changes?',
message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`,
actions: ["Yes", "No", "Cancel"],
});
if (res === "Yes") {
const active = this.activeWorkflow;
actions: ['Yes', 'No', 'Cancel']
})
if (res === 'Yes') {
const active = this.activeWorkflow
if (active !== workflow) {
// We need to switch to the workflow to save it
await workflow.load();
await workflow.load()
}
if (!(await workflow.save())) {
// Save was canceled, restore the previous workflow
if (active !== workflow) {
await active.load();
await active.load()
}
return;
return
}
} else if (res === "Cancel") {
return;
} else if (res === 'Cancel') {
return
}
}
workflow.changeTracker = null;
this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1);
workflow.changeTracker = null
this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1)
if (this.openWorkflows.length) {
this.#activeWorkflow = this.openWorkflows[0];
await this.#activeWorkflow.load();
this.#activeWorkflow = this.openWorkflows[0]
await this.#activeWorkflow.load()
} else {
// Load default
await this.app.loadGraphData();
await this.app.loadGraphData()
}
}
}
export class ComfyWorkflow {
#name;
#path;
#pathParts;
#isFavorite = false;
changeTracker: ChangeTracker | null = null;
unsaved = false;
manager: ComfyWorkflowManager;
#name
#path
#pathParts
#isFavorite = false
changeTracker: ChangeTracker | null = null
unsaved = false
manager: ComfyWorkflowManager
get name() {
return this.#name;
return this.#name
}
get path() {
return this.#path;
return this.#path
}
get pathParts() {
return this.#pathParts;
return this.#pathParts
}
get isFavorite() {
return this.#isFavorite;
return this.#isFavorite
}
get isOpen() {
return !!this.changeTracker;
return !!this.changeTracker
}
constructor(
@@ -263,40 +263,40 @@ export class ComfyWorkflow {
pathParts?: string[],
isFavorite?: boolean
) {
this.manager = manager;
this.manager = manager
if (pathParts) {
this.#updatePath(path, pathParts);
this.#isFavorite = isFavorite;
this.#updatePath(path, pathParts)
this.#isFavorite = isFavorite
} else {
this.#name = path;
this.unsaved = true;
this.#name = path
this.unsaved = true
}
}
#updatePath(path: string, pathParts: string[]) {
this.#path = path;
this.#path = path
if (!pathParts) {
if (!path.includes("\\")) {
pathParts = path.split("/");
if (!path.includes('\\')) {
pathParts = path.split('/')
} else {
pathParts = path.split("\\");
pathParts = path.split('\\')
}
}
this.#pathParts = pathParts;
this.#name = trimJsonExt(pathParts[pathParts.length - 1]);
this.#pathParts = pathParts
this.#name = trimJsonExt(pathParts[pathParts.length - 1])
}
async getWorkflowData() {
const resp = await api.getUserData("workflows/" + this.path);
const resp = await api.getUserData('workflows/' + this.path)
if (resp.status !== 200) {
alert(
`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`
);
return;
)
return
}
return await resp.json();
return await resp.json()
}
load = async () => {
@@ -306,44 +306,44 @@ export class ComfyWorkflow {
true,
true,
this
);
)
} else {
const data = await this.getWorkflowData();
if (!data) return;
await this.manager.app.loadGraphData(data, true, true, this);
const data = await this.getWorkflowData()
if (!data) return
await this.manager.app.loadGraphData(data, true, true, this)
}
}
};
async save(saveAs = false) {
if (!this.path || saveAs) {
return !!(await this.#save(null, false));
return !!(await this.#save(null, false))
} else {
return !!(await this.#save(this.path, true));
return !!(await this.#save(this.path, true))
}
}
async favorite(value: boolean) {
try {
if (this.#isFavorite === value) return;
this.#isFavorite = value;
await this.manager.saveWorkflowMetadata();
this.manager.dispatchEvent(new CustomEvent("favorite", { detail: this }));
if (this.#isFavorite === value) return
this.#isFavorite = value
await this.manager.saveWorkflowMetadata()
this.manager.dispatchEvent(new CustomEvent('favorite', { detail: this }))
} catch (error) {
alert(
"Error favoriting workflow " +
'Error favoriting workflow ' +
this.path +
"\n" +
'\n' +
(error.message ?? error)
);
)
}
}
async rename(path: string) {
path = appendJsonExt(path);
path = appendJsonExt(path)
let resp = await api.moveUserData(
"workflows/" + this.path,
"workflows/" + path
);
'workflows/' + this.path,
'workflows/' + path
)
if (resp.status === 409) {
if (
@@ -351,50 +351,50 @@ export class ComfyWorkflow {
`Workflow '${path}' already exists, do you want to overwrite it?`
)
)
return resp;
return resp
resp = await api.moveUserData(
"workflows/" + this.path,
"workflows/" + path,
'workflows/' + this.path,
'workflows/' + path,
{ overwrite: true }
);
)
}
if (resp.status !== 200) {
alert(
`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`
);
return;
)
return
}
const isFav = this.isFavorite;
const isFav = this.isFavorite
if (isFav) {
await this.favorite(false);
await this.favorite(false)
}
path = (await resp.json()).substring("workflows/".length);
this.#updatePath(path, null);
path = (await resp.json()).substring('workflows/'.length)
this.#updatePath(path, null)
if (isFav) {
await this.favorite(true);
await this.favorite(true)
}
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
setStorageValue('Comfy.PreviousWorkflow', this.path ?? '')
}
async insert() {
const data = await this.getWorkflowData();
if (!data) return;
const data = await this.getWorkflowData()
if (!data) return
const old = localStorage.getItem("litegrapheditor_clipboard");
const old = localStorage.getItem('litegrapheditor_clipboard')
// @ts-ignore
const graph = new LGraph(data);
const graph = new LGraph(data)
const canvas = new LGraphCanvas(null, graph, {
// @ts-ignore
skip_events: true,
skip_render: true,
});
canvas.selectNodes();
canvas.copyToClipboard();
this.manager.app.canvas.pasteFromClipboard();
localStorage.setItem("litegrapheditor_clipboard", old);
skip_render: true
})
canvas.selectNodes()
canvas.copyToClipboard()
this.manager.app.canvas.pasteFromClipboard()
localStorage.setItem('litegrapheditor_clipboard', old)
}
async delete() {
@@ -402,84 +402,84 @@ export class ComfyWorkflow {
try {
if (this.isFavorite) {
await this.favorite(false);
await this.favorite(false)
}
await api.deleteUserData("workflows/" + this.path);
this.unsaved = true;
this.#path = null;
this.#pathParts = null;
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1);
this.manager.dispatchEvent(new CustomEvent("delete", { detail: this }));
await api.deleteUserData('workflows/' + this.path)
this.unsaved = true
this.#path = null
this.#pathParts = null
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1)
this.manager.dispatchEvent(new CustomEvent('delete', { detail: this }))
} catch (error) {
alert(`Error deleting workflow: ${error.message || error}`);
alert(`Error deleting workflow: ${error.message || error}`)
}
}
track() {
if (this.changeTracker) {
this.changeTracker.restore();
this.changeTracker.restore()
} else {
this.changeTracker = new ChangeTracker(this);
this.changeTracker = new ChangeTracker(this)
}
}
async #save(path: string | null, overwrite: boolean) {
if (!path) {
path = prompt(
"Save workflow as:",
trimJsonExt(this.path) ?? this.name ?? "workflow"
);
if (!path) return;
'Save workflow as:',
trimJsonExt(this.path) ?? this.name ?? 'workflow'
)
if (!path) return
}
path = appendJsonExt(path);
path = appendJsonExt(path)
const p = await this.manager.app.graphToPrompt();
const json = JSON.stringify(p.workflow, null, 2);
let resp = await api.storeUserData("workflows/" + path, json, {
const p = await this.manager.app.graphToPrompt()
const json = JSON.stringify(p.workflow, null, 2)
let resp = await api.storeUserData('workflows/' + path, json, {
stringify: false,
throwOnError: false,
overwrite,
});
overwrite
})
if (resp.status === 409) {
if (
!confirm(
`Workflow '${path}' already exists, do you want to overwrite it?`
)
)
return;
resp = await api.storeUserData("workflows/" + path, json, {
stringify: false,
});
return
resp = await api.storeUserData('workflows/' + path, json, {
stringify: false
})
}
if (resp.status !== 200) {
alert(
`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`
);
return;
)
return
}
path = (await resp.json()).substring("workflows/".length);
path = (await resp.json()).substring('workflows/'.length)
if (!this.path) {
// Saved new workflow, patch this instance
this.#updatePath(path, null);
await this.manager.loadWorkflows();
this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
this.#updatePath(path, null)
await this.manager.loadWorkflows()
this.unsaved = false
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
setStorageValue('Comfy.PreviousWorkflow', this.path ?? '')
} else if (path !== this.path) {
// Saved as, open the new copy
await this.manager.loadWorkflows();
const workflow = this.manager.workflowLookup[path];
await workflow.load();
await this.manager.loadWorkflows()
const workflow = this.manager.workflowLookup[path]
await workflow.load()
} else {
// Normal save
this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("save", { detail: this }));
this.unsaved = false
this.manager.dispatchEvent(new CustomEvent('save', { detail: this }))
}
return true;
return true
}
}

View File

@@ -1,191 +1,191 @@
import { ComfyNodeDef } from "@/types/apiTypes";
import { getNodeSource } from "@/types/nodeSource";
import Fuse, { IFuseOptions, FuseSearchOptions } from "fuse.js";
import _ from "lodash";
import { ComfyNodeDef } from '@/types/apiTypes'
import { getNodeSource } from '@/types/nodeSource'
import Fuse, { IFuseOptions, FuseSearchOptions } from 'fuse.js'
import _ from 'lodash'
export const SYSTEM_NODE_DEFS: ComfyNodeDef[] = [
{
name: "PrimitiveNode",
display_name: "Primitive",
category: "utils",
name: 'PrimitiveNode',
display_name: 'Primitive',
category: 'utils',
input: { required: {}, optional: {} },
output: ["*"],
output_name: ["connect to widget input"],
output: ['*'],
output_name: ['connect to widget input'],
output_is_list: [false],
python_module: "nodes",
description: "Primitive values like numbers, strings, and booleans.",
python_module: 'nodes',
description: 'Primitive values like numbers, strings, and booleans.'
},
{
name: "Reroute",
display_name: "Reroute",
category: "utils",
input: { required: { "": ["*"] }, optional: {} },
output: ["*"],
output_name: [""],
name: 'Reroute',
display_name: 'Reroute',
category: 'utils',
input: { required: { '': ['*'] }, optional: {} },
output: ['*'],
output_name: [''],
output_is_list: [false],
python_module: "nodes",
description: "Reroute the connection to another node.",
python_module: 'nodes',
description: 'Reroute the connection to another node.'
},
{
name: "Note",
display_name: "Note",
category: "utils",
name: 'Note',
display_name: 'Note',
category: 'utils',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
python_module: "nodes",
description: "Node that add notes to your project",
},
];
python_module: 'nodes',
description: 'Node that add notes to your project'
}
]
export class FuseSearch<T> {
private fuse: Fuse<T>;
public readonly data: T[];
private fuse: Fuse<T>
public readonly data: T[]
constructor(
data: T[],
options?: IFuseOptions<T>,
createIndex: boolean = true
) {
this.data = data;
this.data = data
const index =
createIndex && options?.keys
? Fuse.createIndex(options.keys, data)
: undefined;
this.fuse = new Fuse(data, options, index);
: undefined
this.fuse = new Fuse(data, options, index)
}
public search(query: string, options?: FuseSearchOptions): T[] {
if (!query || query === "") {
return [...this.data];
if (!query || query === '') {
return [...this.data]
}
return this.fuse.search(query, options).map((result) => result.item);
return this.fuse.search(query, options).map((result) => result.item)
}
}
export type FilterAndValue<T = string> = [NodeFilter<T>, T];
export type FilterAndValue<T = string> = [NodeFilter<T>, T]
export abstract class NodeFilter<FilterOptionT = string> {
public abstract readonly id: string;
public abstract readonly name: string;
public abstract readonly invokeSequence: string;
public abstract readonly longInvokeSequence: string;
public readonly fuseSearch: FuseSearch<FilterOptionT>;
public abstract readonly id: string
public abstract readonly name: string
public abstract readonly invokeSequence: string
public abstract readonly longInvokeSequence: string
public readonly fuseSearch: FuseSearch<FilterOptionT>
constructor(nodeDefs: ComfyNodeDef[], options?: IFuseOptions<FilterOptionT>) {
this.fuseSearch = new FuseSearch(this.getAllNodeOptions(nodeDefs), options);
this.fuseSearch = new FuseSearch(this.getAllNodeOptions(nodeDefs), options)
}
private getAllNodeOptions(nodeDefs: ComfyNodeDef[]): FilterOptionT[] {
return [
...new Set(
nodeDefs.reduce((acc, nodeDef) => {
return [...acc, ...this.getNodeOptions(nodeDef)];
return [...acc, ...this.getNodeOptions(nodeDef)]
}, [])
),
];
)
]
}
public abstract getNodeOptions(node: ComfyNodeDef): FilterOptionT[];
public abstract getNodeOptions(node: ComfyNodeDef): FilterOptionT[]
public matches(node: ComfyNodeDef, value: FilterOptionT): boolean {
return this.getNodeOptions(node).includes(value);
return this.getNodeOptions(node).includes(value)
}
}
export class InputTypeFilter extends NodeFilter<string> {
public readonly id: string = "input";
public readonly name = "Input Type";
public readonly invokeSequence = "i";
public readonly longInvokeSequence = "input";
public readonly id: string = 'input'
public readonly name = 'Input Type'
public readonly invokeSequence = 'i'
public readonly longInvokeSequence = 'input'
public override getNodeOptions(node: ComfyNodeDef): string[] {
const inputs = {
...(node.input.required || {}),
...(node.input.optional || {}),
};
...(node.input.optional || {})
}
return Object.values(inputs).map((input) => {
const [inputType, inputSpec] = input;
return typeof inputType === "string" ? inputType : "COMBO";
});
const [inputType, inputSpec] = input
return typeof inputType === 'string' ? inputType : 'COMBO'
})
}
}
export class OutputTypeFilter extends NodeFilter<string> {
public readonly id: string = "output";
public readonly name = "Output Type";
public readonly invokeSequence = "o";
public readonly longInvokeSequence = "output";
public readonly id: string = 'output'
public readonly name = 'Output Type'
public readonly invokeSequence = 'o'
public readonly longInvokeSequence = 'output'
public override getNodeOptions(node: ComfyNodeDef): string[] {
const outputs = node.output || [];
const outputs = node.output || []
// "custom_nodes.was-node-suite-comfyui"
// has a custom node with an output that is not an array.
// https://github.com/WASasquatch/was-node-suite-comfyui/pull/440
if (!(outputs instanceof Array)) {
console.error("Invalid output type", node);
return [];
console.error('Invalid output type', node)
return []
}
return outputs.map((output) => {
return typeof output === "string" ? output : output[0];
});
return typeof output === 'string' ? output : output[0]
})
}
}
export class NodeSourceFilter extends NodeFilter<string> {
public readonly id: string = "source";
public readonly name = "Source";
public readonly invokeSequence = "s";
public readonly longInvokeSequence = "source";
public readonly id: string = 'source'
public readonly name = 'Source'
public readonly invokeSequence = 's'
public readonly longInvokeSequence = 'source'
public override getNodeOptions(node: ComfyNodeDef): string[] {
return [getNodeSource(node.python_module).displayText];
return [getNodeSource(node.python_module).displayText]
}
}
export class NodeCategoryFilter extends NodeFilter<string> {
public readonly id: string = "category";
public readonly name = "Category";
public readonly invokeSequence = "c";
public readonly longInvokeSequence = "category";
public readonly id: string = 'category'
public readonly name = 'Category'
public readonly invokeSequence = 'c'
public readonly longInvokeSequence = 'category'
public override getNodeOptions(node: ComfyNodeDef): string[] {
return [node.category];
return [node.category]
}
}
export class NodeSearchService {
public readonly nodeFuseSearch: FuseSearch<ComfyNodeDef>;
public readonly nodeFilters: NodeFilter<string>[];
public readonly nodeFuseSearch: FuseSearch<ComfyNodeDef>
public readonly nodeFilters: NodeFilter<string>[]
constructor(data: ComfyNodeDef[]) {
this.nodeFuseSearch = new FuseSearch(data, {
keys: ["name", "display_name", "description"],
keys: ['name', 'display_name', 'description'],
includeScore: true,
threshold: 0.6,
shouldSort: true,
});
shouldSort: true
})
const filterSearchOptions = {
includeScore: true,
threshold: 0.6,
shouldSort: true,
};
shouldSort: true
}
this.nodeFilters = [
new InputTypeFilter(data, filterSearchOptions),
new OutputTypeFilter(data, filterSearchOptions),
new NodeCategoryFilter(data, filterSearchOptions),
];
new NodeCategoryFilter(data, filterSearchOptions)
]
if (data[0].python_module !== undefined) {
this.nodeFilters.push(new NodeSourceFilter(data, filterSearchOptions));
this.nodeFilters.push(new NodeSourceFilter(data, filterSearchOptions))
}
}
public endsWithFilterStartSequence(query: string): boolean {
return query.endsWith(":");
return query.endsWith(':')
}
public searchNode(
@@ -193,19 +193,19 @@ export class NodeSearchService {
filters: FilterAndValue<string>[] = [],
options?: FuseSearchOptions
): ComfyNodeDef[] {
const matchedNodes = this.nodeFuseSearch.search(query);
const matchedNodes = this.nodeFuseSearch.search(query)
const results = matchedNodes.filter((node) => {
return _.every(filters, (filterAndValue) => {
const [filter, value] = filterAndValue;
return filter.matches(node, value);
});
});
const [filter, value] = filterAndValue
return filter.matches(node, value)
})
})
return options?.limit ? results.slice(0, options.limit) : results;
return options?.limit ? results.slice(0, options.limit) : results
}
public getFilterById(id: string): NodeFilter<string> | undefined {
return this.nodeFilters.find((filter) => filter.id === id);
return this.nodeFilters.find((filter) => filter.id === id)
}
}

View File

@@ -1,72 +1,72 @@
import { NodeSearchService } from "@/services/nodeSearchService";
import { ComfyNodeDef } from "@/types/apiTypes";
import { defineStore } from "pinia";
import { NodeSearchService } from '@/services/nodeSearchService'
import { ComfyNodeDef } from '@/types/apiTypes'
import { defineStore } from 'pinia'
export const SYSTEM_NODE_DEFS: ComfyNodeDef[] = [
{
name: "PrimitiveNode",
display_name: "Primitive",
category: "utils",
name: 'PrimitiveNode',
display_name: 'Primitive',
category: 'utils',
input: { required: {}, optional: {} },
output: ["*"],
output_name: ["connect to widget input"],
output: ['*'],
output_name: ['connect to widget input'],
output_is_list: [false],
python_module: "nodes",
description: "Primitive values like numbers, strings, and booleans.",
python_module: 'nodes',
description: 'Primitive values like numbers, strings, and booleans.'
},
{
name: "Reroute",
display_name: "Reroute",
category: "utils",
input: { required: { "": ["*"] }, optional: {} },
output: ["*"],
output_name: [""],
name: 'Reroute',
display_name: 'Reroute',
category: 'utils',
input: { required: { '': ['*'] }, optional: {} },
output: ['*'],
output_name: [''],
output_is_list: [false],
python_module: "nodes",
description: "Reroute the connection to another node.",
python_module: 'nodes',
description: 'Reroute the connection to another node.'
},
{
name: "Note",
display_name: "Note",
category: "utils",
name: 'Note',
display_name: 'Note',
category: 'utils',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
python_module: "nodes",
description: "Node that add notes to your project",
},
];
python_module: 'nodes',
description: 'Node that add notes to your project'
}
]
const SYSTEM_NODE_DEFS_BY_NAME = SYSTEM_NODE_DEFS.reduce((acc, nodeDef) => {
acc[nodeDef.name] = nodeDef;
return acc;
}, {}) as Record<string, ComfyNodeDef>;
acc[nodeDef.name] = nodeDef
return acc
}, {}) as Record<string, ComfyNodeDef>
interface State {
nodeDefsByName: Record<string, ComfyNodeDef>;
nodeDefsByName: Record<string, ComfyNodeDef>
}
export const useNodeDefStore = defineStore("nodeDef", {
export const useNodeDefStore = defineStore('nodeDef', {
state: (): State => ({
nodeDefsByName: SYSTEM_NODE_DEFS_BY_NAME,
nodeDefsByName: SYSTEM_NODE_DEFS_BY_NAME
}),
getters: {
nodeDefs(state) {
return Object.values(state.nodeDefsByName);
return Object.values(state.nodeDefsByName)
},
nodeSearchService(state) {
return new NodeSearchService(Object.values(state.nodeDefsByName));
},
return new NodeSearchService(Object.values(state.nodeDefsByName))
}
},
actions: {
addNodeDef(nodeDef: ComfyNodeDef) {
this.nodeDefsByName[nodeDef.name] = nodeDef;
this.nodeDefsByName[nodeDef.name] = nodeDef
},
addNodeDefs(nodeDefs: ComfyNodeDef[]) {
for (const nodeDef of nodeDefs) {
this.nodeDefsByName[nodeDef.name] = nodeDef;
this.nodeDefsByName[nodeDef.name] = nodeDef
}
},
},
});
}
}
})

View File

@@ -1,184 +1,184 @@
import { api } from "@/scripts/api";
import { app } from "@/scripts/app";
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import {
validateTaskItem,
TaskItem,
TaskType,
TaskPrompt,
TaskStatus,
TaskOutput,
} from "@/types/apiTypes";
import { plainToClass } from "class-transformer";
import _ from "lodash";
import { defineStore } from "pinia";
import { toRaw } from "vue";
TaskOutput
} from '@/types/apiTypes'
import { plainToClass } from 'class-transformer'
import _ from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
// Task type used in the API.
export type APITaskType = "queue" | "history";
export type APITaskType = 'queue' | 'history'
export enum TaskItemDisplayStatus {
Running = "Running",
Pending = "Pending",
Completed = "Completed",
Failed = "Failed",
Cancelled = "Cancelled",
Running = 'Running',
Pending = 'Pending',
Completed = 'Completed',
Failed = 'Failed',
Cancelled = 'Cancelled'
}
export class TaskItemImpl {
taskType: TaskType;
prompt: TaskPrompt;
status?: TaskStatus;
outputs?: TaskOutput;
taskType: TaskType
prompt: TaskPrompt
status?: TaskStatus
outputs?: TaskOutput
get apiTaskType(): APITaskType {
switch (this.taskType) {
case "Running":
case "Pending":
return "queue";
case "History":
return "history";
case 'Running':
case 'Pending':
return 'queue'
case 'History':
return 'history'
}
}
get queueIndex() {
return this.prompt[0];
return this.prompt[0]
}
get promptId() {
return this.prompt[1];
return this.prompt[1]
}
get promptInputs() {
return this.prompt[2];
return this.prompt[2]
}
get extraData() {
return this.prompt[3];
return this.prompt[3]
}
get outputsToExecute() {
return this.prompt[4];
return this.prompt[4]
}
get extraPngInfo() {
return this.extraData.extra_pnginfo;
return this.extraData.extra_pnginfo
}
get clientId() {
return this.extraData.client_id;
return this.extraData.client_id
}
get workflow() {
return this.extraPngInfo.workflow;
return this.extraPngInfo.workflow
}
get messages() {
return this.status?.messages || [];
return this.status?.messages || []
}
get interrupted() {
return _.some(
this.messages,
(message) => message[0] === "execution_interrupted"
);
(message) => message[0] === 'execution_interrupted'
)
}
get isHistory() {
return this.taskType === "History";
return this.taskType === 'History'
}
get isRunning() {
return this.taskType === "Running";
return this.taskType === 'Running'
}
get displayStatus(): TaskItemDisplayStatus {
switch (this.taskType) {
case "Running":
return TaskItemDisplayStatus.Running;
case "Pending":
return TaskItemDisplayStatus.Pending;
case "History":
case 'Running':
return TaskItemDisplayStatus.Running
case 'Pending':
return TaskItemDisplayStatus.Pending
case 'History':
switch (this.status!.status_str) {
case "success":
return TaskItemDisplayStatus.Completed;
case "error":
case 'success':
return TaskItemDisplayStatus.Completed
case 'error':
return this.interrupted
? TaskItemDisplayStatus.Cancelled
: TaskItemDisplayStatus.Failed;
: TaskItemDisplayStatus.Failed
}
}
}
get executionStartTimestamp() {
const message = this.messages.find(
(message) => message[0] === "execution_start"
);
return message ? message[1].timestamp : undefined;
(message) => message[0] === 'execution_start'
)
return message ? message[1].timestamp : undefined
}
get executionEndTimestamp() {
const messages = this.messages.filter((message) =>
[
"execution_success",
"execution_interrupted",
"execution_error",
'execution_success',
'execution_interrupted',
'execution_error'
].includes(message[0])
);
)
if (!messages.length) {
return undefined;
return undefined
}
return _.max(messages.map((message) => message[1].timestamp));
return _.max(messages.map((message) => message[1].timestamp))
}
get executionTime() {
if (!this.executionStartTimestamp || !this.executionEndTimestamp) {
return undefined;
return undefined
}
return this.executionEndTimestamp - this.executionStartTimestamp;
return this.executionEndTimestamp - this.executionStartTimestamp
}
get executionTimeInSeconds() {
return this.executionTime !== undefined
? this.executionTime / 1000
: undefined;
: undefined
}
public async loadWorkflow() {
await app.loadGraphData(toRaw(this.workflow));
await app.loadGraphData(toRaw(this.workflow))
if (this.outputs) {
app.nodeOutputs = toRaw(this.outputs);
app.nodeOutputs = toRaw(this.outputs)
}
}
}
interface State {
runningTasks: TaskItemImpl[];
pendingTasks: TaskItemImpl[];
historyTasks: TaskItemImpl[];
runningTasks: TaskItemImpl[]
pendingTasks: TaskItemImpl[]
historyTasks: TaskItemImpl[]
}
export const useQueueStore = defineStore("queue", {
export const useQueueStore = defineStore('queue', {
state: (): State => ({
runningTasks: [],
pendingTasks: [],
historyTasks: [],
historyTasks: []
}),
getters: {
tasks(state) {
return [
...state.pendingTasks,
...state.runningTasks,
...state.historyTasks,
];
},
...state.historyTasks
]
}
},
actions: {
// Fetch the queue data from the API
async update() {
const [queue, history] = await Promise.all([
api.getQueue(),
api.getHistory(),
]);
api.getHistory()
])
const toClassAll = (tasks: TaskItem[]): TaskItemImpl[] =>
tasks
@@ -186,21 +186,21 @@ export const useQueueStore = defineStore("queue", {
.filter((result) => result.success)
.map((result) => plainToClass(TaskItemImpl, result.data))
// Desc order to show the latest tasks first
.sort((a, b) => b.queueIndex - a.queueIndex);
.sort((a, b) => b.queueIndex - a.queueIndex)
this.runningTasks = toClassAll(queue.Running);
this.pendingTasks = toClassAll(queue.Pending);
this.historyTasks = toClassAll(history.History);
this.runningTasks = toClassAll(queue.Running)
this.pendingTasks = toClassAll(queue.Pending)
this.historyTasks = toClassAll(history.History)
},
async clear() {
await Promise.all(
["queue", "history"].map((type) => api.clearItems(type))
);
await this.update();
['queue', 'history'].map((type) => api.clearItems(type))
)
await this.update()
},
async delete(task: TaskItemImpl) {
await api.deleteItem(task.apiTaskType, task.promptId);
await this.update();
},
},
});
await api.deleteItem(task.apiTaskType, task.promptId)
await this.update()
}
}
})

View File

@@ -7,35 +7,35 @@
* settings directly updates the settingStore.settingValues.
*/
import { app } from "@/scripts/app";
import { ComfySettingsDialog } from "@/scripts/ui/settings";
import { defineStore } from "pinia";
import { app } from '@/scripts/app'
import { ComfySettingsDialog } from '@/scripts/ui/settings'
import { defineStore } from 'pinia'
interface State {
settingValues: Record<string, any>;
settingValues: Record<string, any>
}
export const useSettingStore = defineStore("setting", {
export const useSettingStore = defineStore('setting', {
state: (): State => ({
settingValues: {},
settingValues: {}
}),
actions: {
addSettings(settings: ComfySettingsDialog) {
for (const id in settings.settingsLookup) {
const value = settings.getSettingValue(id);
this.settingValues[id] = value;
const value = settings.getSettingValue(id)
this.settingValues[id] = value
}
},
set(key: string, value: any) {
this.settingValues[key] = value;
app.ui.settings.setSettingValue(key, value);
this.settingValues[key] = value
app.ui.settings.setSettingValue(key, value)
},
get(key: string) {
return (
this.settingValues[key] ?? app.ui.settings.getSettingDefaultValue(key)
);
},
},
});
)
}
}
})

View File

@@ -1,32 +1,32 @@
import { defineStore } from "pinia";
import { defineStore } from 'pinia'
interface WorkspaceState {
activeSidebarTab: string | null;
sidebarTabsOrder: string[]; // Array of tab IDs in order
activeSidebarTab: string | null
sidebarTabsOrder: string[] // Array of tab IDs in order
}
export const useWorkspaceStore = defineStore("workspace", {
export const useWorkspaceStore = defineStore('workspace', {
state: (): WorkspaceState => ({
activeSidebarTab: null,
sidebarTabsOrder: [],
sidebarTabsOrder: []
}),
actions: {
updateActiveSidebarTab(tabId: string) {
this.activeSidebarTab = tabId;
this.activeSidebarTab = tabId
},
updateSidebarOrder(newOrder: string[]) {
this.sidebarTabsOrder = newOrder;
this.sidebarTabsOrder = newOrder
},
serialize() {
return JSON.stringify({
activeSidebarTab: this.activeSidebarTab,
sidebarTabsOrder: this.sidebarTabsOrder,
});
sidebarTabsOrder: this.sidebarTabsOrder
})
},
deserialize(state: string) {
const parsedState = JSON.parse(state);
this.sidebarTabsOrder = parsedState.sidebarTabsOrder;
this.activeSidebarTab = parsedState.activeSidebarTab;
},
},
});
const parsedState = JSON.parse(state)
this.sidebarTabsOrder = parsedState.sidebarTabsOrder
this.activeSidebarTab = parsedState.activeSidebarTab
}
}
})

View File

@@ -1,65 +1,65 @@
import { ZodType, z } from "zod";
import { zComfyWorkflow } from "./comfyWorkflow";
import { fromZodError } from "zod-validation-error";
import { ZodType, z } from 'zod'
import { zComfyWorkflow } from './comfyWorkflow'
import { fromZodError } from 'zod-validation-error'
const zNodeId = z.union([z.number(), z.string()]);
const zNodeType = z.string();
const zQueueIndex = z.number();
const zPromptId = z.string();
const zNodeId = z.union([z.number(), z.string()])
const zNodeType = z.string()
const zQueueIndex = z.number()
const zPromptId = z.string()
const zPromptInputItem = z.object({
inputs: z.record(z.string(), z.any()),
class_type: zNodeType,
});
class_type: zNodeType
})
const zPromptInputs = z.record(zPromptInputItem);
const zPromptInputs = z.record(zPromptInputItem)
const zExtraPngInfo = z
.object({
workflow: zComfyWorkflow,
workflow: zComfyWorkflow
})
.passthrough();
.passthrough()
const zExtraData = z.object({
extra_pnginfo: zExtraPngInfo,
client_id: z.string(),
});
const zOutputsToExecute = z.array(zNodeId);
client_id: z.string()
})
const zOutputsToExecute = z.array(zNodeId)
const zMessageDetailBase = z.object({
prompt_id: zPromptId,
timestamp: z.number(),
});
timestamp: z.number()
})
const zExecutionStartMessage = z.tuple([
z.literal("execution_start"),
zMessageDetailBase,
]);
z.literal('execution_start'),
zMessageDetailBase
])
const zExecutionSuccessMessage = z.tuple([
z.literal("execution_success"),
zMessageDetailBase,
]);
z.literal('execution_success'),
zMessageDetailBase
])
const zExecutionCachedMessage = z.tuple([
z.literal("execution_cached"),
z.literal('execution_cached'),
zMessageDetailBase.extend({
nodes: z.array(zNodeId),
}),
]);
nodes: z.array(zNodeId)
})
])
const zExecutionInterruptedMessage = z.tuple([
z.literal("execution_interrupted"),
z.literal('execution_interrupted'),
zMessageDetailBase.extend({
// InterruptProcessingException
node_id: zNodeId,
node_type: zNodeType,
executed: z.array(zNodeId),
}),
]);
executed: z.array(zNodeId)
})
])
const zExecutionErrorMessage = z.tuple([
z.literal("execution_error"),
z.literal('execution_error'),
zMessageDetailBase.extend({
node_id: zNodeId,
node_type: zNodeType,
@@ -69,154 +69,154 @@ const zExecutionErrorMessage = z.tuple([
exception_type: z.string(),
traceback: z.string(),
current_inputs: z.any(),
current_outputs: z.any(),
}),
]);
current_outputs: z.any()
})
])
const zStatusMessage = z.union([
zExecutionStartMessage,
zExecutionSuccessMessage,
zExecutionCachedMessage,
zExecutionInterruptedMessage,
zExecutionErrorMessage,
]);
zExecutionErrorMessage
])
const zStatus = z.object({
status_str: z.enum(["success", "error"]),
status_str: z.enum(['success', 'error']),
completed: z.boolean(),
messages: z.array(zStatusMessage),
});
messages: z.array(zStatusMessage)
})
// TODO: this is a placeholder
const zOutput = z.any();
const zOutput = z.any()
const zTaskPrompt = z.tuple([
zQueueIndex,
zPromptId,
zPromptInputs,
zExtraData,
zOutputsToExecute,
]);
zOutputsToExecute
])
const zRunningTaskItem = z.object({
taskType: z.literal("Running"),
taskType: z.literal('Running'),
prompt: zTaskPrompt,
// @Deprecated
remove: z.object({
name: z.literal("Cancel"),
cb: z.function(),
}),
});
name: z.literal('Cancel'),
cb: z.function()
})
})
const zPendingTaskItem = z.object({
taskType: z.literal("Pending"),
prompt: zTaskPrompt,
});
taskType: z.literal('Pending'),
prompt: zTaskPrompt
})
const zTaskOutput = z.record(zNodeId, zOutput);
const zTaskOutput = z.record(zNodeId, zOutput)
const zHistoryTaskItem = z.object({
taskType: z.literal("History"),
taskType: z.literal('History'),
prompt: zTaskPrompt,
status: zStatus.optional(),
outputs: zTaskOutput,
});
outputs: zTaskOutput
})
const zTaskItem = z.union([
zRunningTaskItem,
zPendingTaskItem,
zHistoryTaskItem,
]);
zHistoryTaskItem
])
const zTaskType = z.union([
z.literal("Running"),
z.literal("Pending"),
z.literal("History"),
]);
z.literal('Running'),
z.literal('Pending'),
z.literal('History')
])
export type TaskType = z.infer<typeof zTaskType>;
export type TaskPrompt = z.infer<typeof zTaskPrompt>;
export type TaskStatus = z.infer<typeof zStatus>;
export type TaskOutput = z.infer<typeof zTaskOutput>;
export type TaskType = z.infer<typeof zTaskType>
export type TaskPrompt = z.infer<typeof zTaskPrompt>
export type TaskStatus = z.infer<typeof zStatus>
export type TaskOutput = z.infer<typeof zTaskOutput>
// `/queue`
export type RunningTaskItem = z.infer<typeof zRunningTaskItem>;
export type PendingTaskItem = z.infer<typeof zPendingTaskItem>;
export type RunningTaskItem = z.infer<typeof zRunningTaskItem>
export type PendingTaskItem = z.infer<typeof zPendingTaskItem>
// `/history`
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>;
export type TaskItem = z.infer<typeof zTaskItem>;
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>
export type TaskItem = z.infer<typeof zTaskItem>
export function validateTaskItem(taskItem: unknown) {
const result = zTaskItem.safeParse(taskItem);
const result = zTaskItem.safeParse(taskItem)
if (!result.success) {
const zodError = fromZodError(result.error);
const zodError = fromZodError(result.error)
// TODO accept a callback to report error.
console.warn(
`Invalid TaskItem: ${JSON.stringify(taskItem)}\n${zodError.message}`
);
)
}
return result;
return result
}
function inputSpec(
spec: [ZodType, ZodType],
allowUpcast: boolean = true
): ZodType {
const [inputType, inputSpec] = spec;
const [inputType, inputSpec] = spec
// e.g. "INT" => ["INT", {}]
const upcastTypes: ZodType[] = allowUpcast
? [inputType.transform((type) => [type, {}])]
: [];
: []
return z.union([
z.tuple([inputType, inputSpec]),
z.tuple([inputType]).transform(([type]) => [type, {}]),
...upcastTypes,
]);
...upcastTypes
])
}
const zIntInputSpec = inputSpec([
z.literal("INT"),
z.literal('INT'),
z.object({
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional(),
default: z.number().optional(),
forceInput: z.boolean().optional(),
}),
]);
forceInput: z.boolean().optional()
})
])
const zFloatInputSpec = inputSpec([
z.literal("FLOAT"),
z.literal('FLOAT'),
z.object({
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional(),
round: z.number().optional(),
default: z.number().optional(),
forceInput: z.boolean().optional(),
}),
]);
forceInput: z.boolean().optional()
})
])
const zBooleanInputSpec = inputSpec([
z.literal("BOOLEAN"),
z.literal('BOOLEAN'),
z.object({
label_on: z.string().optional(),
label_off: z.string().optional(),
default: z.boolean().optional(),
forceInput: z.boolean().optional(),
}),
]);
forceInput: z.boolean().optional()
})
])
const zStringInputSpec = inputSpec([
z.literal("STRING"),
z.literal('STRING'),
z.object({
default: z.string().optional(),
multiline: z.boolean().optional(),
dynamicPrompts: z.boolean().optional(),
forceInput: z.boolean().optional(),
}),
]);
forceInput: z.boolean().optional()
})
])
// Dropdown Selection.
const zComboInputSpec = inputSpec(
@@ -226,19 +226,19 @@ const zComboInputSpec = inputSpec(
default: z.any().optional(),
control_after_generate: z.boolean().optional(),
image_upload: z.boolean().optional(),
forceInput: z.boolean().optional(),
}),
forceInput: z.boolean().optional()
})
],
/* allowUpcast=*/ false
);
)
const zCustomInputSpec = inputSpec([
z.string(),
z.object({
default: z.any().optional(),
forceInput: z.boolean().optional(),
}),
]);
forceInput: z.boolean().optional()
})
])
const zInputSpec = z.union([
zIntInputSpec,
@@ -246,14 +246,14 @@ const zInputSpec = z.union([
zBooleanInputSpec,
zStringInputSpec,
zComboInputSpec,
zCustomInputSpec,
]);
zCustomInputSpec
])
const zComfyNodeDataType = z.string();
const zComfyComboOutput = z.array(z.any());
const zComfyNodeDataType = z.string()
const zComfyComboOutput = z.array(z.any())
const zComfyOutputSpec = z.array(
z.union([zComfyNodeDataType, zComfyComboOutput])
);
)
const zComfyNodeDef = z.object({
input: z.object({
@@ -261,7 +261,7 @@ const zComfyNodeDef = z.object({
optional: z.record(zInputSpec).optional(),
// Frontend repo is not using it, but some custom nodes are using the
// hidden field to pass various values.
hidden: z.record(z.any()).optional(),
hidden: z.record(z.any()).optional()
}),
output: zComfyOutputSpec,
output_is_list: z.array(z.boolean()),
@@ -271,23 +271,23 @@ const zComfyNodeDef = z.object({
description: z.string(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
});
python_module: z.string()
})
// `/object_info`
export type ComfyInputSpec = z.infer<typeof zInputSpec>;
export type ComfyOutputSpec = z.infer<typeof zComfyOutputSpec>;
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>;
export type ComfyInputSpec = z.infer<typeof zInputSpec>
export type ComfyOutputSpec = z.infer<typeof zComfyOutputSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export function validateComfyNodeDef(data: any): ComfyNodeDef {
const result = zComfyNodeDef.safeParse(data);
const result = zComfyNodeDef.safeParse(data)
if (!result.success) {
const zodError = fromZodError(result.error);
const zodError = fromZodError(result.error)
const error = new Error(
`Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}`
);
error.cause = zodError;
throw error;
)
error.cause = zodError
throw error
}
return result.data;
return result.data
}

View File

@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from 'zod'
const nodeSlotSchema = z
.object({
@@ -26,9 +26,9 @@ const nodeSlotSchema = z
TAESD: z.string(),
TIMESTEP_KEYFRAME: z.string().optional(),
UPSCALE_MODEL: z.string().optional(),
VAE: z.string(),
VAE: z.string()
})
.passthrough();
.passthrough()
const litegraphBaseSchema = z
.object({
@@ -52,44 +52,44 @@ const litegraphBaseSchema = z
WIDGET_SECONDARY_TEXT_COLOR: z.string(),
LINK_COLOR: z.string(),
EVENT_LINK_COLOR: z.string(),
CONNECTING_LINK_COLOR: z.string(),
CONNECTING_LINK_COLOR: z.string()
})
.passthrough();
.passthrough()
const comfyBaseSchema = z.object({
["fg-color"]: z.string(),
["bg-color"]: z.string(),
["comfy-menu-bg"]: z.string(),
["comfy-input-bg"]: z.string(),
["input-text"]: z.string(),
["descrip-text"]: z.string(),
["drag-text"]: z.string(),
["error-text"]: z.string(),
["border-color"]: z.string(),
["tr-even-bg-color"]: z.string(),
["tr-odd-bg-color"]: z.string(),
["content-bg"]: z.string(),
["content-fg"]: z.string(),
["content-hover-bg"]: z.string(),
["content-hover-fg"]: z.string(),
});
['fg-color']: z.string(),
['bg-color']: z.string(),
['comfy-menu-bg']: z.string(),
['comfy-input-bg']: z.string(),
['input-text']: z.string(),
['descrip-text']: z.string(),
['drag-text']: z.string(),
['error-text']: z.string(),
['border-color']: z.string(),
['tr-even-bg-color']: z.string(),
['tr-odd-bg-color']: z.string(),
['content-bg']: z.string(),
['content-fg']: z.string(),
['content-hover-bg']: z.string(),
['content-hover-fg']: z.string()
})
const colorsSchema = z
.object({
node_slot: nodeSlotSchema,
litegraph_base: litegraphBaseSchema,
comfy_base: comfyBaseSchema,
comfy_base: comfyBaseSchema
})
.passthrough();
.passthrough()
const paletteSchema = z.object({
id: z.string(),
name: z.string(),
colors: colorsSchema,
});
colors: colorsSchema
})
const colorPalettesSchema = z.record(paletteSchema);
const colorPalettesSchema = z.record(paletteSchema)
export type Colors = z.infer<typeof colorsSchema>;
export type Palette = z.infer<typeof paletteSchema>;
export type ColorPalettes = z.infer<typeof colorPalettesSchema>;
export type Colors = z.infer<typeof colorsSchema>
export type Palette = z.infer<typeof paletteSchema>
export type ColorPalettes = z.infer<typeof colorPalettesSchema>

44
src/types/comfy.d.ts vendored
View File

@@ -1,21 +1,21 @@
import { LGraphNode, IWidget } from "./litegraph";
import { ComfyApp } from "../../scripts/app";
import { LGraphNode, IWidget } from './litegraph'
import { ComfyApp } from '../../scripts/app'
export interface ComfyExtension {
/**
* The name of the extension
*/
name: string;
name: string
/**
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance
*/
init?(app: ComfyApp): Promise<void>;
init?(app: ComfyApp): Promise<void>
/**
* Allows any additonal setup, called after the application is fully set up and running
* @param app The ComfyUI app instance
*/
setup?(app: ComfyApp): Promise<void>;
setup?(app: ComfyApp): Promise<void>
/**
* Called before nodes are registered with the graph
* @param defs The collection of node definitions, add custom ones or edit existing ones
@@ -24,7 +24,7 @@ export interface ComfyExtension {
addCustomNodeDefs?(
defs: Record<string, ComfyObjectInfo>,
app: ComfyApp
): Promise<void>;
): Promise<void>
/**
* Allows the extension to add custom widgets
* @param app The ComfyUI app instance
@@ -42,7 +42,7 @@ export interface ComfyExtension {
app
) => { widget?: IWidget; minWidth?: number; minHeight?: number }
>
>;
>
/**
* Allows the extension to add additional handling to the node before it is registered with LGraph
* @param nodeType The node class (not an instance)
@@ -53,12 +53,12 @@ export interface ComfyExtension {
nodeType: typeof LGraphNode,
nodeData: ComfyObjectInfo,
app: ComfyApp
): Promise<void>;
): Promise<void>
/**
* Allows the extension to register additional nodes with LGraph after standard nodes are added
* @param app The ComfyUI app instance
*/
registerCustomNodes?(app: ComfyApp): Promise<void>;
registerCustomNodes?(app: ComfyApp): Promise<void>
/**
* Allows the extension to modify a node that has been reloaded onto the graph.
* If you break something in the backend and want to patch workflows in the frontend
@@ -66,26 +66,26 @@ export interface ComfyExtension {
* @param node The node that has been loaded
* @param app The ComfyUI app instance
*/
loadedGraphNode?(node: LGraphNode, app: ComfyApp);
loadedGraphNode?(node: LGraphNode, app: ComfyApp)
/**
* Allows the extension to run code after the constructor of the node
* @param node The node that has been created
* @param app The ComfyUI app instance
*/
nodeCreated?(node: LGraphNode, app: ComfyApp);
nodeCreated?(node: LGraphNode, app: ComfyApp)
}
export type ComfyObjectInfo = {
name: string;
display_name?: string;
description?: string;
category: string;
name: string
display_name?: string
description?: string
category: string
input?: {
required?: Record<string, ComfyObjectInfoConfig>;
optional?: Record<string, ComfyObjectInfoConfig>;
};
output?: string[];
output_name: string[];
};
required?: Record<string, ComfyObjectInfoConfig>
optional?: Record<string, ComfyObjectInfoConfig>
}
output?: string[]
output_name: string[]
}
export type ComfyObjectInfoConfig = [string | any[]] | [string | any[], any];
export type ComfyObjectInfoConfig = [string | any[]] | [string | any[], any]

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
const zComfyLink = z.tuple([
z.number(), // Link id
@@ -7,26 +7,26 @@ const zComfyLink = z.tuple([
z.number(), // Output slot# of source node
z.number(), // Node id of destination node
z.number(), // Input slot# of destination node
z.string(), // Data type
]);
z.string() // Data type
])
const zNodeOutput = z
.object({
name: z.string(),
type: z.string(),
links: z.array(z.number()).nullable(),
slot_index: z.number().optional(),
slot_index: z.number().optional()
})
.passthrough();
.passthrough()
const zNodeInput = z
.object({
name: z.string(),
type: z.string(),
link: z.number().nullable(),
slot_index: z.number().optional(),
slot_index: z.number().optional()
})
.passthrough();
.passthrough()
const zFlags = z
.object({
@@ -34,22 +34,22 @@ const zFlags = z
pinned: z.boolean().optional(),
allow_interaction: z.boolean().optional(),
horizontal: z.boolean().optional(),
skip_repeated_outputs: z.boolean().optional(),
skip_repeated_outputs: z.boolean().optional()
})
.passthrough();
.passthrough()
const zProperties = z
.object({
["Node name for S&R"]: z.string().optional(),
['Node name for S&R']: z.string().optional()
})
.passthrough();
.passthrough()
const zVector2 = z.union([
z.object({ 0: z.number(), 1: z.number() }).transform((v) => [v[0], v[1]]),
z.tuple([z.number(), z.number()]),
]);
z.tuple([z.number(), z.number()])
])
const zWidgetValues = z.union([z.array(z.any()), z.record(z.any())]);
const zWidgetValues = z.union([z.array(z.any()), z.record(z.any())])
const zComfyNode = z
.object({
@@ -65,9 +65,9 @@ const zComfyNode = z
properties: zProperties,
widgets_values: zWidgetValues.optional(),
color: z.string().optional(),
bgcolor: z.string().optional(),
bgcolor: z.string().optional()
})
.passthrough();
.passthrough()
const zGroup = z
.object({
@@ -75,9 +75,9 @@ const zGroup = z
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
color: z.string(),
font_size: z.number(),
locked: z.boolean().optional(),
locked: z.boolean().optional()
})
.passthrough();
.passthrough()
const zInfo = z
.object({
@@ -87,30 +87,30 @@ const zInfo = z
version: z.string(),
created: z.string(),
modified: z.string(),
software: z.string(),
software: z.string()
})
.passthrough();
.passthrough()
const zDS = z
.object({
scale: z.number(),
offset: zVector2,
offset: zVector2
})
.passthrough();
.passthrough()
const zConfig = z
.object({
links_ontop: z.boolean().optional(),
align_to_grid: z.boolean().optional(),
align_to_grid: z.boolean().optional()
})
.passthrough();
.passthrough()
const zExtra = z
.object({
ds: zDS.optional(),
info: zInfo.optional(),
info: zInfo.optional()
})
.passthrough();
.passthrough()
export const zComfyWorkflow = z
.object({
@@ -121,26 +121,26 @@ export const zComfyWorkflow = z
groups: z.array(zGroup).optional(),
config: zConfig.optional().nullable(),
extra: zExtra.optional().nullable(),
version: z.number(),
version: z.number()
})
.passthrough();
.passthrough()
export type NodeInput = z.infer<typeof zNodeInput>;
export type NodeOutput = z.infer<typeof zNodeOutput>;
export type ComfyLink = z.infer<typeof zComfyLink>;
export type ComfyNode = z.infer<typeof zComfyNode>;
export type ComfyWorkflowJSON = z.infer<typeof zComfyWorkflow>;
export type NodeInput = z.infer<typeof zNodeInput>
export type NodeOutput = z.infer<typeof zNodeOutput>
export type ComfyLink = z.infer<typeof zComfyLink>
export type ComfyNode = z.infer<typeof zComfyNode>
export type ComfyWorkflowJSON = z.infer<typeof zComfyWorkflow>
export async function parseComfyWorkflow(
data: string
): Promise<ComfyWorkflowJSON> {
// Validate
const result = await zComfyWorkflow.safeParseAsync(JSON.parse(data));
const result = await zComfyWorkflow.safeParseAsync(JSON.parse(data))
if (!result.success) {
// TODO: Pretty print the error on UI modal.
const error = fromZodError(result.error);
alert(`Invalid workflow against zod schema:\n${error}`);
throw error;
const error = fromZodError(result.error)
alert(`Invalid workflow against zod schema:\n${error}`)
throw error
}
return result.data;
return result.data
}

View File

@@ -1,30 +1,30 @@
import { Component } from "vue";
import { Component } from 'vue'
export interface BaseSidebarTabExtension {
id: string;
title: string;
icon?: string;
order?: number;
tooltip?: string;
id: string
title: string
icon?: string
order?: number
tooltip?: string
}
export interface VueSidebarTabExtension extends BaseSidebarTabExtension {
type: "vue";
component: Component;
type: 'vue'
component: Component
}
export interface CustomSidebarTabExtension extends BaseSidebarTabExtension {
type: "custom";
render: (container: HTMLElement) => void;
destroy?: () => void;
type: 'custom'
render: (container: HTMLElement) => void
destroy?: () => void
}
export type SidebarTabExtension =
| VueSidebarTabExtension
| CustomSidebarTabExtension;
| CustomSidebarTabExtension
export interface ExtensionManager {
registerSidebarTab(tab: SidebarTabExtension): void;
unregisterSidebarTab(id: string): void;
getSidebarTabs(): SidebarTabExtension[];
registerSidebarTab(tab: SidebarTabExtension): void
unregisterSidebarTab(id: string): void
getSidebarTabs(): SidebarTabExtension[]
}

View File

@@ -1,46 +1,46 @@
import "@comfyorg/litegraph";
import '@comfyorg/litegraph'
/**
* ComfyUI extensions of litegraph
*/
declare module "@comfyorg/litegraph" {
declare module '@comfyorg/litegraph' {
interface LGraphNode {
/**
* Callback fired on each node after the graph is configured
*/
onAfterGraphConfigured?(): void;
onAfterGraphConfigured?(): void
/**
* If the node is a frontend only node and should not be serialized into the prompt.
*/
isVirtualNode?: boolean;
isVirtualNode?: boolean
}
interface IWidget<TValue = any, TOptions = any> {
/**
* Allows for additional cleanup when removing a widget when converting to input.
*/
onRemove?(): void;
onRemove?(): void
}
interface INodeOutputSlot {
widget?: unknown;
widget?: unknown
}
interface INodeInputSlot {
widget?: unknown;
widget?: unknown
}
}
/**
* Extended types for litegraph, to be merged upstream once it has stabilized.
*/
declare module "@comfyorg/litegraph" {
declare module '@comfyorg/litegraph' {
interface INodeInputSlot {
pos?: [number, number];
pos?: [number, number]
}
interface LGraphNode {
widgets_values?: unknown[];
widgets_values?: unknown[]
}
}

View File

@@ -2,18 +2,18 @@
Extended types for litegraph, to be merged upstream once it has stabilized.
Augmenting the LiteGraph type really didn't want to work, however doing it like this seems to allow it.
*/
declare module "@comfyorg/litegraph" {
declare module '@comfyorg/litegraph' {
interface LiteGraphExtended {
search_filter_enabled: boolean;
middle_click_slot_add_default_node: boolean;
registered_slot_out_types: Record<string, { nodes: string[] }>;
registered_slot_in_types: Record<string, { nodes: string[] }>;
slot_types_out: string[];
slot_types_default_out: Record<string, string[]>;
slot_types_default_in: Record<string, string[]>;
search_filter_enabled: boolean
middle_click_slot_add_default_node: boolean
registered_slot_out_types: Record<string, { nodes: string[] }>
registered_slot_in_types: Record<string, { nodes: string[] }>
slot_types_out: string[]
slot_types_default_out: Record<string, string[]>
slot_types_default_in: Record<string, string[]>
}
import type { LiteGraph as LG } from "@comfyorg/litegraph/src/litegraph";
export const LiteGraph: LiteGraphExtended & typeof LG;
export * from "@comfyorg/litegraph/src/litegraph";
import type { LiteGraph as LG } from '@comfyorg/litegraph/src/litegraph'
export const LiteGraph: LiteGraphExtended & typeof LG
export * from '@comfyorg/litegraph/src/litegraph'
}

View File

@@ -1,25 +1,25 @@
export type NodeSourceType = "core" | "custom_nodes";
export type NodeSourceType = 'core' | 'custom_nodes'
export type NodeSource = {
type: NodeSourceType;
className: string;
displayText: string;
};
type: NodeSourceType
className: string
displayText: string
}
export const getNodeSource = (python_module: string): NodeSource => {
const modules = python_module.split(".");
if (["nodes", "comfy_extras"].includes(modules[0])) {
const modules = python_module.split('.')
if (['nodes', 'comfy_extras'].includes(modules[0])) {
return {
type: "core",
className: "comfy-core",
displayText: "Comfy Core",
};
} else if (modules[0] === "custom_nodes") {
return {
type: "custom_nodes",
className: "comfy-custom-nodes",
displayText: modules[1],
};
} else {
throw new Error(`Unknown node source: ${python_module}`);
type: 'core',
className: 'comfy-core',
displayText: 'Comfy Core'
}
};
} else if (modules[0] === 'custom_nodes') {
return {
type: 'custom_nodes',
className: 'comfy-custom-nodes',
displayText: modules[1]
}
} else {
throw new Error(`Unknown node source: ${python_module}`)
}
}

View File

@@ -1,39 +1,39 @@
export type StorageLocation = "browser" | "server";
export type StorageLocation = 'browser' | 'server'
export type SettingInputType =
| "boolean"
| "number"
| "slider"
| "combo"
| "text"
| "hidden";
| 'boolean'
| 'number'
| 'slider'
| 'combo'
| 'text'
| 'hidden'
export type SettingCustomRenderer = (
name: string,
setter: (v: any) => void,
value: any,
attrs: any
) => HTMLElement;
) => HTMLElement
export interface SettingOption {
text: string;
value?: string;
text: string
value?: string
}
export interface Setting {
id: string;
onChange?: (value: any, oldValue?: any) => void;
name: string;
render: () => HTMLElement;
id: string
onChange?: (value: any, oldValue?: any) => void
name: string
render: () => HTMLElement
}
export interface SettingParams {
id: string;
name: string;
type: SettingInputType | SettingCustomRenderer;
defaultValue: any;
onChange?: (newValue: any, oldValue?: any) => void;
attrs?: any;
tooltip?: string;
options?: Array<string | SettingOption> | ((value: any) => SettingOption[]);
id: string
name: string
type: SettingInputType | SettingCustomRenderer
defaultValue: any
onChange?: (newValue: any, oldValue?: any) => void
attrs?: any
tooltip?: string
options?: Array<string | SettingOption> | ((value: any) => SettingOption[])
}

Some files were not shown because too many files have changed in this diff Show More