Compare commits
20 Commits
bl-queue
...
contextmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20e68a15c | ||
|
|
e59d2dd8df | ||
|
|
d54923f766 | ||
|
|
c30f528d11 | ||
|
|
0497421349 | ||
|
|
01b4ad0dbb | ||
|
|
31c85387ba | ||
|
|
8108aaa2d4 | ||
|
|
9c245e9c23 | ||
|
|
cb40da612b | ||
|
|
ddb3a0bfc6 | ||
|
|
5773df6ef7 | ||
|
|
bc281b2513 | ||
|
|
1d06b4d63b | ||
|
|
14c07fd734 | ||
|
|
7cc08e8e35 | ||
|
|
9c0b3c4f7d | ||
|
|
bb83b0107c | ||
|
|
0685a1da3c | ||
|
|
5b37fc59e7 |
8
.github/workflows/update-locales.yaml
vendored
@@ -18,14 +18,14 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup ComfyUI Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import * as fs from 'fs'
|
||||
|
||||
@@ -130,7 +129,8 @@ export class ComfyPage {
|
||||
|
||||
// Buttons
|
||||
public readonly resetViewButton: Locator
|
||||
public readonly queueButton: Locator
|
||||
public readonly queueButton: Locator // Run button in Legacy UI
|
||||
public readonly runButton: Locator // Run button (renamed "Queue" -> "Run")
|
||||
|
||||
// Inputs
|
||||
public readonly workflowUploadInput: Locator
|
||||
@@ -165,6 +165,9 @@ export class ComfyPage {
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||
this.runButton = page
|
||||
.getByTestId('queue-button')
|
||||
.getByRole('button', { name: 'Run' })
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
|
||||
@@ -1086,12 +1089,6 @@ export class ComfyPage {
|
||||
|
||||
const targetPosition = await targetSlot.getPosition()
|
||||
|
||||
// Debug: Log the positions we're trying to use
|
||||
console.log('Drag positions:', {
|
||||
source: sourcePosition,
|
||||
target: targetPosition
|
||||
})
|
||||
|
||||
await this.dragAndDrop(sourcePosition, targetPosition)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { VueNodeFixture } from './utils/vueNodeFixtures'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
@@ -106,6 +108,24 @@ export class VueNodeHelpers {
|
||||
await this.page.keyboard.press('Backspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
const nodeId = await node.evaluate((el) => el.getAttribute('data-node-id'))
|
||||
if (!nodeId) {
|
||||
throw new Error(
|
||||
`Vue node titled "${title}" is missing its data-node-id attribute`
|
||||
)
|
||||
}
|
||||
|
||||
return new VueNodeFixture(this.getNodeLocator(nodeId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Vue nodes to be rendered
|
||||
*/
|
||||
|
||||
@@ -1,131 +1,66 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { NodeReference } from './litegraphUtils'
|
||||
|
||||
/**
|
||||
* VueNodeFixture provides Vue-specific testing utilities for interacting with
|
||||
* Vue node components. It bridges the gap between litegraph node references
|
||||
* and Vue UI components.
|
||||
*/
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
constructor(
|
||||
private readonly nodeRef: NodeReference,
|
||||
private readonly page: Page
|
||||
) {}
|
||||
constructor(private readonly locator: Locator) {}
|
||||
|
||||
/**
|
||||
* Get the node's header element using data-testid
|
||||
*/
|
||||
async getHeader(): Promise<Locator> {
|
||||
const nodeId = this.nodeRef.id
|
||||
return this.page.locator(`[data-testid="node-header-${nodeId}"]`)
|
||||
get header(): Locator {
|
||||
return this.locator.locator('[data-testid^="node-header-"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node's title element
|
||||
*/
|
||||
async getTitleElement(): Promise<Locator> {
|
||||
const header = await this.getHeader()
|
||||
return header.locator('[data-testid="node-title"]')
|
||||
get title(): Locator {
|
||||
return this.locator.locator('[data-testid="node-title"]')
|
||||
}
|
||||
|
||||
get titleInput(): Locator {
|
||||
return this.locator.locator('[data-testid="node-title-input"]')
|
||||
}
|
||||
|
||||
get body(): Locator {
|
||||
return this.locator.locator('[data-testid^="node-body-"]')
|
||||
}
|
||||
|
||||
get collapseButton(): Locator {
|
||||
return this.locator.locator('[data-testid="node-collapse-button"]')
|
||||
}
|
||||
|
||||
get collapseIcon(): Locator {
|
||||
return this.collapseButton.locator('i')
|
||||
}
|
||||
|
||||
get root(): Locator {
|
||||
return this.locator
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current title text
|
||||
*/
|
||||
async getTitle(): Promise<string> {
|
||||
const titleElement = await this.getTitleElement()
|
||||
return (await titleElement.textContent()) || ''
|
||||
return (await this.title.textContent()) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new title by double-clicking and entering text
|
||||
*/
|
||||
async setTitle(newTitle: string): Promise<void> {
|
||||
const titleElement = await this.getTitleElement()
|
||||
await titleElement.dblclick()
|
||||
|
||||
const input = (await this.getHeader()).locator(
|
||||
'[data-testid="node-title-input"]'
|
||||
)
|
||||
await input.fill(newTitle)
|
||||
async setTitle(value: string): Promise<void> {
|
||||
await this.header.dblclick()
|
||||
const input = this.titleInput
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(value)
|
||||
await input.press('Enter')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel title editing
|
||||
*/
|
||||
async cancelTitleEdit(): Promise<void> {
|
||||
const titleElement = await this.getTitleElement()
|
||||
await titleElement.dblclick()
|
||||
|
||||
const input = (await this.getHeader()).locator(
|
||||
'[data-testid="node-title-input"]'
|
||||
)
|
||||
await this.header.dblclick()
|
||||
const input = this.titleInput
|
||||
await expect(input).toBeVisible()
|
||||
await input.press('Escape')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the title is currently being edited
|
||||
*/
|
||||
async isEditingTitle(): Promise<boolean> {
|
||||
const header = await this.getHeader()
|
||||
const input = header.locator('[data-testid="node-title-input"]')
|
||||
return await input.isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collapse/expand button
|
||||
*/
|
||||
async getCollapseButton(): Promise<Locator> {
|
||||
const header = await this.getHeader()
|
||||
return header.locator('[data-testid="node-collapse-button"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the node's collapsed state
|
||||
*/
|
||||
async toggleCollapse(): Promise<void> {
|
||||
const button = await this.getCollapseButton()
|
||||
await button.click()
|
||||
await this.collapseButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collapse icon element
|
||||
*/
|
||||
async getCollapseIcon(): Promise<Locator> {
|
||||
const button = await this.getCollapseButton()
|
||||
return button.locator('i')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collapse icon's CSS classes
|
||||
*/
|
||||
async getCollapseIconClass(): Promise<string> {
|
||||
const icon = await this.getCollapseIcon()
|
||||
return (await icon.getAttribute('class')) || ''
|
||||
return (await this.collapseIcon.getAttribute('class')) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the collapse button is visible
|
||||
*/
|
||||
async isCollapseButtonVisible(): Promise<boolean> {
|
||||
const button = await this.getCollapseButton()
|
||||
return await button.isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node's body/content element
|
||||
*/
|
||||
async getBody(): Promise<Locator> {
|
||||
const nodeId = this.nodeRef.id
|
||||
return this.page.locator(`[data-testid="node-body-${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the node body is visible (not collapsed)
|
||||
*/
|
||||
async isBodyVisible(): Promise<boolean> {
|
||||
const body = await this.getBody()
|
||||
return await body.isVisible()
|
||||
boundingBox(): ReturnType<Locator['boundingBox']> {
|
||||
return this.locator.boundingBox()
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -2,70 +2,46 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('Vue Nodes Renaming', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should display node title', async ({ comfyPage }) => {
|
||||
// Get the KSampler node from the default workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
|
||||
const title = await vueNode.getTitle()
|
||||
expect(title).toBe('KSampler')
|
||||
|
||||
// Verify title is visible in the header
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('KSampler')
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.header).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should allow title renaming by double clicking on the node header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
// Test renaming with Enter
|
||||
await vueNode.setTitle('My Custom Sampler')
|
||||
const newTitle = await vueNode.getTitle()
|
||||
expect(newTitle).toBe('My Custom Sampler')
|
||||
|
||||
// Verify the title is displayed
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('My Custom Sampler')
|
||||
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
|
||||
await expect(vueNode.header).toContainText('My Custom Sampler')
|
||||
|
||||
// Test cancel with Escape
|
||||
const titleElement = await vueNode.getTitleElement()
|
||||
await titleElement.dblclick()
|
||||
await vueNode.title.dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Type a different value but cancel
|
||||
const input = (await vueNode.getHeader()).locator(
|
||||
'[data-testid="node-title-input"]'
|
||||
)
|
||||
await input.fill('This Should Be Cancelled')
|
||||
await input.press('Escape')
|
||||
await vueNode.titleInput.fill('This Should Be Cancelled')
|
||||
await vueNode.titleInput.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Title should remain as the previously saved value
|
||||
const titleAfterCancel = await vueNode.getTitle()
|
||||
expect(titleAfterCancel).toBe('My Custom Sampler')
|
||||
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
|
||||
})
|
||||
|
||||
test('Double click node body does not trigger edit', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const loadCheckpointNode = comfyPage.vueNodes
|
||||
.getNodeByTitle('Load Checkpoint')
|
||||
.first()
|
||||
const nodeBbox = await loadCheckpointNode.boundingBox()
|
||||
if (!nodeBbox) throw new Error('Node not found')
|
||||
await loadCheckpointNode.dblclick()
|
||||
|
||||
@@ -50,15 +50,23 @@ test.describe('Vue Node Selection', () => {
|
||||
})
|
||||
}
|
||||
|
||||
test('should select all nodes with ctrl+a', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(initialCount)
|
||||
})
|
||||
|
||||
test('should select pinned node without dragging', async ({ comfyPage }) => {
|
||||
const PIN_HOTKEY = 'p'
|
||||
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
|
||||
|
||||
// Select a node by clicking its title
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
|
||||
// Pin it using the hotkey (as a user would)
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
|
||||
@@ -20,6 +20,9 @@ test.describe('Vue Node Bypass', () => {
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-bypassed-state.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -2,7 +2,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('Vue Node Collapse', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -10,43 +9,50 @@ test.describe('Vue Node Collapse', () => {
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow collapsing node with collapse icon', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get the KSampler node from the default workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.root).toBeVisible()
|
||||
|
||||
// Initially should not be collapsed
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
const body = await vueNode.getBody()
|
||||
const body = vueNode.body
|
||||
await expect(body).toBeVisible()
|
||||
const expandedBoundingBox = await vueNode.boundingBox()
|
||||
if (!expandedBoundingBox)
|
||||
throw new Error('Failed to get node bounding box before collapse')
|
||||
|
||||
// Collapse the node
|
||||
await vueNode.toggleCollapse()
|
||||
expect(await node.isCollapsed()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify node content is hidden
|
||||
const collapsedSize = await node.getSize()
|
||||
await expect(body).not.toBeVisible()
|
||||
const collapsedBoundingBox = await vueNode.boundingBox()
|
||||
if (!collapsedBoundingBox)
|
||||
throw new Error('Failed to get node bounding box after collapse')
|
||||
expect(collapsedBoundingBox.height).toBeLessThan(expandedBoundingBox.height)
|
||||
|
||||
// Expand again
|
||||
await vueNode.toggleCollapse()
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(body).toBeVisible()
|
||||
|
||||
// Size should be restored
|
||||
const expandedSize = await node.getSize()
|
||||
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
|
||||
const expandedBoundingBoxAfter = await vueNode.boundingBox()
|
||||
if (!expandedBoundingBoxAfter)
|
||||
throw new Error('Failed to get node bounding box after expand')
|
||||
expect(expandedBoundingBoxAfter.height).toBeGreaterThanOrEqual(
|
||||
collapsedBoundingBox.height
|
||||
)
|
||||
})
|
||||
|
||||
test('should show collapse/expand icon state', async ({ comfyPage }) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.root).toBeVisible()
|
||||
|
||||
// Check initial expanded state icon
|
||||
let iconClass = await vueNode.getCollapseIconClass()
|
||||
@@ -66,9 +72,8 @@ test.describe('Vue Node Collapse', () => {
|
||||
test('should preserve title when collapsing/expanding', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.root).toBeVisible()
|
||||
|
||||
// Set custom title
|
||||
await vueNode.setTitle('Test Sampler')
|
||||
@@ -83,7 +88,6 @@ test.describe('Vue Node Collapse', () => {
|
||||
expect(await vueNode.getTitle()).toBe('Test Sampler')
|
||||
|
||||
// Verify title is still displayed
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('Test Sampler')
|
||||
await expect(vueNode.header).toContainText('Test Sampler')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
@@ -3,7 +3,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const ERROR_CLASS = /border-error/
|
||||
const ERROR_CLASS = /border-node-stroke-error/
|
||||
|
||||
test.describe('Vue Node Error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -17,16 +17,21 @@ test.describe('Vue Node Error', () => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Close missing nodes warning dialog
|
||||
await comfyPage.page.getByRole('button', { name: 'Close' }).click()
|
||||
await comfyPage.page.waitForSelector('.comfy-missing-nodes', {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Expect error state on missing unknown node
|
||||
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'UNKNOWN NODE'
|
||||
})
|
||||
await expect(unknownNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('should display error state when node causes execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
|
||||
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -4,7 +4,7 @@ import {
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const MUTE_HOTKEY = 'Control+m'
|
||||
const MUTE_CLASS = /opacity-50/
|
||||
const MUTE_OPACITY = '0.5'
|
||||
|
||||
test.describe('Vue Node Mute', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +19,11 @@ test.describe('Vue Node Mute', () => {
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-muted-state.png')
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
})
|
||||
|
||||
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
|
||||
@@ -35,11 +36,11 @@ test.describe('Vue Node Mute', () => {
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(ksamplerNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Widget Reactivity', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
test('Should display added widgets', async ({ comfyPage }) => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
|
||||
)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['4']
|
||||
node.widgets.push(node.widgets[0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['4']
|
||||
node.widgets[2] = node.widgets[0]
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['4']
|
||||
node.widgets.splice(0, 0, node.widgets[0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
test('Should hide removed widgets', async ({ comfyPage }) => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-3"] > .lg-node-widgets > div'
|
||||
)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['3']
|
||||
node.widgets.pop()
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(5)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['3']
|
||||
node.widgets.length--
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['3']
|
||||
node.widgets.splice(0, 1)
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
})
|
||||
})
|
||||
@@ -89,6 +89,21 @@
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
|
||||
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
|
||||
--color-alpha-charcoal-600-30: color-mix(
|
||||
in srgb,
|
||||
var(--color-charcoal-600) 30%,
|
||||
transparent
|
||||
);
|
||||
--color-alpha-stone-100-20: color-mix(
|
||||
in srgb,
|
||||
var(--color-stone-100) 20%,
|
||||
transparent
|
||||
);
|
||||
--color-alpha-gray-500-50: color-mix(
|
||||
in srgb,
|
||||
var(--color-gray-500) 50%,
|
||||
transparent
|
||||
);
|
||||
|
||||
/* PrimeVue pulled colors */
|
||||
--color-muted: var(--p-text-muted-color);
|
||||
@@ -129,6 +144,7 @@
|
||||
|
||||
/* --- */
|
||||
|
||||
--accent-primary: var(--color-charcoal-700);
|
||||
--backdrop: var(--color-white);
|
||||
--dialog-surface: var(--color-neutral-200);
|
||||
--node-component-border: var(--color-gray-400);
|
||||
@@ -154,13 +170,22 @@
|
||||
from var(--color-zinc-500) r g b / 10%
|
||||
);
|
||||
--node-component-widget-skeleton-surface: var(--color-zinc-300);
|
||||
--node-stroke: var(--color-stone-100);
|
||||
--node-component-disabled: var(--color-alpha-stone-100-20);
|
||||
--node-icon-disabled: var(--color-alpha-gray-500-50);
|
||||
--node-stroke: var(--color-gray-400);
|
||||
--node-stroke-selected: var(--color-accent-primary);
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-blue-100);
|
||||
}
|
||||
|
||||
.dark-theme {
|
||||
--accent-primary: var(--color-pure-white);
|
||||
--backdrop: var(--color-neutral-900);
|
||||
--dialog-surface: var(--color-neutral-700);
|
||||
--node-component-border: var(--color-stone-200);
|
||||
--node-component-border-error: var(--color-danger-100);
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-slate-300);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-outline: var(--color-white);
|
||||
@@ -176,7 +201,12 @@
|
||||
--node-component-tooltip-border: var(--color-slate-300);
|
||||
--node-component-tooltip-surface: var(--color-charcoal-800);
|
||||
--node-component-widget-skeleton-surface: var(--color-zinc-800);
|
||||
--node-stroke: var(--color-slate-100);
|
||||
--node-component-disabled: var(--color-alpha-charcoal-600-30);
|
||||
--node-icon-disabled: var(--color-alpha-stone-100-20);
|
||||
--node-stroke: var(--color-stone-200);
|
||||
--node-stroke-selected: var(--color-pure-white);
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-blue-100);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -213,7 +243,12 @@
|
||||
--color-node-component-widget-skeleton-surface: var(
|
||||
--node-component-widget-skeleton-surface
|
||||
);
|
||||
--color-node-component-disabled: var(--node-component-disabled);
|
||||
--color-node-icon-disabled: var(--node-icon-disabled);
|
||||
--color-node-stroke: var(--node-stroke);
|
||||
--color-node-stroke-selected: var(--node-stroke-selected);
|
||||
--color-node-stroke-error: var(--node-stroke-error);
|
||||
--color-node-stroke-executing: var(--node-stroke-executing);
|
||||
}
|
||||
|
||||
@custom-variant dark-theme {
|
||||
|
||||
1
packages/design-system/src/icons/play.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3" d="m4 2 9.333 6L4 14V2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@@ -82,7 +82,7 @@ export function formatSize(value?: number) {
|
||||
* - filename: 'file'
|
||||
* - suffix: 'txt'
|
||||
*/
|
||||
function getFilenameDetails(fullFilename: string) {
|
||||
export function getFilenameDetails(fullFilename: string) {
|
||||
if (fullFilename.includes('.')) {
|
||||
return {
|
||||
filename: fullFilename.split('.').slice(0, -1).join('.'),
|
||||
@@ -451,3 +451,26 @@ export function stringToLocale(locale: string): SupportedLocale {
|
||||
? (locale as SupportedLocale)
|
||||
: 'en'
|
||||
}
|
||||
|
||||
export function formatDuration(milliseconds: number): string {
|
||||
if (!milliseconds || milliseconds < 0) return '0s'
|
||||
|
||||
const totalSeconds = Math.floor(milliseconds / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const remainingSeconds = Math.floor(totalSeconds % 60)
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
if (remainingSeconds > 0 || parts.length === 0) {
|
||||
parts.push(`${remainingSeconds}s`)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const iconGroupClasses = cn(
|
||||
'outline-hidden border-none p-0 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-700',
|
||||
'text-neutral-950 dark-theme:text-white',
|
||||
'transition-all duration-200',
|
||||
'cursor-pointer'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<IconButton @click="toggle">
|
||||
<i class="icon-[lucide--more-vertical] text-sm" />
|
||||
<IconButton :size="size" :type="type" @click="toggle">
|
||||
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
|
||||
<i v-else class="icon-[lucide--more-vertical] text-sm" />
|
||||
</IconButton>
|
||||
|
||||
<Popover
|
||||
@@ -13,6 +14,8 @@
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
@@ -25,12 +28,28 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
interface MoreButtonProps extends BaseButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
isVertical = false
|
||||
} = defineProps<MoreButtonProps>()
|
||||
|
||||
defineEmits<{
|
||||
menuOpened: []
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
@@ -45,7 +64,7 @@ const pt = computed(() => ({
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg',
|
||||
'mt-1 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800',
|
||||
'text-neutral dark-theme:text-white',
|
||||
'shadow-lg',
|
||||
|
||||
@@ -11,7 +11,15 @@ import CardTop from './CardTop.vue'
|
||||
|
||||
interface CardStoryArgs {
|
||||
// CardContainer props
|
||||
containerRatio: 'square' | 'portrait' | 'tallPortrait'
|
||||
containerSize: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
|
||||
variant: 'default' | 'ghost' | 'outline'
|
||||
rounded: 'none' | 'sm' | 'lg' | 'xl'
|
||||
customAspectRatio?: string
|
||||
hasBorder: boolean
|
||||
hasBackground: boolean
|
||||
hasShadow: boolean
|
||||
hasCursor: boolean
|
||||
customClass: string
|
||||
maxWidth: number
|
||||
minWidth: number
|
||||
|
||||
@@ -44,10 +52,44 @@ interface CardStoryArgs {
|
||||
const meta: Meta<CardStoryArgs> = {
|
||||
title: 'Components/Card/Card',
|
||||
argTypes: {
|
||||
containerRatio: {
|
||||
containerSize: {
|
||||
control: 'select',
|
||||
options: ['square', 'portrait', 'tallPortrait'],
|
||||
description: 'Card container aspect ratio'
|
||||
options: ['mini', 'compact', 'regular', 'portrait', 'tall'],
|
||||
description: 'Card container size preset'
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'ghost', 'outline'],
|
||||
description: 'Card visual variant'
|
||||
},
|
||||
rounded: {
|
||||
control: 'select',
|
||||
options: ['none', 'sm', 'lg', 'xl'],
|
||||
description: 'Border radius size'
|
||||
},
|
||||
customAspectRatio: {
|
||||
control: 'text',
|
||||
description: 'Custom aspect ratio (e.g., "16/9")'
|
||||
},
|
||||
hasBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Add border styling'
|
||||
},
|
||||
hasBackground: {
|
||||
control: 'boolean',
|
||||
description: 'Add background styling'
|
||||
},
|
||||
hasShadow: {
|
||||
control: 'boolean',
|
||||
description: 'Add shadow styling'
|
||||
},
|
||||
hasCursor: {
|
||||
control: 'boolean',
|
||||
description: 'Add cursor pointer'
|
||||
},
|
||||
customClass: {
|
||||
control: 'text',
|
||||
description: 'Additional custom CSS classes'
|
||||
},
|
||||
topRatio: {
|
||||
control: 'select',
|
||||
@@ -149,8 +191,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
template: `
|
||||
<div class="min-h-screen">
|
||||
<CardContainer
|
||||
:ratio="args.containerRatio"
|
||||
class="max-w-[320px] mx-auto"
|
||||
:size="args.containerSize"
|
||||
:variant="args.variant"
|
||||
:rounded="args.rounded"
|
||||
:custom-aspect-ratio="args.customAspectRatio"
|
||||
:has-border="args.hasBorder"
|
||||
:has-background="args.hasBackground"
|
||||
:has-shadow="args.hasShadow"
|
||||
:has-cursor="args.hasCursor"
|
||||
:class="args.customClass || 'max-w-[320px] mx-auto'"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop :ratio="args.topRatio">
|
||||
@@ -205,7 +254,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3 bg-neutral-100">
|
||||
<CardBottom>
|
||||
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
|
||||
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
@@ -218,7 +267,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
export const Default: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
containerSize: 'portrait',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -243,7 +300,15 @@ export const Default: Story = {
|
||||
export const SquareCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -268,7 +333,15 @@ export const SquareCard: Story = {
|
||||
export const TallPortraitCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
containerSize: 'tall',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
@@ -293,7 +366,15 @@ export const TallPortraitCard: Story = {
|
||||
export const ImageCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
containerSize: 'portrait',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -314,10 +395,50 @@ export const ImageCard: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const MiniCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'mini',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: false,
|
||||
title: 'Mini Asset',
|
||||
description: '',
|
||||
backgroundColor: '#06b6d4',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Asset'],
|
||||
showFileSize: true,
|
||||
fileSize: '124 KB',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const MinimalCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
@@ -338,10 +459,209 @@ export const MinimalCard: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const GhostVariant: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'compact',
|
||||
variant: 'ghost',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Workflow Template',
|
||||
description: 'Ghost variant for workflow templates',
|
||||
backgroundColor: '#10b981',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Template'],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const OutlineVariant: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'outline',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Outline Card',
|
||||
description: 'Card with outline variant styling',
|
||||
backgroundColor: '#f59e0b',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomAspectRatio: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
customAspectRatio: '16/9',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: false,
|
||||
title: 'Wide Format Card',
|
||||
description: '',
|
||||
backgroundColor: '#8b5cf6',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Wide'],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const RoundedNone: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'none',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Sharp Corners',
|
||||
description: 'Card with no border radius',
|
||||
backgroundColor: '#dc2626',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const RoundedXL: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'xl',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Extra Rounded',
|
||||
description: 'Card with extra large border radius',
|
||||
backgroundColor: '#059669',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const NoStylesCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: false,
|
||||
hasBackground: false,
|
||||
hasShadow: false,
|
||||
hasCursor: true,
|
||||
customClass: 'bg-gradient-to-br from-blue-500 to-purple-600',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Custom Styled Card',
|
||||
description: 'Card with all default styles removed and custom gradient',
|
||||
backgroundColor: 'transparent',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const FullFeaturedCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
containerSize: 'tall',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
|
||||
@@ -8,26 +8,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { ratio = 'square', type } = defineProps<{
|
||||
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
|
||||
type?: string
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
size = 'regular',
|
||||
variant = 'default',
|
||||
rounded = 'md',
|
||||
customAspectRatio,
|
||||
hasBorder = true,
|
||||
hasBackground = true,
|
||||
hasShadow = true,
|
||||
hasCursor = true,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
size?: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
|
||||
variant?: 'default' | 'ghost' | 'outline'
|
||||
rounded?: 'none' | 'md' | 'lg' | 'xl'
|
||||
customAspectRatio?: string
|
||||
hasBorder?: boolean
|
||||
hasBackground?: boolean
|
||||
hasShadow?: boolean
|
||||
hasCursor?: boolean
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
// Base structure classes
|
||||
const structureClasses = 'flex flex-col overflow-hidden'
|
||||
|
||||
// Rounded corners
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
md: 'rounded',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl'
|
||||
} as const
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const baseClasses =
|
||||
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||
|
||||
if (type === 'workflow-template-card') {
|
||||
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
|
||||
// Variant styles
|
||||
const variantClasses = {
|
||||
default: cn(
|
||||
hasBackground && 'bg-white dark-theme:bg-zinc-800',
|
||||
hasBorder && 'border border-zinc-200 dark-theme:border-zinc-700',
|
||||
hasShadow && 'shadow-sm',
|
||||
hasCursor && 'cursor-pointer'
|
||||
),
|
||||
ghost: cn(
|
||||
hasCursor && 'cursor-pointer',
|
||||
'p-2 transition-colors duration-200'
|
||||
),
|
||||
outline: cn(
|
||||
hasBorder && 'border-2 border-zinc-300 dark-theme:border-zinc-600',
|
||||
hasCursor && 'cursor-pointer',
|
||||
'hover:border-zinc-400 dark-theme:hover:border-zinc-500 transition-colors'
|
||||
)
|
||||
}
|
||||
|
||||
const ratioClasses = {
|
||||
smallSquare: 'aspect-240/311',
|
||||
square: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tallPortrait: 'aspect-256/353'
|
||||
}
|
||||
// Size/aspect ratio
|
||||
const aspectRatio = customAspectRatio
|
||||
? `aspect-[${customAspectRatio}]`
|
||||
: {
|
||||
mini: 'aspect-100/120',
|
||||
compact: 'aspect-240/311',
|
||||
regular: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tall: 'aspect-256/353'
|
||||
}[size]
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
return cn(
|
||||
structureClasses,
|
||||
roundedClasses[rounded],
|
||||
variantClasses[variant],
|
||||
aspectRatio,
|
||||
customClass
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,31 +2,27 @@
|
||||
<div :class="topStyle">
|
||||
<slot class="absolute top-0 left-0 h-full w-full"></slot>
|
||||
|
||||
<div
|
||||
v-if="slots['top-left']"
|
||||
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
|
||||
>
|
||||
<div v-if="slots['top-left']" :class="slotClasses['top-left']">
|
||||
<slot name="top-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['top-right']"
|
||||
class="absolute top-2 right-2 flex flex-wrap justify-end gap-2"
|
||||
>
|
||||
<div v-if="slots['top-right']" :class="slotClasses['top-right']">
|
||||
<slot name="top-right"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-left']"
|
||||
class="absolute bottom-2 left-2 flex flex-wrap justify-start gap-2"
|
||||
>
|
||||
<div v-if="slots['center-left']" :class="slotClasses['center-left']">
|
||||
<slot name="center-left"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="slots['center-right']" :class="slotClasses['center-right']">
|
||||
<slot name="center-right"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="slots['bottom-left']" :class="slotClasses['bottom-left']">
|
||||
<slot name="bottom-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-right']"
|
||||
class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-2"
|
||||
>
|
||||
<div v-if="slots['bottom-right']" :class="slotClasses['bottom-right']">
|
||||
<slot name="bottom-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,10 +31,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
const {
|
||||
ratio = 'square',
|
||||
topLeftClass,
|
||||
topRightClass,
|
||||
centerLeftClass,
|
||||
centerRightClass,
|
||||
bottomLeftClass,
|
||||
bottomRightClass
|
||||
} = defineProps<{
|
||||
ratio?: 'square' | 'landscape'
|
||||
topLeftClass?: string
|
||||
topRightClass?: string
|
||||
centerLeftClass?: string
|
||||
centerRightClass?: string
|
||||
bottomLeftClass?: string
|
||||
bottomRightClass?: string
|
||||
}>()
|
||||
|
||||
const topStyle = computed(() => {
|
||||
@@ -51,4 +63,26 @@ const topStyle = computed(() => {
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
})
|
||||
|
||||
// Get default classes for each slot position
|
||||
const defaultSlotClasses = {
|
||||
'top-left': 'absolute top-2 left-2 flex flex-wrap justify-start gap-2',
|
||||
'top-right': 'absolute top-2 right-2 flex flex-wrap justify-end gap-2',
|
||||
'center-left':
|
||||
'absolute top-1/2 left-2 flex -translate-y-1/2 flex-wrap justify-start gap-2',
|
||||
'center-right':
|
||||
'absolute top-1/2 right-2 flex -translate-y-1/2 flex-wrap justify-end gap-2',
|
||||
'bottom-left': 'absolute bottom-2 left-2 flex flex-wrap justify-start gap-2',
|
||||
'bottom-right': 'absolute right-2 bottom-2 flex flex-wrap justify-end gap-2'
|
||||
}
|
||||
|
||||
// Compute all slot classes once and cache them
|
||||
const slotClasses = computed(() => ({
|
||||
'top-left': cn(defaultSlotClasses['top-left'], topLeftClass),
|
||||
'top-right': cn(defaultSlotClasses['top-right'], topRightClass),
|
||||
'center-left': cn(defaultSlotClasses['center-left'], centerLeftClass),
|
||||
'center-right': cn(defaultSlotClasses['center-right'], centerRightClass),
|
||||
'bottom-left': cn(defaultSlotClasses['bottom-left'], bottomLeftClass),
|
||||
'bottom-right': cn(defaultSlotClasses['bottom-right'], bottomRightClass)
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex shrink-0 items-center justify-center gap-1 rounded bg-[#D9D9D966]/40 px-2 py-1 text-xs font-bold text-white/90"
|
||||
>
|
||||
<slot name="icon" class="text-xs text-white/90"></slot>
|
||||
<div :class="chipClasses">
|
||||
<slot name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const { label } = defineProps<{
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { label, variant = 'dark' } = defineProps<{
|
||||
label: string
|
||||
variant?: 'dark' | 'light'
|
||||
}>()
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
|
||||
|
||||
const variantStyles = {
|
||||
dark: 'bg-zinc-500/40 text-white/90',
|
||||
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
|
||||
}
|
||||
|
||||
const chipClasses = computed(() => {
|
||||
return cn(baseClasses, variantStyles[variant])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative flex h-full w-full items-center justify-center overflow-hidden"
|
||||
:class="containerClass"
|
||||
>
|
||||
<Skeleton
|
||||
v-if="!isImageLoaded"
|
||||
@@ -41,17 +42,20 @@ import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
import type { ClassValue } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
alt = '',
|
||||
containerClass = '',
|
||||
imageClass = '',
|
||||
imageStyle,
|
||||
rootMargin = '300px'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
imageClass?: string | string[] | Record<string, boolean>
|
||||
containerClass?: ClassValue
|
||||
imageClass?: ClassValue
|
||||
imageStyle?: Record<string, any>
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
@@ -141,8 +141,10 @@
|
||||
<CardContainer
|
||||
v-for="n in isLoading ? 12 : 0"
|
||||
:key="`initial-skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
@@ -172,9 +174,11 @@
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
@mouseenter="hoveredTemplate = template.name"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="onLoadWorkflow(template)"
|
||||
@@ -316,8 +320,10 @@
|
||||
<CardContainer
|
||||
v-for="n in isLoadingMore ? 6 : 0"
|
||||
:key="`skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
|
||||
@@ -135,6 +135,7 @@ import type { CSSProperties, Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
@@ -265,7 +266,7 @@ const moreMenuItem = computed(() =>
|
||||
)
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'docs',
|
||||
type: 'item',
|
||||
@@ -305,8 +306,12 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
}
|
||||
]
|
||||
|
||||
// Extension manager - only in non-cloud distributions
|
||||
if (!isCloud) {
|
||||
items.push({
|
||||
key: 'manager',
|
||||
type: 'item',
|
||||
icon: PuzzleIcon,
|
||||
@@ -319,17 +324,20 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'more',
|
||||
type: 'item',
|
||||
icon: '',
|
||||
label: t('helpCenter.more'),
|
||||
visible: hasVisibleMoreItems.value,
|
||||
action: () => {}, // No action for more item
|
||||
items: moreItems.value
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'more',
|
||||
type: 'item',
|
||||
icon: '',
|
||||
label: t('helpCenter.more'),
|
||||
visible: hasVisibleMoreItems.value,
|
||||
action: () => {}, // No action for more item
|
||||
items: moreItems.value
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Utility Functions
|
||||
@@ -420,6 +428,9 @@ const formatReleaseDate = (dateString?: string): string => {
|
||||
}
|
||||
|
||||
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
|
||||
// Hide update buttons in cloud distribution
|
||||
if (isCloud) return false
|
||||
|
||||
return (
|
||||
releaseStore.shouldShowUpdateButton &&
|
||||
release === releaseStore.recentReleases[0]
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<template #content>
|
||||
<!-- Card Examples -->
|
||||
<div :style="gridStyle">
|
||||
<CardContainer v-for="i in 100" :key="i" ratio="square">
|
||||
<CardContainer v-for="i in 100" :key="i" size="regular">
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
* Vue node lifecycle management for LiteGraph integration
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -132,44 +134,57 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
})
|
||||
|
||||
const safeWidgets = node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return reactiveWidgets
|
||||
},
|
||||
set(v) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(
|
||||
() =>
|
||||
node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
|
||||
const nodeType =
|
||||
node.type ||
|
||||
node.constructor?.comfyClass ||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { st, te } from '@/i18n'
|
||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||
import type {
|
||||
IContextMenuOptions,
|
||||
IContextMenuValue,
|
||||
@@ -6,18 +7,42 @@ import type {
|
||||
IWidget
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Add translation for litegraph context menu.
|
||||
*/
|
||||
export const useContextMenuTranslation = () => {
|
||||
// Install compatibility layer BEFORE any extensions load
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||
|
||||
const f = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
const getCanvasCenterMenuOptions = function (
|
||||
this: LGraphCanvas,
|
||||
...args: Parameters<typeof f>
|
||||
) {
|
||||
const res = f.apply(this, args) as ReturnType<typeof f>
|
||||
|
||||
// Add items from new extension API
|
||||
const newApiItems = app.collectCanvasMenuItems(this)
|
||||
for (const item of newApiItems) {
|
||||
// @ts-expect-error - Generic types differ but runtime compatibility is ensured
|
||||
res.push(item)
|
||||
}
|
||||
|
||||
// Add legacy monkey-patched items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
this,
|
||||
...args
|
||||
)
|
||||
for (const item of legacyItems) {
|
||||
// @ts-expect-error - Generic types differ but runtime compatibility is ensured
|
||||
res.push(item)
|
||||
}
|
||||
|
||||
// Translate all items
|
||||
for (const item of res) {
|
||||
if (item?.content) {
|
||||
item.content = st(`contextMenu.${item.content}`, item.content)
|
||||
|
||||
@@ -115,11 +115,14 @@ const onConfigure = function (
|
||||
this.arrange()
|
||||
}
|
||||
})
|
||||
if (serialisedNode.properties?.proxyWidgets)
|
||||
if (serialisedNode.properties?.proxyWidgets) {
|
||||
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
|
||||
serialisedNode.widgets_values?.forEach((v, index) => {
|
||||
if (v !== null) this.widgets[index].value = v
|
||||
})
|
||||
const parsed = parseProxyWidgets(serialisedNode.properties.proxyWidgets)
|
||||
serialisedNode.widgets_values?.forEach((v, index) => {
|
||||
const widget = this.widgets.find((w) => w.name == parsed[index][1])
|
||||
if (v !== null && widget) widget.value = v
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function newProxyWidget(
|
||||
|
||||
@@ -178,8 +178,9 @@ app.registerExtension({
|
||||
audioUIWidget.options.canvasOnly = true
|
||||
|
||||
const onAudioWidgetUpdate = () => {
|
||||
if (typeof audioWidget.value !== 'string') return
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
getResourceURL(...splitFilePath(audioWidget.value as string))
|
||||
getResourceURL(...splitFilePath(audioWidget.value))
|
||||
)
|
||||
}
|
||||
// Initially load default audio file to audioUIWidget.
|
||||
|
||||
115
src/lib/litegraph/src/contextMenuCompat.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { IContextMenuValue } from './interfaces'
|
||||
|
||||
/**
|
||||
* Simple compatibility layer for legacy getCanvasMenuOptions and getNodeMenuOptions monkey patches.
|
||||
* To disable legacy support, set ENABLE_LEGACY_SUPPORT = false
|
||||
*/
|
||||
const ENABLE_LEGACY_SUPPORT = true
|
||||
|
||||
type AnyFunction = (...args: any[]) => any
|
||||
|
||||
class LegacyMenuCompat {
|
||||
private originalMethods = new Map<string, AnyFunction>()
|
||||
private hasWarned = new Set<string>()
|
||||
private currentExtension: string | null = null
|
||||
|
||||
/**
|
||||
* Set the name of the extension that is currently being set up.
|
||||
* This allows us to track which extension is monkey-patching.
|
||||
* @param extensionName The name of the extension
|
||||
*/
|
||||
setCurrentExtension(extensionName: string | null) {
|
||||
this.currentExtension = extensionName
|
||||
}
|
||||
|
||||
/**
|
||||
* Install compatibility layer to detect monkey-patching
|
||||
* @param prototype The prototype to install on (e.g., LGraphCanvas.prototype)
|
||||
* @param methodName The method name to track (e.g., 'getCanvasMenuOptions')
|
||||
*/
|
||||
install(prototype: any, methodName: string) {
|
||||
if (!ENABLE_LEGACY_SUPPORT) return
|
||||
|
||||
// Store original
|
||||
const originalMethod = prototype[methodName]
|
||||
this.originalMethods.set(methodName, originalMethod)
|
||||
|
||||
// Wrap with getter/setter to detect patches
|
||||
let currentImpl = originalMethod
|
||||
|
||||
Object.defineProperty(prototype, methodName, {
|
||||
get() {
|
||||
return currentImpl
|
||||
},
|
||||
set: (newImpl: AnyFunction) => {
|
||||
// Log once per unique function
|
||||
const fnKey = `${methodName}:${newImpl.toString().slice(0, 100)}`
|
||||
if (!this.hasWarned.has(fnKey)) {
|
||||
this.hasWarned.add(fnKey)
|
||||
|
||||
const extensionInfo = this.currentExtension
|
||||
? ` (Extension: "${this.currentExtension}")`
|
||||
: ''
|
||||
|
||||
console.warn(
|
||||
`%c[DEPRECATED]%c Monkey-patching ${methodName} is deprecated.${extensionInfo}\n` +
|
||||
`Please use the new context menu API instead.\n\n` +
|
||||
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
|
||||
'color: orange; font-weight: bold',
|
||||
'color: inherit'
|
||||
)
|
||||
}
|
||||
currentImpl = newImpl
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract items that were added by legacy monkey patches
|
||||
* @param methodName The method name that was monkey-patched
|
||||
* @param context The context to call methods with (e.g., canvas instance)
|
||||
* @param args Arguments to pass to the methods
|
||||
* @returns Array of menu items added by monkey patches
|
||||
*/
|
||||
extractLegacyItems(
|
||||
methodName: string,
|
||||
context: any,
|
||||
...args: any[]
|
||||
): IContextMenuValue[] {
|
||||
if (!ENABLE_LEGACY_SUPPORT) return []
|
||||
|
||||
const originalMethod = this.originalMethods.get(methodName)
|
||||
if (!originalMethod) return []
|
||||
|
||||
try {
|
||||
// Get baseline from original
|
||||
const originalItems = originalMethod.apply(context, args) as
|
||||
| IContextMenuValue[]
|
||||
| undefined
|
||||
if (!originalItems) return []
|
||||
|
||||
// Get current method (potentially patched)
|
||||
const currentMethod = context.constructor.prototype[methodName]
|
||||
if (!currentMethod || currentMethod === originalMethod) return []
|
||||
|
||||
// Get items from patched method
|
||||
const patchedItems = currentMethod.apply(context, args) as
|
||||
| IContextMenuValue[]
|
||||
| undefined
|
||||
if (!patchedItems) return []
|
||||
|
||||
// Return items that were added (simple slice approach)
|
||||
if (patchedItems.length > originalItems.length) {
|
||||
return patchedItems.slice(originalItems.length)
|
||||
}
|
||||
|
||||
return []
|
||||
} catch (e) {
|
||||
console.error('[Context Menu Compat] Failed to extract legacy items:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const legacyMenuCompat = new LegacyMenuCompat()
|
||||
@@ -9,8 +9,14 @@
|
||||
"downloadImage": "Download image",
|
||||
"downloadVideo": "Download video",
|
||||
"editOrMaskImage": "Edit or mask image",
|
||||
"editImage": "Edit image",
|
||||
"deleteImage": "Delete image",
|
||||
"deleteAudioFile": "Delete audio file",
|
||||
"removeImage": "Remove image",
|
||||
"removeVideo": "Remove video",
|
||||
"chart": "Chart",
|
||||
"chartLowercase": "chart",
|
||||
"file": "file",
|
||||
"viewImageOfTotal": "View image {index} of {total}",
|
||||
"viewVideoOfTotal": "View video {index} of {total}",
|
||||
"imagePreview": "Image preview - Use arrow keys to navigate between images",
|
||||
|
||||
29
src/platform/assets/components/Media3DBottom.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
16
src/platform/assets/components/Media3DTop.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--box] text-3xl text-zinc-600 dark-theme:text-zinc-200"
|
||||
/>
|
||||
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
|
||||
$t('3D Model')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
50
src/platform/assets/components/MediaAssetActions.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<IconGroup>
|
||||
<IconButton size="sm" @click="handleDelete">
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
<IconButton size="sm" @click="handleDownload">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</IconButton>
|
||||
<MoreButton
|
||||
size="sm"
|
||||
@menu-opened="emit('menuStateChanged', true)"
|
||||
@menu-closed="emit('menuStateChanged', false)"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetMoreMenu :close="close" />
|
||||
</template>
|
||||
</MoreButton>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
menuStateChanged: [isOpen: boolean]
|
||||
}>()
|
||||
|
||||
const { asset } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const handleDelete = () => {
|
||||
if (asset.value) {
|
||||
actions.deleteAsset(asset.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<div class="h-[1px] bg-neutral-200 dark-theme:bg-neutral-700"></div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
318
src/platform/assets/components/MediaAssetCard.stories.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetCard from './MediaAssetCard.vue'
|
||||
|
||||
const meta: Meta<typeof MediaAssetCard> = {
|
||||
title: 'AssetLibrary/MediaAssetCard',
|
||||
component: MediaAssetCard,
|
||||
argTypes: {
|
||||
context: {
|
||||
control: 'select',
|
||||
options: ['input', 'output']
|
||||
},
|
||||
loading: {
|
||||
control: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Public sample media URLs
|
||||
const SAMPLE_MEDIA = {
|
||||
image1: 'https://i.imgur.com/OB0y6MR.jpg',
|
||||
image2: 'https://i.imgur.com/CzXTtJV.jpg',
|
||||
image3: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
|
||||
video:
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||
videoThumbnail:
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg',
|
||||
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
|
||||
}
|
||||
|
||||
const sampleAsset: AssetMeta = {
|
||||
id: 'asset-1',
|
||||
name: 'sample-image.png',
|
||||
kind: 'image',
|
||||
duration: 3345,
|
||||
size: 2048576,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image1,
|
||||
dimensions: {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
|
||||
export const ImageAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'output', outputCount: 3 },
|
||||
asset: sampleAsset,
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
|
||||
export const VideoAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-2',
|
||||
name: 'Big_Buck_Bunny.mp4',
|
||||
kind: 'video',
|
||||
size: 10485760,
|
||||
duration: 13425,
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
|
||||
src: SAMPLE_MEDIA.video, // Actual video file
|
||||
dimensions: {
|
||||
width: 1280,
|
||||
height: 720
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Model3DAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-3',
|
||||
name: 'Asset-3d-model.glb',
|
||||
kind: '3D',
|
||||
size: 7340032,
|
||||
src: '',
|
||||
dimensions: undefined,
|
||||
duration: 18023
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const AudioAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-3',
|
||||
name: 'SoundHelix-Song.mp3',
|
||||
kind: 'audio',
|
||||
size: 5242880,
|
||||
src: SAMPLE_MEDIA.audio,
|
||||
dimensions: undefined,
|
||||
duration: 23180
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoadingState: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: sampleAsset,
|
||||
loading: true
|
||||
}
|
||||
}
|
||||
|
||||
export const LongFileName: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SelectedState: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'output', outputCount: 2 },
|
||||
asset: sampleAsset,
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WebMVideo: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
id: 'asset-webm',
|
||||
name: 'animated-clip.webm',
|
||||
kind: 'video',
|
||||
size: 3145728,
|
||||
created_at: Date.now().toString(),
|
||||
preview_url: SAMPLE_MEDIA.image1, // Poster image
|
||||
src: 'https://www.w3schools.com/html/movie.mp4', // Actual video
|
||||
duration: 620,
|
||||
dimensions: {
|
||||
width: 640,
|
||||
height: 360
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GifAnimation: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
id: 'asset-gif',
|
||||
name: 'animation.gif',
|
||||
kind: 'image',
|
||||
size: 1572864,
|
||||
duration: 1345,
|
||||
created_at: Date.now().toString(),
|
||||
src: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
|
||||
dimensions: {
|
||||
width: 480,
|
||||
height: 270
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GridLayout: Story = {
|
||||
render: () => ({
|
||||
components: { MediaAssetCard },
|
||||
setup() {
|
||||
const assets: AssetMeta[] = [
|
||||
{
|
||||
id: 'grid-1',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image1,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-2',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image2,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-3',
|
||||
name: 'video-file.mp4',
|
||||
kind: 'video',
|
||||
size: 10485760,
|
||||
duration: 13425,
|
||||
created_at: Date.now().toString(),
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
|
||||
src: SAMPLE_MEDIA.video, // Actual video
|
||||
dimensions: { width: 1280, height: 720 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-4',
|
||||
name: 'audio-file.mp3',
|
||||
kind: 'audio',
|
||||
size: 5242880,
|
||||
duration: 180,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.audio,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-5',
|
||||
name: 'animation.gif',
|
||||
kind: 'image',
|
||||
size: 3145728,
|
||||
duration: 1345,
|
||||
created_at: Date.now().toString(),
|
||||
src: 'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
|
||||
dimensions: { width: 480, height: 360 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-6',
|
||||
name: 'Asset-3d-model.glb',
|
||||
kind: '3D',
|
||||
size: 7340032,
|
||||
src: '',
|
||||
dimensions: undefined,
|
||||
duration: 18023,
|
||||
created_at: Date.now().toString(),
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-7',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image3,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
return { assets }
|
||||
},
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; padding: 16px;">
|
||||
<MediaAssetCard
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
:context="{ type: Math.random() > 0.5 ? 'input' : 'output', outputCount: Math.floor(Math.random() * 5) }"
|
||||
:asset="asset"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
233
src/platform/assets/components/MediaAssetCard.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<CardContainer
|
||||
ref="cardContainerRef"
|
||||
role="button"
|
||||
:aria-label="
|
||||
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
|
||||
"
|
||||
:tabindex="loading ? -1 : 0"
|
||||
size="mini"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:class="containerClasses"
|
||||
@click="handleCardClick"
|
||||
@keydown.enter="handleCardClick"
|
||||
@keydown.space.prevent="handleCardClick"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop
|
||||
ratio="square"
|
||||
:bottom-left-class="durationChipClasses"
|
||||
:bottom-right-class="durationChipClasses"
|
||||
>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div
|
||||
class="h-full w-full animate-pulse rounded-lg bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<component
|
||||
:is="getTopComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
@view="actions.viewAsset(asset!.id)"
|
||||
@download="actions.downloadAsset(asset!.id)"
|
||||
@play="actions.playAsset(asset!.id)"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Actions overlay (top-left) - show on hover or when menu is open, but not when video is playing -->
|
||||
<template v-if="showActionsOverlay" #top-left>
|
||||
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
|
||||
</template>
|
||||
|
||||
<!-- Zoom button (top-right) - show on hover, but not when video is playing -->
|
||||
<template v-if="showZoomOverlay" #top-right>
|
||||
<IconButton size="sm" @click="actions.viewAsset(asset!.id)">
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<!-- Duration/Format chips (bottom-left) - hide when video is playing -->
|
||||
<template v-if="showDurationChips" #bottom-left>
|
||||
<SquareChip variant="light" :label="formattedDuration" />
|
||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||
</template>
|
||||
|
||||
<!-- Output count (bottom-right) - hide when video is playing -->
|
||||
<template v-if="showOutputCount" #bottom-right>
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:label="context?.outputCount?.toString() ?? '0'"
|
||||
@click="actions.openMoreOutputs(asset?.id || '')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div class="flex flex-col items-center justify-between gap-1">
|
||||
<div
|
||||
class="h-4 w-2/3 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
<div
|
||||
class="h-3 w-1/2 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<component
|
||||
:is="getBottomComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
/>
|
||||
</template>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import { formatDuration } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type {
|
||||
AssetContext,
|
||||
AssetMeta,
|
||||
MediaKind
|
||||
} from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetActions from './MediaAssetActions.vue'
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
|
||||
},
|
||||
bottom: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
|
||||
}
|
||||
}
|
||||
|
||||
function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
}
|
||||
|
||||
function getBottomComponent(kind: MediaKind) {
|
||||
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
|
||||
}
|
||||
|
||||
const { context, asset, loading, selected } = defineProps<{
|
||||
context: AssetContext
|
||||
asset?: AssetMeta
|
||||
loading?: boolean
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const cardContainerRef = ref<HTMLElement>()
|
||||
|
||||
const isVideoPlaying = ref(false)
|
||||
const isMenuOpen = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
|
||||
const isHovered = useElementHover(cardContainerRef)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
provide(MediaAssetKey, {
|
||||
asset: toRef(() => asset),
|
||||
context: toRef(() => context),
|
||||
isVideoPlaying,
|
||||
showVideoControls
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
return cn(
|
||||
'gap-1',
|
||||
selected
|
||||
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
|
||||
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
|
||||
)
|
||||
})
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
if (!asset?.duration) return ''
|
||||
return formatDuration(asset.duration)
|
||||
})
|
||||
|
||||
const fileFormat = computed(() => {
|
||||
if (!asset?.name) return ''
|
||||
const parts = asset.name.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
|
||||
})
|
||||
|
||||
const durationChipClasses = computed(() => {
|
||||
if (asset?.kind === 'audio') {
|
||||
return '-translate-y-11'
|
||||
}
|
||||
if (asset?.kind === 'video' && showVideoControls.value) {
|
||||
return '-translate-y-16'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const showHoverActions = computed(() => {
|
||||
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
|
||||
})
|
||||
|
||||
const showZoomButton = computed(() => {
|
||||
return asset?.kind === 'image' || asset?.kind === '3D'
|
||||
})
|
||||
|
||||
const showActionsOverlay = computed(() => {
|
||||
return showHoverActions.value && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const showZoomOverlay = computed(() => {
|
||||
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const showDurationChips = computed(() => {
|
||||
return !loading && asset?.duration && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const showOutputCount = computed(() => {
|
||||
return !loading && context?.outputCount && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (asset) {
|
||||
actions.selectAsset(asset)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
158
src/platform/assets/components/MediaAssetMoreMenu.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Inspect asset"
|
||||
@click="handleInspect"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Add to current workflow"
|
||||
@click="handleAddToWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[comfy--node] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Download"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider />
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Open as workflow in new tab"
|
||||
@click="handleOpenWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[comfy--workflow] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Export workflow"
|
||||
@click="handleExportWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--file-output] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Copy job ID"
|
||||
@click="handleCopyJobId"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider />
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Delete"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
||||
|
||||
const { close } = defineProps<{
|
||||
close: () => void
|
||||
}>()
|
||||
|
||||
const { asset, context } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const showWorkflowOptions = computed(() => {
|
||||
return context.value.type
|
||||
})
|
||||
|
||||
const handleInspect = () => {
|
||||
if (asset.value) {
|
||||
actions.viewAsset(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleAddToWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.addWorkflow(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleOpenWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.openWorkflow(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleExportWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.exportWorkflow(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleCopyJobId = () => {
|
||||
if (asset.value) {
|
||||
actions.copyAssetUrl(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (asset.value) {
|
||||
actions.deleteAsset(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
30
src/platform/assets/components/MediaAudioBottom.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
28
src/platform/assets/components/MediaAudioTop.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--music] text-3xl text-zinc-600 dark-theme:text-zinc-200"
|
||||
/>
|
||||
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
|
||||
$t('Audio')
|
||||
}}</span>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
class="absolute bottom-0 left-0 w-full p-2"
|
||||
:src="asset.src"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
</script>
|
||||
30
src/platform/assets/components/MediaImageBottom.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
27
src/platform/assets/components/MediaImageTop.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<LazyImage
|
||||
v-if="asset.src"
|
||||
:src="asset.src"
|
||||
:alt="asset.name"
|
||||
:container-class="'aspect-square'"
|
||||
:image-class="'w-full h-full object-cover'"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i class="pi pi-image text-3xl text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
</script>
|
||||
30
src/platform/assets/components/MediaVideoBottom.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
57
src/platform/assets/components/MediaVideoTop.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden rounded bg-black"
|
||||
@mouseenter="showControls = true"
|
||||
@mouseleave="showControls = false"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:controls="showControls"
|
||||
preload="none"
|
||||
:poster="asset.preview_url"
|
||||
class="relative h-full w-full object-contain"
|
||||
@click.stop
|
||||
@play="onVideoPlay"
|
||||
@pause="onVideoPause"
|
||||
>
|
||||
<source :src="asset.src || ''" />
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [assetId: string]
|
||||
videoPlayingStateChanged: [isPlaying: boolean]
|
||||
videoControlsChanged: [showControls: boolean]
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
const showControls = ref(true)
|
||||
|
||||
watch(showControls, (controlsVisible) => {
|
||||
emit('videoControlsChanged', controlsVisible)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
emit('videoControlsChanged', showControls.value)
|
||||
})
|
||||
|
||||
const onVideoPlay = () => {
|
||||
showControls.value = true
|
||||
emit('videoPlayingStateChanged', true)
|
||||
}
|
||||
|
||||
const onVideoPause = () => {
|
||||
emit('videoPlayingStateChanged', false)
|
||||
}
|
||||
</script>
|
||||
62
src/platform/assets/composables/useMediaAssetActions.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable no-console */
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const selectAsset = (asset: AssetMeta) => {
|
||||
console.log('Asset selected:', asset)
|
||||
}
|
||||
|
||||
const viewAsset = (assetId: string) => {
|
||||
console.log('Viewing asset:', assetId)
|
||||
}
|
||||
|
||||
const downloadAsset = (assetId: string) => {
|
||||
console.log('Downloading asset:', assetId)
|
||||
}
|
||||
|
||||
const deleteAsset = (assetId: string) => {
|
||||
console.log('Deleting asset:', assetId)
|
||||
}
|
||||
|
||||
const playAsset = (assetId: string) => {
|
||||
console.log('Playing asset:', assetId)
|
||||
}
|
||||
|
||||
const copyAssetUrl = (assetId: string) => {
|
||||
console.log('Copy asset URL:', assetId)
|
||||
}
|
||||
|
||||
const copyJobId = (jobId: string) => {
|
||||
console.log('Copy job ID:', jobId)
|
||||
}
|
||||
|
||||
const addWorkflow = (assetId: string) => {
|
||||
console.log('Adding asset to workflow:', assetId)
|
||||
}
|
||||
|
||||
const openWorkflow = (assetId: string) => {
|
||||
console.log('Opening workflow for asset:', assetId)
|
||||
}
|
||||
|
||||
const exportWorkflow = (assetId: string) => {
|
||||
console.log('Exporting workflow for asset:', assetId)
|
||||
}
|
||||
|
||||
const openMoreOutputs = (assetId: string) => {
|
||||
console.log('Opening more outputs for asset:', assetId)
|
||||
}
|
||||
|
||||
return {
|
||||
selectAsset,
|
||||
viewAsset,
|
||||
downloadAsset,
|
||||
deleteAsset,
|
||||
playAsset,
|
||||
copyAssetUrl,
|
||||
copyJobId,
|
||||
addWorkflow,
|
||||
openWorkflow,
|
||||
exportWorkflow,
|
||||
openMoreOutputs
|
||||
}
|
||||
}
|
||||
46
src/platform/assets/schemas/mediaAssetSchema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { assetItemSchema } from './assetSchema'
|
||||
|
||||
const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D'])
|
||||
export type MediaKind = z.infer<typeof zMediaKindSchema>
|
||||
|
||||
const zDimensionsSchema = z.object({
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive()
|
||||
})
|
||||
|
||||
// Extend the base asset schema with media-specific fields
|
||||
const zMediaAssetDisplayItemSchema = assetItemSchema.extend({
|
||||
// New required fields
|
||||
kind: zMediaKindSchema,
|
||||
src: z.string().url(),
|
||||
|
||||
// New optional fields
|
||||
duration: z.number().nonnegative().optional(),
|
||||
dimensions: zDimensionsSchema.optional(),
|
||||
jobId: z.string().optional(),
|
||||
isMulti: z.boolean().optional()
|
||||
})
|
||||
|
||||
// Asset context schema
|
||||
const zAssetContextSchema = z.object({
|
||||
type: z.enum(['input', 'output']),
|
||||
outputCount: z.number().positive().optional() // Only for output context
|
||||
})
|
||||
|
||||
// Export the inferred types
|
||||
export type AssetMeta = z.infer<typeof zMediaAssetDisplayItemSchema>
|
||||
export type AssetContext = z.infer<typeof zAssetContextSchema>
|
||||
|
||||
// Injection key for MediaAsset provide/inject pattern
|
||||
interface MediaAssetProviderValue {
|
||||
asset: Ref<AssetMeta | undefined>
|
||||
context: Ref<AssetContext>
|
||||
isVideoPlaying: Ref<boolean>
|
||||
showVideoControls: Ref<boolean>
|
||||
}
|
||||
|
||||
export const MediaAssetKey: InjectionKey<MediaAssetProviderValue> =
|
||||
Symbol('mediaAsset')
|
||||
18
src/platform/distribution/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Distribution types and compile-time constants for managing
|
||||
* multi-distribution builds (Desktop, Localhost, Cloud)
|
||||
*/
|
||||
|
||||
type Distribution = 'desktop' | 'localhost' | 'cloud'
|
||||
|
||||
declare global {
|
||||
const __DISTRIBUTION__: Distribution
|
||||
}
|
||||
|
||||
/** Current distribution - replaced at compile time */
|
||||
const DISTRIBUTION: Distribution = __DISTRIBUTION__
|
||||
|
||||
/** Distribution type checks */
|
||||
// const isDesktop = DISTRIBUTION === 'desktop'
|
||||
// const isLocalhost = DISTRIBUTION === 'localhost'
|
||||
export const isCloud = DISTRIBUTION === 'cloud'
|
||||
@@ -5,6 +5,7 @@ import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { app } from '@/scripts/app'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
// Keep one adapter per graph so rendering and interaction share state.
|
||||
const adapterByGraph = new WeakMap<LGraph, LinkConnectorAdapter>()
|
||||
@@ -130,6 +131,15 @@ export class LinkConnectorAdapter {
|
||||
|
||||
/** Drops moving links onto the canvas (no target). */
|
||||
dropOnCanvas(event: CanvasPointerEvent): void {
|
||||
//Add extra check for connection to subgraphInput/subgraphOutput
|
||||
if (isSubgraph(this.network)) {
|
||||
const { canvasX, canvasY } = event
|
||||
const ioNode = this.network.getIoNodeOnPos?.(canvasX, canvasY)
|
||||
if (ioNode) {
|
||||
this.linkConnector.dropOnIoNode(ioNode, event)
|
||||
return
|
||||
}
|
||||
}
|
||||
this.linkConnector.dropOnNothing(event)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<SlotConnectionDot
|
||||
ref="connectionDotRef"
|
||||
:color="slotColor"
|
||||
:class="cn('-translate-x-1/2', errorClassesDot)"
|
||||
:class="cn('-translate-x-1/2', 'w-3', errorClassesDot)"
|
||||
@pointerdown="onPointerDown"
|
||||
/>
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
outlineClass,
|
||||
{
|
||||
'animate-pulse': executing,
|
||||
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
||||
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
||||
bypassed,
|
||||
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
|
||||
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
|
||||
muted,
|
||||
'will-change-transform': isDragging
|
||||
},
|
||||
@@ -174,9 +174,6 @@ const {
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
|
||||
// Inject transform state for coordinate conversion
|
||||
const transformState = inject(TransformStateKey)
|
||||
if (!transformState) {
|
||||
throw new Error(
|
||||
@@ -184,16 +181,13 @@ if (!transformState) {
|
||||
)
|
||||
}
|
||||
|
||||
// Computed selection state - only this node re-evaluates when its selection changes
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const isSelected = computed(() => {
|
||||
return selectedNodeIds.value.has(nodeData.id)
|
||||
})
|
||||
|
||||
// Use execution state composable
|
||||
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
|
||||
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
|
||||
|
||||
// Direct access to execution store for error state
|
||||
const executionStore = useExecutionStore()
|
||||
const hasExecutionError = computed(
|
||||
() => executionStore.lastExecutionErrorNodeId === nodeData.id
|
||||
@@ -225,9 +219,16 @@ const nodeBodyBackgroundColor = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const nodeOpacity = computed(
|
||||
() => useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||
)
|
||||
const nodeOpacity = computed(() => {
|
||||
const globalOpacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||
|
||||
// For muted/bypassed nodes, apply the 0.5 multiplier on top of global opacity
|
||||
if (bypassed.value || muted.value) {
|
||||
return globalOpacity * 0.5
|
||||
}
|
||||
|
||||
return globalOpacity
|
||||
})
|
||||
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
|
||||
@@ -319,10 +320,9 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
)
|
||||
|
||||
const borderClass = computed(() => {
|
||||
return (
|
||||
(hasAnyError.value && 'border-error') ||
|
||||
(executing.value && 'border-node-executing')
|
||||
)
|
||||
if (hasAnyError.value) return 'border-node-stroke-error'
|
||||
if (executing.value) return 'border-node-stroke-executing'
|
||||
return 'border-node-stroke'
|
||||
})
|
||||
|
||||
const outlineClass = computed(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets flex flex-col gap-2 pr-4',
|
||||
'lg-node-widgets flex flex-col gap-2 pr-3',
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<SlotConnectionDot
|
||||
ref="connectionDotRef"
|
||||
:color="slotColor"
|
||||
class="translate-x-1/2"
|
||||
class="w-3 translate-x-1/2"
|
||||
@pointerdown="onPointerDown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,14 +17,17 @@ export const useNodeExecutionState = (
|
||||
nodeLocatorIdMaybe: MaybeRefOrGetter<string | undefined>
|
||||
) => {
|
||||
const locatorId = computed(() => toValue(nodeLocatorIdMaybe) ?? '')
|
||||
const { nodeLocationProgressStates } = storeToRefs(useExecutionStore())
|
||||
const { nodeLocationProgressStates, isIdle } =
|
||||
storeToRefs(useExecutionStore())
|
||||
|
||||
const progressState = computed(() => {
|
||||
const id = locatorId.value
|
||||
return id ? nodeLocationProgressStates.value[id] : undefined
|
||||
})
|
||||
|
||||
const executing = computed(() => progressState.value?.state === 'running')
|
||||
const executing = computed(
|
||||
() => !isIdle.value && progressState.value?.state === 'running'
|
||||
)
|
||||
|
||||
const progress = computed(() => {
|
||||
const state = progressState.value
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Button v-bind="filteredProps" size="small" @click="handleClick" />
|
||||
<Button
|
||||
v-bind="filteredProps"
|
||||
:aria-label="widget.name || widget.label"
|
||||
size="small"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<div
|
||||
class="max-h-[48rem] rounded border border-gray-300 p-4 dark-theme:border-gray-600"
|
||||
>
|
||||
<Chart :type="chartType" :data="chartData" :options="chartOptions" />
|
||||
<Chart
|
||||
:type="chartType"
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
:aria-label="`${widget.name || $t('g.chart')} - ${chartType} ${$t('g.chartLowercase')}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
class="h-4 w-8 overflow-hidden !rounded-full border-none"
|
||||
:aria-label="widget.name"
|
||||
:pt="{
|
||||
preview: '!w-full !h-full !border-none'
|
||||
}"
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
>
|
||||
<!-- Edit button -->
|
||||
<button
|
||||
:aria-label="$t('g.editImage')"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
|
||||
style="background-color: #262729"
|
||||
@click="handleEdit"
|
||||
@@ -58,6 +59,7 @@
|
||||
</button>
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
:aria-label="$t('g.deleteImage')"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
|
||||
style="background-color: #262729"
|
||||
@click="clearFile"
|
||||
@@ -128,6 +130,7 @@
|
||||
<div class="flex gap-1">
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
:aria-label="$t('g.deleteAudioFile')"
|
||||
class="flex h-8 w-8 items-center justify-center rounded border-none transition-all duration-150 hover:bg-[#262729] focus:outline-none"
|
||||
@click="clearFile"
|
||||
>
|
||||
@@ -168,6 +171,7 @@
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="widget.options?.accept"
|
||||
:aria-label="`${$t('g.upload')} ${widget.name || $t('g.file')}`"
|
||||
:multiple="false"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
@@ -65,17 +66,24 @@ const useGrouping = computed(() => {
|
||||
|
||||
// Check if increment/decrement buttons should be disabled due to precision limits
|
||||
const buttonsDisabled = computed(() => {
|
||||
const currentValue = localValue.value || 0
|
||||
return !Number.isSafeInteger(currentValue)
|
||||
const currentValue = localValue.value ?? 0
|
||||
return (
|
||||
!Number.isFinite(currentValue) ||
|
||||
Math.abs(currentValue) > Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
})
|
||||
|
||||
// Tooltip message for disabled buttons
|
||||
const buttonTooltip = computed(() => {
|
||||
if (buttonsDisabled.value) {
|
||||
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const inputNumberPt = useNumberWidgetButtonPt({
|
||||
roundedLeft: true,
|
||||
roundedRight: true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -84,19 +92,14 @@ const buttonTooltip = computed(() => {
|
||||
<InputNumber
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:show-buttons="!buttonsDisabled"
|
||||
button-layout="horizontal"
|
||||
size="small"
|
||||
:step="stepValue"
|
||||
:use-grouping="useGrouping"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:aria-label="widget.name"
|
||||
:pt="{
|
||||
incrementButton:
|
||||
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',
|
||||
decrementButton:
|
||||
'!rounded-l-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40'
|
||||
}"
|
||||
:show-buttons="!buttonsDisabled"
|
||||
:pt="inputNumberPt"
|
||||
@update:model-value="onChange"
|
||||
>
|
||||
<template #incrementicon>
|
||||
@@ -113,11 +116,16 @@ const buttonTooltip = computed(() => {
|
||||
<style scoped>
|
||||
:deep(.p-inputnumber-input) {
|
||||
background-color: transparent;
|
||||
border: 1px solid color-mix(in oklab, #d4d4d8 10%, transparent);
|
||||
border: 1px solid var(--node-stroke);
|
||||
border-top: transparent;
|
||||
border-bottom: transparent;
|
||||
height: 1.625rem;
|
||||
margin: 1px 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.p-inputnumber-button.p-disabled .pi),
|
||||
:deep(.p-inputnumber-button.p-disabled .p-icon) {
|
||||
color: var(--color-node-icon-disabled) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
v-bind="filteredProps"
|
||||
class="flex-grow text-xs"
|
||||
:step="stepValue"
|
||||
:aria-label="widget.name"
|
||||
@update:model-value="updateLocalValue"
|
||||
/>
|
||||
<InputNumber
|
||||
@@ -19,9 +20,12 @@
|
||||
:step="stepValue"
|
||||
:min-fraction-digits="precision"
|
||||
:max-fraction-digits="precision"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
pt:pc-input-text:root="min-w-full bg-transparent border-none text-center"
|
||||
class="w-16"
|
||||
:show-buttons="!buttonsDisabled"
|
||||
:pt="sliderNumberPt"
|
||||
@update:model-value="handleNumberInputUpdate"
|
||||
/>
|
||||
</div>
|
||||
@@ -41,6 +45,7 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
@@ -101,4 +106,24 @@ const stepValue = computed(() => {
|
||||
// precision 1 → 0.1, precision 2 → 0.01, etc.
|
||||
return 1 / Math.pow(10, precision.value)
|
||||
})
|
||||
|
||||
const buttonsDisabled = computed(() => {
|
||||
const currentValue = localValue.value ?? 0
|
||||
return (
|
||||
!Number.isFinite(currentValue) ||
|
||||
Math.abs(currentValue) > Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
})
|
||||
|
||||
const sliderNumberPt = useNumberWidgetButtonPt({
|
||||
roundedLeft: true,
|
||||
roundedRight: true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputnumber-button.p-disabled .pi),
|
||||
:deep(.p-inputnumber-button.p-disabled .p-icon) {
|
||||
color: var(--color-node-icon-disabled) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs py-2 px-4')"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:options="multiSelectOptions"
|
||||
v-bind="combinedProps"
|
||||
class="w-full text-xs"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
display="chip"
|
||||
:pt="{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
v-bind="filteredProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs lod-toggle')"
|
||||
:placeholder="placeholder || widget.name || ''"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
rows="3"
|
||||
data-capture-wheel="true"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<ToggleSwitch
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:aria-label="widget.name"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-model="localValue"
|
||||
v-bind="combinedProps"
|
||||
class="w-full text-xs"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
|
||||
@@ -14,10 +14,10 @@ defineProps<{
|
||||
<div
|
||||
class="flex h-[30px] items-center justify-between gap-2 overscroll-contain"
|
||||
>
|
||||
<div class="relative mr-4 flex h-6 items-center">
|
||||
<div class="relative flex h-6 items-center">
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="lod-toggle w-20 flex-1 truncate text-sm font-normal text-node-component-slot-text"
|
||||
class="lod-toggle w-28 flex-1 truncate text-sm font-normal text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
|
||||
@@ -6,7 +6,7 @@ export const WidgetInputBaseClass = cn([
|
||||
'text-node-component-widget-input',
|
||||
// Outline
|
||||
'border-none',
|
||||
'outline outline-offset-[-1px] outline-zinc-300/10',
|
||||
'outline outline-offset-[-1px] outline-node-stroke',
|
||||
// Rounded
|
||||
'rounded-lg',
|
||||
// Hover
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const sharedButtonClasses =
|
||||
'!inline-flex !items-center !justify-center !border-0 !bg-transparent text-inherit transition-colors duration-150 ease-in-out ' +
|
||||
'hover:!bg-[var(--color-node-component-surface-hovered)] active:!bg-[var(--color-node-component-surface-selected)] ' +
|
||||
'disabled:!bg-[var(--color-node-component-disabled)] disabled:!text-[var(--color-node-icon-disabled)] disabled:cursor-not-allowed'
|
||||
|
||||
export function useNumberWidgetButtonPt(options?: {
|
||||
roundedLeft?: boolean
|
||||
roundedRight?: boolean
|
||||
}) {
|
||||
const { roundedLeft = false, roundedRight = false } = options ?? {}
|
||||
|
||||
const increment = `${sharedButtonClasses}${roundedRight ? ' !rounded-r-lg' : ''}`
|
||||
const decrement = `${sharedButtonClasses}${roundedLeft ? ' !rounded-l-lg' : ''}`
|
||||
|
||||
return {
|
||||
incrementButton: {
|
||||
class: increment.trim()
|
||||
},
|
||||
decrementButton: {
|
||||
class: decrement.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -122,8 +123,25 @@ export const useExtensionService = () => {
|
||||
extensionStore.enabledExtensions.map(async (ext) => {
|
||||
if (method in ext) {
|
||||
try {
|
||||
return await ext[method](...args, app)
|
||||
// Set current extension name for legacy compatibility tracking
|
||||
if (method === 'setup') {
|
||||
legacyMenuCompat.setCurrentExtension(ext.name)
|
||||
}
|
||||
|
||||
const result = await ext[method](...args, app)
|
||||
|
||||
// Clear current extension after setup
|
||||
if (method === 'setup') {
|
||||
legacyMenuCompat.setCurrentExtension(null)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
// Clear current extension on error too
|
||||
if (method === 'setup') {
|
||||
legacyMenuCompat.setCurrentExtension(null)
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Error calling extension '${ext.name}' method '${method}'`,
|
||||
{ error },
|
||||
|
||||
@@ -239,6 +239,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.addEventListener('execution_start', handleExecutionStart)
|
||||
api.addEventListener('execution_cached', handleExecutionCached)
|
||||
api.addEventListener('execution_interrupted', handleExecutionInterrupted)
|
||||
api.addEventListener('execution_success', handleExecutionSuccess)
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
api.addEventListener('executing', handleExecuting)
|
||||
api.addEventListener('progress', handleProgress)
|
||||
@@ -253,6 +254,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.removeEventListener('execution_start', handleExecutionStart)
|
||||
api.removeEventListener('execution_cached', handleExecutionCached)
|
||||
api.removeEventListener('execution_interrupted', handleExecutionInterrupted)
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess)
|
||||
api.removeEventListener('executed', handleExecuted)
|
||||
api.removeEventListener('executing', handleExecuting)
|
||||
api.removeEventListener('progress', handleProgress)
|
||||
@@ -277,7 +279,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionInterrupted() {
|
||||
nodeProgressStates.value = {}
|
||||
resetExecutionState()
|
||||
}
|
||||
|
||||
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
|
||||
@@ -285,6 +287,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
activePrompt.value.nodes[e.detail.node] = true
|
||||
}
|
||||
|
||||
function handleExecutionSuccess() {
|
||||
resetExecutionState()
|
||||
}
|
||||
|
||||
function handleExecuting(e: CustomEvent<NodeId | null>): void {
|
||||
// Clear the current node progress when a new node starts executing
|
||||
_executingNodeProgress.value = null
|
||||
@@ -346,6 +352,19 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
|
||||
lastExecutionError.value = e.detail
|
||||
resetExecutionState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset execution-related state after a run completes or is stopped.
|
||||
*/
|
||||
function resetExecutionState() {
|
||||
nodeProgressStates.value = {}
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
}
|
||||
activePromptId.value = null
|
||||
_executingNodeProgress.value = null
|
||||
}
|
||||
|
||||
function getNodeIdIfExecuting(nodeId: string | number) {
|
||||
|
||||
65
tests-ui/tests/extensions/contextMenuExtensionName.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/**
|
||||
* Test that demonstrates the extension name appearing in deprecation warnings
|
||||
*/
|
||||
describe('Context Menu Extension Name in Warnings', () => {
|
||||
it('should include extension name in deprecation warning', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
// Install compatibility layer
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||
|
||||
// Simulate what happens during extension setup
|
||||
legacyMenuCompat.setCurrentExtension('MyCustomExtension')
|
||||
|
||||
// Extension monkey-patches the method
|
||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original as any).apply(this, args)
|
||||
items.push({ content: 'My Custom Menu Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Clear extension (happens after setup completes)
|
||||
legacyMenuCompat.setCurrentExtension(null)
|
||||
|
||||
// Verify the warning includes the extension name
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
const warningMessage = warnSpy.mock.calls[0][0]
|
||||
|
||||
expect(warningMessage).toContain('[DEPRECATED]')
|
||||
expect(warningMessage).toContain('getCanvasMenuOptions')
|
||||
expect(warningMessage).toContain('"MyCustomExtension"')
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should not include extension name if not set', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
// Install compatibility layer
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')
|
||||
|
||||
// Extension monkey-patches without setting current extension
|
||||
const original = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (...args: any[]) {
|
||||
const items = (original as any).apply(this, args)
|
||||
items.push({ content: 'My Node Menu Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Verify the warning does NOT include extension info
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
const warningMessage = warnSpy.mock.calls[0][0]
|
||||
|
||||
expect(warningMessage).toContain('[DEPRECATED]')
|
||||
expect(warningMessage).toContain('getNodeMenuOptions')
|
||||
expect(warningMessage).not.toContain('Extension:')
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
219
tests-ui/tests/litegraph/core/contextMenuCompat.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
describe('contextMenuCompat', () => {
|
||||
let originalGetCanvasMenuOptions: typeof LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
let mockCanvas: LGraphCanvas
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original method
|
||||
originalGetCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
|
||||
// Create mock canvas
|
||||
mockCanvas = {
|
||||
constructor: {
|
||||
prototype: LGraphCanvas.prototype
|
||||
}
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
// Clear console warnings
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original method
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = originalGetCanvasMenuOptions
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('install', () => {
|
||||
it('should install compatibility layer on prototype', () => {
|
||||
const methodName = 'getCanvasMenuOptions'
|
||||
|
||||
// Install compatibility layer
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
|
||||
|
||||
// The method should still be callable
|
||||
expect(typeof LGraphCanvas.prototype.getCanvasMenuOptions).toBe(
|
||||
'function'
|
||||
)
|
||||
})
|
||||
|
||||
it('should detect monkey patches and warn', () => {
|
||||
const methodName = 'getCanvasMenuOptions'
|
||||
const warnSpy = vi.spyOn(console, 'warn')
|
||||
|
||||
// Install compatibility layer
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
|
||||
|
||||
// Set current extension before monkey-patching
|
||||
legacyMenuCompat.setCurrentExtension('Test Extension')
|
||||
|
||||
// Simulate extension monkey-patching
|
||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original as any).apply(this, args)
|
||||
items.push({ content: 'Custom Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Should have logged a warning with extension name
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEPRECATED]'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"Test Extension"'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
// Clear extension
|
||||
legacyMenuCompat.setCurrentExtension(null)
|
||||
})
|
||||
|
||||
it('should only warn once per unique function', () => {
|
||||
const methodName = 'getCanvasMenuOptions'
|
||||
const warnSpy = vi.spyOn(console, 'warn')
|
||||
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
|
||||
|
||||
const patchFunction = function (this: LGraphCanvas, ...args: any[]) {
|
||||
const items = (originalGetCanvasMenuOptions as any).apply(this, args)
|
||||
items.push({ content: 'Custom', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Patch twice with same function
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
|
||||
|
||||
// Should only warn once
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractLegacyItems', () => {
|
||||
beforeEach(() => {
|
||||
// Setup a mock original method
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [
|
||||
{ content: 'Item 1', callback: () => {} },
|
||||
{ content: 'Item 2', callback: () => {} }
|
||||
]
|
||||
}
|
||||
|
||||
// Install compatibility layer
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||
})
|
||||
|
||||
it('should extract items added by monkey patches', () => {
|
||||
// Monkey-patch to add items
|
||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original as any).apply(this, args)
|
||||
items.push({ content: 'Custom Item 1', callback: () => {} })
|
||||
items.push({ content: 'Custom Item 2', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Extract legacy items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
expect(legacyItems).toHaveLength(2)
|
||||
expect(legacyItems[0]).toMatchObject({ content: 'Custom Item 1' })
|
||||
expect(legacyItems[1]).toMatchObject({ content: 'Custom Item 2' })
|
||||
})
|
||||
|
||||
it('should return empty array when no items added', () => {
|
||||
// No monkey-patching, so no extra items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
expect(legacyItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should return empty array when patched method returns same count', () => {
|
||||
// Monkey-patch that replaces items but keeps same count
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [
|
||||
{ content: 'Replaced 1', callback: () => {} },
|
||||
{ content: 'Replaced 2', callback: () => {} }
|
||||
]
|
||||
}
|
||||
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
expect(legacyItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Monkey-patch that throws error
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
throw new Error('Test error')
|
||||
}
|
||||
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
expect(legacyItems).toHaveLength(0)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to extract legacy items'),
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration', () => {
|
||||
it('should work with multiple extensions patching', () => {
|
||||
// Setup base method
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [{ content: 'Base Item', callback: () => {} }]
|
||||
}
|
||||
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||
|
||||
// First extension patches
|
||||
const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original1 as any).apply(this, args)
|
||||
items.push({ content: 'Extension 1 Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Second extension patches
|
||||
const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original2 as any).apply(this, args)
|
||||
items.push({ content: 'Extension 2 Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Extract legacy items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
// Should extract both items added by extensions
|
||||
expect(legacyItems).toHaveLength(2)
|
||||
expect(legacyItems[0]).toMatchObject({ content: 'Extension 1 Item' })
|
||||
expect(legacyItems[1]).toMatchObject({ content: 'Extension 2 Item' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -97,4 +97,4 @@ async def set_settings(request: Request):
|
||||
return web.Response(status=500, text=f"Error: {str(e)}")
|
||||
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -1,673 +1,67 @@
|
||||
import torch
|
||||
import comfy.utils as utils
|
||||
from comfy.model_patcher import ModelPatcher
|
||||
import nodes
|
||||
import time
|
||||
import os
|
||||
import folder_paths
|
||||
|
||||
|
||||
class ErrorRaiseNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "raise_error"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "Raise an error for development purposes"
|
||||
|
||||
def raise_error(self):
|
||||
raise Exception("Error node was called!")
|
||||
|
||||
|
||||
class ErrorRaiseNodeWithMessage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"message": ("STRING", {"multiline": True})}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
|
||||
FUNCTION = "raise_error"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "Raise an error with message for development purposes"
|
||||
|
||||
def raise_error(self, message: str):
|
||||
raise Exception(message)
|
||||
|
||||
|
||||
class ExperimentalNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "experimental_function"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A experimental node"
|
||||
|
||||
EXPERIMENTAL = True
|
||||
|
||||
def experimental_function(self):
|
||||
print("Experimental node was called!")
|
||||
|
||||
|
||||
class DeprecatedNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "deprecated_function"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A deprecated node"
|
||||
|
||||
DEPRECATED = True
|
||||
|
||||
def deprecated_function(self):
|
||||
print("Deprecated node was called!")
|
||||
|
||||
|
||||
class LongComboDropdown:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"option": ([f"Option {i}" for i in range(1_000)],)}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "long_combo_dropdown"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A long combo dropdown"
|
||||
|
||||
def long_combo_dropdown(self, option: str):
|
||||
print(option)
|
||||
|
||||
|
||||
class NodeWithOptionalInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"required_input": ("IMAGE",)},
|
||||
"optional": {"optional_input": ("IMAGE", {"default": None})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "node_with_optional_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with an optional input"
|
||||
|
||||
def node_with_optional_input(self, required_input, optional_input=None):
|
||||
print(
|
||||
f"Calling node with required_input: {required_input} and optional_input: {optional_input}"
|
||||
)
|
||||
return (required_input,)
|
||||
|
||||
|
||||
class NodeWithOptionalComboInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"optional_combo_input": (
|
||||
[f"Random Unique Option {time.time()}" for _ in range(8)],
|
||||
{"default": None},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "node_with_optional_combo_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with an optional combo input that returns unique values every time INPUT_TYPES is called"
|
||||
|
||||
def node_with_optional_combo_input(self, optional_combo_input=None):
|
||||
print(f"Calling node with optional_combo_input: {optional_combo_input}")
|
||||
return (optional_combo_input,)
|
||||
|
||||
|
||||
class NodeWithOnlyOptionalInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"optional": {
|
||||
"text": ("STRING", {"multiline": True, "dynamicPrompts": True}),
|
||||
"clip": ("CLIP", {}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "node_with_only_optional_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with only optional input"
|
||||
|
||||
def node_with_only_optional_input(self, clip=None, text=None):
|
||||
pass
|
||||
|
||||
|
||||
class NodeWithOutputList:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = (
|
||||
"INT",
|
||||
"INT",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"INTEGER OUTPUT",
|
||||
"INTEGER LIST OUTPUT",
|
||||
)
|
||||
OUTPUT_IS_LIST = (
|
||||
False,
|
||||
True,
|
||||
)
|
||||
FUNCTION = "node_with_output_list"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with an output list"
|
||||
|
||||
def node_with_output_list(self):
|
||||
return (1, [1, 2, 3])
|
||||
|
||||
|
||||
class NodeWithForceInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"int_input": ("INT", {"forceInput": True}),
|
||||
"int_input_widget": ("INT", {"default": 1}),
|
||||
},
|
||||
"optional": {"float_input": ("FLOAT", {"forceInput": True})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "node_with_force_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a forced input"
|
||||
|
||||
def node_with_force_input(
|
||||
self, int_input: int, int_input_widget: int, float_input: float = 0.0
|
||||
):
|
||||
print(
|
||||
f"int_input: {int_input}, int_input_widget: {int_input_widget}, float_input: {float_input}"
|
||||
)
|
||||
|
||||
|
||||
class NodeWithDefaultInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"int_input": ("INT", {"defaultInput": True}),
|
||||
"int_input_widget": ("INT", {"default": 1}),
|
||||
},
|
||||
"optional": {"float_input": ("FLOAT", {"defaultInput": True})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "node_with_default_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a default input"
|
||||
|
||||
def node_with_default_input(
|
||||
self, int_input: int, int_input_widget: int, float_input: float = 0.0
|
||||
):
|
||||
print(
|
||||
f"int_input: {int_input}, int_input_widget: {int_input_widget}, float_input: {float_input}"
|
||||
)
|
||||
|
||||
|
||||
class NodeWithStringInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"string_input": ("STRING",)}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "node_with_string_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a string input"
|
||||
|
||||
def node_with_string_input(self, string_input: str):
|
||||
print(f"string_input: {string_input}")
|
||||
|
||||
|
||||
class NodeWithUnionInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"string_or_int_input": ("STRING,INT",),
|
||||
"string_input": ("STRING", {"forceInput": True}),
|
||||
"int_input": ("INT", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "node_with_union_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a union input"
|
||||
|
||||
def node_with_union_input(
|
||||
self,
|
||||
string_or_int_input: str | int = "",
|
||||
string_input: str = "",
|
||||
int_input: int = 0,
|
||||
):
|
||||
print(
|
||||
f"string_or_int_input: {string_or_int_input}, string_input: {string_input}, int_input: {int_input}"
|
||||
)
|
||||
return {
|
||||
"ui": {
|
||||
"text": string_or_int_input,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NodeWithBooleanInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"boolean_input": ("BOOLEAN",)}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "node_with_boolean_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a boolean input"
|
||||
|
||||
def node_with_boolean_input(self, boolean_input: bool):
|
||||
print(f"boolean_input: {boolean_input}")
|
||||
|
||||
|
||||
class SimpleSlider:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"value": (
|
||||
"FLOAT",
|
||||
{
|
||||
"display": "slider",
|
||||
"default": 0.5,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.001,
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLOAT",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "DevTools"
|
||||
|
||||
def execute(self, value):
|
||||
return (value,)
|
||||
|
||||
|
||||
class NodeWithSeedInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"seed": ("INT", {"default": 0})}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "node_with_seed_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a seed input"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def node_with_seed_input(self, seed: int):
|
||||
print(f"seed: {seed}")
|
||||
|
||||
|
||||
class DummyPatch(torch.nn.Module):
|
||||
def __init__(self, module: torch.nn.Module, dummy_float: float = 0.0):
|
||||
super().__init__()
|
||||
self.module = module
|
||||
self.dummy_float = dummy_float
|
||||
|
||||
def forward(self, *args, **kwargs):
|
||||
if isinstance(self.module, DummyPatch):
|
||||
raise Exception(f"Calling nested dummy patch! {self.dummy_float}")
|
||||
|
||||
return self.module(*args, **kwargs)
|
||||
|
||||
|
||||
class ObjectPatchNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"target_module": ("STRING", {"multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"dummy_float": ("FLOAT", {"default": 0.0}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL",)
|
||||
FUNCTION = "apply_patch"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that applies an object patch"
|
||||
|
||||
def apply_patch(
|
||||
self, model: ModelPatcher, target_module: str, dummy_float: float = 0.0
|
||||
) -> ModelPatcher:
|
||||
module = utils.get_attr(model.model, target_module)
|
||||
work_model = model.clone()
|
||||
work_model.add_object_patch(target_module, DummyPatch(module, dummy_float))
|
||||
return (work_model,)
|
||||
|
||||
|
||||
class RemoteWidgetNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that lazily fetches options from a remote endpoint"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class RemoteWidgetNodeWithParams:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
"query_params": {
|
||||
"sort": "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = (
|
||||
"A node that lazily fetches options from a remote endpoint with query params"
|
||||
)
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class RemoteWidgetNodeWithRefresh:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
"refresh": 300,
|
||||
"max_retries": 10,
|
||||
"timeout": 256,
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that lazily fetches options from a remote endpoint and refresh the options every 300 ms"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class RemoteWidgetNodeWithRefreshButton:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
"refresh_button": True,
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that lazily fetches options from a remote endpoint and has a refresh button to manually reload options"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class RemoteWidgetNodeWithControlAfterRefresh:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
"refresh_button": True,
|
||||
"control_after_refresh": "first",
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that lazily fetches options from a remote endpoint and has a refresh button to manually reload options and select the first option on refresh"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class NodeWithOutputCombo:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"subset_options": (["A", "B"], {"forceInput": True}),
|
||||
"subset_options_v2": (
|
||||
"COMBO",
|
||||
{"options": ["A", "B"], "forceInput": True},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (["A", "B", "C"],)
|
||||
FUNCTION = "node_with_output_combo"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that outputs a combo type"
|
||||
|
||||
def node_with_output_combo(self, subset_options: str):
|
||||
return (subset_options,)
|
||||
|
||||
|
||||
class MultiSelectNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"foo": (
|
||||
"COMBO",
|
||||
{
|
||||
"options": ["A", "B", "C"],
|
||||
"multi_select": {
|
||||
"placeholder": "Choose foos",
|
||||
"chip": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
OUTPUT_IS_LIST = [True]
|
||||
FUNCTION = "multi_select_node"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that outputs a multi select type"
|
||||
|
||||
def multi_select_node(self, foo: list[str]) -> list[str]:
|
||||
return (foo,)
|
||||
|
||||
|
||||
class LoadAnimatedImageTest(nodes.LoadImage):
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = [
|
||||
f
|
||||
for f in os.listdir(input_dir)
|
||||
if os.path.isfile(os.path.join(input_dir, f)) and f.endswith(".webp")
|
||||
]
|
||||
files = folder_paths.filter_files_content_types(files, ["image"])
|
||||
return {
|
||||
"required": {"image": (sorted(files), {"animated_image_upload": True})},
|
||||
}
|
||||
|
||||
|
||||
class NodeWithValidation:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"int_input": ("INT",)},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, int_input: int):
|
||||
if int_input < 0:
|
||||
raise ValueError("int_input must be greater than 0")
|
||||
return True
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that validates an input"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, int_input: int):
|
||||
print(f"int_input: {int_input}")
|
||||
return tuple()
|
||||
|
||||
class NodeWithV2ComboInput:
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"combo_input": (
|
||||
"COMBO",
|
||||
{"options": ["A", "B"]},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("COMBO",)
|
||||
FUNCTION = "node_with_v2_combo_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = (
|
||||
"A node that outputs a combo type that adheres to the v2 combo input spec"
|
||||
)
|
||||
|
||||
def node_with_v2_combo_input(self, combo_input: str):
|
||||
return (combo_input,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsErrorRaiseNode": ErrorRaiseNode,
|
||||
"DevToolsErrorRaiseNodeWithMessage": ErrorRaiseNodeWithMessage,
|
||||
"DevToolsExperimentalNode": ExperimentalNode,
|
||||
"DevToolsDeprecatedNode": DeprecatedNode,
|
||||
"DevToolsLongComboDropdown": LongComboDropdown,
|
||||
"DevToolsNodeWithOptionalInput": NodeWithOptionalInput,
|
||||
"DevToolsNodeWithOptionalComboInput": NodeWithOptionalComboInput,
|
||||
"DevToolsNodeWithOnlyOptionalInput": NodeWithOnlyOptionalInput,
|
||||
"DevToolsNodeWithOutputList": NodeWithOutputList,
|
||||
"DevToolsNodeWithForceInput": NodeWithForceInput,
|
||||
"DevToolsNodeWithDefaultInput": NodeWithDefaultInput,
|
||||
"DevToolsNodeWithStringInput": NodeWithStringInput,
|
||||
"DevToolsNodeWithUnionInput": NodeWithUnionInput,
|
||||
"DevToolsSimpleSlider": SimpleSlider,
|
||||
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
|
||||
"DevToolsObjectPatchNode": ObjectPatchNode,
|
||||
"DevToolsNodeWithBooleanInput": NodeWithBooleanInput,
|
||||
"DevToolsRemoteWidgetNode": RemoteWidgetNode,
|
||||
"DevToolsRemoteWidgetNodeWithParams": RemoteWidgetNodeWithParams,
|
||||
"DevToolsRemoteWidgetNodeWithRefresh": RemoteWidgetNodeWithRefresh,
|
||||
"DevToolsRemoteWidgetNodeWithRefreshButton": RemoteWidgetNodeWithRefreshButton,
|
||||
"DevToolsRemoteWidgetNodeWithControlAfterRefresh": RemoteWidgetNodeWithControlAfterRefresh,
|
||||
"DevToolsNodeWithOutputCombo": NodeWithOutputCombo,
|
||||
"DevToolsMultiSelectNode": MultiSelectNode,
|
||||
"DevToolsLoadAnimatedImageTest": LoadAnimatedImageTest,
|
||||
"DevToolsNodeWithValidation": NodeWithValidation,
|
||||
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DevToolsErrorRaiseNode": "Raise Error",
|
||||
"DevToolsErrorRaiseNodeWithMessage": "Raise Error with Message",
|
||||
"DevToolsExperimentalNode": "Experimental Node",
|
||||
"DevToolsDeprecatedNode": "Deprecated Node",
|
||||
"DevToolsLongComboDropdown": "Long Combo Dropdown",
|
||||
"DevToolsNodeWithOptionalInput": "Node With Optional Input",
|
||||
"DevToolsNodeWithOptionalComboInput": "Node With Optional Combo Input",
|
||||
"DevToolsNodeWithOnlyOptionalInput": "Node With Only Optional Input",
|
||||
"DevToolsNodeWithOutputList": "Node With Output List",
|
||||
"DevToolsNodeWithForceInput": "Node With Force Input",
|
||||
"DevToolsNodeWithDefaultInput": "Node With Default Input",
|
||||
"DevToolsNodeWithStringInput": "Node With String Input",
|
||||
"DevToolsNodeWithUnionInput": "Node With Union Input",
|
||||
"DevToolsSimpleSlider": "Simple Slider",
|
||||
"DevToolsNodeWithSeedInput": "Node With Seed Input",
|
||||
"DevToolsObjectPatchNode": "Object Patch Node",
|
||||
"DevToolsNodeWithBooleanInput": "Node With Boolean Input",
|
||||
"DevToolsRemoteWidgetNode": "Remote Widget Node",
|
||||
"DevToolsRemoteWidgetNodeWithParams": "Remote Widget Node With Sort Query Param",
|
||||
"DevToolsRemoteWidgetNodeWithRefresh": "Remote Widget Node With 300ms Refresh",
|
||||
"DevToolsRemoteWidgetNodeWithRefreshButton": "Remote Widget Node With Refresh Button",
|
||||
"DevToolsRemoteWidgetNodeWithControlAfterRefresh": "Remote Widget Node With Refresh Button and Control After Refresh",
|
||||
"DevToolsNodeWithOutputCombo": "Node With Output Combo",
|
||||
"DevToolsMultiSelectNode": "Multi Select Node",
|
||||
"DevToolsLoadAnimatedImageTest": "Load Animated Image",
|
||||
"DevToolsNodeWithValidation": "Node With Validation",
|
||||
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
|
||||
}
|
||||
from __future__ import annotations
|
||||
|
||||
from .nodes import (
|
||||
DeprecatedNode,
|
||||
DummyPatch,
|
||||
ErrorRaiseNode,
|
||||
ErrorRaiseNodeWithMessage,
|
||||
ExperimentalNode,
|
||||
LoadAnimatedImageTest,
|
||||
LongComboDropdown,
|
||||
MultiSelectNode,
|
||||
NodeWithBooleanInput,
|
||||
NodeWithDefaultInput,
|
||||
NodeWithForceInput,
|
||||
NodeWithOptionalComboInput,
|
||||
NodeWithOptionalInput,
|
||||
NodeWithOnlyOptionalInput,
|
||||
NodeWithOutputCombo,
|
||||
NodeWithOutputList,
|
||||
NodeWithSeedInput,
|
||||
NodeWithStringInput,
|
||||
NodeWithUnionInput,
|
||||
NodeWithValidation,
|
||||
NodeWithV2ComboInput,
|
||||
ObjectPatchNode,
|
||||
RemoteWidgetNode,
|
||||
RemoteWidgetNodeWithControlAfterRefresh,
|
||||
RemoteWidgetNodeWithParams,
|
||||
RemoteWidgetNodeWithRefresh,
|
||||
RemoteWidgetNodeWithRefreshButton,
|
||||
SimpleSlider,
|
||||
NODE_CLASS_MAPPINGS,
|
||||
NODE_DISPLAY_NAME_MAPPINGS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeprecatedNode",
|
||||
"DummyPatch",
|
||||
"ErrorRaiseNode",
|
||||
"ErrorRaiseNodeWithMessage",
|
||||
"ExperimentalNode",
|
||||
"LoadAnimatedImageTest",
|
||||
"LongComboDropdown",
|
||||
"MultiSelectNode",
|
||||
"NodeWithBooleanInput",
|
||||
"NodeWithDefaultInput",
|
||||
"NodeWithForceInput",
|
||||
"NodeWithOptionalComboInput",
|
||||
"NodeWithOptionalInput",
|
||||
"NodeWithOnlyOptionalInput",
|
||||
"NodeWithOutputCombo",
|
||||
"NodeWithOutputList",
|
||||
"NodeWithSeedInput",
|
||||
"NodeWithStringInput",
|
||||
"NodeWithUnionInput",
|
||||
"NodeWithValidation",
|
||||
"NodeWithV2ComboInput",
|
||||
"ObjectPatchNode",
|
||||
"RemoteWidgetNode",
|
||||
"RemoteWidgetNodeWithControlAfterRefresh",
|
||||
"RemoteWidgetNodeWithParams",
|
||||
"RemoteWidgetNodeWithRefresh",
|
||||
"RemoteWidgetNodeWithRefreshButton",
|
||||
"SimpleSlider",
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
]
|
||||
|
||||
93
tools/devtools/nodes/__init__.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import (
|
||||
DeprecatedNode,
|
||||
ErrorRaiseNode,
|
||||
ErrorRaiseNodeWithMessage,
|
||||
ExperimentalNode,
|
||||
NODE_CLASS_MAPPINGS as errors_class_mappings,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as errors_display_name_mappings,
|
||||
)
|
||||
from .inputs import (
|
||||
LongComboDropdown,
|
||||
NodeWithBooleanInput,
|
||||
NodeWithDefaultInput,
|
||||
NodeWithForceInput,
|
||||
NodeWithOptionalComboInput,
|
||||
NodeWithOptionalInput,
|
||||
NodeWithOnlyOptionalInput,
|
||||
NodeWithOutputList,
|
||||
NodeWithSeedInput,
|
||||
NodeWithStringInput,
|
||||
NodeWithUnionInput,
|
||||
NodeWithValidation,
|
||||
NodeWithV2ComboInput,
|
||||
SimpleSlider,
|
||||
NODE_CLASS_MAPPINGS as inputs_class_mappings,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as inputs_display_name_mappings,
|
||||
)
|
||||
from .models import (
|
||||
DummyPatch,
|
||||
LoadAnimatedImageTest,
|
||||
ObjectPatchNode,
|
||||
NODE_CLASS_MAPPINGS as models_class_mappings,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as models_display_name_mappings,
|
||||
)
|
||||
from .remote import (
|
||||
MultiSelectNode,
|
||||
NodeWithOutputCombo,
|
||||
RemoteWidgetNode,
|
||||
RemoteWidgetNodeWithControlAfterRefresh,
|
||||
RemoteWidgetNodeWithParams,
|
||||
RemoteWidgetNodeWithRefresh,
|
||||
RemoteWidgetNodeWithRefreshButton,
|
||||
NODE_CLASS_MAPPINGS as remote_class_mappings,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as remote_display_name_mappings,
|
||||
)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
**errors_class_mappings,
|
||||
**inputs_class_mappings,
|
||||
**remote_class_mappings,
|
||||
**models_class_mappings,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**errors_display_name_mappings,
|
||||
**inputs_display_name_mappings,
|
||||
**remote_display_name_mappings,
|
||||
**models_display_name_mappings,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"DeprecatedNode",
|
||||
"DummyPatch",
|
||||
"ErrorRaiseNode",
|
||||
"ErrorRaiseNodeWithMessage",
|
||||
"ExperimentalNode",
|
||||
"LoadAnimatedImageTest",
|
||||
"LongComboDropdown",
|
||||
"MultiSelectNode",
|
||||
"NodeWithBooleanInput",
|
||||
"NodeWithDefaultInput",
|
||||
"NodeWithForceInput",
|
||||
"NodeWithOptionalComboInput",
|
||||
"NodeWithOptionalInput",
|
||||
"NodeWithOnlyOptionalInput",
|
||||
"NodeWithOutputCombo",
|
||||
"NodeWithOutputList",
|
||||
"NodeWithSeedInput",
|
||||
"NodeWithStringInput",
|
||||
"NodeWithUnionInput",
|
||||
"NodeWithValidation",
|
||||
"NodeWithV2ComboInput",
|
||||
"ObjectPatchNode",
|
||||
"RemoteWidgetNode",
|
||||
"RemoteWidgetNodeWithControlAfterRefresh",
|
||||
"RemoteWidgetNodeWithParams",
|
||||
"RemoteWidgetNodeWithRefresh",
|
||||
"RemoteWidgetNodeWithRefreshButton",
|
||||
"SimpleSlider",
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
]
|
||||
89
tools/devtools/nodes/errors.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ErrorRaiseNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "raise_error"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "Raise an error for development purposes"
|
||||
|
||||
def raise_error(self):
|
||||
raise Exception("Error node was called!")
|
||||
|
||||
|
||||
class ErrorRaiseNodeWithMessage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"message": ("STRING", {"multiline": True})}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
|
||||
FUNCTION = "raise_error"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "Raise an error with message for development purposes"
|
||||
|
||||
def raise_error(self, message: str):
|
||||
raise Exception(message)
|
||||
|
||||
|
||||
class ExperimentalNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "experimental_function"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A experimental node"
|
||||
|
||||
EXPERIMENTAL = True
|
||||
|
||||
def experimental_function(self):
|
||||
print("Experimental node was called!")
|
||||
|
||||
|
||||
class DeprecatedNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "deprecated_function"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A deprecated node"
|
||||
|
||||
DEPRECATED = True
|
||||
|
||||
def deprecated_function(self):
|
||||
print("Deprecated node was called!")
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsErrorRaiseNode": ErrorRaiseNode,
|
||||
"DevToolsErrorRaiseNodeWithMessage": ErrorRaiseNodeWithMessage,
|
||||
"DevToolsExperimentalNode": ExperimentalNode,
|
||||
"DevToolsDeprecatedNode": DeprecatedNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DevToolsErrorRaiseNode": "Raise Error",
|
||||
"DevToolsErrorRaiseNodeWithMessage": "Raise Error with Message",
|
||||
"DevToolsExperimentalNode": "Experimental Node",
|
||||
"DevToolsDeprecatedNode": "Deprecated Node",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"ErrorRaiseNode",
|
||||
"ErrorRaiseNodeWithMessage",
|
||||
"ExperimentalNode",
|
||||
"DeprecatedNode",
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
]
|
||||
357
tools/devtools/nodes/inputs.py
Normal file
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class LongComboDropdown:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"option": ([f"Option {i}" for i in range(1_000)],)}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "long_combo_dropdown"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A long combo dropdown"
|
||||
|
||||
def long_combo_dropdown(self, option: str):
|
||||
print(option)
|
||||
|
||||
|
||||
class NodeWithOptionalInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"required_input": ("IMAGE",)},
|
||||
"optional": {"optional_input": ("IMAGE", {"default": None})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "node_with_optional_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with an optional input"
|
||||
|
||||
def node_with_optional_input(self, required_input, optional_input=None):
|
||||
print(
|
||||
f"Calling node with required_input: {required_input} and optional_input: {optional_input}"
|
||||
)
|
||||
return (required_input,)
|
||||
|
||||
|
||||
class NodeWithOptionalComboInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"optional_combo_input": (
|
||||
[f"Random Unique Option {time.time()}" for _ in range(8)],
|
||||
{"default": None},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "node_with_optional_combo_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with an optional combo input that returns unique values every time INPUT_TYPES is called"
|
||||
|
||||
def node_with_optional_combo_input(self, optional_combo_input=None):
|
||||
print(f"Calling node with optional_combo_input: {optional_combo_input}")
|
||||
return (optional_combo_input,)
|
||||
|
||||
|
||||
class NodeWithOnlyOptionalInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"optional": {
|
||||
"text": ("STRING", {"multiline": True, "dynamicPrompts": True}),
|
||||
"clip": ("CLIP", {}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "node_with_only_optional_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with only optional input"
|
||||
|
||||
def node_with_only_optional_input(self, clip=None, text=None):
|
||||
pass
|
||||
|
||||
|
||||
class NodeWithOutputList:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = (
|
||||
"INT",
|
||||
"INT",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"INTEGER OUTPUT",
|
||||
"INTEGER LIST OUTPUT",
|
||||
)
|
||||
OUTPUT_IS_LIST = (
|
||||
False,
|
||||
True,
|
||||
)
|
||||
FUNCTION = "node_with_output_list"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with an output list"
|
||||
|
||||
def node_with_output_list(self):
|
||||
return (1, [1, 2, 3])
|
||||
|
||||
|
||||
class NodeWithForceInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"int_input": ("INT", {"forceInput": True}),
|
||||
"int_input_widget": ("INT", {"default": 1}),
|
||||
},
|
||||
"optional": {"float_input": ("FLOAT", {"forceInput": True})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "node_with_force_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a forced input"
|
||||
|
||||
def node_with_force_input(
|
||||
self, int_input: int, int_input_widget: int, float_input: float = 0.0
|
||||
):
|
||||
print(
|
||||
f"int_input: {int_input}, int_input_widget: {int_input_widget}, float_input: {float_input}"
|
||||
)
|
||||
|
||||
|
||||
class NodeWithDefaultInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"int_input": ("INT", {"defaultInput": True}),
|
||||
"int_input_widget": ("INT", {"default": 1}),
|
||||
},
|
||||
"optional": {"float_input": ("FLOAT", {"defaultInput": True})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "node_with_default_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a default input"
|
||||
|
||||
def node_with_default_input(
|
||||
self, int_input: int, int_input_widget: int, float_input: float = 0.0
|
||||
):
|
||||
print(
|
||||
f"int_input: {int_input}, int_input_widget: {int_input_widget}, float_input: {float_input}"
|
||||
)
|
||||
|
||||
|
||||
class NodeWithStringInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"string_input": ("STRING",)}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "node_with_string_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a string input"
|
||||
|
||||
def node_with_string_input(self, string_input: str):
|
||||
print(f"string_input: {string_input}")
|
||||
|
||||
|
||||
class NodeWithUnionInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"string_or_int_input": ("STRING,INT",),
|
||||
"string_input": ("STRING", {"forceInput": True}),
|
||||
"int_input": ("INT", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "node_with_union_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a union input"
|
||||
|
||||
def node_with_union_input(
|
||||
self,
|
||||
string_or_int_input: str | int = "",
|
||||
string_input: str = "",
|
||||
int_input: int = 0,
|
||||
):
|
||||
print(
|
||||
f"string_or_int_input: {string_or_int_input}, string_input: {string_input}, int_input: {int_input}"
|
||||
)
|
||||
return {
|
||||
"ui": {
|
||||
"text": string_or_int_input,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NodeWithBooleanInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"boolean_input": ("BOOLEAN",)}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "node_with_boolean_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a boolean input"
|
||||
|
||||
def node_with_boolean_input(self, boolean_input: bool):
|
||||
print(f"boolean_input: {boolean_input}")
|
||||
|
||||
|
||||
class SimpleSlider:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"value": (
|
||||
"FLOAT",
|
||||
{
|
||||
"display": "slider",
|
||||
"default": 0.5,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.001,
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLOAT",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "DevTools"
|
||||
|
||||
def execute(self, value):
|
||||
return (value,)
|
||||
|
||||
|
||||
class NodeWithSeedInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"seed": ("INT", {"default": 0})}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "node_with_seed_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a seed input"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def node_with_seed_input(self, seed: int):
|
||||
print(f"seed: {seed}")
|
||||
|
||||
|
||||
class NodeWithValidation:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"int_input": ("INT",)},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, int_input: int):
|
||||
if int_input < 0:
|
||||
raise ValueError("int_input must be greater than 0")
|
||||
return True
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that validates an input"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, int_input: int):
|
||||
print(f"int_input: {int_input}")
|
||||
return tuple()
|
||||
|
||||
|
||||
class NodeWithV2ComboInput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"combo_input": (
|
||||
"COMBO",
|
||||
{"options": ["A", "B"]},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("COMBO",)
|
||||
FUNCTION = "node_with_v2_combo_input"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = (
|
||||
"A node that outputs a combo type that adheres to the v2 combo input spec"
|
||||
)
|
||||
|
||||
def node_with_v2_combo_input(self, combo_input: str):
|
||||
return (combo_input,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsLongComboDropdown": LongComboDropdown,
|
||||
"DevToolsNodeWithOptionalInput": NodeWithOptionalInput,
|
||||
"DevToolsNodeWithOptionalComboInput": NodeWithOptionalComboInput,
|
||||
"DevToolsNodeWithOnlyOptionalInput": NodeWithOnlyOptionalInput,
|
||||
"DevToolsNodeWithOutputList": NodeWithOutputList,
|
||||
"DevToolsNodeWithForceInput": NodeWithForceInput,
|
||||
"DevToolsNodeWithDefaultInput": NodeWithDefaultInput,
|
||||
"DevToolsNodeWithStringInput": NodeWithStringInput,
|
||||
"DevToolsNodeWithUnionInput": NodeWithUnionInput,
|
||||
"DevToolsNodeWithBooleanInput": NodeWithBooleanInput,
|
||||
"DevToolsSimpleSlider": SimpleSlider,
|
||||
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
|
||||
"DevToolsNodeWithValidation": NodeWithValidation,
|
||||
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DevToolsLongComboDropdown": "Long Combo Dropdown",
|
||||
"DevToolsNodeWithOptionalInput": "Node With Optional Input",
|
||||
"DevToolsNodeWithOptionalComboInput": "Node With Optional Combo Input",
|
||||
"DevToolsNodeWithOnlyOptionalInput": "Node With Only Optional Input",
|
||||
"DevToolsNodeWithOutputList": "Node With Output List",
|
||||
"DevToolsNodeWithForceInput": "Node With Force Input",
|
||||
"DevToolsNodeWithDefaultInput": "Node With Default Input",
|
||||
"DevToolsNodeWithStringInput": "Node With String Input",
|
||||
"DevToolsNodeWithUnionInput": "Node With Union Input",
|
||||
"DevToolsNodeWithBooleanInput": "Node With Boolean Input",
|
||||
"DevToolsSimpleSlider": "Simple Slider",
|
||||
"DevToolsNodeWithSeedInput": "Node With Seed Input",
|
||||
"DevToolsNodeWithValidation": "Node With Validation",
|
||||
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"LongComboDropdown",
|
||||
"NodeWithOptionalInput",
|
||||
"NodeWithOptionalComboInput",
|
||||
"NodeWithOnlyOptionalInput",
|
||||
"NodeWithOutputList",
|
||||
"NodeWithForceInput",
|
||||
"NodeWithDefaultInput",
|
||||
"NodeWithStringInput",
|
||||
"NodeWithUnionInput",
|
||||
"NodeWithBooleanInput",
|
||||
"SimpleSlider",
|
||||
"NodeWithSeedInput",
|
||||
"NodeWithValidation",
|
||||
"NodeWithV2ComboInput",
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
]
|
||||
84
tools/devtools/nodes/models.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import torch
|
||||
|
||||
import comfy.utils as utils
|
||||
from comfy.model_patcher import ModelPatcher
|
||||
import nodes
|
||||
import folder_paths
|
||||
|
||||
|
||||
class DummyPatch(torch.nn.Module):
|
||||
def __init__(self, module: torch.nn.Module, dummy_float: float = 0.0):
|
||||
super().__init__()
|
||||
self.module = module
|
||||
self.dummy_float = dummy_float
|
||||
|
||||
def forward(self, *args, **kwargs):
|
||||
if isinstance(self.module, DummyPatch):
|
||||
raise Exception(f"Calling nested dummy patch! {self.dummy_float}")
|
||||
|
||||
return self.module(*args, **kwargs)
|
||||
|
||||
|
||||
class ObjectPatchNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"target_module": ("STRING", {"multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"dummy_float": ("FLOAT", {"default": 0.0}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL",)
|
||||
FUNCTION = "apply_patch"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that applies an object patch"
|
||||
|
||||
def apply_patch(
|
||||
self, model: ModelPatcher, target_module: str, dummy_float: float = 0.0
|
||||
) -> ModelPatcher:
|
||||
module = utils.get_attr(model.model, target_module)
|
||||
work_model = model.clone()
|
||||
work_model.add_object_patch(target_module, DummyPatch(module, dummy_float))
|
||||
return (work_model,)
|
||||
|
||||
|
||||
class LoadAnimatedImageTest(nodes.LoadImage):
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = [
|
||||
f
|
||||
for f in os.listdir(input_dir)
|
||||
if os.path.isfile(os.path.join(input_dir, f)) and f.endswith(".webp")
|
||||
]
|
||||
files = folder_paths.filter_files_content_types(files, ["image"])
|
||||
return {
|
||||
"required": {"image": (sorted(files), {"animated_image_upload": True})},
|
||||
}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsObjectPatchNode": ObjectPatchNode,
|
||||
"DevToolsLoadAnimatedImageTest": LoadAnimatedImageTest,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DevToolsObjectPatchNode": "Object Patch Node",
|
||||
"DevToolsLoadAnimatedImageTest": "Load Animated Image",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"DummyPatch",
|
||||
"ObjectPatchNode",
|
||||
"LoadAnimatedImageTest",
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
]
|
||||
220
tools/devtools/nodes/remote.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class RemoteWidgetNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that lazily fetches options from a remote endpoint"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class RemoteWidgetNodeWithParams:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
"query_params": {
|
||||
"sort": "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = (
|
||||
"A node that lazily fetches options from a remote endpoint with query params"
|
||||
)
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class RemoteWidgetNodeWithRefresh:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
"refresh": 300,
|
||||
"max_retries": 10,
|
||||
"timeout": 256,
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that lazily fetches options from a remote endpoint and refresh the options every 300 ms"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class RemoteWidgetNodeWithRefreshButton:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
"refresh_button": True,
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that lazily fetches options from a remote endpoint and has a refresh button to manually reload options"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class RemoteWidgetNodeWithControlAfterRefresh:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"remote_widget_value": (
|
||||
"COMBO",
|
||||
{
|
||||
"remote": {
|
||||
"route": "/api/models/checkpoints",
|
||||
"refresh_button": True,
|
||||
"control_after_refresh": "first",
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "remote_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that lazily fetches options from a remote endpoint and has a refresh button to manually reload options and select the first option on refresh"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
|
||||
def remote_widget(self, remote_widget_value: str):
|
||||
return (remote_widget_value,)
|
||||
|
||||
|
||||
class NodeWithOutputCombo:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"subset_options": (["A", "B"], {"forceInput": True}),
|
||||
"subset_options_v2": (
|
||||
"COMBO",
|
||||
{"options": ["A", "B"], "forceInput": True},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (["A", "B", "C"],)
|
||||
FUNCTION = "node_with_output_combo"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that outputs a combo type"
|
||||
|
||||
def node_with_output_combo(self, subset_options: str):
|
||||
return (subset_options,)
|
||||
|
||||
|
||||
class MultiSelectNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"foo": (
|
||||
"COMBO",
|
||||
{
|
||||
"options": ["A", "B", "C"],
|
||||
"multi_select": {
|
||||
"placeholder": "Choose foos",
|
||||
"chip": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
OUTPUT_IS_LIST = [True]
|
||||
FUNCTION = "multi_select_node"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node that outputs a multi select type"
|
||||
|
||||
def multi_select_node(self, foo: list[str]) -> list[str]:
|
||||
return (foo,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsRemoteWidgetNode": RemoteWidgetNode,
|
||||
"DevToolsRemoteWidgetNodeWithParams": RemoteWidgetNodeWithParams,
|
||||
"DevToolsRemoteWidgetNodeWithRefresh": RemoteWidgetNodeWithRefresh,
|
||||
"DevToolsRemoteWidgetNodeWithRefreshButton": RemoteWidgetNodeWithRefreshButton,
|
||||
"DevToolsRemoteWidgetNodeWithControlAfterRefresh": RemoteWidgetNodeWithControlAfterRefresh,
|
||||
"DevToolsNodeWithOutputCombo": NodeWithOutputCombo,
|
||||
"DevToolsMultiSelectNode": MultiSelectNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DevToolsRemoteWidgetNode": "Remote Widget Node",
|
||||
"DevToolsRemoteWidgetNodeWithParams": "Remote Widget Node With Sort Query Param",
|
||||
"DevToolsRemoteWidgetNodeWithRefresh": "Remote Widget Node With 300ms Refresh",
|
||||
"DevToolsRemoteWidgetNodeWithRefreshButton": "Remote Widget Node With Refresh Button",
|
||||
"DevToolsRemoteWidgetNodeWithControlAfterRefresh": "Remote Widget Node With Refresh Button and Control After Refresh",
|
||||
"DevToolsNodeWithOutputCombo": "Node With Output Combo",
|
||||
"DevToolsMultiSelectNode": "Multi Select Node",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"RemoteWidgetNode",
|
||||
"RemoteWidgetNodeWithParams",
|
||||
"RemoteWidgetNodeWithRefresh",
|
||||
"RemoteWidgetNodeWithRefreshButton",
|
||||
"RemoteWidgetNodeWithControlAfterRefresh",
|
||||
"NodeWithOutputCombo",
|
||||
"MultiSelectNode",
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
]
|
||||