Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
84c5f7bf16 [3d] add output preview screen for load3d node 2025-02-07 19:14:34 -05:00
82 changed files with 2048 additions and 2671 deletions

View File

@@ -13,7 +13,7 @@ jobs:
update-locales:
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend

View File

@@ -9,7 +9,7 @@ jobs:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event.label.name == 'New Browser Test Expectations'
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend

View File

@@ -15,9 +15,7 @@ jobs:
jest-tests:
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
with:
devtools_ref: 7b81139e904519db8e5481899ef36bbb4393cb6b
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- name: Run Jest tests
run: |
npm run test:generate
@@ -27,9 +25,7 @@ jobs:
playwright-tests-chromium:
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
with:
devtools_ref: 7b81139e904519db8e5481899ef36bbb4393cb6b
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
@@ -46,9 +42,7 @@ jobs:
playwright-tests-chromium-2x:
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
with:
devtools_ref: 7b81139e904519db8e5481899ef36bbb4393cb6b
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
@@ -65,9 +59,7 @@ jobs:
playwright-tests-mobile-chrome:
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
with:
devtools_ref: 7b81139e904519db8e5481899ef36bbb4393cb6b
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend

View File

@@ -1,43 +0,0 @@
{
"last_node_id": 10,
"last_link_id": 9,
"nodes": [
{
"id": 10,
"type": "DevToolsNodeWithSeedInput",
"pos": [
20,
50
],
"size": [
315,
82
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "DevToolsNodeWithSeedInput"
},
"widgets_values": [
0,
"randomize"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -1,6 +1,5 @@
import { expect } from '@playwright/test'
import { SettingParams } from '../src/types/settingTypes'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Topbar commands', () => {
@@ -135,90 +134,6 @@ test.describe('Topbar commands', () => {
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(true)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
})
test.describe('Passing through attrs to setting components', () => {
const testCases: Array<{
config: Partial<SettingParams>
selector: string
}> = [
{
config: {
type: 'boolean',
defaultValue: true
},
selector: '.p-toggleswitch.p-component'
},
{
config: {
type: 'number',
defaultValue: 10
},
selector: '.p-inputnumber input'
},
{
config: {
type: 'slider',
defaultValue: 10
},
selector: '.p-slider.p-component'
},
{
config: {
type: 'combo',
defaultValue: 'foo',
options: ['foo', 'bar', 'baz']
},
selector: '.p-select.p-component'
},
{
config: {
type: 'text',
defaultValue: 'Hello'
},
selector: '.p-inputtext'
},
{
config: {
type: 'color',
defaultValue: '#000000'
},
selector: '.p-colorpicker-preview'
}
] as const
for (const { config, selector } of testCases) {
test(`${config.type} component should respect disabled attr`, async ({
comfyPage
}) => {
await comfyPage.page.evaluate((config) => {
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'Comfy.TestSetting',
name: 'Test',
// The `disabled` attr is common to all settings components
attrs: { disabled: true },
...config
}
]
})
}, config)
await comfyPage.settingDialog.open()
const component = comfyPage.settingDialog.root
.getByText('TestSetting Test')
.locator(selector)
const isDisabled = await component.evaluate((el) =>
el.tagName === 'INPUT'
? (el as HTMLInputElement).disabled
: el.classList.contains('p-disabled')
)
expect(isDisabled).toBe(true)
})
}
})
})
test.describe('About panel', () => {

View File

@@ -3,10 +3,6 @@ import { Page } from '@playwright/test'
export class SettingDialog {
constructor(public readonly page: Page) {}
get root() {
return this.page.locator('div.settings-container')
}
async open() {
const button = this.page.locator('button.comfy-settings-btn:visible')
await button.click()

View File

@@ -62,9 +62,6 @@ export class NodeWidgetReference {
readonly node: NodeReference
) {}
/**
* @returns The position of the widget's center
*/
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
@@ -92,22 +89,6 @@ export class NodeWidgetReference {
position: await this.getPosition()
})
}
async dragHorizontal(delta: number) {
const pos = await this.getPosition()
const canvas = this.node.comfyPage.canvas
const canvasPos = (await canvas.boundingBox())!
this.node.comfyPage.dragAndDrop(
{
x: canvasPos.x + pos.x,
y: canvasPos.y + pos.y
},
{
x: canvasPos.x + pos.x + delta,
y: canvasPos.y + pos.y
}
)
}
}
export class NodeReference {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -35,20 +35,4 @@ test.describe('Keybindings', () => {
true
)
})
test('Should not trigger keybinding reserved by text input when typing in input fields', async ({
comfyPage
}) => {
await comfyPage.registerKeybinding({ key: 'Ctrl+v' }, () => {
window['TestCommand'] = true
})
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.press('Control+v')
await expect(textBox).toBeFocused()
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
undefined
)
})
})

View File

@@ -30,24 +30,9 @@ test.describe('Remote COMBO Widget', () => {
}, nodeName)
}
const getWidgetValue = async (comfyPage: ComfyPage, nodeName: string) => {
return await comfyPage.page.evaluate((name) => {
const node = window['app'].graph.nodes.find((node) => node.title === name)
return node.widgets[0].value
}, nodeName)
}
const clickRefreshButton = (comfyPage: ComfyPage, nodeName: string) => {
return comfyPage.page.evaluate((name) => {
const node = window['app'].graph.nodes.find((node) => node.title === name)
const buttonWidget = node.widgets.find((w) => w.name === 'refresh')
return buttonWidget?.callback()
}, nodeName)
}
const waitForWidgetUpdate = async (comfyPage: ComfyPage) => {
// Force re-render to trigger first access of widget's options
await comfyPage.page.mouse.click(400, 300)
await comfyPage.page.mouse.click(100, 100)
await comfyPage.page.waitForTimeout(256)
}
@@ -194,7 +179,7 @@ test.describe('Remote COMBO Widget', () => {
const initialOptions = await getWidgetOptions(comfyPage, nodeName)
// Wait for the refresh (TTL) to expire
await comfyPage.page.waitForTimeout(512)
await comfyPage.page.waitForTimeout(302)
await comfyPage.page.mouse.click(100, 100)
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
@@ -236,79 +221,14 @@ test.describe('Remote COMBO Widget', () => {
const nodeName = 'Remote Widget Node'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
// Wait for timeout and backoff, then force re-render, repeat
const requestTimeout = 512
await comfyPage.page.waitForTimeout(requestTimeout)
await waitForWidgetUpdate(comfyPage)
await comfyPage.page.waitForTimeout(requestTimeout * 2)
await waitForWidgetUpdate(comfyPage)
await comfyPage.page.waitForTimeout(requestTimeout * 3)
// Wait for a few retries
await comfyPage.page.waitForTimeout(1024)
// Verify exponential backoff between retries
const intervals = timestamps.slice(1).map((t, i) => t - timestamps[i])
expect(intervals[1]).toBeGreaterThan(intervals[0])
})
test('clicking refresh button forces a refresh', async ({ comfyPage }) => {
await comfyPage.page.route(
'**/api/models/checkpoints**',
async (route) => {
await route.fulfill({
body: JSON.stringify([`${Date.now()}`]),
status: 200
})
}
)
const nodeName = 'Remote Widget Node With Refresh Button'
// Trigger initial fetch when adding node to the graph
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
const initialOptions = await getWidgetOptions(comfyPage, nodeName)
// Click refresh button
await clickRefreshButton(comfyPage, nodeName)
// Verify refresh occurred
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
expect(refreshedOptions).not.toEqual(initialOptions)
})
test('control_after_refresh is applied after refresh', async ({
comfyPage
}) => {
const options = [
['first option', 'second option', 'third option'],
['new first option', 'first option', 'second option', 'third option']
]
await comfyPage.page.route(
'**/api/models/checkpoints**',
async (route) => {
const next = options.shift()
await route.fulfill({
body: JSON.stringify(next),
status: 200
})
}
)
const nodeName =
'Remote Widget Node With Refresh Button and Control After Refresh'
// Trigger initial fetch when adding node to the graph
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
// Click refresh button
await clickRefreshButton(comfyPage, nodeName)
// Verify the selected value of the widget is the first option in the refreshed list
const refreshedValue = await getWidgetValue(comfyPage, nodeName)
expect(refreshedValue).toEqual('new first option')
})
})
test.describe('Cache Behavior', () => {

View File

@@ -40,47 +40,3 @@ test.describe('Boolean widget', () => {
)
})
})
test.describe('Slider widget', () => {
test('Can drag adjust value', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('simple_slider')
await comfyPage.page.waitForTimeout(300)
const node = (await comfyPage.getFirstNodeRef())!
const widget = await node.getWidget(0)
await comfyPage.page.evaluate(() => {
const widget = window['app'].graph.nodes[0].widgets[0]
widget.callback = (value: number) => {
window['widgetValue'] = value
}
})
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('slider_widget_dragged.png')
expect(
await comfyPage.page.evaluate(() => window['widgetValue'])
).toBeDefined()
})
})
test.describe('Number widget', () => {
test('Can drag adjust value', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/seed_widget')
await comfyPage.page.waitForTimeout(300)
const node = (await comfyPage.getFirstNodeRef())!
const widget = await node.getWidget(0)
await comfyPage.page.evaluate(() => {
const widget = window['app'].graph.nodes[0].widgets[0]
widget.callback = (value: number) => {
window['widgetValue'] = value
}
})
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
expect(
await comfyPage.page.evaluate(() => window['widgetValue'])
).toBeDefined()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

12
package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.9.14",
"version": "1.9.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.9.14",
"version": "1.9.6",
"license": "GPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.16",
"@comfyorg/litegraph": "^0.8.77",
"@comfyorg/litegraph": "^0.8.70",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -1944,9 +1944,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.77",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.77.tgz",
"integrity": "sha512-PMMJBishdaB/Nz3wYV/iC0qO8lqzoG5r+Zky/hCFt2LLCrRhktn+HciT4Kimk6ekldWiridNY165sazQLKQWGQ==",
"version": "0.8.70",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.70.tgz",
"integrity": "sha512-YZSMVzr/gUn7Xoe4orFjypq58faDy1kMvF1/kGTzmukF6w7WZ3tPjkng1ZBAWIztjcGDSmeTLYRayj5hfaDavA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.9.14",
"version": "1.9.6",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -84,7 +84,7 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.16",
"@comfyorg/litegraph": "^0.8.77",
"@comfyorg/litegraph": "^0.8.70",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -26,9 +26,8 @@ try {
// Create the PR
console.log('Creating PR...')
const prBody = `Automated update of litegraph to version ${newVersion}. Ref: https://github.com/Comfy-Org/litegraph.js/releases/tag/v${newVersion}`
execSync(
`gh pr create --title "Update litegraph ${newVersion}" --label "dependencies" --body "${prBody}"`,
`gh pr create --title "Update litegraph ${newVersion}" --label "dependencies" --body "Automated update of litegraph to version ${newVersion}"`,
{ stdio: 'inherit' }
)

View File

@@ -45,6 +45,8 @@ body {
height: 100vh;
margin: 0;
overflow: hidden;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto 1fr auto;
background: var(--bg-color) var(--bg-img);
color: var(--fg-color);
min-height: -webkit-fill-available;
@@ -54,6 +56,87 @@ body {
font-family: Arial, sans-serif;
}
/**
+------------------+------------------+------------------+
| |
| .comfyui-body- |
| top |
| (spans all cols) |
| |
+------------------+------------------+------------------+
| | | |
| .comfyui-body- | #graph-canvas | .comfyui-body- |
| left | | right |
| | | |
| | | |
+------------------+------------------+------------------+
| |
| .comfyui-body- |
| bottom |
| (spans all cols) |
| |
+------------------+------------------+------------------+
*/
.comfyui-body-top {
order: -5;
/* Span across all columns */
grid-column: 1/-1;
/* Position at the first row */
grid-row: 1;
/* Top menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
/* Top menu bar z-index needs to be higher than bottom menu bar z-index as by default
pysssss's image feed is located at body-bottom, and it can overlap with the queue button, which
is located in body-top. */
z-index: 1001;
display: flex;
flex-direction: column;
}
.comfyui-body-left {
order: -4;
/* Position in the first column */
grid-column: 1;
/* Position below the top element */
grid-row: 2;
z-index: 10;
display: flex;
}
.graph-canvas-container {
width: 100%;
height: 100%;
order: -3;
grid-column: 2;
grid-row: 2;
position: relative;
overflow: hidden;
}
#graph-canvas {
width: 100%;
height: 100%;
touch-action: none;
}
.comfyui-body-right {
order: -2;
z-index: 10;
grid-column: 3;
grid-row: 2;
}
.comfyui-body-bottom {
order: 4;
/* Span across all columns */
grid-column: 1/-1;
grid-row: 3;
/* Bottom menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
z-index: 1000;
display: flex;
flex-direction: column;
}
.comfy-multiline-input {
background-color: var(--comfy-input-bg);
color: var(--input-text);
@@ -468,6 +551,82 @@ dialog::backdrop {
justify-content: center;
}
#comfy-settings-dialog {
padding: 0;
width: 41rem;
}
#comfy-settings-dialog tr > td:first-child {
text-align: right;
}
#comfy-settings-dialog tbody button,
#comfy-settings-dialog table > button {
background-color: var(--bg-color);
border: 1px var(--border-color) solid;
border-radius: 0;
color: var(--input-text);
font-size: 1rem;
padding: 0.5rem;
}
#comfy-settings-dialog button:hover {
background-color: var(--tr-odd-bg-color);
}
/* General CSS for tables */
.comfy-table {
border-collapse: collapse;
color: var(--input-text);
font-family: Arial, sans-serif;
width: 100%;
}
.comfy-table caption {
position: sticky;
top: 0;
background-color: var(--bg-color);
color: var(--input-text);
font-size: 1rem;
font-weight: bold;
padding: 8px;
text-align: center;
border-bottom: 1px solid var(--border-color);
}
.comfy-table caption .comfy-btn {
position: absolute;
top: -2px;
right: 0;
bottom: 0;
cursor: pointer;
border: none;
height: 100%;
border-radius: 0;
aspect-ratio: 1/1;
user-select: none;
font-size: 20px;
}
.comfy-table caption .comfy-btn:focus {
outline: none;
}
.comfy-table tr:nth-child(even) {
background-color: var(--tr-even-bg-color);
}
.comfy-table tr:nth-child(odd) {
background-color: var(--tr-odd-bg-color);
}
.comfy-table td,
.comfy-table th {
border: 1px solid var(--border-color);
padding: 8px;
}
/* Context menu */
.litegraph .dialog {
@@ -566,6 +725,24 @@ dialog::backdrop {
will-change: transform;
}
@media only screen and (max-width: 450px) {
#comfy-settings-dialog .comfy-table tbody {
display: grid;
}
#comfy-settings-dialog .comfy-table tr {
display: grid;
}
#comfy-settings-dialog tr > td:first-child {
text-align: center;
border-bottom: none;
padding-bottom: 0;
}
#comfy-settings-dialog tr > td:not(:first-child) {
text-align: center;
border-top: none;
}
}
audio.comfy-audio.empty-audio-widget {
display: none;
}
@@ -576,6 +753,7 @@ audio.comfy-audio.empty-audio-widget {
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* Set auto complete panel's width as it is not accessible within vue-root */

View File

@@ -57,6 +57,6 @@ const positionCSS = computed<CSSProperties>(() =>
<style scoped>
.comfy-menu-hamburger {
@apply fixed z-[9999] flex flex-row;
@apply pointer-events-auto fixed z-[9999] flex flex-row;
}
</style>

View File

@@ -4,7 +4,7 @@
:style="style"
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
>
<div class="actionbar-content flex items-center select-none" ref="panelRef">
<div class="actionbar-content flex items-center" ref="panelRef">
<span class="drag-handle cursor-move mr-2 p-0!" ref="dragHandleRef">
</span>
<ComfyQueueButton />
@@ -239,8 +239,4 @@ watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
:deep(.p-panel-header) {
display: none;
}
.drag-handle {
@apply w-3 h-max;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="color-picker-wrapper flex items-center gap-2">
<ColorPicker v-model="modelValue" v-bind="$attrs" />
<ColorPicker v-model="modelValue" />
<InputText v-model="modelValue" class="w-28" :placeholder="label" />
</div>
</template>
@@ -14,8 +14,4 @@ defineProps<{
defaultValue?: string
label?: string
}>()
defineOptions({
inheritAttrs: false
})
</script>

View File

@@ -2,11 +2,7 @@
<template>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex flex-grow items-center">
<span
class="text-muted"
:class="props.labelClass"
:id="`${props.id}-label`"
>
<span class="text-muted" :class="props.labelClass">
<slot name="name-prefix"></slot>
{{ props.item.name }}
<i
@@ -21,7 +17,6 @@
<component
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
:aria-labelledby="`${props.id}-label`"
v-model:modelValue="formValue"
v-bind="getFormAttrs(props.item)"
/>

View File

@@ -8,7 +8,6 @@
:min="min"
:max="max"
:step="step"
v-bind="$attrs"
/>
<InputNumber
:modelValue="modelValue"
@@ -71,8 +70,4 @@ const updateValue = (newValue: number | null) => {
localValue.value = newValue
emit('update:modelValue', newValue)
}
defineOptions({
inheritAttrs: false
})
</script>

View File

@@ -4,15 +4,6 @@
<ul v-if="itemList?.length" class="pl-4 m-0 flex flex-col gap-2">
<li v-for="item of itemList" :key="item">{{ item }}</li>
</ul>
<Message
v-if="hint"
icon="pi pi-info-circle"
severity="secondary"
size="small"
variant="simple"
>
{{ hint }}
</Message>
<div class="flex gap-4 justify-end">
<Button
:label="$t('g.cancel')"
@@ -72,7 +63,6 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Message from 'primevue/message'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -82,7 +72,6 @@ const props = defineProps<{
type: ConfirmationDialogType
onConfirm: (value?: boolean) => void
itemList?: string[]
hint?: string
}>()
const onCancel = () => useDialogStore().closeDialog()

View File

@@ -1,31 +1,28 @@
<template>
<!-- Load splitter overlay only after comfyApp is ready. -->
<!-- If load immediately, the top-level splitter stateKey won't be correctly
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay
v-if="comfyAppReady && betaMenuEnabled && !workspaceStore.focusMode"
>
<template #side-bar-panel>
<SideToolbar />
</template>
<template #bottom-panel>
<BottomPanel />
</template>
<template #graph-canvas-panel>
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
<GraphCanvasMenu v-if="canvasMenuEnabled" />
</template>
</LiteGraphCanvasSplitterOverlay>
<TitleEditor />
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
<canvas
ref="canvasRef"
id="graph-canvas"
tabindex="1"
class="w-full h-full touch-none"
/>
<teleport to=".graph-canvas-container">
<!-- Load splitter overlay only after comfyApp is ready. -->
<!-- If load immediately, the top-level splitter stateKey won't be correctly
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay
v-if="comfyAppReady && betaMenuEnabled && !workspaceStore.focusMode"
>
<template #side-bar-panel>
<SideToolbar />
</template>
<template #bottom-panel>
<BottomPanel />
</template>
<template #graph-canvas-panel>
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
<GraphCanvasMenu v-if="canvasMenuEnabled" />
</template>
</LiteGraphCanvasSplitterOverlay>
<TitleEditor />
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
<canvas ref="canvasRef" id="graph-canvas" tabindex="1" />
</teleport>
<NodeSearchboxPopover />
<NodeTooltip v-if="tooltipEnabled" />
<NodeBadge />

View File

@@ -1,6 +1,6 @@
<template>
<ButtonGroup
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000]"
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000] pointer-events-auto"
>
<Button
severity="secondary"

View File

@@ -17,11 +17,10 @@
class="w-full"
:class="{ 'p-invalid': pathError }"
@update:modelValue="validatePath"
@focus="onFocus"
/>
<InputIcon
class="pi pi-info-circle"
v-tooltip.top="$t('install.installLocationTooltip')"
v-tooltip="$t('install.installLocationTooltip')"
/>
</IconField>
<Button icon="pi pi-folder" @click="browsePath" class="w-12" />
@@ -82,7 +81,6 @@ const pathError = defineModel<string>('pathError', { required: true })
const pathExists = ref(false)
const appData = ref('')
const appPath = ref('')
const inputTouched = ref(false)
const electron = electronAPI()
@@ -134,13 +132,4 @@ const browsePath = async () => {
pathError.value = t('install.failedToSelectDirectory')
}
}
const onFocus = () => {
if (!inputTouched.value) {
inputTouched.value = true
return
}
// Refresh validation on re-focus
validatePath(installPath.value)
}
</script>

View File

@@ -80,28 +80,11 @@ const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
}
}
const userIsInChina = ref(false)
onMounted(async () => {
userIsInChina.value = await isInChina()
})
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() => [
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device), torchMirror]
])
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
@@ -119,4 +102,13 @@ const validationStateTooltip = computed(() => {
return t('install.settings.checkingMirrors')
}
})
onMounted(async () => {
// Check if user is in China and set fallback mirrors directly
if (await isInChina()) {
for (const [item, modelValue] of mirrors.value) {
modelValue.value = item.fallbackMirror
}
}
})
</script>

View File

@@ -44,7 +44,10 @@ const normalizedSettingId = computed(() => {
})
onMounted(() => {
modelValue.value = item.mirror
// Set mirror value if not already set
if (!modelValue.value) {
modelValue.value = item.mirror
}
})
watch(validationState, (newState) => {

View File

@@ -1,168 +0,0 @@
<template>
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
<Load3DControls
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:showPreview="showPreview"
:lightIntensity="lightIntensity"
:showLightIntensityButton="showLightIntensityButton"
:fov="fov"
:showFOVButton="showFOVButton"
:showPreviewButton="showPreviewButton"
@toggleCamera="onToggleCamera"
@toggleGrid="onToggleGrid"
@togglePreview="onTogglePreview"
@updateBackgroundColor="onUpdateBackgroundColor"
@updateLightIntensity="onUpdateLightIntensity"
@updateFOV="onUpdateFOV"
ref="load3dControlsRef"
/>
<div
v-if="animations && animations.length > 0"
class="absolute top-0 left-0 w-full flex justify-center pt-2 gap-2 items-center z-10"
>
<Button class="p-button-rounded p-button-text" @click="togglePlay">
<i
:class="[
'pi',
playing ? 'pi-pause' : 'pi-play',
'text-white text-lg'
]"
></i>
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
optionLabel="name"
optionValue="value"
@change="speedChange"
class="w-24"
/>
<Select
v-model="selectedAnimation"
:options="animations"
optionLabel="name"
optionValue="index"
@change="animationChange"
class="w-32"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { ref, watch } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
const props = defineProps<{
animations: Array<{ name: string; index: number }>
playing: boolean
backgroundColor: string
showGrid: boolean
showPreview: boolean
lightIntensity: number
showLightIntensityButton: boolean
fov: number
showFOVButton: boolean
showPreviewButton: boolean
}>()
const emit = defineEmits<{
(e: 'toggleCamera'): void
(e: 'toggleGrid', value: boolean): void
(e: 'togglePreview', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
(e: 'togglePlay', value: boolean): void
(e: 'speedChange', value: number): void
(e: 'animationChange', value: number): void
(e: 'updateLightIntensity', value: number): void
(e: 'updateFOV', value: number): void
}>()
const animations = ref(props.animations)
const playing = ref(props.playing)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const showPreview = ref(props.showPreview)
const lightIntensity = ref(props.lightIntensity)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const fov = ref(props.fov)
const showFOVButton = ref(props.showFOVButton)
const showPreviewButton = ref(props.showPreviewButton)
const load3dControlsRef = ref(null)
const speedOptions = [
{ name: '0.1x', value: 0.1 },
{ name: '0.5x', value: 0.5 },
{ name: '1x', value: 1 },
{ name: '1.5x', value: 1.5 },
{ name: '2x', value: 2 }
]
watch(backgroundColor, (newValue) => {
load3dControlsRef.value.backgroundColor = newValue
})
watch(showLightIntensityButton, (newValue) => {
load3dControlsRef.value.showLightIntensityButton = newValue
})
watch(showFOVButton, (newValue) => {
load3dControlsRef.value.showFOVButton = newValue
})
watch(showPreviewButton, (newValue) => {
load3dControlsRef.value.showPreviewButton = newValue
})
const onToggleCamera = () => {
emit('toggleCamera')
}
const onToggleGrid = (value: boolean) => emit('toggleGrid', value)
const onTogglePreview = (value: boolean) => {
emit('togglePreview', value)
}
const onUpdateBackgroundColor = (color: string) =>
emit('updateBackgroundColor', color)
const onUpdateLightIntensity = (lightIntensity: number) => {
emit('updateLightIntensity', lightIntensity)
}
const onUpdateFOV = (fov: number) => {
emit('updateFOV', fov)
}
const togglePlay = () => {
playing.value = !playing.value
emit('togglePlay', playing.value)
}
const speedChange = () => {
emit('speedChange', selectedSpeed.value)
}
const animationChange = () => {
emit('animationChange', selectedAnimation.value)
}
defineExpose({
animations,
selectedAnimation,
playing,
backgroundColor,
showGrid,
lightIntensity,
showLightIntensityButton,
fov,
showFOVButton
})
</script>

View File

@@ -1,205 +0,0 @@
<template>
<div class="absolute top-2 left-2 flex flex-col gap-2 z-20">
<Button class="p-button-rounded p-button-text" @click="toggleCamera">
<i
class="pi pi-camera text-white text-lg"
v-tooltip.right="{ value: t('load3d.switchCamera'), showDelay: 300 }"
></i>
</Button>
<Button
class="p-button-rounded p-button-text"
:class="{ 'p-button-outlined': showGrid }"
@click="toggleGrid"
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
>
<i class="pi pi-table text-white text-lg"></i>
</Button>
<Button class="p-button-rounded p-button-text" @click="openColorPicker">
<i
class="pi pi-palette text-white text-lg"
v-tooltip.right="{ value: t('load3d.backgroundColor'), showDelay: 300 }"
></i>
<input
type="color"
ref="colorPickerRef"
:value="backgroundColor"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
<div class="relative" v-if="showLightIntensityButton">
<Button
class="p-button-rounded p-button-text"
@click="toggleLightIntensity"
>
<i
class="pi pi-sun text-white text-lg"
v-tooltip.right="{
value: t('load3d.lightIntensity'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showLightIntensity"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="lightIntensity"
class="w-full"
@change="updateLightIntensity"
:min="1"
:max="20"
:step="1"
/>
</div>
</div>
<div class="relative" v-if="showFOVButton">
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
<i
class="pi pi-expand text-white text-lg"
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
></i>
</Button>
<div
v-show="showFOV"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="fov"
class="w-full"
@change="updateFOV"
:min="10"
:max="150"
:step="1"
/>
</div>
</div>
<div v-if="showPreviewButton">
<Button class="p-button-rounded p-button-text" @click="togglePreview">
<i
:class="[
'pi',
showPreview ? 'pi-eye' : 'pi-eye-slash',
'text-white text-lg'
]"
v-tooltip.right="{ value: t('load3d.previewOutput'), showDelay: 300 }"
></i>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref } from 'vue'
import { t } from '@/i18n'
const props = defineProps<{
backgroundColor: string
showGrid: boolean
showPreview: boolean
lightIntensity: number
showLightIntensityButton: boolean
fov: number
showFOVButton: boolean
showPreviewButton: boolean
}>()
const emit = defineEmits<{
(e: 'toggleCamera'): void
(e: 'toggleGrid', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
(e: 'updateLightIntensity', value: number): void
(e: 'updateFOV', value: number): void
(e: 'togglePreview', value: boolean): void
}>()
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const showPreview = ref(props.showPreview)
const colorPickerRef = ref<HTMLInputElement | null>(null)
const lightIntensity = ref(props.lightIntensity)
const showLightIntensity = ref(false)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const fov = ref(props.fov)
const showFOV = ref(false)
const showFOVButton = ref(props.showFOVButton)
const showPreviewButton = ref(props.showPreviewButton)
const toggleCamera = () => {
emit('toggleCamera')
}
const toggleGrid = () => {
showGrid.value = !showGrid.value
emit('toggleGrid', showGrid.value)
}
const togglePreview = () => {
showPreview.value = !showPreview.value
emit('togglePreview', showPreview.value)
}
const updateBackgroundColor = (color: string) => {
emit('updateBackgroundColor', color)
}
const openColorPicker = () => {
colorPickerRef.value?.click()
}
const toggleLightIntensity = () => {
showLightIntensity.value = !showLightIntensity.value
}
const updateLightIntensity = () => {
emit('updateLightIntensity', lightIntensity.value)
}
const toggleFOV = () => {
showFOV.value = !showFOV.value
}
const updateFOV = () => {
emit('updateFOV', fov.value)
}
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.relative')) {
showLightIntensity.value = false
showFOV.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeSlider)
})
defineExpose({
backgroundColor,
showGrid,
lightIntensity,
showLightIntensityButton,
fov,
showFOVButton,
showPreviewButton
})
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96"
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96 pointer-events-auto"
>
<div
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"

View File

@@ -1,5 +1,5 @@
<template>
<div class="absolute top-0 left-0 w-auto max-w-full">
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
<WorkflowTabs />
</div>
</template>

View File

@@ -1,33 +1,35 @@
<template>
<div
ref="topMenuRef"
class="comfyui-menu flex items-center"
v-show="showTopMenu"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<h1 class="comfyui-logo mx-2 app-drag">ComfyUI</h1>
<CommandMenubar />
<div class="flex-grow min-w-0 app-drag h-full">
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
</div>
<div class="comfyui-menu-right flex-shrink-0" ref="menuRight"></div>
<Actionbar />
<BottomPanelToggleButton class="flex-shrink-0" />
<Button
class="flex-shrink-0"
icon="pi pi-bars"
severity="secondary"
text
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
:aria-label="$t('menu.hideMenu')"
@click="workspaceState.focusMode = true"
@contextmenu="showNativeMenu"
/>
<teleport :to="teleportTarget">
<div
v-show="menuSetting !== 'Bottom'"
class="window-actions-spacer flex-shrink-0"
/>
</div>
ref="topMenuRef"
class="comfyui-menu flex items-center"
v-show="showTopMenu"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<h1 class="comfyui-logo mx-2 app-drag">ComfyUI</h1>
<CommandMenubar />
<div class="flex-grow min-w-0 app-drag h-full">
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
</div>
<div class="comfyui-menu-right flex-shrink-0" ref="menuRight"></div>
<Actionbar />
<BottomPanelToggleButton class="flex-shrink-0" />
<Button
class="flex-shrink-0"
icon="pi pi-bars"
severity="secondary"
text
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
:aria-label="$t('menu.hideMenu')"
@click="workspaceState.focusMode = true"
@contextmenu="showNativeMenu"
/>
<div
v-show="menuSetting !== 'Bottom'"
class="window-actions-spacer flex-shrink-0"
/>
</div>
</teleport>
<!-- Virtual top menu for native window (drag handle) -->
<div
@@ -62,6 +64,11 @@ const workflowTabsPosition = computed(() =>
)
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
const teleportTarget = computed(() =>
settingStore.get('Comfy.UseNewMenu') === 'Top'
? '.comfyui-body-top'
: '.comfyui-body-bottom'
)
const showTopMenu = computed(
() => betaMenuEnabled.value && !workspaceState.focusMode
)

View File

@@ -31,7 +31,6 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
usePragmaticDraggable,
@@ -52,8 +51,6 @@ const props = defineProps<{
workflowOption: WorkflowOption
}>()
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowTabRef = ref<HTMLElement | null>(null)
@@ -62,8 +59,7 @@ const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) {
if (
!(await useWorkflowService().closeWorkflow(opt.workflow, {
warnIfUnsaved: !workspaceStore.shiftDown,
hint: t('sideToolbar.workflowTab.dirtyCloseHint')
warnIfUnsaved: !workspaceStore.shiftDown
}))
) {
// User clicked cancel

View File

@@ -1,25 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { InputSpec } from '@/types/apiTypes'
export const useBooleanWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec
) => {
const inputOptions = inputData[1]
const defaultVal = inputOptions?.default ?? false
const options = {
on: inputOptions?.label_on,
off: inputOptions?.label_off
}
return {
widget: node.addWidget('toggle', inputName, defaultVal, () => {}, options)
}
}
return widgetConstructor
}

View File

@@ -1,61 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { addValueControlWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useWidgetStore } from '@/stores/widgetStore'
import type { InputSpec } from '@/types/apiTypes'
import { useRemoteWidget } from './useRemoteWidget'
export const useComboWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec
) => {
const widgetStore = useWidgetStore()
const { remote, options } = inputData[1]
const defaultValue = widgetStore.getDefaultValue(inputData)
const res = {
widget: node.addWidget('combo', inputName, defaultValue, () => {}, {
values: options ?? inputData[0]
}) as IComboWidget
}
if (remote) {
const remoteWidget = useRemoteWidget({
inputData,
defaultValue,
node,
widget: res.widget
})
if (remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = res.widget.options
res.widget.options = new Proxy(
origOptions as Record<string | symbol, any>,
{
get(target, prop: string | symbol) {
if (prop !== 'values') return target[prop]
return remoteWidget.getValue()
}
}
)
}
if (inputData[1]?.control_after_generate) {
res.widget.linkedWidgets = addValueControlWidgets(
node,
res.widget,
undefined,
undefined,
inputData
)
}
return res
}
return widgetConstructor
}

View File

@@ -1,59 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { INumericWidget } from '@comfyorg/litegraph/dist/types/widgets'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
import type { InputSpec } from '@/types/apiTypes'
import { getNumberDefaults } from '@/utils/mathUtil'
export const useFloatWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec
) => {
// TODO: Move to outer scope to avoid re-initializing on every call
// Blocked on ComfyWidgets lazy initialization.
const settingStore = useSettingStore()
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
const inputOptions = inputData[1]
const widgetType = sliderEnabled
? inputOptions.display === 'slider'
? 'slider'
: 'number'
: 'number'
const precision =
settingStore.get('Comfy.FloatRoundingPrecision') || undefined
const enableRounding = !settingStore.get('Comfy.DisableFloatRounding')
const { val, config } = getNumberDefaults(inputOptions, {
defaultStep: 0.5,
precision,
enableRounding
})
return {
widget: node.addWidget(
widgetType,
inputName,
val,
function (this: INumericWidget, v: number) {
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
} else {
this.value = v
}
},
config
)
}
}
return widgetConstructor
}

View File

@@ -1,189 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { api } from '@/scripts/api'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useToastStore } from '@/stores/toastStore'
import type { ComfyApp } from '@/types'
import type { InputSpec } from '@/types/apiTypes'
export const useImageUploadWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec,
app: ComfyApp
) => {
const imageWidget = node.widgets?.find(
(w) => w.name === (inputData[1]?.widget ?? 'image')
) as IStringWidget
const { image_folder = 'input' } = inputData[1] ?? {}
function showImage(name: string) {
const img = new Image()
img.onload = () => {
node.imgs = [img]
app.graph.setDirtyCanvas(true)
}
const folder_separator = name.lastIndexOf('/')
let subfolder = ''
if (folder_separator > -1) {
subfolder = name.substring(0, folder_separator)
name = name.substring(folder_separator + 1)
}
img.src = api.apiURL(
`/view?filename=${encodeURIComponent(name)}&type=${image_folder}&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}`
)
node.setSizeForImage?.()
}
const default_value = imageWidget.value
Object.defineProperty(imageWidget, 'value', {
set: function (value) {
this._real_value = value
},
get: function () {
if (!this._real_value) {
return default_value
}
let value = this._real_value
if (value.filename) {
const real_value = value
value = ''
if (real_value.subfolder) {
value = real_value.subfolder + '/'
}
value += real_value.filename
if (real_value.type && real_value.type !== 'input')
value += ` [${real_value.type}]`
}
return value
}
})
// Add our own callback to the combo widget to render an image when it changes
// TODO: Explain this?
// @ts-expect-error LGraphNode.callback is not typed
const cb = node.callback
imageWidget.callback = function (...args) {
showImage(imageWidget.value)
if (cb) {
return cb.apply(this, args)
}
}
// 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)
}
})
// Add types for upload parameters
async function uploadFile(file: File, updateNode: boolean, 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
})
if (resp.status === 200) {
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
if (!imageWidget.options) {
imageWidget.options = { values: [] }
}
if (!imageWidget.options.values) {
imageWidget.options.values = []
}
if (!imageWidget.options.values.includes(path)) {
imageWidget.options.values.push(path)
}
if (updateNode) {
showImage(path)
imageWidget.value = path
}
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
useToastStore().addAlert(String(error))
}
}
const fileInput = document.createElement('input')
Object.assign(fileInput, {
type: 'file',
accept: 'image/jpeg,image/png,image/webp',
style: 'display: none',
onchange: async () => {
// Add null check for files
if (fileInput.files && fileInput.files.length) {
await uploadFile(fileInput.files[0], true)
}
}
})
document.body.append(fileInput)
// Create the button widget for selecting the files
const uploadWidget = node.addWidget('button', inputName, 'image', () => {
fileInput.click()
})
uploadWidget.label = 'choose file to upload'
// @ts-expect-error IWidget.serialize is not typed
uploadWidget.serialize = false
// Add handler to check if an image is being dragged over our node
node.onDragOver = function (e: DragEvent) {
if (e.dataTransfer && e.dataTransfer.items) {
const image = [...e.dataTransfer.items].find((f) => f.kind === 'file')
return !!image
}
return false
}
// On drop upload files
node.onDragDrop = function (e: DragEvent) {
console.log('onDragDrop called')
let handled = false
if (e.dataTransfer?.files) {
for (const file of e.dataTransfer.files) {
if (file.type.startsWith('image/')) {
uploadFile(file, !handled)
handled = true
}
}
}
return handled
}
node.pasteFile = function (file: File) {
if (file.type.startsWith('image/')) {
const is_pasted =
file.name === 'image.png' && file.lastModified - Date.now() < 2000
uploadFile(file, true, is_pasted)
return true
}
return false
}
return { widget: uploadWidget }
}
return widgetConstructor
}

View File

@@ -1,69 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { INumericWidget } from '@comfyorg/litegraph/dist/types/widgets'
import type { ComfyApp } from '@/scripts/app'
import {
type ComfyWidgetConstructor,
addValueControlWidget
} from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
import { InputSpec } from '@/types/apiTypes'
import { getNumberDefaults } from '@/utils/mathUtil'
export const useIntWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec,
app: ComfyApp,
widgetName?: string
) => {
const settingStore = useSettingStore()
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
const inputOptions = inputData[1]
const widgetType = sliderEnabled
? inputOptions.display === 'slider'
? 'slider'
: 'number'
: 'number'
const { val, config } = getNumberDefaults(inputOptions, {
defaultStep: 1,
precision: 0,
enableRounding: true
})
config.precision = 0
const result = {
widget: node.addWidget(
widgetType,
inputName,
val,
function (this: INumericWidget, v: number) {
const s = (this.options.step ?? 1) / 10
let sh = (this.options.min ?? 0) % s
if (isNaN(sh)) {
sh = 0
}
this.value = Math.round((v - sh) / s) * s + sh
},
config
)
}
if (inputData[1]?.control_after_generate) {
const seedControl = addValueControlWidget(
node,
result.widget,
'randomize',
undefined,
widgetName,
inputData
)
result.widget.linkedWidgets = [seedControl]
}
return result
}
return widgetConstructor
}

View File

@@ -1,121 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { Editor as TiptapEditor } from '@tiptap/core'
import TiptapLink from '@tiptap/extension-link'
import TiptapTable from '@tiptap/extension-table'
import TiptapTableCell from '@tiptap/extension-table-cell'
import TiptapTableHeader from '@tiptap/extension-table-header'
import TiptapTableRow from '@tiptap/extension-table-row'
import TiptapStarterKit from '@tiptap/starter-kit'
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyApp } from '@/types'
import type { InputSpec } from '@/types/apiTypes'
function addMarkdownWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string },
app: ComfyApp
) {
TiptapMarkdown.configure({
html: false,
breaks: true,
transformPastedText: true
})
const editor = new TiptapEditor({
extensions: [
TiptapStarterKit,
TiptapMarkdown,
TiptapLink,
TiptapTable,
TiptapTableCell,
TiptapTableHeader,
TiptapTableRow
],
content: opts.defaultVal,
editable: false
})
const inputEl = editor.options.element as HTMLElement
inputEl.classList.add('comfy-markdown')
const textarea = document.createElement('textarea')
inputEl.append(textarea)
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
return textarea.value
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
}
})
widget.inputEl = inputEl
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button !== 0) {
app.canvas.processMouseDown(event)
return
}
if (event.target instanceof HTMLAnchorElement) {
return
}
inputEl.classList.add('editing')
setTimeout(() => {
textarea.focus()
}, 0)
})
textarea.addEventListener('blur', () => {
inputEl.classList.remove('editing')
})
textarea.addEventListener('change', () => {
editor.commands.setContent(textarea.value)
widget.callback?.(widget.value)
})
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
event.stopPropagation()
})
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
return { minWidth: 400, minHeight: 200, widget }
}
export const useMarkdownWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec,
app: ComfyApp
) => {
const defaultVal = inputData[1]?.default || ''
return addMarkdownWidget(
node,
inputName,
{ defaultVal, ...inputData[1] },
app
)
}
return widgetConstructor
}

View File

@@ -1,221 +0,0 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { IWidget } from '@comfyorg/litegraph'
import axios from 'axios'
import type { InputSpec, RemoteWidgetConfig } from '@/types/apiTypes'
const MAX_RETRIES = 5
const TIMEOUT = 4096
export interface CacheEntry<T> {
data: T
timestamp?: number
error?: Error | null
fetchPromise?: Promise<T>
controller?: AbortController
lastErrorTime?: number
retryCount?: number
failed?: boolean
}
const dataCache = new Map<string, CacheEntry<any>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
const { route, query_params = {}, refresh = 0 } = config
const paramsKey = Object.entries(query_params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return [route, `r=${refresh}`, paramsKey].join(';')
}
const getBackoff = (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 512)
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
entry?.data && entry?.timestamp && entry.timestamp > 0
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
entry?.timestamp && Date.now() - entry.timestamp >= ttl
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
entry?.fetchPromise !== undefined
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
entry?.failed === true
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
entry?.error &&
entry?.lastErrorTime &&
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
const fetchData = async (
config: RemoteWidgetConfig,
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
timeout
})
return response_key ? res.data[response_key] : res.data
}
export function useRemoteWidget<
T extends string | number | boolean | object
>(options: {
inputData: InputSpec
defaultValue: T
node: LGraphNode
widget: IWidget
}) {
const { inputData, defaultValue, node, widget } = options
const config: RemoteWidgetConfig = inputData[1].remote
const { refresh = 0, max_retries = MAX_RETRIES } = config
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(config)
let isLoaded = false
const setSuccess = (entry: CacheEntry<T>, data: T) => {
entry.retryCount = 0
entry.lastErrorTime = 0
entry.error = null
entry.timestamp = Date.now()
entry.data = data ?? defaultValue
}
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
entry.retryCount = (entry.retryCount || 0) + 1
entry.lastErrorTime = Date.now()
entry.error = error instanceof Error ? error : new Error(String(error))
entry.data ??= defaultValue
entry.fetchPromise = undefined
if (entry.retryCount >= max_retries) {
setFailed(entry)
}
}
const setFailed = (entry: CacheEntry<T>) => {
dataCache.set(cacheKey, {
data: entry.data ?? defaultValue,
failed: true
})
}
const isFirstLoad = () => {
return !isLoaded && isInitialized(dataCache.get(cacheKey))
}
const onFirstLoad = (data: T[]) => {
isLoaded = true
widget.value = data[0]
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
const fetchValue = async () => {
const entry = dataCache.get(cacheKey)
if (isFailed(entry)) return entry!.data
const isValid =
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
if (isValid || isBackingOff(entry) || isFetching(entry)) return entry!.data
const currentEntry: CacheEntry<T> = entry || { data: defaultValue }
dataCache.set(cacheKey, currentEntry)
try {
currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData(config, currentEntry.controller)
const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data)
return currentEntry.data
} catch (err) {
setError(currentEntry, err)
return currentEntry.data
} finally {
currentEntry.fetchPromise = undefined
currentEntry.controller = undefined
}
}
const onRefresh = () => {
if (config.control_after_refresh) {
const data = getCachedValue()
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
switch (config.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break
case 'last':
widget.value = data.at(-1) ?? defaultValue
break
}
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
}
/**
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
*/
const clearCachedValue = () => {
const entry = dataCache.get(cacheKey)
if (!entry) return
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
dataCache.delete(cacheKey)
}
/**
* Get the cached value of the widget without starting a new fetch.
* @returns the most recently computed value of the widget.
*/
function getCachedValue() {
return dataCache.get(cacheKey)?.data as T
}
/**
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
* Starts the fetch process then returns the cached value immediately.
* @param onFulfilled - Optional callback to be called when the fetch is resolved.
* @returns the most recent value of the widget.
*/
function getValue(onFulfilled?: () => void) {
fetchValue().then((data) => {
if (isFirstLoad()) onFirstLoad(data)
onFulfilled?.()
})
return getCachedValue() ?? defaultValue
}
/**
* Force the widget to refresh its value
*/
function refreshValue() {
clearCachedValue()
getValue(onRefresh)
}
/**
* Add a refresh button to the node that, when clicked, will force the widget to refresh
*/
function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', refreshValue)
}
return {
getCachedValue,
getValue,
refreshValue,
addRefreshButton,
getCacheEntry: () => dataCache.get(cacheKey),
cacheKey
}
}

View File

@@ -1,27 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyApp } from '@/types'
import type { InputSpec } from '@/types/apiTypes'
import { useIntWidget } from './useIntWidget'
export const useSeedWidget = () => {
const IntWidget = useIntWidget()
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec,
app: ComfyApp,
widgetName?: string
) => {
inputData[1] = {
...inputData[1],
control_after_generate: true
}
return IntWidget(node, inputName, inputData, app, widgetName)
}
return widgetConstructor
}

View File

@@ -1,91 +0,0 @@
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
import type { ComfyApp } from '@/types'
import type { InputSpec } from '@/types/apiTypes'
function addMultilineWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string; placeholder?: string },
app: ComfyApp
) {
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
if (app.vueAppReady) {
inputEl.spellcheck = useSettingStore().get(
'Comfy.TextareaWidget.Spellcheck'
)
}
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
return inputEl.value
},
setValue(v: string) {
inputEl.value = v
}
})
widget.inputEl = inputEl
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
})
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
return { minWidth: 400, minHeight: 200, widget }
}
export const useStringWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec,
app: ComfyApp
) => {
const defaultVal = inputData[1]?.default || ''
const multiline = !!inputData[1]?.multiline
let res: { widget: IWidget }
if (multiline) {
res = addMultilineWidget(
node,
inputName,
{ defaultVal, ...inputData[1] },
app
)
} else {
res = {
widget: node.addWidget('text', inputName, defaultVal, () => {}, {})
}
}
if (inputData[1].dynamicPrompts != undefined)
res.widget.dynamicPrompts = inputData[1].dynamicPrompts
return res
}
return widgetConstructor
}

View File

@@ -1,35 +0,0 @@
export const RESERVED_BY_TEXT_INPUT = new Set([
'Ctrl + a',
'Ctrl + c',
'Ctrl + v',
'Ctrl + x',
'Ctrl + z',
'Ctrl + y',
'Ctrl + p',
'Enter',
'Shift + Enter',
'Ctrl + Backspace',
'Ctrl + Delete',
'Home',
'Ctrl + Home',
'Ctrl + Shift + Home',
'End',
'Ctrl + End',
'Ctrl + Shift + End',
'PageUp',
'PageDown',
'Shift + PageUp',
'Shift + PageDown',
'ArrowLeft',
'Ctrl + ArrowLeft',
'Shift + ArrowLeft',
'Ctrl + Shift + ArrowLeft',
'ArrowRight',
'Ctrl + ArrowRight',
'Shift + ArrowRight',
'Ctrl + Shift + ArrowRight',
'ArrowUp',
'Shift + ArrowUp',
'ArrowDown',
'Shift + ArrowDown'
])

View File

@@ -10,7 +10,8 @@ useExtensionService().registerExtension({
if (node.widgets) {
// Locate dynamic prompt text widgets
// Include any widgets with dynamicPrompts set to true, and customtext
const widgets = node.widgets.filter((w) => w.dynamicPrompts)
// @ts-expect-error dynamicPrompts is not typed
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
// @ts-expect-error hacky override

View File

@@ -856,7 +856,6 @@ export class GroupNodeHandler {
for (let i = 0; i < c.nodes.length; i++) {
let id = innerNodes?.[i]?.id
// Use existing IDs if they are set on the inner nodes
// @ts-expect-error id can be string or number
if (id == null || isNaN(id)) {
id = undefined
} else {

View File

@@ -1,31 +1,33 @@
// @ts-strict-ignore
import { IWidget } from '@comfyorg/litegraph'
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { nextTick } from 'vue'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { app } from '@/scripts/app'
import { useLoad3dService } from '@/services/load3dService'
import { useToastStore } from '@/stores/toastStore'
const containerToLoad3D = new Map()
app.registerExtension({
name: 'Comfy.Load3D',
getCustomWidgets(app) {
return {
LOAD_3D(node, inputName) {
let load3dNode = app.graph._nodes.filter((wi) => wi.type == 'Load3D')
node.addProperty('Camera Info', '')
const container = document.createElement('div')
container.id = `comfy-load-3d-${load3dNode.length}`
container.classList.add('comfy-load-3d')
const load3d = useLoad3dService().registerLoad3d(
node,
container,
'Load3D'
)
const load3d = new Load3d(container, true)
containerToLoad3D.set(container.id, load3d)
node.onMouseEnter = function () {
if (load3d) {
@@ -46,7 +48,7 @@ app.registerExtension({
load3d.remove()
}
useLoad3dService().removeLoad3d(node)
containerToLoad3D.delete(container.id)
origOnRemoved?.apply(this, [])
}
@@ -63,7 +65,7 @@ app.registerExtension({
if (fileInput.files?.length) {
const modelWidget = node.widgets?.find(
(w: IWidget) => w.name === 'model_file'
) as IStringWidget
)
const uploadPath = await Load3dUtils.uploadFile(
load3d,
fileInput.files[0],
@@ -115,7 +117,11 @@ app.registerExtension({
const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image')
const load3d = useLoad3dService().getLoad3d(node)
const container = sceneWidget.element
const load3d = containerToLoad3D.get(container.id)
load3d.setNode(node)
const modelWidget = node.widgets.find(
(w: IWidget) => w.name === 'model_file'
@@ -129,11 +135,11 @@ app.registerExtension({
let cameraState = node.properties['Camera Info']
const config = new Load3DConfiguration(load3d)
const width = node.widgets.find((w: IWidget) => w.name === 'width')
const height = node.widgets.find((w: IWidget) => w.name === 'height')
const config = new Load3DConfiguration(load3d)
config.configure(
'input',
modelWidget,
@@ -144,12 +150,13 @@ app.registerExtension({
height
)
// @ts-expect-error hacky override
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = load3d.getCameraState()
const { scene: imageData, mask: maskData } = await load3d.captureScene(
width.value as number,
height.value as number
width.value,
height.value
)
const [data, dataMask] = await Promise.all([
@@ -171,17 +178,19 @@ app.registerExtension({
getCustomWidgets(app) {
return {
LOAD_3D_ANIMATION(node, inputName) {
let load3dNode = app.graph._nodes.filter(
(wi) => wi.type == 'Load3DAnimation'
)
node.addProperty('Camera Info', '')
const container = document.createElement('div')
container.id = `comfy-load-3d-animation-${load3dNode.length}`
container.classList.add('comfy-load-3d-animation')
const load3d = useLoad3dService().registerLoad3d(
node,
container,
'Load3DAnimation'
)
const load3d = new Load3dAnimation(container, true)
containerToLoad3D.set(container.id, load3d)
node.onMouseEnter = function () {
if (load3d) {
@@ -202,7 +211,7 @@ app.registerExtension({
load3d.remove()
}
useLoad3dService().removeLoad3d(node)
containerToLoad3D.delete(container.id)
origOnRemoved?.apply(this, [])
}
@@ -219,7 +228,7 @@ app.registerExtension({
if (fileInput.files?.length) {
const modelWidget = node.widgets?.find(
(w: IWidget) => w.name === 'model_file'
) as IStringWidget
)
const uploadPath = await Load3dUtils.uploadFile(
load3d,
fileInput.files[0],
@@ -265,13 +274,17 @@ app.registerExtension({
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 700)])
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 700)])
await nextTick()
const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image')
const load3d = useLoad3dService().getLoad3d(node) as Load3dAnimation
const container = sceneWidget.element
const load3d = containerToLoad3D.get(container.id)
load3d.setNode(node)
const modelWidget = node.widgets.find(
(w: IWidget) => w.name === 'model_file'
@@ -285,11 +298,11 @@ app.registerExtension({
let cameraState = node.properties['Camera Info']
const config = new Load3DConfiguration(load3d)
const width = node.widgets.find((w: IWidget) => w.name === 'width')
const height = node.widgets.find((w: IWidget) => w.name === 'height')
const config = new Load3DConfiguration(load3d)
config.configure(
'input',
modelWidget,
@@ -300,14 +313,15 @@ app.registerExtension({
height
)
// @ts-expect-error hacky override
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = load3d.getCameraState()
load3d.toggleAnimation(false)
const { scene: imageData, mask: maskData } = await load3d.captureScene(
width.value as number,
height.value as number
width.value,
height.value
)
const [data, dataMask] = await Promise.all([
@@ -338,15 +352,15 @@ app.registerExtension({
getCustomWidgets(app) {
return {
PREVIEW_3D(node, inputName) {
const container = document.createElement('div')
let load3dNode = app.graph._nodes.filter((wi) => wi.type == 'Preview3D')
const container = document.createElement('div')
container.id = `comfy-preview-3d-${load3dNode.length}`
container.classList.add('comfy-preview-3d')
const load3d = useLoad3dService().registerLoad3d(
node,
container,
'Preview3D'
)
const load3d = new Load3d(container)
containerToLoad3D.set(container.id, load3d)
node.onMouseEnter = function () {
if (load3d) {
@@ -367,7 +381,7 @@ app.registerExtension({
load3d.remove()
}
useLoad3dService().removeLoad3d(node)
containerToLoad3D.delete(container.id)
origOnRemoved?.apply(this, [])
}
@@ -388,11 +402,17 @@ app.registerExtension({
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 550)])
await nextTick()
const load3d = useLoad3dService().getLoad3d(node)
const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image')
const container = sceneWidget.element
const load3d = containerToLoad3D.get(container.id)
load3d.setNode(node)
const modelWidget = node.widgets.find(
(w: IWidget) => w.name === 'model_file'
@@ -443,15 +463,17 @@ app.registerExtension({
getCustomWidgets(app) {
return {
PREVIEW_3D_ANIMATION(node, inputName) {
const container = document.createElement('div')
let load3dNode = app.graph._nodes.filter(
(wi) => wi.type == 'Preview3DAnimation'
)
const container = document.createElement('div')
container.id = `comfy-preview-3d-animation-${load3dNode.length}`
container.classList.add('comfy-preview-3d-animation')
const load3d = useLoad3dService().registerLoad3d(
node,
container,
'Preview3DAnimation'
)
const load3d = new Load3dAnimation(container)
containerToLoad3D.set(container.id, load3d)
node.onMouseEnter = function () {
if (load3d) {
@@ -472,7 +494,7 @@ app.registerExtension({
load3d.remove()
}
useLoad3dService().removeLoad3d(node)
containerToLoad3D.delete(container.id)
origOnRemoved?.apply(this, [])
}
@@ -501,7 +523,13 @@ app.registerExtension({
await nextTick()
const load3d = useLoad3dService().getLoad3d(node)
const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image')
const container = sceneWidget.element
const load3d = containerToLoad3D.get(container.id)
load3d.setNode(node)
const modelWidget = node.widgets.find(
(w: IWidget) => w.name === 'model_file'

View File

@@ -29,20 +29,6 @@ class Load3DConfiguration {
this.setupDefaultProperties()
}
private setupTargetSize(width: IWidget | null, height: IWidget | null) {
if (width && height) {
this.load3d.setTargetSize(width.value as number, height.value as number)
width.callback = (value: number) => {
this.load3d.setTargetSize(value, height.value as number)
}
height.callback = (value: number) => {
this.load3d.setTargetSize(width.value as number, value)
}
}
}
private setupModelHandling(
modelWidget: IWidget,
loadFolder: 'input' | 'output',
@@ -80,6 +66,20 @@ class Load3DConfiguration {
)
}
private setupTargetSize(width: IWidget | null, height: IWidget | null) {
if (width && height) {
this.load3d.setTargetSize(width.value as number, height.value as number)
width.callback = (value: number) => {
this.load3d.setTargetSize(value, height.value as number)
}
height.callback = (value: number) => {
this.load3d.setTargetSize(width.value as number, value)
}
}
}
private setupDefaultProperties() {
const cameraType = this.load3d.loadNodeProperty(
'Camera Type',
@@ -94,13 +94,20 @@ class Load3DConfiguration {
this.load3d.setBackgroundColor(bgColor)
const fov = this.load3d.loadNodeProperty('FOV', '75')
this.load3d.setFOV(fov)
const lightIntensity = this.load3d.loadNodeProperty('Light Intensity', '5')
this.load3d.setLightIntensity(lightIntensity)
const fov = this.load3d.loadNodeProperty('FOV', '75')
const previewVisible = this.load3d.loadNodeProperty(
'Preview Visible',
false
)
this.load3d.setFOV(fov)
this.load3d.setPreviewVisible(previewVisible)
}
private createModelUpdateHandler(

View File

@@ -1,5 +1,4 @@
import { LGraphNode } from '@comfyorg/litegraph'
import Tooltip from 'primevue/tooltip'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
@@ -8,9 +7,7 @@ import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { App, createApp } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import { useToastStore } from '@/stores/toastStore'
class Load3d {
@@ -44,20 +41,23 @@ class Load3d {
originalRotation: THREE.Euler | null = null
viewHelper: ViewHelper = {} as ViewHelper
viewHelperContainer: HTMLDivElement = {} as HTMLDivElement
cameraSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
gridSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
node: LGraphNode = {} as LGraphNode
bgColorInput: HTMLInputElement = {} as HTMLInputElement
fovSliderContainer: HTMLDivElement = {} as HTMLDivElement
lightSliderContainer: HTMLDivElement = {} as HTMLDivElement
previewRenderer: THREE.WebGLRenderer | null = null
previewCamera: THREE.Camera | null = null
previewContainer: HTMLDivElement = {} as HTMLDivElement
targetWidth: number = 1024
targetHeight: number = 1024
showPreview: boolean = true
node: LGraphNode = {} as LGraphNode
protected controlsApp: App | null = null
protected controlsContainer: HTMLDivElement
previewToggleContainer: HTMLDivElement = {} as HTMLDivElement
isPreviewVisible: boolean = true
constructor(
container: Element | HTMLElement,
options: { createPreview?: boolean } = {}
createPreview: boolean = false
) {
this.scene = new THREE.Scene()
@@ -135,56 +135,22 @@ class Load3d {
this.standardMaterial = this.createSTLMaterial()
this.createViewHelper(container)
this.createGridSwitcher(container)
this.createCameraSwitcher(container)
this.createColorPicker(container)
this.createFOVSlider(container)
this.createLightIntensitySlider(container)
if (options && options.createPreview) {
if (createPreview) {
this.createPreviewToggle(container)
this.createCapturePreview(container)
}
this.controlsContainer = document.createElement('div')
this.controlsContainer.style.position = 'absolute'
this.controlsContainer.style.top = '0'
this.controlsContainer.style.left = '0'
this.controlsContainer.style.width = '100%'
this.controlsContainer.style.height = '100%'
this.controlsContainer.style.pointerEvents = 'none'
this.controlsContainer.style.zIndex = '1'
container.appendChild(this.controlsContainer)
this.mountControls(options)
this.handleResize()
this.startAnimation()
}
protected mountControls(options: { createPreview?: boolean } = {}) {
const controlsMount = document.createElement('div')
controlsMount.style.pointerEvents = 'auto'
this.controlsContainer.appendChild(controlsMount)
this.controlsApp = createApp(Load3DControls, {
backgroundColor: '#282828',
showGrid: true,
showPreview: options.createPreview,
lightIntensity: 5,
showLightIntensityButton: true,
fov: 75,
showFOVButton: true,
showPreviewButton: options.createPreview,
onToggleCamera: () => this.toggleCamera(),
onToggleGrid: (show: boolean) => this.toggleGrid(show),
onTogglePreview: (show: boolean) => this.togglePreview(show),
onUpdateBackgroundColor: (color: string) =>
this.setBackgroundColor(color),
onUpdateLightIntensity: (lightIntensity: number) =>
this.setLightIntensity(lightIntensity),
onUpdateFOV: (fov: number) => this.setFOV(fov)
})
this.controlsApp.directive('tooltip', Tooltip)
this.controlsApp.mount(controlsMount)
}
setNode(node: LGraphNode) {
this.node = node
}
@@ -224,14 +190,15 @@ class Load3d {
`
this.previewContainer.appendChild(this.previewRenderer.domElement)
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
this.previewContainer.style.display = this.isPreviewVisible
? 'block'
: 'none'
container.appendChild(this.previewContainer)
}
updatePreviewRender() {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
return
if (!this.previewRenderer || !this.previewContainer) return
if (
!this.previewCamera ||
@@ -279,6 +246,78 @@ class Load3d {
this.previewRenderer.render(this.scene, this.previewCamera)
}
createPreviewToggle(container: Element | HTMLElement) {
this.previewToggleContainer = document.createElement('div')
this.previewToggleContainer.style.position = 'absolute'
this.previewToggleContainer.style.top = '128px'
this.previewToggleContainer.style.left = '3px'
this.previewToggleContainer.style.width = '20px'
this.previewToggleContainer.style.height = '20px'
this.previewToggleContainer.style.cursor = 'pointer'
this.previewToggleContainer.style.display = 'flex'
this.previewToggleContainer.style.alignItems = 'center'
this.previewToggleContainer.style.justifyContent = 'center'
this.previewToggleContainer.style.borderRadius = '2px'
this.previewToggleContainer.title = 'Toggle Preview'
const eyeIcon = document.createElement('div')
eyeIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
`
const updateButtonState = () => {
if (this.isPreviewVisible) {
this.previewToggleContainer.style.backgroundColor =
'rgba(255, 255, 255, 0.2)'
} else {
this.previewToggleContainer.style.backgroundColor = 'transparent'
}
}
this.previewToggleContainer.addEventListener('mouseenter', () => {
if (!this.isPreviewVisible) {
this.previewToggleContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
}
})
this.previewToggleContainer.addEventListener('mouseleave', () => {
if (!this.isPreviewVisible) {
this.previewToggleContainer.style.backgroundColor = 'transparent'
}
})
this.previewToggleContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.setPreviewVisible(!this.isPreviewVisible)
updateButtonState()
})
this.previewToggleContainer.appendChild(eyeIcon)
container.appendChild(this.previewToggleContainer)
this.isPreviewVisible = this.loadNodeProperty('Preview Visible', true)
updateButtonState()
}
setPreviewVisible(visible: boolean) {
if (!this.previewContainer) return
this.isPreviewVisible = visible
this.previewContainer.style.display = this.isPreviewVisible
? 'block'
: 'none'
this.storeNodeProperty('Preview Visible', this.isPreviewVisible)
}
updatePreviewSize() {
if (!this.previewContainer) return
@@ -288,24 +327,6 @@ class Load3d {
this.previewRenderer?.setSize(previewWidth, previewHeight, false)
}
setTargetSize(width: number, height: number) {
this.targetWidth = width
this.targetHeight = height
this.updatePreviewSize()
if (this.previewRenderer && this.previewCamera) {
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
this.previewCamera.aspect = width / height
this.previewCamera.updateProjectionMatrix()
} else if (this.previewCamera instanceof THREE.OrthographicCamera) {
const frustumSize = 10
const aspect = width / height
this.previewCamera.left = (-frustumSize * aspect) / 2
this.previewCamera.right = (frustumSize * aspect) / 2
this.previewCamera.updateProjectionMatrix()
}
}
}
createViewHelper(container: Element | HTMLElement) {
this.viewHelperContainer = document.createElement('div')
@@ -334,13 +355,274 @@ class Load3d {
this.viewHelper.center = this.controls.target
}
createGridSwitcher(container: Element | HTMLElement) {
this.gridSwitcherContainer = document.createElement('div')
this.gridSwitcherContainer.style.position = 'absolute'
this.gridSwitcherContainer.style.top = '28px'
this.gridSwitcherContainer.style.left = '3px'
this.gridSwitcherContainer.style.width = '20px'
this.gridSwitcherContainer.style.height = '20px'
this.gridSwitcherContainer.style.cursor = 'pointer'
this.gridSwitcherContainer.style.alignItems = 'center'
this.gridSwitcherContainer.style.justifyContent = 'center'
this.gridSwitcherContainer.style.transition = 'background-color 0.2s'
const gridIcon = document.createElement('div')
gridIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M3 3h18v18H3z"/>
<path d="M3 9h18"/>
<path d="M3 15h18"/>
<path d="M9 3v18"/>
<path d="M15 3v18"/>
</svg>
`
const updateButtonState = () => {
if (this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor =
'rgba(255, 255, 255, 0.2)'
} else {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
}
updateButtonState()
this.gridSwitcherContainer.addEventListener('mouseenter', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
}
})
this.gridSwitcherContainer.addEventListener('mouseleave', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
})
this.gridSwitcherContainer.title = 'Toggle Grid'
this.gridSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleGrid(!this.gridHelper.visible)
updateButtonState()
})
this.gridSwitcherContainer.appendChild(gridIcon)
container.appendChild(this.gridSwitcherContainer)
}
createCameraSwitcher(container: Element | HTMLElement) {
this.cameraSwitcherContainer = document.createElement('div')
this.cameraSwitcherContainer.style.position = 'absolute'
this.cameraSwitcherContainer.style.top = '3px'
this.cameraSwitcherContainer.style.left = '3px'
this.cameraSwitcherContainer.style.width = '20px'
this.cameraSwitcherContainer.style.height = '20px'
this.cameraSwitcherContainer.style.cursor = 'pointer'
this.cameraSwitcherContainer.style.alignItems = 'center'
this.cameraSwitcherContainer.style.justifyContent = 'center'
const cameraIcon = document.createElement('div')
cameraIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M18 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Z"/>
<path d="m12 12 4-2.4"/>
<circle cx="12" cy="12" r="3"/>
</svg>
`
this.cameraSwitcherContainer.addEventListener('mouseenter', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.cameraSwitcherContainer.addEventListener('mouseleave', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.cameraSwitcherContainer.title =
'Switch Camera (Perspective/Orthographic)'
this.cameraSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleCamera()
})
this.cameraSwitcherContainer.appendChild(cameraIcon)
container.appendChild(this.cameraSwitcherContainer)
}
createColorPicker(container: Element | HTMLElement) {
const colorPickerContainer = document.createElement('div')
colorPickerContainer.style.position = 'absolute'
colorPickerContainer.style.top = '53px'
colorPickerContainer.style.left = '3px'
colorPickerContainer.style.width = '20px'
colorPickerContainer.style.height = '20px'
colorPickerContainer.style.cursor = 'pointer'
colorPickerContainer.style.alignItems = 'center'
colorPickerContainer.style.justifyContent = 'center'
colorPickerContainer.title = 'Background Color'
const colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.opacity = '0'
colorInput.style.position = 'absolute'
colorInput.style.width = '100%'
colorInput.style.height = '100%'
colorInput.style.cursor = 'pointer'
const colorIcon = document.createElement('div')
colorIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M12 3v18"/>
<path d="M3 12h18"/>
</svg>
`
colorInput.addEventListener('input', (event) => {
const color = (event.target as HTMLInputElement).value
this.setBackgroundColor(color)
this.storeNodeProperty('Background Color', color)
})
this.bgColorInput = colorInput
colorPickerContainer.appendChild(colorInput)
colorPickerContainer.appendChild(colorIcon)
container.appendChild(colorPickerContainer)
}
createFOVSlider(container: Element | HTMLElement) {
this.fovSliderContainer = document.createElement('div')
this.fovSliderContainer.style.position = 'absolute'
this.fovSliderContainer.style.top = '78px'
this.fovSliderContainer.style.left = '3px'
this.fovSliderContainer.style.display = 'flex'
this.fovSliderContainer.style.alignItems = 'center'
this.fovSliderContainer.title = 'FOV (Perspective Camera Only)'
const wrapper = document.createElement('div')
wrapper.style.position = 'relative'
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
const iconContainer = document.createElement('div')
iconContainer.style.width = '20px'
iconContainer.style.height = '20px'
iconContainer.style.cursor = 'pointer'
iconContainer.style.display = 'flex'
iconContainer.style.alignItems = 'center'
iconContainer.style.justifyContent = 'center'
iconContainer.style.borderRadius = '2px'
const fovIcon = document.createElement('div')
fovIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M3 12h4"/>
<path d="M17 12h4"/>
<path d="M12 3v4"/>
<path d="M12 17v4"/>
<circle cx="12" cy="12" r="3"/>
</svg>
`
fovIcon.style.display = 'flex'
fovIcon.style.alignItems = 'center'
fovIcon.style.justifyContent = 'center'
iconContainer.appendChild(fovIcon)
const sliderContainer = document.createElement('div')
sliderContainer.style.position = 'absolute'
sliderContainer.style.left = '25px'
sliderContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
sliderContainer.style.padding = '5px'
sliderContainer.style.borderRadius = '4px'
sliderContainer.style.display = 'none'
sliderContainer.style.width = '150px'
sliderContainer.style.zIndex = '1000'
const slider = document.createElement('input')
slider.type = 'range'
slider.min = '10'
slider.max = '150'
slider.value = '75'
slider.style.width = '100%'
slider.style.height = '10px'
slider.addEventListener('input', (event) => {
const value = parseInt((event.target as HTMLInputElement).value)
this.setFOV(value)
this.storeNodeProperty('FOV', value)
})
let isHovered = false
const showSlider = () => {
if (this.activeCamera === this.perspectiveCamera) {
sliderContainer.style.display = 'block'
iconContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
isHovered = true
}
}
const hideSlider = () => {
isHovered = false
setTimeout(() => {
if (!isHovered) {
sliderContainer.style.display = 'none'
iconContainer.style.backgroundColor = 'transparent'
}
}, 100)
}
iconContainer.addEventListener('mouseenter', showSlider)
iconContainer.addEventListener('mouseleave', hideSlider)
sliderContainer.addEventListener('mouseenter', () => {
isHovered = true
})
sliderContainer.addEventListener('mouseleave', hideSlider)
sliderContainer.appendChild(slider)
wrapper.appendChild(iconContainer)
wrapper.appendChild(sliderContainer)
this.fovSliderContainer.appendChild(wrapper)
container.appendChild(this.fovSliderContainer)
this.updateFOVSliderVisibility()
}
updateFOVSliderVisibility() {
if (this.activeCamera === this.perspectiveCamera) {
this.fovSliderContainer.style.display = 'block'
} else {
this.fovSliderContainer.style.display = 'none'
}
}
setTargetSize(width: number, height: number) {
this.targetWidth = width
this.targetHeight = height
this.updatePreviewSize()
if (this.previewRenderer && this.previewCamera) {
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
this.previewCamera.aspect = width / height
this.previewCamera.updateProjectionMatrix()
} else if (this.previewCamera instanceof THREE.OrthographicCamera) {
const frustumSize = 10
const aspect = width / height
this.previewCamera.left = (-frustumSize * aspect) / 2
this.previewCamera.right = (frustumSize * aspect) / 2
this.previewCamera.updateProjectionMatrix()
}
}
}
setFOV(fov: number) {
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.fov = fov
this.perspectiveCamera.updateProjectionMatrix()
this.renderer.render(this.scene, this.activeCamera)
this.storeNodeProperty('FOV', fov)
}
if (
@@ -353,6 +635,118 @@ class Load3d {
}
}
createLightIntensitySlider(container: Element | HTMLElement) {
this.lightSliderContainer = document.createElement('div')
this.lightSliderContainer.style.position = 'absolute'
this.lightSliderContainer.style.top = '103px'
this.lightSliderContainer.style.left = '3px'
this.lightSliderContainer.style.display = 'flex'
this.lightSliderContainer.style.alignItems = 'center'
this.lightSliderContainer.title = 'Light Intensity'
const wrapper = document.createElement('div')
wrapper.style.position = 'relative'
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
const iconContainer = document.createElement('div')
iconContainer.style.width = '20px'
iconContainer.style.height = '20px'
iconContainer.style.cursor = 'pointer'
iconContainer.style.display = 'flex'
iconContainer.style.alignItems = 'center'
iconContainer.style.justifyContent = 'center'
iconContainer.style.borderRadius = '2px'
const lightIcon = document.createElement('div')
lightIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
`
lightIcon.style.display = 'flex'
lightIcon.style.alignItems = 'center'
lightIcon.style.justifyContent = 'center'
iconContainer.appendChild(lightIcon)
const sliderContainer = document.createElement('div')
sliderContainer.style.position = 'absolute'
sliderContainer.style.left = '25px'
sliderContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
sliderContainer.style.padding = '5px'
sliderContainer.style.borderRadius = '4px'
sliderContainer.style.display = 'none'
sliderContainer.style.width = '150px'
sliderContainer.style.zIndex = '1000'
const slider = document.createElement('input')
slider.type = 'range'
slider.min = '1'
slider.max = '20'
slider.step = '1'
slider.value = '5'
slider.style.width = '100%'
slider.style.height = '10px'
slider.addEventListener('input', (event) => {
const value = parseFloat((event.target as HTMLInputElement).value)
this.setLightIntensity(value)
this.storeNodeProperty('Light Intensity', value)
})
let isHovered = false
const showSlider = () => {
sliderContainer.style.display = 'block'
iconContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
isHovered = true
}
const hideSlider = () => {
isHovered = false
setTimeout(() => {
if (!isHovered) {
sliderContainer.style.display = 'none'
iconContainer.style.backgroundColor = 'transparent'
}
}, 100)
}
iconContainer.addEventListener('mouseenter', showSlider)
iconContainer.addEventListener('mouseleave', hideSlider)
sliderContainer.addEventListener('mouseenter', () => {
isHovered = true
})
sliderContainer.addEventListener('mouseleave', hideSlider)
sliderContainer.appendChild(slider)
wrapper.appendChild(iconContainer)
wrapper.appendChild(sliderContainer)
this.lightSliderContainer.appendChild(wrapper)
container.appendChild(this.lightSliderContainer)
const savedIntensity = this.loadNodeProperty('Light Intensity', 5)
slider.value = savedIntensity.toString()
this.setLightIntensity(savedIntensity)
this.updateLightIntensitySliderVisibility()
}
updateLightIntensitySliderVisibility() {
if (this.materialMode === 'original') {
this.lightSliderContainer.style.display = 'block'
} else {
this.lightSliderContainer.style.display = 'none'
}
}
getCameraState() {
const currentType = this.getCurrentCameraType()
return {
@@ -372,6 +766,15 @@ class Load3d {
zoom: number
cameraType: 'perspective' | 'orthographic'
}) {
if (
this.activeCamera !==
(state.cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera)
) {
//this.toggleCamera(state.cameraType)
}
this.activeCamera.position.copy(state.position)
this.controls.target.copy(state.target)
@@ -430,10 +833,7 @@ class Load3d {
setMaterialMode(mode: 'original' | 'normal' | 'wireframe' | 'depth') {
this.materialMode = mode
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.showLightIntensityButton.value =
mode == 'original'
}
this.updateLightIntensitySliderVisibility()
if (this.currentModel) {
if (mode === 'depth') {
@@ -608,10 +1008,7 @@ class Load3d {
)
this.viewHelper.center = this.controls.target
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.showFOVButton.value =
this.getCurrentCameraType() == 'perspective'
}
this.updateFOVSliderVisibility()
this.storeNodeProperty('Camera Type', this.getCurrentCameraType())
this.handleResize()
@@ -632,16 +1029,6 @@ class Load3d {
}
}
togglePreview(showPreview: boolean) {
if (this.previewRenderer) {
this.showPreview = showPreview
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
this.storeNodeProperty('Show Preview', showPreview)
}
}
setLightIntensity(intensity: number) {
this.lights.forEach((light) => {
if (light instanceof THREE.DirectionalLight) {
@@ -658,15 +1045,13 @@ class Load3d {
light.intensity = intensity * 0.5
}
})
this.storeNodeProperty('Light Intensity', intensity)
}
startAnimation() {
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
if (this.showPreview) {
if (this.isPreviewVisible) {
this.updatePreviewRender()
}
@@ -771,10 +1156,7 @@ class Load3d {
this.controls.dispose()
this.viewHelper.dispose()
this.renderer.dispose()
if (this.controlsApp) {
this.controlsApp.unmount()
this.controlsApp = null
}
this.fovSliderContainer.remove()
this.renderer.domElement.remove()
this.scene.clear()
}
@@ -1044,11 +1426,9 @@ class Load3d {
this.renderer.setClearColor(new THREE.Color(color))
this.renderer.render(this.scene, this.activeCamera)
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.backgroundColor.value = color
if (this.bgColorInput) {
this.bgColorInput.value = color
}
this.storeNodeProperty('Background Color', color)
}
}

View File

@@ -1,9 +1,5 @@
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import * as THREE from 'three'
import { createApp } from 'vue'
import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls.vue'
import Load3d from '@/extensions/core/load3d/Load3d'
class Load3dAnimation extends Load3d {
@@ -14,61 +10,167 @@ class Load3dAnimation extends Load3d {
isAnimationPlaying: boolean = false
animationSpeed: number = 1.0
playPauseContainer: HTMLDivElement = {} as HTMLDivElement
animationSelect: HTMLSelectElement = {} as HTMLSelectElement
speedSelect: HTMLSelectElement = {} as HTMLSelectElement
constructor(
container: Element | HTMLElement,
options: { createPreview?: boolean } = {}
createPreview: boolean = false
) {
super(container, options)
super(container, createPreview)
this.createPlayPauseButton(container)
this.createAnimationList(container)
this.createSpeedSelect(container)
}
protected mountControls(options: { createPreview?: boolean } = {}) {
const controlsMount = document.createElement('div')
controlsMount.style.pointerEvents = 'auto'
this.controlsContainer.appendChild(controlsMount)
this.controlsApp = createApp(Load3DAnimationControls, {
backgroundColor: '#282828',
showGrid: true,
showPreview: options.createPreview,
animations: [],
playing: false,
lightIntensity: 5,
showLightIntensityButton: true,
fov: 75,
showFOVButton: true,
showPreviewButton: options.createPreview,
onToggleCamera: () => this.toggleCamera(),
onToggleGrid: (show: boolean) => this.toggleGrid(show),
onTogglePreview: (show: boolean) => this.togglePreview(show),
onUpdateBackgroundColor: (color: string) =>
this.setBackgroundColor(color),
onTogglePlay: (play: boolean) => this.toggleAnimation(play),
onSpeedChange: (speed: number) => this.setAnimationSpeed(speed),
onAnimationChange: (selectedAnimation: number) =>
this.updateSelectedAnimation(selectedAnimation),
onUpdateLightIntensity: (lightIntensity: number) =>
this.setLightIntensity(lightIntensity),
onUpdateFOV: (fov: number) => this.setFOV(fov)
createAnimationList(container: Element | HTMLElement) {
this.animationSelect = document.createElement('select')
Object.assign(this.animationSelect.style, {
position: 'absolute',
top: '3px',
left: '50%',
transform: 'translateX(15px)',
width: '90px',
height: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
padding: '0 8px',
cursor: 'pointer',
display: 'none',
outline: 'none'
})
this.controlsApp.use(PrimeVue)
this.controlsApp.directive('tooltip', Tooltip)
this.controlsApp.mount(controlsMount)
this.animationSelect.addEventListener('mouseenter', () => {
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.animationSelect.addEventListener('mouseleave', () => {
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.animationSelect.addEventListener('change', (event) => {
const select = event.target as HTMLSelectElement
this.updateSelectedAnimation(select.selectedIndex)
})
container.appendChild(this.animationSelect)
}
updateAnimationList() {
if (this.controlsApp?._instance?.exposed) {
if (this.animationClips.length > 0) {
this.controlsApp._instance.exposed.animations.value =
this.animationClips.map((clip, index) => ({
name: clip.name || `Animation ${index + 1}`,
index
}))
} else {
this.controlsApp._instance.exposed.animations.value = []
this.animationSelect.innerHTML = ''
this.animationClips.forEach((clip, index) => {
const option = document.createElement('option')
option.value = index.toString()
option.text = clip.name || `Animation ${index + 1}`
option.selected = index === this.selectedAnimationIndex
this.animationSelect.appendChild(option)
})
}
createPlayPauseButton(container: Element | HTMLElement) {
this.playPauseContainer = document.createElement('div')
this.playPauseContainer.style.position = 'absolute'
this.playPauseContainer.style.top = '3px'
this.playPauseContainer.style.left = '50%'
this.playPauseContainer.style.transform = 'translateX(-50%)'
this.playPauseContainer.style.width = '20px'
this.playPauseContainer.style.height = '20px'
this.playPauseContainer.style.cursor = 'pointer'
this.playPauseContainer.style.alignItems = 'center'
this.playPauseContainer.style.justifyContent = 'center'
const updateButtonState = () => {
const icon = this.playPauseContainer.querySelector('svg')
if (icon) {
if (this.isAnimationPlaying) {
icon.innerHTML = `
<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>
`
this.playPauseContainer.title = 'Pause Animation'
} else {
icon.innerHTML = `
<path d="M8 5v14l11-7z"/>
`
this.playPauseContainer.title = 'Play Animation'
}
}
}
const playIcon = document.createElement('div')
playIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M8 5v14l11-7z"/>
</svg>
`
this.playPauseContainer.addEventListener('mouseenter', () => {
this.playPauseContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.playPauseContainer.addEventListener('mouseleave', () => {
this.playPauseContainer.style.backgroundColor = 'transparent'
})
this.playPauseContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleAnimation()
updateButtonState()
})
this.playPauseContainer.appendChild(playIcon)
container.appendChild(this.playPauseContainer)
this.playPauseContainer.style.display = 'none'
}
createSpeedSelect(container: Element | HTMLElement) {
this.speedSelect = document.createElement('select')
Object.assign(this.speedSelect.style, {
position: 'absolute',
top: '3px',
left: '50%',
transform: 'translateX(-75px)',
width: '60px',
height: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
padding: '0 8px',
cursor: 'pointer',
display: 'none',
outline: 'none'
})
const speeds = [0.1, 0.5, 1, 1.5, 2]
speeds.forEach((speed) => {
const option = document.createElement('option')
option.value = speed.toString()
option.text = `${speed}x`
option.selected = speed === 1
this.speedSelect.appendChild(option)
})
this.speedSelect.addEventListener('mouseenter', () => {
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.speedSelect.addEventListener('mouseleave', () => {
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.speedSelect.addEventListener('change', (event) => {
const select = event.target as HTMLSelectElement
const newSpeed = parseFloat(select.value)
this.setAnimationSpeed(newSpeed)
})
container.appendChild(this.speedSelect)
}
protected async setupModel(model: THREE.Object3D) {
@@ -101,7 +203,16 @@ class Load3dAnimation extends Load3d {
}
}
this.updateAnimationList()
if (this.animationClips.length > 0) {
this.playPauseContainer.style.display = 'block'
this.animationSelect.style.display = 'block'
this.speedSelect.style.display = 'block'
this.updateAnimationList()
} else {
this.playPauseContainer.style.display = 'none'
this.animationSelect.style.display = 'none'
this.speedSelect.style.display = 'none'
}
}
setAnimationSpeed(speed: number) {
@@ -147,9 +258,7 @@ class Load3dAnimation extends Load3d {
this.animationActions = [action]
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.selectedAnimation.value = index
}
this.updateAnimationList()
}
clearModel() {
@@ -165,12 +274,23 @@ class Load3dAnimation extends Load3d {
this.isAnimationPlaying = false
this.animationSpeed = 1.0
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.animations.value = []
this.controlsApp._instance.exposed.selectedAnimation.value = 0
super.clearModel()
if (this.animationSelect) {
this.animationSelect.style.display = 'none'
this.animationSelect.innerHTML = ''
}
super.clearModel()
if (this.speedSelect) {
this.speedSelect.style.display = 'none'
this.speedSelect.value = '1'
}
}
getAnimationNames(): string[] {
return this.animationClips.map((clip, index) => {
return clip.name || `Animation ${index + 1}`
})
}
toggleAnimation(play?: boolean) {
@@ -181,8 +301,15 @@ class Load3dAnimation extends Load3d {
this.isAnimationPlaying = play ?? !this.isAnimationPlaying
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.playing.value = this.isAnimationPlaying
const icon = this.playPauseContainer.querySelector('svg')
if (icon) {
if (this.isAnimationPlaying) {
icon.innerHTML = '<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>'
this.playPauseContainer.title = 'Pause Animation'
} else {
icon.innerHTML = '<path d="M8 5v14l11-7z"/>'
this.playPauseContainer.title = 'Play Animation'
}
}
this.animationActions.forEach((action) => {
@@ -201,7 +328,7 @@ class Load3dAnimation extends Load3d {
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
if (this.showPreview) {
if (this.isPreviewVisible) {
this.updatePreviewRender()
}

View File

@@ -1,6 +1,5 @@
// @ts-strict-ignore
import type { IWidget } from '@comfyorg/litegraph'
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
import type { DOMWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
@@ -38,7 +37,7 @@ function getResourceURL(
}
async function uploadFile(
audioWidget: IStringWidget,
audioWidget: IWidget,
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
file: File,
updateNode: boolean,
@@ -152,9 +151,9 @@ app.registerExtension({
return {
AUDIOUPLOAD(node, inputName: string) {
// The widget that allows user to select file.
const audioWidget = node.widgets.find(
const audioWidget: IWidget = node.widgets.find(
(w: IWidget) => w.name === 'audio'
) as IStringWidget
)
const audioUIWidget = node.widgets.find(
(w: IWidget) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>

View File

@@ -1,4 +1,4 @@
import { ComfyNodeDef, InputSpec } from '@/types/apiTypes'
import { ComfyNodeDef } from '@/types/apiTypes'
import { app } from '../../scripts/app'
@@ -7,16 +7,8 @@ import { app } from '../../scripts/app'
app.registerExtension({
name: 'Comfy.UploadImage',
beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef) {
// Check if there is a required input named 'image' in the nodeData
const imageInputSpec: InputSpec | undefined =
nodeData?.input?.required?.image
// Get the config from the image input spec if it exists
const config = imageInputSpec?.[1] ?? {}
const { image_upload = false, image_folder = 'input' } = config
if (image_upload && nodeData?.input?.required) {
nodeData.input.required.upload = ['IMAGEUPLOAD', { image_folder }]
if (nodeData?.input?.required?.image?.[1]?.image_upload === true) {
nodeData.input.required.upload = ['IMAGEUPLOAD']
}
}
})

View File

@@ -101,8 +101,10 @@ app.registerExtension({
capture
)
btn.disabled = true
// @ts-expect-error hacky override
btn.serializeValue = () => undefined
// @ts-expect-error hacky override
camera.serializeValue = async () => {
if (captureOnQueue.value) {
capture()

View File

@@ -0,0 +1,145 @@
import axios from 'axios'
import { useWidgetStore } from '@/stores/widgetStore'
import type { InputSpec } from '@/types/apiTypes'
export interface CacheEntry<T> {
data: T[]
timestamp: number
loading: boolean
error: Error | null
fetchPromise?: Promise<T[]>
controller?: AbortController
lastErrorTime: number
retryCount: number
}
const dataCache = new Map<string, CacheEntry<any>>()
const createCacheKey = (inputData: InputSpec): string => {
const { route, query_params = {}, refresh = 0 } = inputData[1]
const paramsKey = Object.entries(query_params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return [route, `r=${refresh}`, paramsKey].join(';')
}
const getBackoff = (retryCount: number) => {
return Math.min(1000 * Math.pow(2, retryCount), 512)
}
async function fetchData<T>(
inputData: InputSpec,
controller: AbortController
): Promise<T[]> {
const { route, response_key, query_params } = inputData[1]
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
validateStatus: (status) => status === 200
})
return response_key ? res.data[response_key] : res.data
}
export function useRemoteWidget<T>(inputData: InputSpec) {
const { refresh = 0 } = inputData[1]
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(inputData)
const defaultValue = useWidgetStore().getDefaultValue(inputData)
const setSuccess = (entry: CacheEntry<T>, data: T[]) => {
entry.retryCount = 0
entry.lastErrorTime = 0
entry.error = null
entry.timestamp = Date.now()
entry.data = data ?? defaultValue
}
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
entry.retryCount = (entry.retryCount || 0) + 1
entry.lastErrorTime = Date.now()
entry.error = error instanceof Error ? error : new Error(String(error))
entry.data ??= defaultValue
}
const isInitialized = () => {
const entry = dataCache.get(cacheKey)
return entry?.data && entry.timestamp > 0
}
const isStale = () => {
const entry = dataCache.get(cacheKey)
return entry?.timestamp && Date.now() - entry.timestamp >= refresh
}
const isFetching = () => {
const entry = dataCache.get(cacheKey)
return entry?.fetchPromise
}
const isBackingOff = () => {
const entry = dataCache.get(cacheKey)
return (
entry?.error &&
entry.lastErrorTime &&
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount)
)
}
const fetchOptions = async () => {
const entry = dataCache.get(cacheKey)
const isValid = isInitialized() && (isPermanent || !isStale())
if (isValid || isBackingOff()) return entry!.data
if (isFetching()) return entry!.fetchPromise
const currentEntry: CacheEntry<T> = entry || {
data: defaultValue,
timestamp: 0,
loading: false,
error: null,
fetchPromise: undefined,
controller: undefined,
retryCount: 0,
lastErrorTime: 0
}
dataCache.set(cacheKey, currentEntry)
try {
currentEntry.loading = true
currentEntry.error = null
currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData<T>(
inputData,
currentEntry.controller
)
const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data)
return currentEntry.data
} catch (err) {
setError(currentEntry, err)
return currentEntry.data
} finally {
currentEntry.loading = false
currentEntry.fetchPromise = undefined
currentEntry.controller = undefined
}
}
return {
getCacheKey: () => cacheKey,
getCacheEntry: () => dataCache.get(cacheKey),
forceUpdate: () => {
const entry = dataCache.get(cacheKey)
if (entry?.fetchPromise) entry.controller?.abort() // Abort in-flight request
dataCache.delete(cacheKey)
},
fetchOptions,
defaultValue
}
}

View File

@@ -315,7 +315,6 @@
"deleteFailed": "Attempt to delete the workflow failed.",
"dirtyCloseTitle": "Save Changes?",
"dirtyClose": "The files below have been changed. Would you like to save them before closing?",
"dirtyCloseHint": "Hold Shift to close without prompt",
"confirmOverwriteTitle": "Overwrite existing file?",
"confirmOverwrite": "The file below already exists. Would you like to overwrite it?",
"workflowTreeType": {
@@ -770,13 +769,5 @@
"successMessage": "Copied to clipboard",
"errorMessage": "Failed to copy to clipboard",
"errorNotSupported": "Clipboard API not supported in your browser"
},
"load3d": {
"switchCamera": "Switch Camera",
"showGrid": "Show Grid",
"backgroundColor": "Background Color",
"lightIntensity": "Light Intensity",
"fov": "FOV",
"previewOutput": "Preview Output"
}
}

View File

@@ -311,14 +311,6 @@
"maxLength": "Message trop long"
}
},
"load3d": {
"backgroundColor": "Couleur de fond",
"fov": "FOV",
"lightIntensity": "Intensité de la lumière",
"previewOutput": "Aperçu de la sortie",
"showGrid": "Afficher la grille",
"switchCamera": "Changer de caméra"
},
"maintenance": {
"None": "Aucun",
"OK": "OK",
@@ -735,7 +727,6 @@
"deleteFailedTitle": "Échec de la suppression",
"deleted": "Flux de travail supprimé",
"dirtyClose": "Les fichiers ci-dessous ont été modifiés. Souhaitez-vous les enregistrer avant de fermer ?",
"dirtyCloseHint": "Maintenez Shift pour fermer sans invite",
"dirtyCloseTitle": "Enregistrer les modifications ?",
"workflowTreeType": {
"bookmarks": "Favoris",

View File

@@ -311,14 +311,6 @@
"maxLength": "メッセージが長すぎます"
}
},
"load3d": {
"backgroundColor": "背景色",
"fov": "FOV",
"lightIntensity": "光の強度",
"previewOutput": "出力のプレビュー",
"showGrid": "グリッドを表示",
"switchCamera": "カメラを切り替える"
},
"maintenance": {
"None": "なし",
"OK": "OK",
@@ -735,7 +727,6 @@
"deleteFailedTitle": "削除に失敗しました",
"deleted": "ワークフローが削除されました",
"dirtyClose": "以下のファイルが変更されました。閉じる前に保存しますか?",
"dirtyCloseHint": "Shiftキーを押しながら閉じると、プロンプトなしで閉じます",
"dirtyCloseTitle": "変更を保存しますか?",
"workflowTreeType": {
"bookmarks": "ブックマーク",

View File

@@ -1,7 +1,7 @@
{
"clipboard": {
"errorMessage": "클립보드에 복사하지 못했습니다",
"errorNotSupported": "브라우저 클립보드 API 지원지 않습니다.",
"errorNotSupported": "당신의 브라우저에서 클립보드 API 지원지 않습니다",
"successMessage": "클립보드에 복사됨"
},
"color": {
@@ -14,16 +14,16 @@
"yellow": "노란색"
},
"contextMenu": {
" to input": " 위젯을 입력으로",
" to widget": " 입력을 위젯으로",
" to input": " 입력으로",
" to widget": " 위젯으로",
"Add Group": "그룹 추가",
"Add Group For Selected Nodes": "선택 노드 그룹 추가",
"Add Group For Selected Nodes": "선택 노드에 대한 그룹 추가",
"Add Node": "노드 추가",
"Bypass": "실행 건너뛰기",
"Bypass": "우회",
"Clone": "복제",
"Collapse": "접기",
"Collapse": "축소",
"Colors": "색상",
"Convert ": "[변환] ",
"Convert ": "변환 ",
"Convert Input to Widget": "입력을 위젯으로 변환",
"Convert Widget to Input": "위젯을 입력으로 변환",
"Convert to Group Node": "그룹 노드로 변환",
@@ -40,7 +40,7 @@
"Properties Panel": "속성 패널",
"Remove": "제거",
"Resize": "크기 조정",
"Save Selected as Template": "선택된 부분을 템플릿으로 저장",
"Save Selected as Template": "선택 항목을 템플릿으로 저장",
"Search": "검색",
"Shapes": "형태",
"Title": "제목",
@@ -87,8 +87,8 @@
"reinstall": "재설치"
},
"desktopUpdate": {
"description": "ComfyUI 데스크톱 새로운 종속성을 설치하고 있습니다. 이 작업은 몇 분 정도 걸릴 수 있습니다.",
"terminalDefaultMessage": "업데이트 콘솔 출력은 여기에 표시됩니다.",
"description": "ComfyUI 데스크톱 새로운 종속성을 설치하고 있습니다. 이 작업은 몇 분 정도 걸릴 수 있습니다.",
"terminalDefaultMessage": "업데이트로부터의 콘솔 출력은 여기에 표시됩니다.",
"title": "ComfyUI 데스크톱 업데이트 중"
},
"downloadGit": {
@@ -311,14 +311,6 @@
"maxLength": "메시지가 너무 깁니다"
}
},
"load3d": {
"backgroundColor": "배경색",
"fov": "FOV",
"lightIntensity": "조명 강도",
"previewOutput": "출력 미리보기",
"showGrid": "그리드 표시",
"switchCamera": "카메라 전환"
},
"maintenance": {
"None": "없음",
"OK": "확인",
@@ -735,7 +727,6 @@
"deleteFailedTitle": "삭제 실패",
"deleted": "워크플로가 삭제되었습니다.",
"dirtyClose": "아래 파일들이 변경되었습니다. 닫기 전에 저장하시겠습니까?",
"dirtyCloseHint": "프롬프트 없이 닫으려면 Shift를 누르세요",
"dirtyCloseTitle": "변경 사항 저장",
"workflowTreeType": {
"bookmarks": "북마크",

View File

@@ -311,14 +311,6 @@
"maxLength": "Сообщение слишком длинное"
}
},
"load3d": {
"backgroundColor": "Цвет фона",
"fov": "Угол обзора",
"lightIntensity": "Интенсивность света",
"previewOutput": "Предварительный просмотр",
"showGrid": "Показать сетку",
"switchCamera": "Переключить камеру"
},
"maintenance": {
"None": "Нет",
"OK": "OK",
@@ -735,7 +727,6 @@
"deleteFailedTitle": "Не удалось удалить",
"deleted": "Рабочий процесс удалён",
"dirtyClose": "Файлы ниже были изменены. Вы хотите сохранить их перед закрытием?",
"dirtyCloseHint": "Удерживайте Shift, чтобы закрыть без подсказки",
"dirtyCloseTitle": "Сохранить изменения?",
"workflowTreeType": {
"bookmarks": "Закладки",

View File

@@ -311,14 +311,6 @@
"maxLength": "消息过长"
}
},
"load3d": {
"backgroundColor": "背景颜色",
"fov": "视场",
"lightIntensity": "光照强度",
"previewOutput": "预览输出",
"showGrid": "显示网格",
"switchCamera": "切换摄像头"
},
"maintenance": {
"None": "无",
"OK": "确定",
@@ -735,7 +727,6 @@
"deleteFailedTitle": "删除失败",
"deleted": "工作流已删除",
"dirtyClose": "以下文件已被更改。您想在关闭之前保存它们吗?",
"dirtyCloseHint": "按住 Shift 关闭而不提示",
"dirtyCloseTitle": "保存更改?",
"workflowTreeType": {
"bookmarks": "书签",

View File

@@ -203,13 +203,13 @@ export class ComfyApp {
this.vueAppReady = false
this.ui = new ComfyUI(this)
this.api = api
// Dummy placeholder elements before GraphCanvas is mounted.
this.bodyTop = $el('div.comfyui-body-top')
this.bodyLeft = $el('div.comfyui-body-left')
this.bodyRight = $el('div.comfyui-body-right')
this.bodyBottom = $el('div.comfyui-body-bottom')
this.canvasContainer = $el('div.graph-canvas-container')
this.bodyTop = $el('div.comfyui-body-top', { parent: document.body })
this.bodyLeft = $el('div.comfyui-body-left', { parent: document.body })
this.bodyRight = $el('div.comfyui-body-right', { parent: document.body })
this.bodyBottom = $el('div.comfyui-body-bottom', { parent: document.body })
this.canvasContainer = $el('div.graph-canvas-container', {
parent: document.body
})
this.menu = new ComfyAppMenu(this)
this.bypassBgColor = '#FF00FF'
@@ -774,12 +774,6 @@ export class ComfyApp {
* Set up the app on the page
*/
async setup(canvasEl: HTMLCanvasElement) {
this.bodyTop = document.getElementById('comfyui-body-top')
this.bodyLeft = document.getElementById('comfyui-body-left')
this.bodyRight = document.getElementById('comfyui-body-right')
this.bodyBottom = document.getElementById('comfyui-body-bottom')
this.canvasContainer = document.getElementById('graph-canvas-container')
this.canvasEl = canvasEl
this.resizeCanvas()
@@ -1293,7 +1287,7 @@ export class ComfyApp {
// Store all widget values
if (widgets) {
for (let i = 0; i < widgets.length; i++) {
for (const i in widgets) {
const widget = widgets[i]
if (!widget.options || widget.options.serialize !== false) {
inputs[widget.name] = widget.serializeValue
@@ -1304,7 +1298,7 @@ export class ComfyApp {
}
// Store all node links
for (let i = 0; i < node.inputs.length; i++) {
for (let i in node.inputs) {
let parent = node.getInputNode(i)
if (parent) {
let link = node.getInputLink(i)
@@ -1324,18 +1318,14 @@ export class ComfyApp {
} else if (link && parent.mode === LGraphEventMode.BYPASS) {
let all_inputs = [link.origin_slot]
if (parent.inputs) {
// @ts-expect-error convert list of strings to list of numbers
all_inputs = all_inputs.concat(Object.keys(parent.inputs))
for (let parent_input in all_inputs) {
// @ts-expect-error assign string to number
parent_input = all_inputs[parent_input]
if (
parent.inputs[parent_input]?.type === node.inputs[i].type
) {
// @ts-expect-error convert string to number
link = parent.getInputLink(parent_input)
if (link) {
// @ts-expect-error convert string to number
parent = parent.getInputNode(parent_input)
}
found = true
@@ -1357,7 +1347,6 @@ export class ComfyApp {
if (link) {
inputs[node.inputs[i].name] = [
String(link.origin_id),
// @ts-expect-error link.origin_slot is already number.
parseInt(link.origin_slot)
]
}
@@ -1474,7 +1463,9 @@ export class ComfyApp {
for (const widget of node.widgets) {
// Allow widgets to run callbacks after a prompt has been queued
// e.g. random seed after every gen
// @ts-expect-error
if (widget.afterQueued) {
// @ts-expect-error
widget.afterQueued()
}
}

View File

@@ -1,8 +1,8 @@
// @ts-strict-ignore
import { LGraphCanvas, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type { Size, Vector4 } from '@comfyorg/litegraph'
import type {
import type { Vector4 } from '@comfyorg/litegraph'
import {
ICustomWidget,
IWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
@@ -19,20 +19,13 @@ interface Rect {
y: number
}
interface DOMSizeInfo {
minHeight: number
prefHeight: number
w: DOMWidget<HTMLElement, object>
diff?: number
}
export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends ICustomWidget<T> {
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
type: 'custom'
name: string
computedHeight?: number
element: T
element?: T
options: DOMWidgetOptions<T, V>
value: V
y?: number
@@ -87,7 +80,7 @@ function getClipPath(
canvasRect: DOMRect
): string {
const selectedNode: LGraphNode = Object.values(
app.canvas.selected_nodes ?? {}
app.canvas.selected_nodes
)[0] as LGraphNode
if (selectedNode && selectedNode !== node) {
const elRect = element.getBoundingClientRect()
@@ -127,29 +120,21 @@ function getClipPath(
return ''
}
function isDomWidget(
widget: IWidget
): widget is DOMWidget<HTMLElement, object> {
return !!widget.element
}
function computeSize(this: LGraphNode, size: Size): void {
if (!this.widgets?.[0]?.last_y) return
function computeSize(size: [number, number]): void {
if (this.widgets?.[0]?.last_y == null) return
let y = this.widgets[0].last_y
let freeSpace = size[1] - y
let widgetHeight = 0
let dom: DOMSizeInfo[] = []
let dom = []
for (const w of this.widgets) {
// @ts-expect-error custom widget type
if (w.type === 'converted-widget') {
// Ignore
// @ts-expect-error custom widget type
delete w.computedHeight
} else if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4
} else if (isDomWidget(w)) {
} else if (w.element) {
// Extract DOM widget size info
const styles = getComputedStyle(w.element)
let minHeight =
@@ -159,17 +144,14 @@ function computeSize(this: LGraphNode, size: Size): void {
w.options.getMaxHeight?.() ??
parseInt(styles.getPropertyValue('--comfy-widget-max-height'))
let prefHeight: string | number =
let prefHeight =
w.options.getHeight?.() ??
styles.getPropertyValue('--comfy-widget-height')
// @ts-expect-error number has no endsWith
if (prefHeight.endsWith?.('%')) {
prefHeight =
size[1] *
// @ts-expect-error number has no substring
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100)
} else {
// @ts-expect-error number is not assignable to param of type string
prefHeight = parseInt(prefHeight)
if (isNaN(minHeight)) {
minHeight = prefHeight
@@ -218,7 +200,8 @@ function computeSize(this: LGraphNode, size: Size): void {
}
}
if (this.imgs && !this.widgets?.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
if (this.imgs && !this.widgets.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
// Allocate space for image
freeSpace -= 220
}
@@ -227,7 +210,7 @@ function computeSize(this: LGraphNode, size: Size): void {
if (freeSpace < 0) {
// Not enough space for all widgets so we need to grow
size[1] -= freeSpace
this.graph?.setDirtyCanvas(true)
this.graph.setDirtyCanvas(true)
} else {
// Share the space between each
const growDiff = freeSpace - growBy
@@ -250,9 +233,7 @@ function computeSize(this: LGraphNode, size: Size): void {
// Grow any that are auto height
const shared = freeSpace / canGrow.length
for (const d of canGrow) {
if (d.w.computedHeight) {
d.w.computedHeight += shared
}
d.w.computedHeight += shared
}
}
}
@@ -260,9 +241,7 @@ function computeSize(this: LGraphNode, size: Size): void {
// Position each of the widgets
for (const w of this.widgets) {
w.y = y
// @ts-expect-error custom widget type
if (w.computedHeight) {
// @ts-expect-error custom widget type
y += w.computedHeight
} else if (w.computeSize) {
y += w.computeSize()[1] + 4
@@ -274,17 +253,16 @@ function computeSize(this: LGraphNode, size: Size): void {
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set()
//@ts-ignore
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
LGraphCanvas.prototype.computeVisibleNodes = function (
nodes?: LGraphNode[],
out?: LGraphNode[]
): LGraphNode[] {
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
//@ts-ignore
LGraphCanvas.prototype.computeVisibleNodes = function (): LGraphNode[] {
const visibleNodes = computeVisibleNodes.apply(this, arguments)
for (const node of app.graph.nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets ?? []) {
for (const w of node.widgets) {
if (w.element) {
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
@@ -292,7 +270,7 @@ LGraphCanvas.prototype.computeVisibleNodes = function (
const wasHidden = w.element.hidden
const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
w.element.hidden = actualHidden
w.element.style.display = actualHidden ? 'none' : ''
w.element.style.display = actualHidden ? 'none' : null
if (actualHidden && !wasHidden) {
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
}
@@ -304,116 +282,6 @@ LGraphCanvas.prototype.computeVisibleNodes = function (
return visibleNodes
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
implements DOMWidget<T, V>
{
type: 'custom'
name: string
element: T
options: DOMWidgetOptions<T, V>
computedHeight?: number
callback?: (value: V) => void
private mouseDownHandler?: (event: MouseEvent) => void
constructor(
name: string,
type: string,
element: T,
options: DOMWidgetOptions<T, V> = {}
) {
// @ts-expect-error custom widget type
this.type = type
this.name = name
this.element = element
this.options = options
if (element.blur) {
this.mouseDownHandler = (event) => {
if (!element.contains(event.target as HTMLElement)) {
element.blur()
}
}
document.addEventListener('mousedown', this.mouseDownHandler)
}
}
get value(): V {
return this.options.getValue?.() ?? ('' as V)
}
set value(v: V) {
this.options.setValue?.(v)
this.callback?.(this.value)
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number
): void {
if (this.computedHeight == null) {
computeSize.call(node, node.size)
}
const { offset, scale } = app.canvas.ds
const hidden =
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
(this.computedHeight ?? 0) <= 0 ||
// @ts-expect-error custom widget type
this.type === 'converted-widget' ||
// @ts-expect-error custom widget type
this.type === 'hidden'
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
const isCollapsed = this.element.dataset.collapsed === 'true'
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
const wasHidden = this.element.hidden
this.element.hidden = actualHidden
this.element.style.display = actualHidden ? 'none' : ''
if (actualHidden && !wasHidden) {
this.options.onHide?.(this)
}
if (actualHidden) {
return
}
const elRect = ctx.canvas.getBoundingClientRect()
const margin = 10
const top = node.pos[0] + offset[0] + margin
const left = node.pos[1] + offset[1] + margin + y
Object.assign(this.element.style, {
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${top * scale}px`,
top: `${left * scale}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
position: 'absolute',
zIndex: app.graph.nodes.indexOf(node),
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
})
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
const clipPath = getClipPath(node, this.element, elRect)
this.element.style.clipPath = clipPath ?? 'none'
this.element.style.willChange = 'clip-path'
}
this.options.onDraw?.(this)
}
onRemove(): void {
if (this.mouseDownHandler) {
document.removeEventListener('mousedown', this.mouseDownHandler)
}
this.element.remove()
}
}
LGraphNode.prototype.addDOMWidget = function <
T extends HTMLElement,
V extends object | string
@@ -431,6 +299,16 @@ LGraphNode.prototype.addDOMWidget = function <
element.hidden = true
element.style.display = 'none'
let mouseDownHandler
if (element.blur) {
mouseDownHandler = (event) => {
if (!element.contains(event.target)) {
element.blur()
}
}
document.addEventListener('mousedown', mouseDownHandler)
}
const { nodeData } = this.constructor
const tooltip = (nodeData?.input.required?.[name] ??
nodeData?.input.optional?.[name])?.[1]?.tooltip
@@ -438,23 +316,88 @@ LGraphNode.prototype.addDOMWidget = function <
element.title = tooltip
}
const widget = new DOMWidgetImpl(name, type, element, options)
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
// Some custom nodes are explicitly expecting getter and setter of `value`
// property to be on instance instead of prototype.
Object.defineProperty(widget, 'value', {
get(this: DOMWidgetImpl<T, V>): V {
return this.options.getValue?.() ?? ('' as V)
const widget: DOMWidget<T, V> = {
// @ts-expect-error All unrecognized types will be treated the same way as 'custom'
// in litegraph internally.
type,
name,
get value(): V {
return options.getValue?.() ?? undefined
},
set(this: DOMWidgetImpl<T, V>, v: V) {
this.options.setValue?.(v)
this.callback?.(this.value)
}
})
set value(v: V) {
options.setValue?.(v)
widget.callback?.(widget.value)
},
draw: function (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number,
widgetHeight: number
) {
if (widget.computedHeight == null) {
computeSize.call(node, node.size)
}
// Ensure selectOn exists before iteration
const selectEvents = options.selectOn ?? ['focus', 'click']
for (const evt of selectEvents) {
const { offset, scale } = app.canvas.ds
const hidden =
(!!options.hideOnZoom && app.canvas.low_quality) ||
widget.computedHeight <= 0 ||
// @ts-expect-error Used by widgetInputs.ts
widget.type === 'converted-widget' ||
// @ts-expect-error Used by groupNode.ts
widget.type === 'hidden'
element.dataset.shouldHide = hidden ? 'true' : 'false'
const isInVisibleNodes = element.dataset.isInVisibleNodes === 'true'
const isCollapsed = element.dataset.collapsed === 'true'
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
const wasHidden = element.hidden
element.hidden = actualHidden
element.style.display = actualHidden ? 'none' : null
if (actualHidden && !wasHidden) {
widget.options.onHide?.(widget)
}
if (actualHidden) {
return
}
const elRect = ctx.canvas.getBoundingClientRect()
const margin = 10
const top = node.pos[0] + offset[0] + margin
const left = node.pos[1] + offset[1] + margin + y
Object.assign(element.style, {
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${top * scale}px`,
top: `${left * scale}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
position: 'absolute',
zIndex: app.graph.nodes.indexOf(node),
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
})
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
element.style.clipPath = getClipPath(node, element, elRect)
element.style.willChange = 'clip-path'
}
this.options.onDraw?.(widget)
},
element,
options,
onRemove() {
if (mouseDownHandler) {
document.removeEventListener('mousedown', mouseDownHandler)
}
element.remove()
}
}
for (const evt of options.selectOn) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this)
app.canvas.bringToFront(this)
@@ -465,8 +408,8 @@ LGraphNode.prototype.addDOMWidget = function <
elementWidgets.add(this)
const collapse = this.collapse
this.collapse = function (force?: boolean) {
collapse.call(this, force)
this.collapse = function () {
collapse.apply(this, arguments)
if (this.flags?.collapsed) {
element.hidden = true
element.style.display = 'none'
@@ -475,8 +418,8 @@ LGraphNode.prototype.addDOMWidget = function <
}
const { onConfigure } = this
this.onConfigure = function (serializedNode: any) {
onConfigure?.call(this, serializedNode)
this.onConfigure = function () {
onConfigure?.apply(this, arguments)
element.dataset.collapsed = this.flags?.collapsed ? 'true' : 'false'
}
@@ -484,18 +427,16 @@ LGraphNode.prototype.addDOMWidget = function <
this.onRemoved = function () {
element.remove()
elementWidgets.delete(this)
onRemoved?.call(this)
onRemoved?.apply(this, arguments)
}
// @ts-ignore index with symbol
if (!this[SIZE]) {
// @ts-ignore index with symbol
this[SIZE] = true
const onResize = this.onResize
this.onResize = function (size: Size) {
this.onResize = function (size) {
options.beforeResize?.call(widget, this)
computeSize.call(this, size)
onResize?.call(this, size)
onResize?.apply(this, arguments)
options.afterResize?.call(widget, this)
}
}

View File

@@ -1,21 +1,22 @@
// @ts-strict-ignore
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IWidget } from '@comfyorg/litegraph'
import type {
IComboWidget,
IStringWidget
} from '@comfyorg/litegraph/dist/types/widgets'
import { Editor as TiptapEditor } from '@tiptap/core'
import TiptapLink from '@tiptap/extension-link'
import TiptapTable from '@tiptap/extension-table'
import TiptapTableCell from '@tiptap/extension-table-cell'
import TiptapTableHeader from '@tiptap/extension-table-header'
import TiptapTableRow from '@tiptap/extension-table-row'
import TiptapStarterKit from '@tiptap/starter-kit'
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
import { useComboWidget } from '@/composables/widgets/useComboWidget'
import { useFloatWidget } from '@/composables/widgets/useFloatWidget'
import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget'
import { useIntWidget } from '@/composables/widgets/useIntWidget'
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
import { useSeedWidget } from '@/composables/widgets/useSeedWidget'
import { useStringWidget } from '@/composables/widgets/useStringWidget'
import { useRemoteWidget } from '@/hooks/remoteWidgetHook'
import { useSettingStore } from '@/stores/settingStore'
import type { InputSpec } from '@/types/apiTypes'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { InputSpec } from '@/types/apiTypes'
import { api } from './api'
import type { ComfyApp } from './app'
import './domWidget'
@@ -23,7 +24,7 @@ export type ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec,
app: ComfyApp,
app?: ComfyApp,
widgetName?: string
) => { widget: IWidget; minWidth?: number; minHeight?: number }
@@ -31,34 +32,64 @@ function controlValueRunBefore() {
return useSettingStore().get('Comfy.WidgetControlMode') === 'before'
}
export function updateControlWidgetLabel(widget: IWidget) {
export function updateControlWidgetLabel(widget) {
let replacement = 'after'
let find = 'before'
if (controlValueRunBefore()) {
;[find, replacement] = [replacement, find]
}
widget.label = (widget.label ?? widget.name ?? '').replace(find, replacement)
widget.label = (widget.label ?? widget.name).replace(find, replacement)
}
export const IS_CONTROL_WIDGET = Symbol()
const HAS_EXECUTED = Symbol()
function getNumberDefaults(
inputData: InputSpec,
defaultStep,
precision,
enable_rounding
) {
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
// 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)
}
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
}
return {
val: defaultVal,
config: { min, max, step: 10.0 * step, round, precision }
}
}
export function addValueControlWidget(
node: LGraphNode,
targetWidget: IWidget,
defaultValue?: string,
values?: unknown,
widgetName?: string,
inputData?: InputSpec
): IWidget {
let name = inputData?.[1]?.control_after_generate
node,
targetWidget,
defaultValue = 'randomize',
values,
widgetName,
inputData: InputSpec
) {
let name = inputData[1]?.control_after_generate
if (typeof name !== 'string') {
name = widgetName
}
const widgets = addValueControlWidgets(
node,
targetWidget,
defaultValue ?? 'randomize',
defaultValue,
{
addFilterList: false,
controlAfterGenerateName: name
@@ -69,16 +100,16 @@ export function addValueControlWidget(
}
export function addValueControlWidgets(
node: LGraphNode,
targetWidget: IWidget,
defaultValue?: string,
options?: Record<string, any>,
inputData?: InputSpec
): IWidget[] {
node,
targetWidget,
defaultValue = 'randomize',
options,
inputData: InputSpec
) {
if (!defaultValue) defaultValue = 'randomize'
if (!options) options = {}
const getName = (defaultName: string, optionName: string) => {
const getName = (defaultName, optionName) => {
let name = defaultName
if (options[optionName]) {
name = options[optionName]
@@ -90,7 +121,7 @@ export function addValueControlWidgets(
return name
}
const widgets: IWidget[] = []
const widgets = []
const valueControl = node.addWidget(
'combo',
getName('control_after_generate', 'controlAfterGenerateName'),
@@ -100,18 +131,16 @@ export function addValueControlWidgets(
values: ['fixed', 'increment', 'decrement', 'randomize'],
serialize: false // Don't include this in prompt.
}
) as IComboWidget
)
valueControl.tooltip =
'Allows the linked widget to be changed automatically, for example randomizing the noise seed.'
// @ts-ignore index with symbol
valueControl[IS_CONTROL_WIDGET] = true
updateControlWidgetLabel(valueControl)
widgets.push(valueControl)
const isCombo = targetWidget.type === 'combo'
let comboFilter: IStringWidget
if (isCombo && valueControl.options.values) {
let comboFilter
if (isCombo) {
valueControl.options.values.push('increment-wrap')
}
if (isCombo && options.addFilterList !== false) {
@@ -123,7 +152,7 @@ export function addValueControlWidgets(
{
serialize: false // Don't include this in prompt.
}
) as IStringWidget
)
updateControlWidgetLabel(comboFilter)
comboFilter.tooltip =
"Allows for filtering the list of values when changing the value via the control generate mode. Allows for RegEx matches in the format /abc/ to only filter to values containing 'abc'."
@@ -135,14 +164,14 @@ export function addValueControlWidgets(
var v = valueControl.value
if (isCombo && v !== 'fixed') {
let values = targetWidget.options.values ?? []
let values = targetWidget.options.values
const filter = comboFilter?.value
if (filter) {
let check
if (filter.startsWith('/') && filter.endsWith('/')) {
try {
const regex = new RegExp(filter.substring(1, filter.length - 1))
check = (item: string) => regex.test(item)
check = (item) => regex.test(item)
} catch (error) {
console.error(
'Error constructing RegExp filter for node ' + node.id,
@@ -153,17 +182,16 @@ export function addValueControlWidgets(
}
if (!check) {
const lower = filter.toLocaleLowerCase()
check = (item: string) => item.toLocaleLowerCase().includes(lower)
check = (item) => item.toLocaleLowerCase().includes(lower)
}
values = values.filter((item: string) => check(item))
if (!values.length && targetWidget.options.values?.length) {
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
)
}
}
// @ts-expect-error targetWidget.value can be number or string
let current_index = values.indexOf(targetWidget.value)
let current_length = values.length
@@ -191,54 +219,52 @@ export function addValueControlWidgets(
if (current_index >= 0) {
let value = values[current_index]
targetWidget.value = value
targetWidget.callback?.(value)
targetWidget.callback(value)
}
} else {
//number
let { min = 0, max = 1, step = 1 } = targetWidget.options
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) / (step / 10)
let range = (max - min) / (targetWidget.options.step / 10)
//adjust values based on valueControl Behaviour
switch (v) {
case 'fixed':
break
case 'increment':
// @ts-expect-error targetWidget.value can be number or string
targetWidget.value += step / 10
targetWidget.value += targetWidget.options.step / 10
break
case 'decrement':
// @ts-expect-error targetWidget.value can be number or string
targetWidget.value -= step / 10
targetWidget.value -= targetWidget.options.step / 10
break
case 'randomize':
targetWidget.value =
Math.floor(Math.random() * range) * (step / 10) + min
Math.floor(Math.random() * range) *
(targetWidget.options.step / 10) +
min
break
default:
break
}
/*check if values are over or under their respective
* ranges and set them to min or max.*/
// @ts-expect-error targetWidget.value can be number or string
if (targetWidget.value < min) targetWidget.value = min
// @ts-expect-error targetWidget.value can be number or string
if (targetWidget.value > max) targetWidget.value = max
targetWidget.callback?.(targetWidget.value)
targetWidget.callback(targetWidget.value)
}
}
valueControl.beforeQueued = () => {
if (controlValueRunBefore()) {
// Don't run on first execution
// @ts-ignore index with symbol
if (valueControl[HAS_EXECUTED]) {
applyWidgetControl()
}
}
// @ts-ignore index with symbol
valueControl[HAS_EXECUTED] = true
}
@@ -251,16 +277,502 @@ export function addValueControlWidgets(
return widgets
}
const SeedWidget = useSeedWidget()
function seedWidget(node, inputName, inputData: InputSpec, app, widgetName) {
const seed = createIntWidget(node, inputName, inputData, app, true)
const seedControl = addValueControlWidget(
node,
seed.widget,
'randomize',
undefined,
widgetName,
inputData
)
seed.widget.linkedWidgets = [seedControl]
return seed
}
function createIntWidget(
node,
inputName,
inputData: InputSpec,
app,
isSeedInput: boolean = false
) {
const control = inputData[1]?.control_after_generate
if (!isSeedInput && control) {
return seedWidget(
node,
inputName,
inputData,
app,
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 })
return {
widget: node.addWidget(
widgetType,
inputName,
val,
function (v) {
const s = this.options.step / 10
let sh = this.options.min % s
if (isNaN(sh)) {
sh = 0
}
this.value = Math.round((v - sh) / s) * s + sh
},
config
)
}
}
function addMultilineWidget(node, name: string, opts, app: ComfyApp) {
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
if (app.vueAppReady) {
inputEl.spellcheck = useSettingStore().get(
'Comfy.TextareaWidget.Spellcheck'
)
}
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue() {
return inputEl.value
},
setValue(v) {
inputEl.value = v
}
})
widget.inputEl = inputEl
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
})
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
return { minWidth: 400, minHeight: 200, widget }
}
function addMarkdownWidget(node, name: string, opts, app: ComfyApp) {
TiptapMarkdown.configure({
html: false,
breaks: true,
transformPastedText: true
})
const editor = new TiptapEditor({
extensions: [
TiptapStarterKit,
TiptapMarkdown,
TiptapLink,
TiptapTable,
TiptapTableCell,
TiptapTableHeader,
TiptapTableRow
],
content: opts.defaultVal,
editable: false
})
const inputEl = editor.options.element
inputEl.classList.add('comfy-markdown')
const textarea = document.createElement('textarea')
inputEl.append(textarea)
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue() {
return textarea.value
},
setValue(v) {
textarea.value = v
editor.commands.setContent(v)
}
})
widget.inputEl = inputEl
editor.options.element.addEventListener(
'pointerdown',
(event: PointerEvent) => {
if (event.button !== 0) {
app.canvas.processMouseDown(event)
return
}
if (event.target instanceof HTMLAnchorElement) {
return
}
inputEl.classList.add('editing')
setTimeout(() => {
textarea.focus()
}, 0)
}
)
textarea.addEventListener('blur', () => {
inputEl.classList.remove('editing')
})
textarea.addEventListener('change', () => {
editor.commands.setContent(textarea.value)
widget.callback?.(widget.value)
})
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
event.stopPropagation()
})
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
return { minWidth: 400, minHeight: 200, widget }
}
function isSlider(display, app) {
if (app.ui.settings.getSettingValue('Comfy.DisableSliders')) {
return 'number'
}
return display === 'slider' ? 'slider' : 'number'
}
export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
'INT:seed': SeedWidget,
'INT:noise_seed': SeedWidget,
INT: useIntWidget(),
FLOAT: useFloatWidget(),
BOOLEAN: useBooleanWidget(),
STRING: useStringWidget(),
MARKDOWN: useMarkdownWidget(),
COMBO: useComboWidget(),
IMAGEUPLOAD: useImageUploadWidget()
'INT:seed': seedWidget,
'INT:noise_seed': seedWidget,
FLOAT(node, inputName, inputData: InputSpec, app) {
let widgetType: 'number' | 'slider' = isSlider(inputData[1]['display'], app)
let precision = app.ui.settings.getSettingValue(
'Comfy.FloatRoundingPrecision'
)
let disable_rounding = app.ui.settings.getSettingValue(
'Comfy.DisableFloatRounding'
)
if (precision == 0) precision = undefined
const { val, config } = getNumberDefaults(
inputData,
0.5,
precision,
!disable_rounding
)
return {
widget: node.addWidget(
widgetType,
inputName,
val,
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
} else {
this.value = v
}
},
config
)
}
},
INT(node, inputName, inputData: InputSpec, app) {
return createIntWidget(node, inputName, inputData, app)
},
BOOLEAN(node, inputName, inputData) {
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
}
return {
widget: node.addWidget('toggle', inputName, defaultVal, () => {}, options)
}
},
STRING(node, inputName, inputData: InputSpec, app) {
const defaultVal = inputData[1].default || ''
const multiline = !!inputData[1].multiline
let res
if (multiline) {
res = addMultilineWidget(
node,
inputName,
{ defaultVal, ...inputData[1] },
app
)
} else {
res = {
widget: node.addWidget('text', inputName, defaultVal, () => {}, {})
}
}
if (inputData[1].dynamicPrompts != undefined)
res.widget.dynamicPrompts = inputData[1].dynamicPrompts
return res
},
MARKDOWN(node, inputName, inputData: InputSpec, app) {
const defaultVal = inputData[1].default || ''
let res
res = addMarkdownWidget(
node,
inputName,
{ defaultVal, ...inputData[1] },
app
)
return res
},
COMBO(node, inputName, inputData: InputSpec) {
const widgetStore = useWidgetStore()
const { type, options } = inputData[1]
const defaultValue = widgetStore.getDefaultValue(inputData)
const res = {
widget: node.addWidget('combo', inputName, defaultValue, () => {}, {
values: options ?? inputData[0]
})
}
if (type === 'remote') {
const remoteWidget = useRemoteWidget(inputData)
const origOptions = res.widget.options
res.widget.options = new Proxy(
origOptions as Record<string | symbol, any>,
{
get(target, prop: string | symbol) {
if (prop !== 'values') return target[prop]
remoteWidget.fetchOptions().then((options) => {
if (!options || !options.length) return
const isUninitialized =
res.widget.value === remoteWidget.defaultValue &&
!res.widget.options.values?.includes(remoteWidget.defaultValue)
if (isUninitialized) {
res.widget.value = options[0]
res.widget.callback?.(options[0])
node.graph?.setDirtyCanvas(true)
}
})
const current = remoteWidget.getCacheEntry()
return current?.data || widgetStore.getDefaultValue(inputData)
}
}
)
}
if (inputData[1]?.control_after_generate) {
// TODO make combo handle a widget node type?
res.widget.linkedWidgets = addValueControlWidgets(
node,
res.widget,
undefined,
undefined,
inputData
)
}
return res
},
IMAGEUPLOAD(node: LGraphNode, inputName: string, inputData: InputSpec, app) {
// TODO make image upload handle a custom node type?
const imageWidget = node.widgets.find(
(w) => w.name === (inputData[1]?.widget ?? 'image')
)
let uploadWidget
function showImage(name) {
const img = new Image()
img.onload = () => {
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)
}
img.src = api.apiURL(
`/view?filename=${encodeURIComponent(name)}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}`
)
node.setSizeForImage?.()
}
var default_value = imageWidget.value
Object.defineProperty(imageWidget, 'value', {
set: function (value) {
this._real_value = value
},
get: function () {
if (!this._real_value) {
return default_value
}
let value = this._real_value
if (value.filename) {
let real_value = value
value = ''
if (real_value.subfolder) {
value = real_value.subfolder + '/'
}
value += real_value.filename
if (real_value.type && real_value.type !== 'input')
value += ` [${real_value.type}]`
}
return value
}
})
// Add our own callback to the combo widget to render an image when it changes
// TODO: Explain this?
// @ts-expect-error
const cb = node.callback
imageWidget.callback = function () {
showImage(imageWidget.value)
if (cb) {
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)
}
})
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
})
if (resp.status === 200) {
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
if (!imageWidget.options.values.includes(path)) {
imageWidget.options.values.push(path)
}
if (updateNode) {
showImage(path)
imageWidget.value = path
}
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
useToastStore().addAlert(error)
}
}
const fileInput = document.createElement('input')
Object.assign(fileInput, {
type: 'file',
accept: 'image/jpeg,image/png,image/webp',
style: 'display: none',
onchange: async () => {
if (fileInput.files.length) {
await uploadFile(fileInput.files[0], true)
}
}
})
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
// Add handler to check if an image is being dragged over our node
node.onDragOver = function (e: DragEvent) {
if (e.dataTransfer && e.dataTransfer.items) {
const image = [...e.dataTransfer.items].find((f) => f.kind === 'file')
return !!image
}
return false
}
// On drop upload files
node.onDragDrop = function (e: DragEvent) {
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
}
}
return handled
}
node.pasteFile = function (file: File) {
if (file.type.startsWith('image/')) {
const is_pasted =
file.name === 'image.png' && file.lastModified - Date.now() < 2000
uploadFile(file, true, is_pasted)
return true
}
return false
}
return { widget: uploadWidget }
}
}

View File

@@ -133,8 +133,7 @@ export const useDialogService = () => {
title,
message,
type = 'default',
itemList = [],
hint
itemList = []
}: {
/** Dialog heading */
title: string
@@ -142,9 +141,8 @@ export const useDialogService = () => {
message: string
/** Pre-configured dialog type */
type?: ConfirmationDialogType
/** Displayed as an unordered list immediately below the message body */
/** Displayed as an unorderd list immediately below the message body */
itemList?: string[]
hint?: string
}): Promise<boolean | null> {
return new Promise((resolve) => {
const options: ShowDialogOptions = {
@@ -155,8 +153,7 @@ export const useDialogService = () => {
message,
type,
itemList,
onConfirm: resolve,
hint
onConfirm: resolve
},
dialogComponentProps: {
onClose: () => resolve(null)

View File

@@ -18,10 +18,10 @@ export const useKeybindingService = () => {
return
}
// Ignore reserved or non-modifier keybindings if typing in input fields
// Ignore non-modifier keybindings if typing in input fields
const target = event.composedPath()[0] as HTMLElement
if (
keyCombo.isReservedByTextInput &&
!keyCombo.hasModifier &&
(target.tagName === 'TEXTAREA' ||
target.tagName === 'INPUT' ||
(target.tagName === 'SPAN' &&

View File

@@ -1,73 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
export class Load3dService {
private static instance: Load3dService
private nodeToLoad3dMap = new Map<LGraphNode, Load3d | Load3dAnimation>()
private constructor() {}
static getInstance(): Load3dService {
if (!Load3dService.instance) {
Load3dService.instance = new Load3dService()
}
return Load3dService.instance
}
registerLoad3d(
node: LGraphNode,
container: HTMLElement,
type: 'Load3D' | 'Load3DAnimation' | 'Preview3D' | 'Preview3DAnimation'
) {
if (this.nodeToLoad3dMap.has(node)) {
this.removeLoad3d(node)
}
const isAnimation = type.includes('Animation')
const Load3dClass = isAnimation ? Load3dAnimation : Load3d
const isPreview = type.includes('Preview')
const instance = new Load3dClass(container, { createPreview: !isPreview })
instance.setNode(node)
this.nodeToLoad3dMap.set(node, instance)
return instance
}
getLoad3d(node: LGraphNode): Load3d | Load3dAnimation | null {
return this.nodeToLoad3dMap.get(node) || null
}
getNodeByLoad3d(load3d: Load3d | Load3dAnimation): LGraphNode | null {
for (const [node, instance] of this.nodeToLoad3dMap) {
if (instance === load3d) {
return node
}
}
return null
}
removeLoad3d(node: LGraphNode) {
const instance = this.nodeToLoad3dMap.get(node)
if (instance) {
instance.remove()
this.nodeToLoad3dMap.delete(node)
}
}
clear() {
for (const [node] of this.nodeToLoad3dMap) {
this.removeLoad3d(node)
}
}
}
export const useLoad3dService = () => {
return Load3dService.getInstance()
}

View File

@@ -188,17 +188,14 @@ export const useWorkflowService = () => {
*/
const closeWorkflow = async (
workflow: ComfyWorkflow,
options: { warnIfUnsaved: boolean; hint?: string } = {
warnIfUnsaved: true
}
options: { warnIfUnsaved: boolean } = { warnIfUnsaved: true }
): Promise<boolean> => {
if (workflow.isModified && options.warnIfUnsaved) {
const confirmed = await dialogService.confirm({
title: t('sideToolbar.workflowTab.dirtyCloseTitle'),
type: 'dirtyClose',
message: t('sideToolbar.workflowTab.dirtyClose'),
itemList: [workflow.path],
hint: options.hint
itemList: [workflow.path]
})
// Cancel
if (confirmed === null) return false

View File

@@ -2,7 +2,6 @@ import _ from 'lodash'
import { defineStore } from 'pinia'
import { Ref, computed, ref, toRaw } from 'vue'
import { RESERVED_BY_TEXT_INPUT } from '@/constants/reservedKeyCombos'
import { KeyCombo, Keybinding } from '@/types/keyBindingTypes'
export class KeybindingImpl implements Keybinding {
@@ -77,23 +76,6 @@ export class KeyComboImpl implements KeyCombo {
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
}
get modifierCount(): number {
const modifiers = [this.ctrl, this.alt, this.shift]
return modifiers.reduce((acc, cur) => acc + Number(cur), 0)
}
get isShiftOnly(): boolean {
return this.shift && this.modifierCount === 1
}
get isReservedByTextInput(): boolean {
return (
!this.hasModifier ||
this.isShiftOnly ||
RESERVED_BY_TEXT_INPUT.has(this.toString())
)
}
getKeySequences(): string[] {
const sequences: string[] = []
if (this.ctrl) {

View File

@@ -53,7 +53,7 @@ export const useWidgetStore = defineStore('widget', () => {
if (props.default) return props.default
if (widgetType === 'COMBO' && props.options?.length) return props.options[0]
if (props.remote) return 'Loading...'
if (props.type === 'remote') return 'Loading...'
return undefined
}

View File

@@ -269,17 +269,6 @@ function inputSpec<TType extends ZodType, TSpec extends ZodType>(
])
}
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
query_params: z.record(z.string(), z.string()).optional(),
refresh_button: z.boolean().optional(),
control_after_refresh: z.enum(['first', 'last']).optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zBaseInputSpecValue = z
.object({
default: z.any().optional(),
@@ -301,12 +290,7 @@ const zIntInputSpec = inputSpec([
step: z.number().optional(),
// Note: Many node authors are using INT to pass list of INT.
// TODO: Add list of ints type.
default: z.union([z.number(), z.array(z.number())]).optional(),
/**
* If true, a linked widget will be added to the node to select the mode
* of `control_after_generate`.
*/
control_after_generate: z.boolean().optional()
default: z.union([z.number(), z.array(z.number())]).optional()
})
])
@@ -348,8 +332,11 @@ const zStringInputSpec = inputSpec([
const zComboInputProps = zBaseInputSpecValue.extend({
control_after_generate: z.boolean().optional(),
image_upload: z.boolean().optional(),
image_folder: z.enum(['input', 'output', 'temp']).optional(),
remote: zRemoteWidgetConfig.optional()
type: z.enum(['remote']).optional(),
route: z.string().url().or(z.string().startsWith('/')).optional(),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
query_params: z.record(z.string(), z.string()).optional()
})
// Dropdown Selection.
@@ -607,4 +594,3 @@ export type UserDataFullInfo = z.infer<typeof zUserDataFullInfo>
export type TerminalSize = z.infer<typeof zTerminalSize>
export type LogEntry = z.infer<typeof zLogEntry>
export type LogsRawResponse = z.infer<typeof zLogRawResponse>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>

View File

@@ -16,14 +16,6 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
interface IBaseWidget {
onRemove?: () => void
beforeQueued?: () => unknown
afterQueued?: () => unknown
serializeValue?: (node: LGraphNode, index: number) => Promise<unknown>
/**
* If the widget supports dynamic prompts, this will be set to true.
* See extensions/core/dynamicPrompts.ts
*/
dynamicPrompts?: boolean
}
}
@@ -51,7 +43,8 @@ declare module '@comfyorg/litegraph' {
onExecuted?(output: any): void
onNodeCreated?(this: LGraphNode): void
setInnerNodes?(nodes: LGraphNode[]): void
getInnerNodes?(): LGraphNode[]
// TODO: Requires several coercion changes to runtime code.
getInnerNodes?() // : LGraphNode[]
convertToNodes?(): LGraphNode[]
recreate?(): Promise<LGraphNode>
refreshComboInNode?(defs: Record<string, ComfyNodeDef>)

View File

@@ -1,32 +0,0 @@
import type { InputSpec } from '@/types/apiTypes'
export function getNumberDefaults(
inputOptions: InputSpec[1],
options: {
defaultStep: number
precision?: number
enableRounding: boolean
}
) {
const { defaultStep } = options
const {
default: defaultVal = 0,
min = 0,
max = 2048,
step = defaultStep
} = inputOptions
// 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.
const { precision = Math.max(-Math.floor(Math.log10(step)), 0) } = options
let round = inputOptions.round
if (options.enableRounding && (round == undefined || round === true)) {
// by default, round the value to those decimal places shown.
round = Math.round(1000000 * Math.pow(0.1, precision)) / 1000000
}
return {
val: defaultVal,
config: { min, max, step: 10.0 * step, round, precision }
}
}

View File

@@ -1,18 +1,8 @@
<template>
<div class="comfyui-body grid h-screen w-screen overflow-hidden">
<div class="comfyui-body-top" id="comfyui-body-top">
<TopMenubar v-if="useNewMenu === 'Top'" />
</div>
<div class="comfyui-body-bottom" id="comfyui-body-bottom">
<TopMenubar v-if="useNewMenu === 'Bottom'" />
</div>
<div class="comfyui-body-left" id="comfyui-body-left" />
<div class="comfyui-body-right" id="comfyui-body-right" />
<div class="graph-canvas-container" id="graph-canvas-container">
<GraphCanvas @ready="onGraphReady" />
</div>
</div>
<!-- Top menu bar needs to load before the GraphCanvas as it needs to host
the menu buttons added by legacy extension scripts.-->
<TopMenubar />
<GraphCanvas @ready="onGraphReady" />
<GlobalToast />
<UnloadWindowConfirmDialog v-if="!isElectron()" />
<BrowserTabTitle />
@@ -23,7 +13,7 @@
import { useEventListener } from '@vueuse/core'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onBeforeUnmount, onMounted, watch, watchEffect } from 'vue'
import { onBeforeUnmount, onMounted, watch, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
@@ -136,11 +126,9 @@ watchEffect(() => {
}
})
const useNewMenu = computed(() => {
return settingStore.get('Comfy.UseNewMenu')
})
watchEffect(() => {
if (useNewMenu.value === 'Disabled') {
const useNewMenu = settingStore.get('Comfy.UseNewMenu')
if (useNewMenu === 'Disabled') {
app.ui.menuContainer.style.setProperty('display', 'block')
app.ui.restoreMenuPosition()
} else {
@@ -238,85 +226,3 @@ const onGraphReady = () => {
)
}
</script>
<style scoped>
.comfyui-body {
grid-template-columns: auto 1fr auto;
grid-template-rows: auto 1fr auto;
}
/**
+------------------+------------------+------------------+
| |
| .comfyui-body- |
| top |
| (spans all cols) |
| |
+------------------+------------------+------------------+
| | | |
| .comfyui-body- | #graph-canvas | .comfyui-body- |
| left | | right |
| | | |
| | | |
+------------------+------------------+------------------+
| |
| .comfyui-body- |
| bottom |
| (spans all cols) |
| |
+------------------+------------------+------------------+
*/
.comfyui-body-top {
order: -5;
/* Span across all columns */
grid-column: 1/-1;
/* Position at the first row */
grid-row: 1;
/* Top menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
/* Top menu bar z-index needs to be higher than bottom menu bar z-index as by default
pysssss's image feed is located at body-bottom, and it can overlap with the queue button, which
is located in body-top. */
z-index: 1001;
display: flex;
flex-direction: column;
}
.comfyui-body-left {
order: -4;
/* Position in the first column */
grid-column: 1;
/* Position below the top element */
grid-row: 2;
z-index: 10;
display: flex;
}
.graph-canvas-container {
width: 100%;
height: 100%;
order: -3;
grid-column: 2;
grid-row: 2;
position: relative;
overflow: hidden;
}
.comfyui-body-right {
order: -2;
z-index: 10;
grid-column: 3;
grid-row: 2;
}
.comfyui-body-bottom {
order: 4;
/* Span across all columns */
grid-column: 1/-1;
grid-row: 3;
/* Bottom menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
z-index: 1000;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<BaseViewTemplate dark>
<div
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme overflow-y-auto"
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto overflow-y-auto"
>
<div class="max-w-screen-sm w-screen m-8 relative">
<!-- Header -->

View File

@@ -1,6 +1,6 @@
<template>
<div
class="font-sans w-screen h-screen flex flex-col"
class="font-sans w-screen h-screen flex flex-col pointer-events-auto"
:class="[
props.dark
? 'text-neutral-300 bg-neutral-900 dark-theme'

View File

@@ -1,6 +1,6 @@
import axios from 'axios'
import { useRemoteWidget } from '@/composables/widgets/useRemoteWidget'
import { useRemoteWidget } from '@/hooks/remoteWidgetHook'
import type { ComboInputSpecV2 } from '@/types/apiTypes'
jest.mock('axios', () => ({
@@ -21,30 +21,28 @@ jest.mock('@/stores/settingStore', () => ({
})
}))
jest.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({
widgets: {},
getDefaultValue: jest.fn().mockReturnValue('Loading...')
})
}))
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
const DEFAULT_VALUE = 'Loading...'
function createMockInputData(overrides = {}): ComboInputSpecV2 {
return [
'COMBO',
{
name: 'test_widget',
remote: {
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
refresh: 0,
...overrides
}
type: 'remote',
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
refresh: 0,
...overrides
}
]
}
const createMockOptions = (inputOverrides = {}) => ({
inputData: createMockInputData(inputOverrides),
defaultValue: DEFAULT_VALUE,
node: {} as any,
widget: {} as any
})
function mockAxiosResponse(data: unknown, status = 200) {
jest.mocked(axios.get).mockResolvedValueOnce({ data, status })
}
@@ -56,25 +54,16 @@ function mockAxiosError(error: Error | string) {
function createHookWithData(data: unknown, inputOverrides = {}) {
mockAxiosResponse(data)
const hook = useRemoteWidget(createMockOptions(inputOverrides))
const hook = useRemoteWidget(createMockInputData(inputOverrides))
return hook
}
async function setupHookWithResponse(data: unknown, inputOverrides = {}) {
const hook = createHookWithData(data, inputOverrides)
const result = await getResolvedValue(hook)
const result = await hook.fetchOptions()
return { hook, result }
}
async function getResolvedValue(hook: ReturnType<typeof useRemoteWidget>) {
// Create a promise that resolves when the fetch is complete
const responsePromise = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
await responsePromise
return hook.getCachedValue()
}
describe('useRemoteWidget', () => {
let mockInputData: ComboInputSpecV2
@@ -96,26 +85,25 @@ describe('useRemoteWidget', () => {
describe('initialization', () => {
it('should create hook with default values', () => {
const hook = useRemoteWidget(createMockOptions())
expect(hook.getCachedValue()).toBeUndefined()
expect(hook.getValue()).toBe('Loading...')
const hook = useRemoteWidget(mockInputData)
expect(hook.getCacheEntry()).toBeUndefined()
expect(hook.defaultValue).toBe('Loading...')
})
it('should generate consistent cache keys', () => {
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
expect(hook1.cacheKey).toBe(hook2.cacheKey)
const hook1 = useRemoteWidget(mockInputData)
const hook2 = useRemoteWidget(mockInputData)
expect(hook1.getCacheKey()).toBe(hook2.getCacheKey())
})
it('should handle query params in cache key', () => {
const hook1 = useRemoteWidget(
createMockOptions({ query_params: { a: 1 } })
createMockInputData({ query_params: { a: 1 } })
)
const hook2 = useRemoteWidget(
createMockOptions({ query_params: { a: 2 } })
createMockInputData({ query_params: { a: 2 } })
)
expect(hook1.cacheKey).not.toBe(hook2.cacheKey)
expect(hook1.getCacheKey()).not.toBe(hook2.getCacheKey())
})
})
@@ -125,7 +113,7 @@ describe('useRemoteWidget', () => {
const { hook, result } = await setupHookWithResponse(mockData)
expect(result).toEqual(mockData)
expect(jest.mocked(axios.get)).toHaveBeenCalledWith(
hook.cacheKey.split(';')[0], // Get the route part from cache key
hook.getCacheKey().split(';')[0], // Get the route part from cache key
expect.any(Object)
)
})
@@ -151,7 +139,9 @@ describe('useRemoteWidget', () => {
const error = new Error('Network error')
mockAxiosError(error)
const { hook } = await setupHookWithResponse([])
const hook = useRemoteWidget(mockInputData)
const data = await hook.fetchOptions()
expect(data).toBe('Loading...')
const entry = hook.getCacheEntry()
expect(entry?.error).toBeTruthy()
@@ -164,22 +154,26 @@ describe('useRemoteWidget', () => {
})
it('should handle malformed response data', async () => {
const hook = useRemoteWidget(createMockOptions())
const hook = useRemoteWidget(mockInputData)
const { defaultValue } = hook
mockAxiosResponse(null)
const data1 = hook.getValue()
const data1 = await hook.fetchOptions()
mockAxiosResponse(undefined)
const data2 = hook.getValue()
const data2 = await hook.fetchOptions()
expect(data1).toBe(DEFAULT_VALUE)
expect(data2).toBe(DEFAULT_VALUE)
expect(data1).toBe(defaultValue)
expect(data2).toBe(defaultValue)
})
it('should handle non-200 status codes', async () => {
mockAxiosError('Request failed with status code 404')
const { hook } = await setupHookWithResponse([])
const hook = useRemoteWidget(mockInputData)
const data = await hook.fetchOptions()
expect(data).toBe('Loading...')
const entry = hook.getCacheEntry()
expect(entry?.error?.message).toBe('Request failed with status code 404')
})
@@ -200,34 +194,34 @@ describe('useRemoteWidget', () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
await getResolvedValue(hook)
await hook.fetchOptions()
await hook.fetchOptions()
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('permanent widgets should re-fetch if refreshValue is called', async () => {
it('permanent widgets should re-fetch if forceUpdate is called', async () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
await hook.fetchOptions()
const refreshedData = ['data that user forced to be fetched']
mockAxiosResponse(refreshedData)
hook.refreshValue()
const data = await getResolvedValue(hook)
await hook.forceUpdate()
const data = await hook.fetchOptions()
expect(data).toEqual(refreshedData)
})
it('permanent widgets should still retry if request fails', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const hook = useRemoteWidget(mockInputData)
await hook.fetchOptions()
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
jest.setSystemTime(Date.now() + FIRST_BACKOFF)
const secondData = await getResolvedValue(hook)
const secondData = await hook.fetchOptions()
expect(secondData).toBe('Loading...')
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
@@ -235,8 +229,8 @@ describe('useRemoteWidget', () => {
it('should treat empty refresh field as permanent', async () => {
const { hook } = await setupHookWithResponse(['data that is permanent'])
await getResolvedValue(hook)
await getResolvedValue(hook)
await hook.fetchOptions()
await hook.fetchOptions()
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
@@ -251,7 +245,7 @@ describe('useRemoteWidget', () => {
mockAxiosResponse(mockData2)
jest.setSystemTime(Date.now() + refresh)
const newData = await getResolvedValue(hook)
const newData = await hook.fetchOptions()
expect(newData).toEqual(mockData2)
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
@@ -263,7 +257,7 @@ describe('useRemoteWidget', () => {
})
jest.setSystemTime(Date.now() + 128)
await getResolvedValue(hook)
await hook.fetchOptions()
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
@@ -276,12 +270,12 @@ describe('useRemoteWidget', () => {
mockAxiosError('Network error')
jest.setSystemTime(Date.now() + refresh)
await getResolvedValue(hook)
await hook.fetchOptions()
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
mockAxiosResponse(['second success'])
jest.setSystemTime(Date.now() + FIRST_BACKOFF)
const thirdData = await getResolvedValue(hook)
const thirdData = await hook.fetchOptions()
expect(thirdData).toEqual(['second success'])
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(3)
})
@@ -294,7 +288,7 @@ describe('useRemoteWidget', () => {
mockAxiosError('Network error')
jest.setSystemTime(Date.now() + refresh)
const secondData = await getResolvedValue(hook)
const secondData = await hook.fetchOptions()
expect(secondData).toEqual(['a valid value'])
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
@@ -313,33 +307,33 @@ describe('useRemoteWidget', () => {
it('should implement exponential backoff on errors', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const hook = useRemoteWidget(mockInputData)
await hook.fetchOptions()
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
await getResolvedValue(hook)
await hook.fetchOptions()
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
jest.setSystemTime(Date.now() + 500)
await getResolvedValue(hook)
await hook.fetchOptions()
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
jest.setSystemTime(Date.now() + 3000)
await getResolvedValue(hook)
await hook.fetchOptions()
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(entry1?.data).toBeDefined()
})
it('should reset error state on successful fetch', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
const firstData = await getResolvedValue(hook)
const hook = useRemoteWidget(mockInputData)
const firstData = await hook.fetchOptions()
expect(firstData).toBe('Loading...')
jest.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['option1'])
const secondData = await getResolvedValue(hook)
const secondData = await hook.fetchOptions()
expect(secondData).toEqual(['option1'])
const entry = hook.getCacheEntry()
@@ -349,14 +343,14 @@ describe('useRemoteWidget', () => {
it('should save successful data after backoff', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const hook = useRemoteWidget(mockInputData)
await hook.fetchOptions()
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
jest.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['success after backoff'])
const secondData = await getResolvedValue(hook)
const secondData = await hook.fetchOptions()
expect(secondData).toEqual(['success after backoff'])
const entry2 = hook.getCacheEntry()
@@ -368,24 +362,24 @@ describe('useRemoteWidget', () => {
mockAxiosError('Network error')
mockAxiosError('Network error')
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const hook = useRemoteWidget(mockInputData)
await hook.fetchOptions()
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
jest.setSystemTime(Date.now() + 3000)
const secondData = await getResolvedValue(hook)
const secondData = await hook.fetchOptions()
expect(secondData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
jest.setSystemTime(Date.now() + 9000)
const thirdData = await getResolvedValue(hook)
const thirdData = await hook.fetchOptions()
expect(thirdData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
jest.setSystemTime(Date.now() + 120_000)
mockAxiosResponse(['success after multiple backoffs'])
const fourthData = await getResolvedValue(hook)
const fourthData = await hook.fetchOptions()
expect(fourthData).toEqual(['success after multiple backoffs'])
const entry2 = hook.getCacheEntry()
@@ -397,20 +391,20 @@ describe('useRemoteWidget', () => {
describe('cache management', () => {
it('should clear cache entries', async () => {
const { hook } = await setupHookWithResponse(['to be cleared'])
expect(hook.getCachedValue()).toBeDefined()
expect(hook.getCacheEntry()).toBeDefined()
hook.refreshValue()
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
hook.forceUpdate()
expect(hook.getCacheEntry()).toBeUndefined()
})
it('should prevent duplicate in-flight requests', async () => {
const promise = Promise.resolve({ data: ['non-duplicate'] })
jest.mocked(axios.get).mockImplementationOnce(() => promise)
const hook = useRemoteWidget(createMockOptions())
const hook = useRemoteWidget(mockInputData)
const [result1, result2] = await Promise.all([
getResolvedValue(hook),
getResolvedValue(hook)
hook.fetchOptions(),
hook.fetchOptions()
])
expect(result1).toBe(result2)
@@ -421,43 +415,40 @@ describe('useRemoteWidget', () => {
describe('concurrent access and multiple instances', () => {
it('should handle concurrent hook instances with same route', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
const hook1 = useRemoteWidget(mockInputData)
const hook2 = useRemoteWidget(mockInputData)
// Since they have the same route, only one request will be made
await Promise.race([getResolvedValue(hook1), getResolvedValue(hook2)])
const data1 = hook1.getValue()
const data2 = hook2.getValue()
const [data1, data2] = await Promise.all([
hook1.fetchOptions(),
hook2.fetchOptions()
])
expect(data1).toEqual(['shared data'])
expect(data2).toEqual(['shared data'])
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
expect(hook1.getCacheEntry()).toBe(hook2.getCacheEntry())
})
it('should use shared cache across multiple hooks', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
const hook3 = useRemoteWidget(options)
const hook4 = useRemoteWidget(options)
const hook1 = useRemoteWidget(mockInputData)
const hook2 = useRemoteWidget(mockInputData)
const hook3 = useRemoteWidget(mockInputData)
const hook4 = useRemoteWidget(mockInputData)
const data1 = await getResolvedValue(hook1)
const data2 = await getResolvedValue(hook2)
const data3 = await getResolvedValue(hook3)
const data4 = await getResolvedValue(hook4)
const data1 = await hook1.fetchOptions()
const data2 = await hook2.fetchOptions()
const data3 = await hook3.fetchOptions()
const data4 = await hook4.fetchOptions()
expect(data1).toEqual(['shared data'])
expect(data2).toBe(data1)
expect(data3).toBe(data1)
expect(data4).toBe(data1)
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
expect(hook1.getCacheEntry()).toBe(hook2.getCacheEntry())
expect(hook2.getCacheEntry()).toBe(hook3.getCacheEntry())
expect(hook3.getCacheEntry()).toBe(hook4.getCacheEntry())
})
it('should handle rapid cache clearing during fetch', async () => {
@@ -468,17 +459,15 @@ describe('useRemoteWidget', () => {
jest.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
const hook = useRemoteWidget(createMockOptions())
hook.getValue()
hook.refreshValue()
const hook = useRemoteWidget(mockInputData)
const fetchPromise = hook.fetchOptions()
hook.forceUpdate()
resolvePromise!({ data: ['delayed data'] })
const data = await getResolvedValue(hook)
const data = await fetchPromise
// The value should be the default value because the refreshValue
// clears the cache and the fetch is aborted
expect(data).toEqual(DEFAULT_VALUE)
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
expect(data).toEqual(['delayed data'])
expect(hook.getCacheEntry()).toBeUndefined()
})
it('should handle widget destroyed during fetch', async () => {
@@ -489,8 +478,8 @@ describe('useRemoteWidget', () => {
jest.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
let hook = useRemoteWidget(createMockOptions())
const fetchPromise = hook.getValue()
let hook = useRemoteWidget(mockInputData)
const fetchPromise = hook.fetchOptions()
hook = null as any
@@ -498,10 +487,10 @@ describe('useRemoteWidget', () => {
await fetchPromise
expect(hook).toBeNull()
hook = useRemoteWidget(createMockOptions())
hook = useRemoteWidget(mockInputData)
const data2 = await getResolvedValue(hook)
expect(data2).toEqual(DEFAULT_VALUE)
const data2 = await hook.fetchOptions()
expect(data2).toEqual(['delayed data'])
})
})
})