mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-22 13:27:34 +00:00
Compare commits
12 Commits
refactor/m
...
pablo_hack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
714a11872f | ||
|
|
aa407e7cd4 | ||
|
|
a9dce7aa20 | ||
|
|
20738b6349 | ||
|
|
477f9c7631 | ||
|
|
dee494f019 | ||
|
|
39864b67d8 | ||
|
|
32bd570855 | ||
|
|
811d58aef7 | ||
|
|
7d9fa2bfc5 | ||
|
|
2b51babbcd | ||
|
|
cc0ba2d471 |
@@ -51,9 +51,6 @@
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
.cursor/
|
||||
|
||||
@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
|
||||
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
|
||||
|
||||
/** State of a maintenance task, managed by the maintenance task store. */
|
||||
export class MaintenanceTaskRunner {
|
||||
class MaintenanceTaskRunner {
|
||||
constructor(readonly task: MaintenanceTask) {}
|
||||
|
||||
private _state?: MaintenanceTaskState
|
||||
|
||||
169
browser_tests/assets/links/duplicate_links_slot_drift.json
Normal file
169
browser_tests/assets/links/duplicate_links_slot_drift.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"pos": [400, 300],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 120,
|
||||
"lastLinkId": 276,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Slot Drift Duplicate Links",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [0, 300, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 300, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 120,
|
||||
"type": "ComfySwitchNode",
|
||||
"title": "Switch (CFG)",
|
||||
"pos": [100, 100],
|
||||
"size": [200, 80],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "value", "type": "FLOAT", "link": null }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [257, 271, 276]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "ComfySwitchNode" },
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 50],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 276 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 350],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 271 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 257,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 271,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 86,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 276,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] },
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -281,6 +281,14 @@ export class NodeReference {
|
||||
getType(): Promise<string> {
|
||||
return this.getProperty('type')
|
||||
}
|
||||
async centerOnNode(): Promise<void> {
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found`)
|
||||
window.app!.canvas.centerOnNode(node)
|
||||
}, this.id)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
|
||||
await this.getProperty<[number, number]>('pos')
|
||||
|
||||
@@ -30,10 +30,18 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await subgraphNode.centerOnNode()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
await page.mouse.click(seedPos.x, seedPos.y)
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
|
||||
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
@@ -48,9 +56,15 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
const saveImagePos = await saveImageRef.getPosition()
|
||||
// Click left edge — the right side is hidden by the builder panel
|
||||
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
// Node is centered on screen, so click the canvas center
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
@@ -80,6 +94,10 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via menu', async ({
|
||||
|
||||
@@ -23,4 +23,85 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('links/bad_link')
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
|
||||
})
|
||||
|
||||
// Regression: duplicate links with shifted target_slot (widget-to-input
|
||||
// conversion) caused the wrong link to survive during deduplication.
|
||||
// Switch(CFG) node 120 connects to both KSamplerAdvanced 85 and 86 (2 links).
|
||||
// Links 257 and 276 shared the same tuple (origin=120 → target=85 slot=5).
|
||||
// Node 85's input.link was 276 (valid), but the bug kept 257 (stale) and
|
||||
// removed 276, breaking the cfg connection on KSamplerAdvanced 85.
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/10291
|
||||
test('Deduplicates links without breaking connections on slot-drift workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
// Find cfg inputs by name (slot indices shift due to widget-to-input)
|
||||
const cfgInput85 = ksampler85.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfgInput86 = ksampler86.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfg85Linked = cfgInput85?.link != null
|
||||
const cfg86Linked = cfgInput86?.link != null
|
||||
|
||||
// Verify the surviving links exist in the subgraph link map
|
||||
const cfg85LinkValid =
|
||||
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
|
||||
const cfg86LinkValid =
|
||||
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
|
||||
|
||||
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
|
||||
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
|
||||
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
return {
|
||||
cfg85Linked,
|
||||
cfg86Linked,
|
||||
cfg85LinkValid,
|
||||
cfg86LinkValid,
|
||||
cfg85LinkId: cfgInput85?.link ?? null,
|
||||
cfg86LinkId: cfgInput86?.link ?? null,
|
||||
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Both KSamplerAdvanced nodes must have their cfg input connected
|
||||
expect(result.cfg85Linked).toBe(true)
|
||||
expect(result.cfg86Linked).toBe(true)
|
||||
// Links must exist in the subgraph link map
|
||||
expect(result.cfg85LinkValid).toBe(true)
|
||||
expect(result.cfg86LinkValid).toBe(true)
|
||||
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
|
||||
expect(result.switchOutputLinkCount).toBe(2)
|
||||
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
|
||||
expect(result.cfgLinkToNode85Count).toBe(1)
|
||||
// Output link IDs must match the input link IDs (source/target integrity)
|
||||
expect(result.switchOutputLinkIds).toEqual(
|
||||
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
import { importX } from 'eslint-plugin-import-x'
|
||||
import oxlint from 'eslint-plugin-oxlint'
|
||||
import testingLibrary from 'eslint-plugin-testing-library'
|
||||
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
|
||||
@@ -271,6 +272,20 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.ts'],
|
||||
plugins: { 'testing-library': testingLibrary },
|
||||
rules: {
|
||||
'testing-library/prefer-screen-queries': 'error',
|
||||
'testing-library/no-container': 'error',
|
||||
'testing-library/no-node-access': 'error',
|
||||
'testing-library/no-wait-for-multiple-assertions': 'error',
|
||||
'testing-library/prefer-find-by': 'error',
|
||||
'testing-library/prefer-presence-queries': 'error',
|
||||
'testing-library/prefer-user-event': 'error',
|
||||
'testing-library/no-debugging-utils': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
|
||||
@@ -6,7 +6,6 @@ const config: KnipConfig = {
|
||||
entry: [
|
||||
'{build,scripts}/**/*.{js,ts}',
|
||||
'src/assets/css/style.css',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
@@ -14,25 +13,23 @@ const config: KnipConfig = {
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
|
||||
},
|
||||
'apps/desktop-ui': {
|
||||
entry: ['src/main.ts', 'src/i18n.ts'],
|
||||
entry: ['src/i18n.ts'],
|
||||
project: ['src/**/*.{js,ts,vue}']
|
||||
},
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/shared-frontend-utils': {
|
||||
project: ['src/**/*.{js,ts}'],
|
||||
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/registry-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/ingest-types': {
|
||||
project: ['src/**/*.{js,ts}'],
|
||||
entry: ['src/index.ts']
|
||||
project: ['src/**/*.{js,ts}']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3', 'gh', 'generate'],
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify-json/lucide',
|
||||
@@ -40,19 +37,12 @@ const config: KnipConfig = {
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons',
|
||||
// Used by lucideStrokePlugin.js (CSS @plugin)
|
||||
'@iconify/utils'
|
||||
'@primevue/icons'
|
||||
],
|
||||
ignore: [
|
||||
// Auto generated API types
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'packages/ingest-types/src/types.gen.ts',
|
||||
'packages/ingest-types/src/zod.gen.ts',
|
||||
'packages/ingest-types/openapi-ts.config.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Used by stacked PR (feat/glsl-live-preview)
|
||||
'src/renderer/glsl/useGLSLRenderer.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
@@ -60,17 +50,8 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Loaded via @plugin directive in CSS, not detected by knip
|
||||
'packages/design-system/src/css/lucideStrokePlugin.js'
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
css: (text: string) =>
|
||||
[...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)]
|
||||
.map((match) => match[0].replace(/url\(['"]?([^'"()]+)['"]?\)/, '$1'))
|
||||
.join('\n')
|
||||
},
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ export default {
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
|
||||
'./**/*.{ts,tsx,vue,mts,json,yaml}': (stagedFiles: string[]) => {
|
||||
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
|
||||
|
||||
const hasBrowserTestsChanges = stagedFiles
|
||||
|
||||
@@ -135,6 +135,9 @@
|
||||
"@storybook/vue3": "catalog:",
|
||||
"@storybook/vue3-vite": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/user-event": "catalog:",
|
||||
"@testing-library/vue": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/jsdom": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
@@ -153,6 +156,7 @@
|
||||
"eslint-plugin-import-x": "catalog:",
|
||||
"eslint-plugin-oxlint": "catalog:",
|
||||
"eslint-plugin-storybook": "catalog:",
|
||||
"eslint-plugin-testing-library": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
@@ -177,9 +181,7 @@
|
||||
"storybook": "catalog:",
|
||||
"stylelint": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tailwindcss-primeui": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"unplugin-icons": "catalog:",
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "catalog:",
|
||||
"@iconify/tailwind4": "catalog:",
|
||||
"@iconify/utils": "catalog:"
|
||||
"@iconify/utils": "catalog:",
|
||||
"tailwindcss-primeui": "catalog:",
|
||||
"tw-animate-css": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "catalog:",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
|
||||
3
packages/design-system/src/icons/quiver-ai.svg
Normal file
3
packages/design-system/src/icons/quiver-ai.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="281" height="281" viewBox="0 0 281 281" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.069 0C217.427 0.000220786 280.138 62.7116 280.138 140.069V280.138H140.069C62.7116 280.138 0.000220844 217.427 0 140.069C0 62.7114 62.7114 0 140.069 0ZM74.961 66.6054C69.8263 64.8847 64.9385 69.7815 66.6687 74.913L123.558 243.619C125.929 250.65 136.321 248.945 136.321 241.524V135.823H241.329C248.756 135.823 250.453 125.416 243.41 123.056L74.961 66.6054Z" fill="#F8F8F8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 534 B |
@@ -631,3 +631,10 @@ export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
mediaType === '3D'
|
||||
)
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds === 0) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
1071
pnpm-lock.yaml
generated
1071
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,10 @@ catalog:
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@nx/eslint': 22.5.2
|
||||
'@nx/playwright': 22.5.2
|
||||
'@nx/storybook': 22.5.2
|
||||
'@nx/vite': 22.5.2
|
||||
'@nx/eslint': 22.6.1
|
||||
'@nx/playwright': 22.6.1
|
||||
'@nx/storybook': 22.6.1
|
||||
'@nx/vite': 22.6.1
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -34,6 +34,9 @@ catalog:
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
'@testing-library/vue': ^8.1.0
|
||||
'@tiptap/core': ^2.27.2
|
||||
'@tiptap/extension-link': ^2.27.2
|
||||
'@tiptap/extension-table': ^2.27.2
|
||||
@@ -67,6 +70,7 @@ catalog:
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.55.0
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
fast-check: ^4.5.3
|
||||
@@ -79,11 +83,11 @@ catalog:
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^5.75.1
|
||||
knip: ^6.0.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.5.2
|
||||
nx: 22.6.1
|
||||
oxfmt: ^0.40.0
|
||||
oxlint: ^1.55.0
|
||||
oxlint-tsgolint: ^0.17.0
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
useQueueSettingsStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
import { render, screen } from '@/utils/test-utils'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
|
||||
@@ -78,38 +78,40 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl(job)
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const stubs = {
|
||||
BatchCountEdit: BatchCountEditStub,
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' }
|
||||
}
|
||||
|
||||
function renderQueueButton() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
|
||||
return mount(ComfyQueueButton, {
|
||||
return render(ComfyQueueButton, {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
},
|
||||
stubs: {
|
||||
BatchCountEdit: BatchCountEditStub,
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' }
|
||||
}
|
||||
stubs
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyQueueButton', () => {
|
||||
it('renders the batch count control before the run button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const controls = wrapper.get('.queue-button-group').element.children
|
||||
renderQueueButton()
|
||||
const controls = screen.getAllByTestId(/batch-count-edit|queue-button/)
|
||||
|
||||
expect(controls[0]?.getAttribute('data-testid')).toBe('batch-count-edit')
|
||||
expect(controls[1]?.getAttribute('data-testid')).toBe('queue-button')
|
||||
expect(controls[0]).toHaveAttribute('data-testid', 'batch-count-edit')
|
||||
expect(controls[1]).toHaveAttribute('data-testid', 'queue-button')
|
||||
})
|
||||
|
||||
it('keeps the run instant presentation while idle even with active jobs', async () => {
|
||||
const wrapper = createWrapper()
|
||||
renderQueueButton()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
@@ -117,29 +119,27 @@ describe('ComfyQueueButton', () => {
|
||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||
await nextTick()
|
||||
|
||||
const queueButton = wrapper.get('[data-testid="queue-button"]')
|
||||
const queueButton = screen.getByTestId('queue-button')
|
||||
|
||||
expect(queueButton.text()).toContain('Run (Instant)')
|
||||
expect(queueButton.attributes('data-variant')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
expect(queueButton).toHaveTextContent('Run (Instant)')
|
||||
expect(queueButton).toHaveAttribute('data-variant', 'primary')
|
||||
})
|
||||
|
||||
it('switches to stop presentation when instant mode is armed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
renderQueueButton()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-running'
|
||||
await nextTick()
|
||||
|
||||
const queueButton = wrapper.get('[data-testid="queue-button"]')
|
||||
const queueButton = screen.getByTestId('queue-button')
|
||||
|
||||
expect(queueButton.text()).toContain('Stop Run (Instant)')
|
||||
expect(queueButton.attributes('data-variant')).toBe('destructive')
|
||||
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
|
||||
expect(queueButton).toHaveTextContent('Stop Run (Instant)')
|
||||
expect(queueButton).toHaveAttribute('data-variant', 'destructive')
|
||||
})
|
||||
|
||||
it('disarms instant mode without interrupting even when jobs are active', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const { user } = renderQueueButton()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
@@ -148,33 +148,26 @@ describe('ComfyQueueButton', () => {
|
||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||
await nextTick()
|
||||
|
||||
await wrapper.get('[data-testid="queue-button"]').trigger('click')
|
||||
await user!.click(screen.getByTestId('queue-button'))
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
const queueButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
|
||||
expect(queueButtonWhileStopping.text()).toContain('Run (Instant)')
|
||||
expect(queueButtonWhileStopping.attributes('data-variant')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
const queueButton = screen.getByTestId('queue-button')
|
||||
expect(queueButton).toHaveTextContent('Run (Instant)')
|
||||
expect(queueButton).toHaveAttribute('data-variant', 'primary')
|
||||
|
||||
expect(commandStore.execute).not.toHaveBeenCalled()
|
||||
|
||||
const queueButton = wrapper.get('[data-testid="queue-button"]')
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
expect(queueButton.text()).toContain('Run (Instant)')
|
||||
expect(queueButton.attributes('data-variant')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('activates instant running mode when queueing again', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const { user } = renderQueueButton()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-idle'
|
||||
await nextTick()
|
||||
|
||||
await wrapper.get('[data-testid="queue-button"]').trigger('click')
|
||||
await user!.click(screen.getByTestId('queue-button'))
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-running')
|
||||
|
||||
55
src/components/builder/VueNodeSwitchPopup.vue
Normal file
55
src/components/builder/VueNodeSwitchPopup.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<NotificationPopup
|
||||
v-if="appModeStore.showVueNodeSwitchPopup"
|
||||
:title="$t('appBuilder.vueNodeSwitch.title')"
|
||||
show-close
|
||||
position="bottom-left"
|
||||
@close="dismiss"
|
||||
>
|
||||
{{ $t('appBuilder.vueNodeSwitch.content') }}
|
||||
|
||||
<template #footer-start>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<input
|
||||
v-model="dontShowAgain"
|
||||
type="checkbox"
|
||||
class="accent-primary-background"
|
||||
/>
|
||||
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template #footer-end>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="font-normal"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ $t('appBuilder.vueNodeSwitch.dismiss') }}
|
||||
</Button>
|
||||
</template>
|
||||
</NotificationPopup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import NotificationPopup from '@/components/common/NotificationPopup.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const dontShowAgain = ref(false)
|
||||
|
||||
function dismiss() {
|
||||
if (dontShowAgain.value) {
|
||||
void settingStore.set('Comfy.AppBuilder.VueNodeSwitchDismissed', true)
|
||||
}
|
||||
appModeStore.showVueNodeSwitchPopup = false
|
||||
}
|
||||
</script>
|
||||
78
src/components/common/NotificationPopup.test.ts
Normal file
78
src/components/common/NotificationPopup.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import NotificationPopup from './NotificationPopup.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { close: 'Close' } }
|
||||
}
|
||||
})
|
||||
|
||||
function mountPopup(
|
||||
props: ComponentProps<typeof NotificationPopup> = {
|
||||
title: 'Test'
|
||||
},
|
||||
slots: Record<string, string> = {}
|
||||
) {
|
||||
return mount(NotificationPopup, {
|
||||
global: { plugins: [i18n] },
|
||||
props,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
describe('NotificationPopup', () => {
|
||||
it('renders title', () => {
|
||||
const wrapper = mountPopup({ title: 'Hello World' })
|
||||
expect(wrapper.text()).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('has role="status" for accessibility', () => {
|
||||
const wrapper = mountPopup()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders subtitle when provided', () => {
|
||||
const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' })
|
||||
expect(wrapper.text()).toContain('v1.2.3')
|
||||
})
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
const wrapper = mountPopup({
|
||||
title: 'T',
|
||||
icon: 'icon-[lucide--rocket]'
|
||||
})
|
||||
expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits close when close button clicked', async () => {
|
||||
const wrapper = mountPopup({ title: 'T', showClose: true })
|
||||
await wrapper.find('[aria-label="Close"]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders default slot content', () => {
|
||||
const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' })
|
||||
expect(wrapper.text()).toContain('Body text here')
|
||||
})
|
||||
|
||||
it('renders footer slots', () => {
|
||||
const wrapper = mountPopup(
|
||||
{ title: 'T' },
|
||||
{ 'footer-start': 'Left side', 'footer-end': 'Right side' }
|
||||
)
|
||||
expect(wrapper.text()).toContain('Left side')
|
||||
expect(wrapper.text()).toContain('Right side')
|
||||
})
|
||||
|
||||
it('positions bottom-right when specified', () => {
|
||||
const wrapper = mountPopup({ title: 'T', position: 'bottom-right' })
|
||||
const root = wrapper.find('[role="status"]')
|
||||
expect(root.attributes('data-position')).toBe('bottom-right')
|
||||
})
|
||||
})
|
||||
87
src/components/common/NotificationPopup.vue
Normal file
87
src/components/common/NotificationPopup.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
:data-position="position"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto absolute z-1000 flex max-h-96 w-96 flex-col rounded-lg border border-border-default bg-base-background shadow-interface',
|
||||
position === 'bottom-left' && 'bottom-4 left-4',
|
||||
position === 'bottom-right' && 'right-4 bottom-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
v-if="icon"
|
||||
class="flex shrink-0 items-center justify-center rounded-lg bg-primary-background-hover p-3"
|
||||
>
|
||||
<i :class="cn('size-4 text-white', icon)" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="text-sm leading-[1.429] font-normal text-base-foreground">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="subtitle"
|
||||
class="text-sm leading-[1.21] font-normal text-muted-foreground"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="showClose"
|
||||
class="size-6 shrink-0 self-start"
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.default"
|
||||
class="min-h-0 flex-1 overflow-y-auto text-sm text-muted-foreground"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots['footer-start'] || $slots['footer-end']"
|
||||
class="flex items-center justify-between px-4 pb-4"
|
||||
>
|
||||
<div>
|
||||
<slot name="footer-start" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<slot name="footer-end" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
showClose = false,
|
||||
position = 'bottom-left'
|
||||
} = defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
showClose?: boolean
|
||||
position?: 'bottom-left' | 'bottom-right'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
60
src/components/common/WaveAudioPlayer.stories.ts
Normal file
60
src/components/common/WaveAudioPlayer.stories.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import WaveAudioPlayer from './WaveAudioPlayer.vue'
|
||||
|
||||
const meta: Meta<typeof WaveAudioPlayer> = {
|
||||
title: 'Components/Audio/WaveAudioPlayer',
|
||||
component: WaveAudioPlayer,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
src: '/assets/audio/sample.wav',
|
||||
barCount: 40,
|
||||
height: 32
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-80 rounded-lg bg-base-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const BottomAligned: Story = {
|
||||
args: {
|
||||
src: '/assets/audio/sample.wav',
|
||||
barCount: 40,
|
||||
height: 48,
|
||||
align: 'bottom'
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-80 rounded-lg bg-base-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const Expanded: Story = {
|
||||
args: {
|
||||
src: '/assets/audio/sample.wav',
|
||||
variant: 'expanded',
|
||||
barCount: 80,
|
||||
height: 120
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[600px] rounded-2xl bg-base-background/80 p-8 backdrop-blur-sm"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
221
src/components/common/WaveAudioPlayer.vue
Normal file
221
src/components/common/WaveAudioPlayer.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<!-- Compact: [▶] [waveform] [time] -->
|
||||
<div
|
||||
v-if="variant === 'compact'"
|
||||
:class="
|
||||
cn('flex w-full gap-2', align === 'center' ? 'items-center' : 'items-end')
|
||||
"
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
|
||||
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
|
||||
:loading="loading"
|
||||
@click.stop="togglePlayPause"
|
||||
>
|
||||
<i
|
||||
v-if="!isPlaying"
|
||||
class="ml-0.5 icon-[lucide--play] size-3 text-base-foreground"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--pause] size-3 text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
:ref="(el) => (waveformRef = el as HTMLElement)"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-0 flex-1 cursor-pointer gap-px',
|
||||
align === 'center' ? 'items-center' : 'items-end'
|
||||
)
|
||||
"
|
||||
:style="{ height: height + 'px' }"
|
||||
@click="handleWaveformClick"
|
||||
>
|
||||
<div
|
||||
v-for="(bar, index) in bars"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'min-h-0.5 flex-1 rounded-full',
|
||||
loading
|
||||
? 'bg-muted-foreground/20'
|
||||
: index <= playedBarIndex
|
||||
? 'bg-base-foreground'
|
||||
: 'bg-muted-foreground/40'
|
||||
)
|
||||
"
|
||||
:style="{ height: (bar.height / 100) * height + 'px' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{{ formattedCurrentTime }} / {{ formattedDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Expanded: waveform / progress bar + times / transport -->
|
||||
<div v-else class="flex w-full flex-col gap-4" @pointerdown.stop @click.stop>
|
||||
<div
|
||||
class="flex w-full items-center gap-0.5"
|
||||
:style="{ height: height + 'px' }"
|
||||
>
|
||||
<div
|
||||
v-for="(bar, index) in bars"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'min-h-0.5 flex-1 rounded-full',
|
||||
loading ? 'bg-muted-foreground/20' : 'bg-base-foreground'
|
||||
)
|
||||
"
|
||||
:style="{ height: (bar.height / 100) * height + 'px' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
ref="progressRef"
|
||||
class="relative h-1 w-full cursor-pointer rounded-full bg-muted-foreground/20"
|
||||
@click="handleProgressClick"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full rounded-full bg-base-foreground"
|
||||
:style="{ width: progressRatio + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
<span>{{ formattedCurrentTime }}</span>
|
||||
<span>{{ formattedDuration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-20" />
|
||||
|
||||
<div class="flex flex-1 items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 rounded-full"
|
||||
:aria-label="$t('g.skipToStart')"
|
||||
:disabled="loading"
|
||||
@click="seekToStart"
|
||||
>
|
||||
<i class="icon-[lucide--skip-back] size-4 text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-10 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
|
||||
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
|
||||
:loading="loading"
|
||||
@click="togglePlayPause"
|
||||
>
|
||||
<i
|
||||
v-if="!isPlaying"
|
||||
class="ml-0.5 icon-[lucide--play] size-5 text-base-foreground"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--pause] size-5 text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 rounded-full"
|
||||
:aria-label="$t('g.skipToEnd')"
|
||||
:disabled="loading"
|
||||
@click="seekToEnd"
|
||||
>
|
||||
<i class="icon-[lucide--skip-forward] size-4 text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex w-20 items-center gap-1">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 rounded-full"
|
||||
:aria-label="$t('g.volume')"
|
||||
:disabled="loading"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i :class="cn(volumeIcon, 'size-4 text-base-foreground')" />
|
||||
</Button>
|
||||
<Slider
|
||||
:model-value="[volume * 100]"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => (volume = (v?.[0] ?? 100) / 100)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
:ref="(el) => (audioRef = el as HTMLAudioElement)"
|
||||
:src="audioSrc"
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, toRef } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useWaveAudioPlayer } from '@/composables/useWaveAudioPlayer'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
barCount = 40,
|
||||
height = 32,
|
||||
align = 'center',
|
||||
variant = 'compact'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
barCount?: number
|
||||
height?: number
|
||||
align?: 'center' | 'bottom'
|
||||
variant?: 'compact' | 'expanded'
|
||||
}>()
|
||||
|
||||
const progressRef = ref<HTMLElement>()
|
||||
|
||||
const {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying,
|
||||
playedBarIndex,
|
||||
progressRatio,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
togglePlayPause,
|
||||
seekToStart,
|
||||
seekToEnd,
|
||||
volume,
|
||||
volumeIcon,
|
||||
toggleMute,
|
||||
seekToRatio,
|
||||
handleWaveformClick
|
||||
} = useWaveAudioPlayer({
|
||||
src: toRef(() => src),
|
||||
barCount
|
||||
})
|
||||
|
||||
function handleProgressClick(event: MouseEvent) {
|
||||
if (!progressRef.value) return
|
||||
const rect = progressRef.value.getBoundingClientRect()
|
||||
seekToRatio((event.clientX - rect.left) / rect.width)
|
||||
}
|
||||
</script>
|
||||
@@ -97,6 +97,7 @@
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<VueNodeSwitchPopup />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
canvasStore.canvas to be initialized. -->
|
||||
@@ -128,6 +129,7 @@ import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitter
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import VueNodeSwitchPopup from '@/components/builder/VueNodeSwitchPopup.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
|
||||
@@ -17,11 +17,7 @@
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
:position="sidebarLocation === 'right' ? 'bottom-right' : 'bottom-left'"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<template>
|
||||
<audio controls width="100%" height="100%">
|
||||
<source :src="url" :type="htmlAudioType" />
|
||||
{{ $t('g.audioFailedToLoad') }}
|
||||
</audio>
|
||||
<div
|
||||
class="m-auto w-[min(90vw,42rem)] rounded-2xl bg-base-background/80 p-8 backdrop-blur-sm"
|
||||
>
|
||||
<WaveAudioPlayer
|
||||
:src="result.url"
|
||||
variant="expanded"
|
||||
:height="120"
|
||||
:bar-count="80"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import WaveAudioPlayer from '@/components/common/WaveAudioPlayer.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { result } = defineProps<{
|
||||
defineProps<{
|
||||
result: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const url = computed(() => result.url)
|
||||
const htmlAudioType = computed(() => result.htmlAudioType)
|
||||
</script>
|
||||
|
||||
@@ -19,10 +19,13 @@ export const buttonVariants = cva({
|
||||
'bg-transparent text-muted-foreground hover:bg-secondary-background-hover',
|
||||
'destructive-textonly':
|
||||
'bg-transparent text-destructive-background hover:bg-destructive-background/10',
|
||||
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
|
||||
outline:
|
||||
'border border-solid border-border-subtle bg-transparent text-base-foreground hover:bg-secondary-background-hover'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -51,9 +54,11 @@ const variants = [
|
||||
'textonly',
|
||||
'muted-textonly',
|
||||
'destructive-textonly',
|
||||
'link',
|
||||
'base',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
'gradient',
|
||||
'outline'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = [
|
||||
'sm',
|
||||
|
||||
130
src/composables/useWaveAudioPlayer.test.ts
Normal file
130
src/composables/useWaveAudioPlayer.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWaveAudioPlayer } from './useWaveAudioPlayer'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
useMediaControls: () => ({
|
||||
playing: ref(false),
|
||||
currentTime: ref(0),
|
||||
duration: ref(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const mockFetchApi = vi.fn()
|
||||
const originalAudioContext = globalThis.AudioContext
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.AudioContext = originalAudioContext
|
||||
mockFetchApi.mockReset()
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (route: string) => '/api' + route,
|
||||
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useWaveAudioPlayer', () => {
|
||||
it('initializes with default bar count', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src })
|
||||
expect(bars.value).toHaveLength(40)
|
||||
})
|
||||
|
||||
it('initializes with custom bar count', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src, barCount: 20 })
|
||||
expect(bars.value).toHaveLength(20)
|
||||
})
|
||||
|
||||
it('returns playedBarIndex as -1 when duration is 0', () => {
|
||||
const src = ref('')
|
||||
const { playedBarIndex } = useWaveAudioPlayer({ src })
|
||||
expect(playedBarIndex.value).toBe(-1)
|
||||
})
|
||||
|
||||
it('generates bars with heights between 10 and 70', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src })
|
||||
for (const bar of bars.value) {
|
||||
expect(bar.height).toBeGreaterThanOrEqual(10)
|
||||
expect(bar.height).toBeLessThanOrEqual(70)
|
||||
}
|
||||
})
|
||||
|
||||
it('starts in paused state', () => {
|
||||
const src = ref('')
|
||||
const { isPlaying } = useWaveAudioPlayer({ src })
|
||||
expect(isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shows 0:00 for formatted times initially', () => {
|
||||
const src = ref('')
|
||||
const { formattedCurrentTime, formattedDuration } = useWaveAudioPlayer({
|
||||
src
|
||||
})
|
||||
expect(formattedCurrentTime.value).toBe('0:00')
|
||||
expect(formattedDuration.value).toBe('0:00')
|
||||
})
|
||||
|
||||
it('fetches and decodes audio when src changes', async () => {
|
||||
const mockAudioBuffer = {
|
||||
getChannelData: vi.fn(() => new Float32Array(80))
|
||||
}
|
||||
|
||||
const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer))
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined)
|
||||
globalThis.AudioContext = class {
|
||||
decodeAudioData = mockDecodeAudioData
|
||||
close = mockClose
|
||||
} as unknown as typeof AudioContext
|
||||
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
headers: { get: () => 'audio/wav' }
|
||||
})
|
||||
|
||||
const src = ref('/api/view?filename=audio.wav&type=output')
|
||||
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 10 })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/view?filename=audio.wav&type=output'
|
||||
)
|
||||
expect(mockDecodeAudioData).toHaveBeenCalled()
|
||||
expect(bars.value).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('clears blobUrl and shows placeholder bars when fetch fails', async () => {
|
||||
mockFetchApi.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const src = ref('/api/view?filename=audio.wav&type=output')
|
||||
const { bars, loading, audioSrc } = useWaveAudioPlayer({
|
||||
src,
|
||||
barCount: 10
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(bars.value).toHaveLength(10)
|
||||
expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output')
|
||||
})
|
||||
|
||||
it('does not call decodeAudioSource when src is empty', () => {
|
||||
const src = ref('')
|
||||
useWaveAudioPlayer({ src })
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
205
src/composables/useWaveAudioPlayer.ts
Normal file
205
src/composables/useWaveAudioPlayer.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useMediaControls, whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { formatTime } from '@/utils/formatUtil'
|
||||
|
||||
interface WaveformBar {
|
||||
height: number
|
||||
}
|
||||
|
||||
interface UseWaveAudioPlayerOptions {
|
||||
src: Ref<string>
|
||||
barCount?: number
|
||||
}
|
||||
|
||||
export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
const { src, barCount = 40 } = options
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const waveformRef = ref<HTMLElement>()
|
||||
const blobUrl = ref<string>()
|
||||
const loading = ref(false)
|
||||
let decodeRequestId = 0
|
||||
const bars = ref<WaveformBar[]>(generatePlaceholderBars())
|
||||
|
||||
const { playing, currentTime, duration, volume, muted } =
|
||||
useMediaControls(audioRef)
|
||||
|
||||
const playedBarIndex = computed(() => {
|
||||
if (duration.value === 0) return -1
|
||||
return Math.floor((currentTime.value / duration.value) * barCount) - 1
|
||||
})
|
||||
|
||||
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
|
||||
const formattedDuration = computed(() => formatTime(duration.value))
|
||||
|
||||
const audioSrc = computed(() =>
|
||||
src.value ? (blobUrl.value ?? src.value) : ''
|
||||
)
|
||||
|
||||
function generatePlaceholderBars(): WaveformBar[] {
|
||||
return Array.from({ length: barCount }, () => ({
|
||||
height: Math.random() * 60 + 10
|
||||
}))
|
||||
}
|
||||
|
||||
function generateBarsFromBuffer(buffer: AudioBuffer) {
|
||||
const channelData = buffer.getChannelData(0)
|
||||
if (channelData.length === 0) {
|
||||
bars.value = generatePlaceholderBars()
|
||||
return
|
||||
}
|
||||
|
||||
const averages: number[] = []
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const start = Math.floor((i * channelData.length) / barCount)
|
||||
const end = Math.max(
|
||||
start + 1,
|
||||
Math.floor(((i + 1) * channelData.length) / barCount)
|
||||
)
|
||||
let sum = 0
|
||||
for (let j = start; j < end && j < channelData.length; j++) {
|
||||
sum += Math.abs(channelData[j])
|
||||
}
|
||||
averages.push(sum / (end - start))
|
||||
}
|
||||
|
||||
const peak = Math.max(...averages) || 1
|
||||
bars.value = averages.map((avg) => ({
|
||||
height: Math.max(8, (avg / peak) * 100)
|
||||
}))
|
||||
}
|
||||
|
||||
async function decodeAudioSource(url: string) {
|
||||
const requestId = ++decodeRequestId
|
||||
loading.value = true
|
||||
let ctx: AudioContext | undefined
|
||||
try {
|
||||
const apiBase = api.apiURL('/')
|
||||
const route = url.includes(apiBase)
|
||||
? url.slice(url.indexOf(apiBase) + api.apiURL('').length)
|
||||
: url
|
||||
const response = await api.fetchApi(route)
|
||||
if (requestId !== decodeRequestId) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch audio (${response.status})`)
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
|
||||
if (requestId !== decodeRequestId) return
|
||||
|
||||
const blob = new Blob([arrayBuffer.slice(0)], {
|
||||
type: response.headers.get('content-type') ?? 'audio/wav'
|
||||
})
|
||||
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = URL.createObjectURL(blob)
|
||||
|
||||
ctx = new AudioContext()
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
if (requestId !== decodeRequestId) return
|
||||
generateBarsFromBuffer(audioBuffer)
|
||||
} catch {
|
||||
if (requestId === decodeRequestId) {
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
bars.value = generatePlaceholderBars()
|
||||
}
|
||||
} finally {
|
||||
await ctx?.close()
|
||||
if (requestId === decodeRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const progressRatio = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
function togglePlayPause() {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function seekToStart() {
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
function seekToEnd() {
|
||||
currentTime.value = duration.value
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
function seekToRatio(ratio: number) {
|
||||
const clamped = Math.max(0, Math.min(1, ratio))
|
||||
currentTime.value = clamped * duration.value
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
muted.value = !muted.value
|
||||
}
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
if (muted.value || volume.value === 0) return 'icon-[lucide--volume-x]'
|
||||
if (volume.value < 0.5) return 'icon-[lucide--volume-1]'
|
||||
return 'icon-[lucide--volume-2]'
|
||||
})
|
||||
|
||||
function handleWaveformClick(event: MouseEvent) {
|
||||
if (!waveformRef.value || duration.value === 0) return
|
||||
const rect = waveformRef.value.getBoundingClientRect()
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (event.clientX - rect.left) / rect.width)
|
||||
)
|
||||
currentTime.value = ratio * duration.value
|
||||
|
||||
if (!playing.value) {
|
||||
playing.value = true
|
||||
}
|
||||
}
|
||||
|
||||
whenever(
|
||||
src,
|
||||
(url) => {
|
||||
playing.value = false
|
||||
currentTime.value = 0
|
||||
void decodeAudioSource(url)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
decodeRequestId += 1
|
||||
audioRef.value?.pause()
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying: playing,
|
||||
playedBarIndex,
|
||||
progressRatio,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
togglePlayPause,
|
||||
seekToStart,
|
||||
seekToEnd,
|
||||
volume,
|
||||
volumeIcon,
|
||||
toggleMute,
|
||||
seekToRatio,
|
||||
handleWaveformClick
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { app } from '../../scripts/app'
|
||||
import { ComfyApp } from '../../scripts/app'
|
||||
import { $el, ComfyDialog } from '../../scripts/ui'
|
||||
|
||||
export class ClipspaceDialog extends ComfyDialog {
|
||||
class ClipspaceDialog extends ComfyDialog {
|
||||
static items: Array<
|
||||
HTMLButtonElement & {
|
||||
contextPredicate?: () => boolean
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from './subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
duplicateLinksRoot,
|
||||
duplicateLinksSlotShift,
|
||||
duplicateLinksSubgraph
|
||||
} from './__fixtures__/duplicateLinks'
|
||||
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
|
||||
import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets'
|
||||
import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
|
||||
@@ -560,31 +565,39 @@ describe('_removeDuplicateLinks', () => {
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
}
|
||||
|
||||
it('removes orphaned duplicate links from _links and output.links', () => {
|
||||
function createConnectedGraph() {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
expect(graph._links.size).toBe(1)
|
||||
return { graph, source, target }
|
||||
}
|
||||
|
||||
const existingLink = graph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
}
|
||||
function injectDuplicateLink(
|
||||
graph: LGraph,
|
||||
source: LGraphNode,
|
||||
target: LGraphNode
|
||||
) {
|
||||
const dup = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
return dup
|
||||
}
|
||||
|
||||
it('removes orphaned duplicate links from _links and output.links', () => {
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
|
||||
for (let i = 0; i < 3; i++) injectDuplicateLink(graph, source, target)
|
||||
|
||||
expect(graph._links.size).toBe(4)
|
||||
expect(source.outputs[0].links).toHaveLength(4)
|
||||
@@ -597,27 +610,10 @@ describe('_removeDuplicateLinks', () => {
|
||||
})
|
||||
|
||||
it('keeps the link referenced by input.link', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
const keptLinkId = target.inputs[0].link!
|
||||
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
@@ -628,18 +624,8 @@ describe('_removeDuplicateLinks', () => {
|
||||
})
|
||||
|
||||
it('keeps the valid link when input.link is at a shifted slot index', () => {
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
// Connect source:0 -> target:0, establishing input.link on target
|
||||
source.connect(0, target, 0)
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
const validLinkId = target.inputs[0].link!
|
||||
expect(graph._links.has(validLinkId)).toBe(true)
|
||||
|
||||
// Simulate widget-to-input conversion shifting the slot: insert a new
|
||||
// input BEFORE the connected one, moving it from index 0 to index 1.
|
||||
@@ -647,26 +633,13 @@ describe('_removeDuplicateLinks', () => {
|
||||
const connectedInput = target.inputs[0]
|
||||
target.inputs[0] = target.inputs[1]
|
||||
target.inputs[1] = connectedInput
|
||||
// Now target.inputs[1].link === validLinkId, but target.inputs[0].link is null
|
||||
|
||||
// Add a duplicate link with the same connection tuple (target_slot=0
|
||||
// in the LLink, matching the original slot before the shift).
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
|
||||
expect(graph._links.size).toBe(2)
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
// The valid link (referenced by an actual input) must survive
|
||||
expect(graph._links.size).toBe(1)
|
||||
expect(graph._links.has(validLinkId)).toBe(true)
|
||||
expect(graph._links.has(dupLink.id)).toBe(false)
|
||||
@@ -674,50 +647,22 @@ describe('_removeDuplicateLinks', () => {
|
||||
})
|
||||
|
||||
it('repairs input.link when it points to a removed duplicate', () => {
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
|
||||
// Create a duplicate link
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
// Point input.link to the duplicate (simulating corrupted state)
|
||||
target.inputs[0].link = dupLink.id
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
// input.link must point to whichever link survived
|
||||
const survivingId = graph._links.keys().next().value!
|
||||
expect(target.inputs[0].link).toBe(survivingId)
|
||||
expect(graph._links.has(target.inputs[0].link!)).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op when no duplicates exist', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
const { graph } = createConnectedGraph()
|
||||
const linksBefore = graph._links.size
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
@@ -738,29 +683,56 @@ describe('_removeDuplicateLinks', () => {
|
||||
subgraph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dup = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
}
|
||||
for (let i = 0; i < 3; i++) injectDuplicateLink(subgraph, source, target)
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
// Serialize and reconfigure - should clean up during configure
|
||||
const serialized = subgraph.asSerialisable()
|
||||
subgraph.configure(serialized as never)
|
||||
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
})
|
||||
|
||||
it('removes duplicate links via root graph configure()', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
graph.configure(duplicateLinksRoot)
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
const survivingLink = graph._links.values().next().value!
|
||||
const targetNode = graph.getNodeById(survivingLink.target_id)!
|
||||
expect(targetNode.inputs[0].link).toBe(survivingLink.id)
|
||||
const sourceNode = graph.getNodeById(survivingLink.origin_id)!
|
||||
expect(sourceNode.outputs[0].links).toEqual([survivingLink.id])
|
||||
})
|
||||
|
||||
it('preserves link integrity after configure() with slot-shifted duplicates', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
graph.configure(duplicateLinksSlotShift)
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
|
||||
const link = graph._links.values().next().value!
|
||||
const target = graph.getNodeById(link.target_id)!
|
||||
const linkedInput = target.inputs.find((inp) => inp.link === link.id)
|
||||
expect(linkedInput).toBeDefined()
|
||||
|
||||
const source = graph.getNodeById(link.origin_id)!
|
||||
expect(source.outputs[link.origin_slot].links).toContain(link.id)
|
||||
})
|
||||
|
||||
it('deduplicates links inside subgraph definitions during root configure()', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(duplicateLinksSubgraph)
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value!
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
const link = subgraph._links.values().next().value!
|
||||
const target = subgraph.getNodeById(link.target_id)!
|
||||
expect(target.inputs[0].link).toBe(link.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Unpacking', () => {
|
||||
@@ -790,6 +762,21 @@ describe('Subgraph Unpacking', () => {
|
||||
return rootGraph.createSubgraph(createTestSubgraphData())
|
||||
}
|
||||
|
||||
function duplicateExistingLink(graph: LGraph, source: LGraphNode) {
|
||||
const existingLink = graph._links.values().next().value!
|
||||
const dup = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
graph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
return dup
|
||||
}
|
||||
|
||||
it('deduplicates links when unpacking subgraph with duplicate links', () => {
|
||||
registerTestNodes()
|
||||
const rootGraph = new LGraph()
|
||||
@@ -800,24 +787,9 @@ describe('Subgraph Unpacking', () => {
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create a legitimate link
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
// Manually add duplicate links (simulating the bug)
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
}
|
||||
for (let i = 0; i < 3; i++) duplicateExistingLink(subgraph, sourceNode)
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
@@ -839,21 +811,8 @@ describe('Subgraph Unpacking', () => {
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Connect source output 0 → target input 0
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
|
||||
// Add duplicate links to the same connection
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
duplicateExistingLink(subgraph, sourceNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
@@ -13,6 +13,13 @@ import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import {
|
||||
groupLinksByTuple,
|
||||
purgeOrphanedLinks,
|
||||
repairInputLinks,
|
||||
selectSurvivorLink
|
||||
} from './linkDeduplication'
|
||||
|
||||
import type { DragAndScaleState } from './DragAndScale'
|
||||
import { LGraphCanvas } from './LGraphCanvas'
|
||||
import { LGraphGroup } from './LGraphGroup'
|
||||
@@ -168,11 +175,6 @@ export class LGraph
|
||||
static STATUS_STOPPED = 1
|
||||
static STATUS_RUNNING = 2
|
||||
|
||||
/** Generates a unique string key for a link's connection tuple. */
|
||||
static _linkTupleKey(link: LLink): string {
|
||||
return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}`
|
||||
}
|
||||
|
||||
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
||||
static readonly ConfigureProperties = new Set([
|
||||
'nodes',
|
||||
@@ -1626,68 +1628,21 @@ export class LGraph
|
||||
* (origin_id, origin_slot, target_id, target_slot). Keeps the link
|
||||
* referenced by input.link and removes orphaned duplicates from
|
||||
* output.links and the graph's _links map.
|
||||
*
|
||||
* Three phases: group links by tuple, select the survivor, purge duplicates.
|
||||
*/
|
||||
_removeDuplicateLinks(): void {
|
||||
// Group all link IDs by their connection tuple.
|
||||
const groups = new Map<string, LinkId[]>()
|
||||
for (const [id, link] of this._links) {
|
||||
const key = LGraph._linkTupleKey(link)
|
||||
let group = groups.get(key)
|
||||
if (!group) {
|
||||
group = []
|
||||
groups.set(key, group)
|
||||
}
|
||||
group.push(id)
|
||||
}
|
||||
const groups = groupLinksByTuple(this._links)
|
||||
|
||||
for (const [, ids] of groups) {
|
||||
for (const ids of groups.values()) {
|
||||
if (ids.length <= 1) continue
|
||||
|
||||
const sampleLink = this._links.get(ids[0])!
|
||||
const node = this.getNodeById(sampleLink.target_id)
|
||||
const keepId = selectSurvivorLink(ids, node)
|
||||
|
||||
// Find which link ID is actually referenced by any input on the target
|
||||
// node. Cannot rely on target_slot index because widget-to-input
|
||||
// conversions during configure() can shift slot indices.
|
||||
let keepId: LinkId | undefined
|
||||
if (node) {
|
||||
for (const input of node.inputs ?? []) {
|
||||
const match = ids.find((id) => input.link === id)
|
||||
if (match != null) {
|
||||
keepId = match
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
keepId ??= ids[0]
|
||||
|
||||
for (const id of ids) {
|
||||
if (id === keepId) continue
|
||||
|
||||
const link = this._links.get(id)
|
||||
if (!link) continue
|
||||
|
||||
// Remove from origin node's output.links array
|
||||
const originNode = this.getNodeById(link.origin_id)
|
||||
if (originNode) {
|
||||
const output = originNode.outputs?.[link.origin_slot]
|
||||
if (output?.links) {
|
||||
const idx = output.links.indexOf(id)
|
||||
if (idx !== -1) output.links.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
this._links.delete(id)
|
||||
}
|
||||
|
||||
// Ensure input.link points to the surviving link
|
||||
if (node) {
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (ids.includes(input.link as LinkId) && input.link !== keepId) {
|
||||
input.link = keepId
|
||||
}
|
||||
}
|
||||
}
|
||||
purgeOrphanedLinks(ids, keepId, this._links, (id) => this.getNodeById(id))
|
||||
repairInputLinks(ids, keepId, node)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
240
src/lib/litegraph/src/__fixtures__/duplicateLinks.ts
Normal file
240
src/lib/litegraph/src/__fixtures__/duplicateLinks.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Root graph with two nodes (Source, Target) connected by one valid link
|
||||
* plus two duplicate links sharing the same connection tuple.
|
||||
* Tests that configure() deduplicates to a single link.
|
||||
*/
|
||||
export const duplicateLinksRoot: SerialisableGraph = {
|
||||
id: 'dd000000-0000-4000-8000-000000000001',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: 3,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'input_0', type: 'number', link: null }],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [1, 2, 3] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'input_0', type: 'number', link: 1 }],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [] }],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Root graph with slot-shifted duplicates. Target node has an extra input
|
||||
* (simulating widget-to-input conversion) that shifts the connected input
|
||||
* from slot 0 to slot 1. Link 1 is valid (referenced by input.link),
|
||||
* link 2 is a duplicate with the original (pre-shift) target_slot.
|
||||
*/
|
||||
export const duplicateLinksSlotShift: SerialisableGraph = {
|
||||
id: 'dd000000-0000-4000-8000-000000000002',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: 2,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'input_0', type: 'number', link: null }],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [1, 2] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: 'extra_widget', type: 'number', link: null },
|
||||
{ name: 'input_0', type: 'number', link: 1 }
|
||||
],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [] }],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Root graph containing a SubgraphNode whose subgraph definition has
|
||||
* duplicate links. Tests that configure() deduplicates links inside
|
||||
* subgraph definitions during root-level configure.
|
||||
*/
|
||||
export const duplicateLinksSubgraph: SerialisableGraph = {
|
||||
id: 'dd000000-0000-4000-8000-000000000003',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 1,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'dd111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: 'dd111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: 3,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'Subgraph With Duplicates',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [0, 100, 120, 60] },
|
||||
outputNode: { id: -20, bounding: [500, 100, 120, 60] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test/Source',
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [{ name: 'out', type: 'number', links: [1, 2, 3] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test/Target',
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'in', type: 'number', link: 1 }],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
extra: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
82
src/lib/litegraph/src/linkDeduplication.ts
Normal file
82
src/lib/litegraph/src/linkDeduplication.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { LGraphNode, NodeId } from './LGraphNode'
|
||||
import type { LLink, LinkId } from './LLink'
|
||||
|
||||
/** Generates a unique string key for a link's connection tuple. */
|
||||
function linkTupleKey(link: LLink): string {
|
||||
return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}`
|
||||
}
|
||||
|
||||
/** Groups all link IDs by their connection tuple key. */
|
||||
export function groupLinksByTuple(
|
||||
links: Map<LinkId, LLink>
|
||||
): Map<string, LinkId[]> {
|
||||
const groups = new Map<string, LinkId[]>()
|
||||
for (const [id, link] of links) {
|
||||
const key = linkTupleKey(link)
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(id)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the link ID actually referenced by an input on the target node.
|
||||
* Cannot rely on target_slot index because widget-to-input conversions
|
||||
* during configure() can shift slot indices.
|
||||
*/
|
||||
export function selectSurvivorLink(
|
||||
ids: LinkId[],
|
||||
node: LGraphNode | null
|
||||
): LinkId {
|
||||
if (!node) return ids[0]
|
||||
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (!input) continue
|
||||
const match = ids.find((id) => input.link === id)
|
||||
if (match != null) return match
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
|
||||
/** Removes duplicate links from origin outputs and the graph's link map. */
|
||||
export function purgeOrphanedLinks(
|
||||
ids: LinkId[],
|
||||
keepId: LinkId,
|
||||
links: Map<LinkId, LLink>,
|
||||
getNodeById: (id: NodeId) => LGraphNode | null
|
||||
): void {
|
||||
for (const id of ids) {
|
||||
if (id === keepId) continue
|
||||
|
||||
const link = links.get(id)
|
||||
if (!link) continue
|
||||
|
||||
const originNode = getNodeById(link.origin_id)
|
||||
const output = originNode?.outputs?.[link.origin_slot]
|
||||
if (output?.links) {
|
||||
for (let i = output.links.length - 1; i >= 0; i--) {
|
||||
if (output.links[i] === id) output.links.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
links.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensures input.link on the target node points to the surviving link. */
|
||||
export function repairInputLinks(
|
||||
ids: LinkId[],
|
||||
keepId: LinkId,
|
||||
node: LGraphNode | null
|
||||
): void {
|
||||
if (!node) return
|
||||
|
||||
const duplicateIds = new Set(ids)
|
||||
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (input?.link == null || input.link === keepId) continue
|
||||
if (duplicateIds.has(input.link)) {
|
||||
input.link = keepId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,9 +343,13 @@
|
||||
"frameNodes": "Frame Nodes",
|
||||
"listening": "Listening...",
|
||||
"ready": "Ready",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"playPause": "Play/Pause",
|
||||
"playRecording": "Play Recording",
|
||||
"playing": "Playing",
|
||||
"skipToStart": "Skip to Start",
|
||||
"skipToEnd": "Skip to End",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"playbackSpeed": "Playback Speed",
|
||||
"volume": "Volume",
|
||||
@@ -3228,6 +3232,14 @@
|
||||
"desc": "– More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"tryItOut": "Try it out"
|
||||
},
|
||||
"appBuilder": {
|
||||
"vueNodeSwitch": {
|
||||
"title": "Switched over to Nodes 2.0",
|
||||
"content": "For the best experience, App builder uses Nodes 2.0. You can switch back after building the app from the main menu.",
|
||||
"dontShowAgain": "Don't show again",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
},
|
||||
"vueNodesMigration": {
|
||||
"message": "Prefer the legacy design?",
|
||||
"button": "Switch back"
|
||||
|
||||
@@ -8,16 +8,20 @@
|
||||
$t('assetBrowser.media.audioPlaceholder')
|
||||
}}</span>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
class="absolute bottom-0 left-0 w-full p-2"
|
||||
:src="asset.src"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0 w-full p-2">
|
||||
<WaveAudioPlayer
|
||||
:src="asset.src"
|
||||
:bar-count="40"
|
||||
:height="32"
|
||||
align="bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WaveAudioPlayer from '@/components/common/WaveAudioPlayer.vue'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Default mappings from model directories to loader nodes.
|
||||
*
|
||||
* Each entry maps a model folder (as it appears in the model browser)
|
||||
* to the node class that loads models from that folder and the
|
||||
* input key where the model name is inserted.
|
||||
*
|
||||
* An empty key ('') means the node auto-loads models without a widget
|
||||
* selector (createModelNodeFromAsset skips widget assignment).
|
||||
*
|
||||
* Hierarchical fallback is handled by the store: "a/b/c" tries
|
||||
* "a/b/c" → "a/b" → "a", so registering a parent directory covers
|
||||
* all its children unless a more specific entry exists.
|
||||
*
|
||||
* Format: [modelDirectory, nodeClass, inputKey]
|
||||
*/
|
||||
export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
readonly [string, string, string]
|
||||
> = [
|
||||
// ---- ComfyUI core loaders ----
|
||||
['checkpoints', 'CheckpointLoaderSimple', 'ckpt_name'],
|
||||
['checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name'],
|
||||
['loras', 'LoraLoader', 'lora_name'],
|
||||
['loras', 'LoraLoaderModelOnly', 'lora_name'],
|
||||
['vae', 'VAELoader', 'vae_name'],
|
||||
['controlnet', 'ControlNetLoader', 'control_net_name'],
|
||||
['diffusion_models', 'UNETLoader', 'unet_name'],
|
||||
['upscale_models', 'UpscaleModelLoader', 'model_name'],
|
||||
['style_models', 'StyleModelLoader', 'style_model_name'],
|
||||
['gligen', 'GLIGENLoader', 'gligen_name'],
|
||||
['clip_vision', 'CLIPVisionLoader', 'clip_name'],
|
||||
['text_encoders', 'CLIPLoader', 'clip_name'],
|
||||
['audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name'],
|
||||
['model_patches', 'ModelPatchLoader', 'name'],
|
||||
['latent_upscale_models', 'LatentUpscaleModelLoader', 'model_name'],
|
||||
['clip', 'CLIPVisionLoader', 'clip_name'],
|
||||
|
||||
// ---- AnimateDiff (comfyui-animatediff-evolved) ----
|
||||
['animatediff_models', 'ADE_LoadAnimateDiffModel', 'model_name'],
|
||||
['animatediff_motion_lora', 'ADE_AnimateDiffLoRALoader', 'name'],
|
||||
|
||||
// ---- Chatterbox TTS (ComfyUI-Fill-Nodes) ----
|
||||
['chatterbox/chatterbox', 'FL_ChatterboxTTS', ''],
|
||||
['chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', ''],
|
||||
['chatterbox/chatterbox_multilingual', 'FL_ChatterboxMultilingualTTS', ''],
|
||||
['chatterbox/chatterbox_vc', 'FL_ChatterboxVC', ''],
|
||||
|
||||
// ---- SAM / SAM2 (comfyui-segment-anything-2, comfyui-impact-pack) ----
|
||||
['sam2', 'DownloadAndLoadSAM2Model', 'model'],
|
||||
['sams', 'SAMLoader', 'model_name'],
|
||||
|
||||
// ---- SAM3 3D segmentation (comfyui-sam3) ----
|
||||
['sam3', 'LoadSAM3Model', 'model_path'],
|
||||
|
||||
// ---- Ultralytics detection (comfyui-impact-subpack) ----
|
||||
['ultralytics', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
|
||||
// ---- DepthAnything (comfyui-depthanythingv2, comfyui-depthanythingv3) ----
|
||||
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
|
||||
['depthanything3', 'DownloadAndLoadDepthAnythingV3Model', 'model'],
|
||||
|
||||
// ---- IP-Adapter (comfyui_ipadapter_plus) ----
|
||||
['ipadapter', 'IPAdapterModelLoader', 'ipadapter_file'],
|
||||
|
||||
// ---- Segformer (comfyui_layerstyle) ----
|
||||
['segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name'],
|
||||
['segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name'],
|
||||
['segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name'],
|
||||
|
||||
// ---- NLF pose estimation (ComfyUI-WanVideoWrapper) ----
|
||||
['nlf', 'LoadNLFModel', 'nlf_model'],
|
||||
|
||||
// ---- FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast) ----
|
||||
['FlashVSR', 'FlashVSRNode', ''],
|
||||
['FlashVSR-v1.1', 'FlashVSRNode', ''],
|
||||
|
||||
// ---- SEEDVR2 video upscaling (comfyui-seedvr2) ----
|
||||
['SEEDVR2', 'SeedVR2LoadDiTModel', 'model'],
|
||||
|
||||
// ---- Qwen VL vision-language (comfyui-qwen-vl) ----
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-0.6B', 'AILab_QwenVL_PromptEnhancer', 'model_name'],
|
||||
[
|
||||
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
],
|
||||
['LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint'],
|
||||
|
||||
// ---- Qwen3 TTS (ComfyUI-FunBox) ----
|
||||
['qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice'],
|
||||
|
||||
// ---- LivePortrait (comfyui-liveportrait) ----
|
||||
['liveportrait', 'DownloadAndLoadLivePortraitModels', ''],
|
||||
|
||||
// ---- MimicMotion (ComfyUI-MimicMotionWrapper) ----
|
||||
['mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model'],
|
||||
['dwpose', 'MimicMotionGetPoses', ''],
|
||||
|
||||
// ---- Face parsing (comfyui_face_parsing) ----
|
||||
['face_parsing', 'FaceParsingModelLoader(FaceParsing)', ''],
|
||||
|
||||
// ---- Kolors (ComfyUI-KolorsWrapper) ----
|
||||
['diffusers', 'DownloadAndLoadKolorsModel', 'model'],
|
||||
|
||||
// ---- RIFE video frame interpolation (ComfyUI-RIFE) ----
|
||||
['rife', 'RIFE VFI', 'ckpt_name'],
|
||||
|
||||
// ---- UltraShape 3D model generation ----
|
||||
['UltraShape', 'UltraShapeLoadModel', 'checkpoint'],
|
||||
|
||||
// ---- SHaRP depth estimation ----
|
||||
['sharp', 'LoadSharpModel', 'checkpoint_path'],
|
||||
|
||||
// ---- ONNX upscale models ----
|
||||
['onnx', 'UpscaleModelLoader', 'model_name'],
|
||||
|
||||
// ---- Detection models (vitpose, yolo) ----
|
||||
['detection', 'OnnxDetectionModelLoader', 'yolo_model'],
|
||||
|
||||
// ---- HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper) ----
|
||||
[
|
||||
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
],
|
||||
[
|
||||
'LLM/llava-llama-3-8b-v1_1-transformers',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
],
|
||||
|
||||
// ---- CogVideoX (comfyui-cogvideoxwrapper) ----
|
||||
['CogVideo', 'DownloadAndLoadCogVideoModel', ''],
|
||||
['CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model'],
|
||||
['CogVideo/ControlNet', 'DownloadAndLoadCogVideoControlNet', ''],
|
||||
|
||||
// ---- DynamiCrafter (ComfyUI-DynamiCrafterWrapper) ----
|
||||
['checkpoints/dynamicrafter', 'DownloadAndLoadDynamiCrafterModel', 'model'],
|
||||
[
|
||||
'checkpoints/dynamicrafter/controlnet',
|
||||
'DownloadAndLoadDynamiCrafterCNModel',
|
||||
'model'
|
||||
],
|
||||
|
||||
// ---- LayerStyle (ComfyUI_LayerStyle_Advance) ----
|
||||
['BEN', 'LS_LoadBenModel', 'model'],
|
||||
['BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model'],
|
||||
['onnx/human-parts', 'LS_HumanPartsUltra', ''],
|
||||
['lama', 'LaMa', 'lama_model'],
|
||||
|
||||
// ---- Inpaint (comfyui-inpaint-nodes) ----
|
||||
['inpaint', 'INPAINT_LoadInpaintModel', 'model_name'],
|
||||
|
||||
// ---- LayerDiffuse (comfyui-layerdiffuse) ----
|
||||
['layer_model', 'LayeredDiffusionApply', 'config'],
|
||||
|
||||
// ---- LTX Video prompt enhancer (ComfyUI-LTXTricks) ----
|
||||
['LLM/Llama-3.2-3B-Instruct', 'LTXVPromptEnhancerLoader', 'llm_name'],
|
||||
[
|
||||
'LLM/Florence-2-large-PromptGen-v2.0',
|
||||
'LTXVPromptEnhancerLoader',
|
||||
'image_captioner_name'
|
||||
]
|
||||
] as const satisfies ReadonlyArray<readonly [string, string, string]>
|
||||
@@ -1198,6 +1198,12 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
experimental: true,
|
||||
versionAdded: '1.27.1'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
name: 'App Builder Vue Node switch dismissed',
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'Comfy.VueNodes.AutoScaleLayout',
|
||||
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import type { FeatureSurveyConfig } from './useSurveyEligibility'
|
||||
|
||||
/**
|
||||
@@ -9,7 +11,13 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
|
||||
featureId: 'node-search',
|
||||
typeformId: 'goZLqjKL',
|
||||
triggerThreshold: 3,
|
||||
delayMs: 5000
|
||||
delayMs: 5000,
|
||||
isFeatureActive: () => {
|
||||
const settingStore = useSettingStore()
|
||||
return (
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl') !== 'litegraph (legacy)'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,17 @@ describe('useSurveyEligibility', () => {
|
||||
|
||||
expect(isEligible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is not eligible when isFeatureActive returns false', () => {
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
const { isEligible } = useSurveyEligibility({
|
||||
...defaultConfig,
|
||||
isFeatureActive: () => false
|
||||
})
|
||||
|
||||
expect(isEligible.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface FeatureSurveyConfig {
|
||||
triggerThreshold?: number
|
||||
delayMs?: number
|
||||
enabled?: boolean
|
||||
isFeatureActive?: () => boolean
|
||||
}
|
||||
|
||||
interface SurveyState {
|
||||
@@ -61,8 +62,13 @@ export function useSurveyEligibility(
|
||||
|
||||
const hasOptedOut = computed(() => state.value.optedOut)
|
||||
|
||||
const isFeatureActive = computed(
|
||||
() => resolvedConfig.value.isFeatureActive?.() ?? true
|
||||
)
|
||||
|
||||
const isEligible = computed(() => {
|
||||
if (!isSurveyEnabled.value) return false
|
||||
if (!isFeatureActive.value) return false
|
||||
if (!isNightlyLocalhost.value) return false
|
||||
if (!hasReachedThreshold.value) return false
|
||||
if (hasSeenSurvey.value) return false
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('ReleaseNotificationToast', () => {
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
expect(wrapper.find('.icon-\\[lucide--rocket\\]').exists()).toBe(true)
|
||||
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays release version', () => {
|
||||
|
||||
@@ -1,40 +1,17 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="release-toast-popup">
|
||||
<div
|
||||
class="flex max-h-96 w-96 flex-col rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
<NotificationPopup
|
||||
icon="icon-[lucide--rocket]"
|
||||
:title="$t('releaseToast.newVersionAvailable')"
|
||||
:subtitle="latestRelease?.version"
|
||||
:position
|
||||
>
|
||||
<!-- Main content -->
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4">
|
||||
<!-- Header section with icon and text -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-center rounded-lg bg-primary-background-hover p-3"
|
||||
>
|
||||
<i class="icon-[lucide--rocket] size-4 text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="text-sm leading-[1.429] font-normal text-base-foreground"
|
||||
>
|
||||
{{ $t('releaseToast.newVersionAvailable') }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm leading-[1.21] font-normal text-muted-foreground"
|
||||
>
|
||||
{{ latestRelease?.version }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pl-14 text-sm leading-[1.21] font-normal text-muted-foreground"
|
||||
v-html="formattedContent"
|
||||
></div>
|
||||
|
||||
<!-- Description section -->
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto pl-14 text-sm leading-[1.21] font-normal text-muted-foreground"
|
||||
v-html="formattedContent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer section -->
|
||||
<div class="flex items-center justify-between px-4 pb-4">
|
||||
<template #footer-start>
|
||||
<a
|
||||
class="flex items-center gap-2 py-1 text-sm font-normal text-muted-foreground hover:text-base-foreground"
|
||||
:href="changelogUrl"
|
||||
@@ -45,22 +22,27 @@
|
||||
<i class="icon-[lucide--external-link] size-4"></i>
|
||||
{{ $t('releaseToast.whatsNew') }}
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="h-6 cursor-pointer border-none bg-transparent px-0 text-sm font-normal text-muted-foreground hover:text-base-foreground"
|
||||
@click="handleSkip"
|
||||
>
|
||||
{{ $t('releaseToast.skip') }}
|
||||
</button>
|
||||
<button
|
||||
class="h-10 cursor-pointer rounded-lg border-none bg-secondary-background px-4 text-sm font-normal text-base-foreground hover:bg-secondary-background-hover"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
{{ $t('releaseToast.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer-end>
|
||||
<Button
|
||||
variant="link"
|
||||
size="unset"
|
||||
class="h-6 px-0 text-sm font-normal"
|
||||
@click="handleSkip"
|
||||
>
|
||||
{{ $t('releaseToast.skip') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="font-normal"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
{{ $t('releaseToast.update') }}
|
||||
</Button>
|
||||
</template>
|
||||
</NotificationPopup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -69,6 +51,8 @@ import { default as DOMPurify } from 'dompurify'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NotificationPopup from '@/components/common/NotificationPopup.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -79,6 +63,10 @@ import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import { useReleaseStore } from '../common/releaseStore'
|
||||
|
||||
const { position = 'bottom-left' } = defineProps<{
|
||||
position?: 'bottom-left' | 'bottom-right'
|
||||
}>()
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const releaseStore = useReleaseStore()
|
||||
@@ -218,23 +206,3 @@ defineExpose({
|
||||
handleUpdate
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Toast popup - positioning handled by parent */
|
||||
.release-toast-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Sidebar positioning classes applied by parent - matching help center */
|
||||
.release-toast-popup.sidebar-left,
|
||||
.release-toast-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.release-toast-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import {
|
||||
@@ -403,6 +404,7 @@ export const useWorkflowService = () => {
|
||||
// Determine the initial app mode for fresh loads from serialized state.
|
||||
// null means linearMode was never explicitly set (not builder-saved).
|
||||
const freshLoadMode = linearModeToAppMode(workflowData.extra?.linearMode)
|
||||
useAppModeStore().loadSelections(workflowData.extra?.linearData)
|
||||
|
||||
function trackIfEnteringApp(workflow: ComfyWorkflow) {
|
||||
if (!wasAppMode && workflow.initialMode === 'app') {
|
||||
|
||||
@@ -9,6 +9,7 @@ defineOptions({ inheritAttrs: false })
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
mobile?: boolean
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const imageRef = useTemplateRef('imageRef')
|
||||
@@ -48,5 +49,8 @@ const height = ref('')
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
|
||||
<span class="self-center md:z-10">
|
||||
{{ `${width} x ${height}` }}
|
||||
<template v-if="label"> | {{ label }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, useAttrs } from 'vue'
|
||||
import { computed, defineAsyncComponent, useAttrs } from 'vue'
|
||||
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
@@ -19,40 +19,56 @@ const { output } = defineProps<{
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const mediaType = computed(() => getMediaType(output))
|
||||
const outputLabel = computed(
|
||||
() => output.display_name?.trim() || output.filename
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<ImagePreview
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
:class="attrs.class as string"
|
||||
:mobile
|
||||
:src="output.url"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else-if="getMediaType(output) === 'video'"
|
||||
:src="output.url"
|
||||
:class="
|
||||
cn('flex-1 object-contain md:p-3 md:contain-size', attrs.class as string)
|
||||
"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="getMediaType(output) === 'audio'"
|
||||
:class="cn('m-auto w-full', attrs.class as string)"
|
||||
controls
|
||||
:src="output.url"
|
||||
/>
|
||||
<article
|
||||
v-else-if="getMediaType(output) === 'text'"
|
||||
:class="
|
||||
cn(
|
||||
'm-auto my-12 size-full max-w-2xl scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap',
|
||||
attrs.class as string
|
||||
)
|
||||
"
|
||||
v-text="output.content"
|
||||
/>
|
||||
<Preview3d
|
||||
v-else-if="getMediaType(output) === '3d'"
|
||||
:class="attrs.class as string"
|
||||
:model-url="output.url"
|
||||
/>
|
||||
<template v-if="mediaType === 'images' || mediaType === 'video'">
|
||||
<ImagePreview
|
||||
v-if="mediaType === 'images'"
|
||||
:class="attrs.class as string"
|
||||
:mobile
|
||||
:src="output.url"
|
||||
:label="outputLabel"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else
|
||||
:src="output.url"
|
||||
:label="outputLabel"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 object-contain md:p-3 md:contain-size',
|
||||
attrs.class as string
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<audio
|
||||
v-if="mediaType === 'audio'"
|
||||
:class="cn('m-auto w-full', attrs.class as string)"
|
||||
controls
|
||||
:src="output.url"
|
||||
/>
|
||||
<article
|
||||
v-else-if="mediaType === 'text'"
|
||||
:class="
|
||||
cn(
|
||||
'm-auto my-12 size-full max-w-2xl scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap',
|
||||
attrs.class as string
|
||||
)
|
||||
"
|
||||
v-text="output.content"
|
||||
/>
|
||||
<Preview3d
|
||||
v-else-if="mediaType === '3d'"
|
||||
:class="attrs.class as string"
|
||||
:model-url="output.url"
|
||||
/>
|
||||
<span v-if="outputLabel" class="self-center text-sm">
|
||||
{{ outputLabel }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const videoRef = useTemplateRef('videoRef')
|
||||
@@ -23,5 +24,8 @@ const height = ref('')
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="z-10 self-center" v-text="`${width} x ${height}`" />
|
||||
<span class="z-10 self-center">
|
||||
{{ `${width} x ${height}` }}
|
||||
<template v-if="label"> | {{ label }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -96,7 +96,7 @@ import { useAudioService } from '@/services/audioService'
|
||||
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
|
||||
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
|
||||
import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
|
||||
import { formatTime } from '../utils/audioUtils'
|
||||
import { formatTime } from '@/utils/formatUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -55,6 +55,33 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
mockMediaAssets: {
|
||||
media: ref([]),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
fetchMediaList: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue([]),
|
||||
loadMore: vi.fn(),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false)
|
||||
},
|
||||
mockResolveOutputAssetItems: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => mockMediaAssets
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
resolveOutputAssetItems: (...args: unknown[]) =>
|
||||
mockResolveOutputAssetItems(...args)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -484,6 +511,229 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown multi-output jobs', () => {
|
||||
interface MultiOutputInstance extends ComponentPublicInstance {
|
||||
outputItems: FormDropdownItem[]
|
||||
}
|
||||
|
||||
function makeMultiOutputAsset(
|
||||
jobId: string,
|
||||
name: string,
|
||||
nodeId: string,
|
||||
outputCount: number
|
||||
) {
|
||||
return {
|
||||
id: jobId,
|
||||
name,
|
||||
preview_url: `/api/view?filename=${name}&type=output`,
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId,
|
||||
subfolder: '',
|
||||
outputCount,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId,
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mountMultiOutput(
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<MultiOutputInstance> {
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: { widget, modelValue, assetKind: 'image' as const },
|
||||
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
|
||||
}) as unknown as VueWrapper<MultiOutputInstance>
|
||||
}
|
||||
|
||||
const defaultWidget = () =>
|
||||
createMockWidget<string | undefined>({
|
||||
value: 'output_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockMediaAssets.media.value = []
|
||||
mockResolveOutputAssetItems.mockReset()
|
||||
})
|
||||
|
||||
it('shows all outputs after resolving multi-output jobs', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{
|
||||
id: 'job-1-5-output_001.png',
|
||||
name: 'output_001.png',
|
||||
preview_url: '/api/view?filename=output_001.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_002.png',
|
||||
name: 'output_002.png',
|
||||
preview_url: '/api/view?filename=output_002.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_003.png',
|
||||
name: 'output_003.png',
|
||||
preview_url: '/api/view?filename=output_003.png&type=output',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), 'output_001.png')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems.map((i) => i.name)).toEqual([
|
||||
'output_001.png [output]',
|
||||
'output_002.png [output]',
|
||||
'output_003.png [output]'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows preview output when job has only one output', () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
|
||||
]
|
||||
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'single.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
const wrapper = mountMultiOutput(widget, 'single.png')
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('single.png [output]')
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves two multi-output jobs independently', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
|
||||
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(async (meta) => {
|
||||
if (meta.jobId === 'job-A') {
|
||||
return [
|
||||
{ id: 'A-1', name: 'a1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'A-2', name: 'a2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ id: 'B-1', name: 'b1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'B-2', name: 'b2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
})
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(4)
|
||||
})
|
||||
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toContain('a1.png [output]')
|
||||
expect(names).toContain('a2.png [output]')
|
||||
expect(names).toContain('b1.png [output]')
|
||||
expect(names).toContain('b2.png [output]')
|
||||
})
|
||||
|
||||
it('resolves outputs when allOutputs already contains all items', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'job-complete',
|
||||
name: 'preview.png',
|
||||
preview_url: '/api/view?filename=preview.png&type=output',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-complete',
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
outputCount: 2,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: 'out1.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
{
|
||||
filename: 'out2.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{ id: 'c-1', name: 'out1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'c-2', name: 'out2.png', preview_url: '', tags: ['output'] }
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job-complete' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
|
||||
})
|
||||
|
||||
it('falls back to preview when resolver rejects', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
|
||||
]
|
||||
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to resolve multi-output job',
|
||||
'job-fail',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('preview.png [output]')
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown undo tracking', () => {
|
||||
interface UndoTrackingInstance extends ComponentPublicInstance {
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { capitalize } from 'es-toolkit'
|
||||
import { computed, provide, ref, toRef, watch } from 'vue'
|
||||
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
@@ -31,6 +31,9 @@ import type {
|
||||
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
|
||||
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -153,24 +156,82 @@ function assetKindToMediaType(kind: AssetKind): string {
|
||||
return kind === 'mesh' ? '3D' : kind
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-job cache of resolved outputs for multi-output jobs.
|
||||
* Keyed by jobId, populated lazily via resolveOutputAssetItems which
|
||||
* fetches full outputs through getJobDetail (itself LRU-cached).
|
||||
*/
|
||||
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
|
||||
const pendingJobIds = new Set<string>()
|
||||
|
||||
watch(
|
||||
() => outputMediaAssets.media.value,
|
||||
(assets, _, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
pendingJobIds.clear()
|
||||
|
||||
for (const asset of assets) {
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
|
||||
if (
|
||||
outputCount <= 1 ||
|
||||
resolvedByJobId.value.has(meta.jobId) ||
|
||||
pendingJobIds.has(meta.jobId)
|
||||
)
|
||||
continue
|
||||
|
||||
pendingJobIds.add(meta.jobId)
|
||||
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
|
||||
.then((resolved) => {
|
||||
if (cancelled || !resolved.length) return
|
||||
const next = new Map(resolvedByJobId.value)
|
||||
next.set(meta.jobId, resolved)
|
||||
resolvedByJobId.value = next
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to resolve multi-output job', meta.jobId, error)
|
||||
})
|
||||
.finally(() => {
|
||||
pendingJobIds.delete(meta.jobId)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
|
||||
return []
|
||||
|
||||
const targetMediaType = assetKindToMediaType(props.assetKind!)
|
||||
const outputFiles = outputMediaAssets.media.value.filter(
|
||||
(asset) => getMediaTypeFromFilename(asset.name) === targetMediaType
|
||||
)
|
||||
const seen = new Set<string>()
|
||||
const items: FormDropdownItem[] = []
|
||||
|
||||
return outputFiles.map((asset) => {
|
||||
const assets = outputMediaAssets.media.value.flatMap((asset) => {
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
|
||||
return resolved ?? [asset]
|
||||
})
|
||||
|
||||
for (const asset of assets) {
|
||||
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
|
||||
if (seen.has(asset.id)) continue
|
||||
seen.add(asset.id)
|
||||
const annotatedPath = `${asset.name} [output]`
|
||||
return {
|
||||
items.push({
|
||||
id: `output-${annotatedPath}`,
|
||||
preview_url: asset.preview_url || getMediaUrl(asset.name, 'output'),
|
||||
name: annotatedPath,
|
||||
label: getDisplayLabel(annotatedPath)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -156,7 +156,7 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { formatTime } from '../../utils/audioUtils'
|
||||
import { formatTime } from '@/utils/formatUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Format time in MM:SS format
|
||||
*/
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds === 0) return '0:00'
|
||||
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
|
||||
@@ -415,6 +415,7 @@ const zSettings = z.object({
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': z.string(),
|
||||
'Comfy.Canvas.MouseWheelScroll': z.string(),
|
||||
'Comfy.VueNodes.Enabled': z.boolean(),
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed': z.boolean(),
|
||||
'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
|
||||
'Comfy.Assets.UseAssetAPI': z.boolean(),
|
||||
'Comfy.Queue.QPOV2': z.boolean(),
|
||||
|
||||
@@ -110,7 +110,7 @@ interface Load3DNode extends LGraphNode {
|
||||
|
||||
const viewerInstances = new Map<NodeId, ReturnType<UseLoad3dViewerFn>>()
|
||||
|
||||
export class Load3dService {
|
||||
class Load3dService {
|
||||
private static instance: Load3dService
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -47,6 +47,24 @@ vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
|
||||
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
|
||||
}))
|
||||
|
||||
const mockSettings = vi.hoisted(() => {
|
||||
const store: Record<string, unknown> = {}
|
||||
return {
|
||||
store,
|
||||
get: vi.fn((key: string) => store[key] ?? false),
|
||||
set: vi.fn(async (key: string, value: unknown) => {
|
||||
store[key] = value
|
||||
}),
|
||||
reset() {
|
||||
for (const key of Object.keys(store)) delete store[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettings
|
||||
}))
|
||||
|
||||
import { useAppModeStore } from './appModeStore'
|
||||
|
||||
function createBuilderWorkflow(
|
||||
@@ -72,6 +90,7 @@ describe('appModeStore', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.mocked(app.rootGraph).extra = {}
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
mockSettings.reset()
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
|
||||
workflowStore = useWorkflowStore()
|
||||
store = useAppModeStore()
|
||||
@@ -197,14 +216,12 @@ describe('appModeStore', () => {
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(
|
||||
[
|
||||
store.loadSelections({
|
||||
inputs: [
|
||||
[1, 'prompt'],
|
||||
[99, 'width']
|
||||
],
|
||||
[]
|
||||
)
|
||||
await nextTick()
|
||||
]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([[1, 'prompt']])
|
||||
})
|
||||
@@ -215,14 +232,12 @@ describe('appModeStore', () => {
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(
|
||||
[
|
||||
store.loadSelections({
|
||||
inputs: [
|
||||
[1, 'prompt'],
|
||||
[1, 'deleted_widget']
|
||||
],
|
||||
[]
|
||||
)
|
||||
await nextTick()
|
||||
]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([
|
||||
[1, 'prompt'],
|
||||
@@ -236,8 +251,7 @@ describe('appModeStore', () => {
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData([], [1, 99])
|
||||
await nextTick()
|
||||
store.loadSelections({ outputs: [1, 99] })
|
||||
|
||||
expect(store.selectedOutputs).toEqual([1])
|
||||
})
|
||||
@@ -247,8 +261,11 @@ describe('appModeStore', () => {
|
||||
|
||||
// Initially nodes are not resolvable — pruning removes them
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
workflowStore.activeWorkflow = workflowWithLinearData([[1, 'seed']], [1])
|
||||
const inputs: [number, string][] = [[1, 'seed']]
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(inputs, [1])
|
||||
store.loadSelections({ inputs })
|
||||
await nextTick()
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(store.selectedOutputs).toEqual([])
|
||||
|
||||
@@ -268,8 +285,7 @@ describe('appModeStore', () => {
|
||||
it('hasOutputs is false when all output nodes are deleted', async () => {
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData([], [10, 20])
|
||||
await nextTick()
|
||||
store.loadSelections({ outputs: [10, 20] })
|
||||
|
||||
expect(store.selectedOutputs).toEqual([])
|
||||
expect(store.hasOutputs).toBe(false)
|
||||
@@ -329,4 +345,69 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoEnableVueNodes', () => {
|
||||
it('enables Vue nodes when entering select mode with them disabled', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettings.set).toHaveBeenCalledWith(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not enable Vue nodes when already enabled', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = true
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettings.set).not.toHaveBeenCalledWith(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('shows popup when Vue nodes are switched on and not dismissed', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
mockSettings.store['Comfy.AppBuilder.VueNodeSwitchDismissed'] = false
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(store.showVueNodeSwitchPopup).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show popup when previously dismissed', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
mockSettings.store['Comfy.AppBuilder.VueNodeSwitchDismissed'] = true
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(store.showVueNodeSwitchPopup).toBe(false)
|
||||
})
|
||||
|
||||
it('does not enable Vue nodes when entering builder:arrange', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('app')
|
||||
store.selectedOutputs.push(1)
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:arrange')
|
||||
expect(mockSettings.set).not.toHaveBeenCalledWith(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -21,13 +22,16 @@ export function nodeTypeValidForApp(type: string) {
|
||||
|
||||
export const useAppModeStore = defineStore('appMode', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
|
||||
const emptyWorkflowDialog = useEmptyWorkflowDialog()
|
||||
|
||||
const selectedInputs = reactive<[NodeId, string][]>([])
|
||||
const selectedOutputs = reactive<NodeId[]>([])
|
||||
const hasOutputs = computed(() => !!selectedOutputs.length)
|
||||
const showVueNodeSwitchPopup = ref(false)
|
||||
|
||||
const selectedInputs = ref<[NodeId, string][]>([])
|
||||
const selectedOutputs = ref<NodeId[]>([])
|
||||
const hasOutputs = computed(() => !!selectedOutputs.value.length)
|
||||
const hasNodes = computed(() => {
|
||||
// Nodes are not reactive, so trigger recomputation when workflow changes
|
||||
void workflowStore.activeWorkflow
|
||||
@@ -54,8 +58,8 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
|
||||
function loadSelections(data: Partial<LinearData> | undefined) {
|
||||
const { inputs, outputs } = pruneLinearData(data)
|
||||
selectedInputs.splice(0, selectedInputs.length, ...inputs)
|
||||
selectedOutputs.splice(0, selectedOutputs.length, ...outputs)
|
||||
selectedInputs.value = inputs
|
||||
selectedOutputs.value = outputs
|
||||
}
|
||||
|
||||
function resetSelectedToWorkflow() {
|
||||
@@ -65,20 +69,6 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(newWorkflow) => {
|
||||
if (newWorkflow) {
|
||||
loadSelections(
|
||||
newWorkflow.changeTracker?.activeState?.extra?.linearData
|
||||
)
|
||||
} else {
|
||||
loadSelections(undefined)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'configured',
|
||||
@@ -88,7 +78,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
watch(
|
||||
() =>
|
||||
isBuilderMode.value
|
||||
? { inputs: selectedInputs, outputs: selectedOutputs }
|
||||
? { inputs: selectedInputs.value, outputs: selectedOutputs.value }
|
||||
: null,
|
||||
(data) => {
|
||||
if (!data || ChangeTracker.isLoadingGraph) return
|
||||
@@ -103,17 +93,33 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
let unwatch: () => void | undefined
|
||||
watch(isSelectMode, (inSelect) => {
|
||||
let unwatchReadOnly: (() => void) | undefined
|
||||
function enforceReadOnly(inSelect: boolean) {
|
||||
const { state } = getCanvas()
|
||||
if (!state) return
|
||||
state.readOnly = inSelect
|
||||
unwatch?.()
|
||||
unwatchReadOnly?.()
|
||||
if (inSelect)
|
||||
unwatch = watch(
|
||||
unwatchReadOnly = watch(
|
||||
() => state.readOnly,
|
||||
() => (state.readOnly = true)
|
||||
)
|
||||
}
|
||||
|
||||
function autoEnableVueNodes(inSelect: boolean) {
|
||||
if (!inSelect) return
|
||||
if (!settingStore.get('Comfy.VueNodes.Enabled')) {
|
||||
void settingStore.set('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
if (!settingStore.get('Comfy.AppBuilder.VueNodeSwitchDismissed')) {
|
||||
showVueNodeSwitchPopup.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(isSelectMode, (inSelect) => {
|
||||
enforceReadOnly(inSelect)
|
||||
autoEnableVueNodes(inSelect)
|
||||
})
|
||||
|
||||
function enterBuilder() {
|
||||
@@ -144,10 +150,10 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const index = selectedInputs.findIndex(
|
||||
const index = selectedInputs.value.findIndex(
|
||||
([id, name]) => storeId == id && storeName === name
|
||||
)
|
||||
if (index !== -1) selectedInputs.splice(index, 1)
|
||||
if (index !== -1) selectedInputs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -155,10 +161,12 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
exitBuilder,
|
||||
hasNodes,
|
||||
hasOutputs,
|
||||
loadSelections,
|
||||
pruneLinearData,
|
||||
removeSelectedInput,
|
||||
resetSelectedToWorkflow,
|
||||
selectedInputs,
|
||||
selectedOutputs
|
||||
selectedOutputs,
|
||||
showVueNodeSwitchPopup
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { MODEL_NODE_MAPPINGS } from '@/platform/assets/mappings/modelNodeMappings'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
@@ -157,9 +156,254 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
}
|
||||
haveDefaultsLoaded.value = true
|
||||
|
||||
for (const [modelType, nodeClass, key] of MODEL_NODE_MAPPINGS) {
|
||||
quickRegister(modelType, nodeClass, key)
|
||||
}
|
||||
quickRegister('checkpoints', 'CheckpointLoaderSimple', 'ckpt_name')
|
||||
quickRegister('checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name')
|
||||
quickRegister('loras', 'LoraLoader', 'lora_name')
|
||||
quickRegister('loras', 'LoraLoaderModelOnly', 'lora_name')
|
||||
quickRegister('vae', 'VAELoader', 'vae_name')
|
||||
quickRegister('controlnet', 'ControlNetLoader', 'control_net_name')
|
||||
quickRegister('diffusion_models', 'UNETLoader', 'unet_name')
|
||||
quickRegister('upscale_models', 'UpscaleModelLoader', 'model_name')
|
||||
quickRegister('style_models', 'StyleModelLoader', 'style_model_name')
|
||||
quickRegister('gligen', 'GLIGENLoader', 'gligen_name')
|
||||
quickRegister('clip_vision', 'CLIPVisionLoader', 'clip_name')
|
||||
quickRegister('text_encoders', 'CLIPLoader', 'clip_name')
|
||||
quickRegister('audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name')
|
||||
quickRegister('model_patches', 'ModelPatchLoader', 'name')
|
||||
quickRegister(
|
||||
'animatediff_models',
|
||||
'ADE_LoadAnimateDiffModel',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'animatediff_motion_lora',
|
||||
'ADE_AnimateDiffLoRALoader',
|
||||
'name'
|
||||
)
|
||||
|
||||
// Chatterbox TTS nodes: empty key means the node auto-loads models without
|
||||
// a widget selector (createModelNodeFromAsset skips widget assignment)
|
||||
quickRegister('chatterbox/chatterbox', 'FL_ChatterboxTTS', '')
|
||||
quickRegister('chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', '')
|
||||
quickRegister(
|
||||
'chatterbox/chatterbox_multilingual',
|
||||
'FL_ChatterboxMultilingualTTS',
|
||||
''
|
||||
)
|
||||
quickRegister('chatterbox/chatterbox_vc', 'FL_ChatterboxVC', '')
|
||||
|
||||
// Latent upscale models (ComfyUI core - nodes_hunyuan.py)
|
||||
quickRegister(
|
||||
'latent_upscale_models',
|
||||
'LatentUpscaleModelLoader',
|
||||
'model_name'
|
||||
)
|
||||
|
||||
// SAM/SAM2 segmentation models (comfyui-segment-anything-2, comfyui-impact-pack)
|
||||
quickRegister('sam2', 'DownloadAndLoadSAM2Model', 'model')
|
||||
quickRegister('sams', 'SAMLoader', 'model_name')
|
||||
|
||||
// Ultralytics detection models (comfyui-impact-subpack)
|
||||
// Note: ultralytics/bbox and ultralytics/segm fall back to this via hierarchical lookup
|
||||
quickRegister('ultralytics', 'UltralyticsDetectorProvider', 'model_name')
|
||||
|
||||
// DepthAnything models (comfyui-depthanythingv2)
|
||||
quickRegister(
|
||||
'depthanything',
|
||||
'DownloadAndLoadDepthAnythingV2Model',
|
||||
'model'
|
||||
)
|
||||
|
||||
// IP-Adapter models (comfyui_ipadapter_plus)
|
||||
quickRegister('ipadapter', 'IPAdapterModelLoader', 'ipadapter_file')
|
||||
|
||||
// Segformer clothing/fashion segmentation models (comfyui_layerstyle)
|
||||
quickRegister('segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name')
|
||||
quickRegister('segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name')
|
||||
quickRegister('segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name')
|
||||
|
||||
// NLF pose estimation models (ComfyUI-WanVideoWrapper)
|
||||
quickRegister('nlf', 'LoadNLFModel', 'nlf_model')
|
||||
|
||||
// FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast)
|
||||
// Empty key means the node auto-loads models without a widget selector
|
||||
quickRegister('FlashVSR', 'FlashVSRNode', '')
|
||||
quickRegister('FlashVSR-v1.1', 'FlashVSRNode', '')
|
||||
|
||||
// SEEDVR2 video upscaling (comfyui-seedvr2)
|
||||
quickRegister('SEEDVR2', 'SeedVR2LoadDiTModel', 'model')
|
||||
|
||||
// Qwen VL vision-language models (comfyui-qwen-vl)
|
||||
// Register each specific path to avoid LLM fallback catching unrelated models
|
||||
// (e.g., LLM/llava-* should NOT map to AILab_QwenVL)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-2B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-2B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-4B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-4B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-8B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-8B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-32B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-32B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-0.6B',
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister('LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint')
|
||||
|
||||
// Qwen3 TTS speech models (ComfyUI-FunBox)
|
||||
// Top-level 'qwen-tts' catches all qwen-tts/* subdirs via hierarchical fallback
|
||||
quickRegister('qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice')
|
||||
|
||||
// DepthAnything V3 models (comfyui-depthanythingv2)
|
||||
quickRegister(
|
||||
'depthanything3',
|
||||
'DownloadAndLoadDepthAnythingV3Model',
|
||||
'model'
|
||||
)
|
||||
|
||||
// LivePortrait face animation models (comfyui-liveportrait)
|
||||
quickRegister('liveportrait', 'DownloadAndLoadLivePortraitModels', '')
|
||||
|
||||
// MimicMotion video generation models (ComfyUI-MimicMotionWrapper)
|
||||
quickRegister('mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model')
|
||||
quickRegister('dwpose', 'MimicMotionGetPoses', '')
|
||||
|
||||
// Face parsing segmentation models (comfyui_face_parsing)
|
||||
quickRegister('face_parsing', 'FaceParsingModelLoader(FaceParsing)', '')
|
||||
|
||||
// Kolors image generation models (ComfyUI-KolorsWrapper)
|
||||
// Top-level 'diffusers' catches diffusers/Kolors/* subdirs
|
||||
quickRegister('diffusers', 'DownloadAndLoadKolorsModel', 'model')
|
||||
|
||||
// CLIP models for HunyuanVideo (clip/clip-vit-large-patch14 subdir)
|
||||
quickRegister('clip', 'CLIPVisionLoader', 'clip_name')
|
||||
|
||||
// RIFE video frame interpolation (ComfyUI-RIFE)
|
||||
quickRegister('rife', 'RIFE VFI', 'ckpt_name')
|
||||
|
||||
// SAM3 3D segmentation models (comfyui-sam3)
|
||||
quickRegister('sam3', 'LoadSAM3Model', 'model_path')
|
||||
|
||||
// UltraShape 3D model generation
|
||||
quickRegister('UltraShape', 'UltraShapeLoadModel', 'checkpoint')
|
||||
|
||||
// SHaRP depth estimation
|
||||
quickRegister('sharp', 'LoadSharpModel', 'checkpoint_path')
|
||||
|
||||
// ONNX upscale models (used by OnnxDetectionModelLoader and upscale nodes)
|
||||
quickRegister('onnx', 'UpscaleModelLoader', 'model_name')
|
||||
|
||||
// Detection models (vitpose, yolo)
|
||||
quickRegister('detection', 'OnnxDetectionModelLoader', 'yolo_model')
|
||||
|
||||
// HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper)
|
||||
quickRegister(
|
||||
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/llava-llama-3-8b-v1_1-transformers',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
)
|
||||
|
||||
// CogVideoX models (comfyui-cogvideoxwrapper)
|
||||
quickRegister('CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model')
|
||||
// Empty key: HF-download node — don't activate asset browser for the combo widget
|
||||
quickRegister(
|
||||
'CogVideo/ControlNet',
|
||||
'DownloadAndLoadCogVideoControlNet',
|
||||
''
|
||||
)
|
||||
|
||||
// DynamiCrafter models (ComfyUI-DynamiCrafterWrapper)
|
||||
quickRegister(
|
||||
'checkpoints/dynamicrafter',
|
||||
'DownloadAndLoadDynamiCrafterModel',
|
||||
'model'
|
||||
)
|
||||
quickRegister(
|
||||
'checkpoints/dynamicrafter/controlnet',
|
||||
'DownloadAndLoadDynamiCrafterCNModel',
|
||||
'model'
|
||||
)
|
||||
|
||||
// LayerStyle models (ComfyUI_LayerStyle_Advance)
|
||||
quickRegister('BEN', 'LS_LoadBenModel', 'model')
|
||||
quickRegister('BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model')
|
||||
quickRegister('onnx/human-parts', 'LS_HumanPartsUltra', '')
|
||||
quickRegister('lama', 'LaMa', 'lama_model')
|
||||
|
||||
// CogVideoX video generation models (comfyui-cogvideoxwrapper)
|
||||
// Empty key: HF-download node — don't activate asset browser for the combo widget
|
||||
quickRegister('CogVideo', 'DownloadAndLoadCogVideoModel', '')
|
||||
|
||||
// Inpaint models (comfyui-inpaint-nodes)
|
||||
quickRegister('inpaint', 'INPAINT_LoadInpaintModel', 'model_name')
|
||||
|
||||
// LayerDiffuse transparent image generation (comfyui-layerdiffuse)
|
||||
quickRegister('layer_model', 'LayeredDiffusionApply', 'config')
|
||||
|
||||
// LTX Video prompt enhancer models (ComfyUI-LTXTricks)
|
||||
quickRegister(
|
||||
'LLM/Llama-3.2-3B-Instruct',
|
||||
'LTXVPromptEnhancerLoader',
|
||||
'llm_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Florence-2-large-PromptGen-v2.0',
|
||||
'LTXVPromptEnhancerLoader',
|
||||
'image_captioner_name'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -73,6 +73,7 @@ const PROVIDER_COLORS: Record<string, string | [string, string]> = {
|
||||
'moonvalley-marey': '#DAD9C5',
|
||||
openai: '#B6B6B6',
|
||||
pixverse: ['#B465E6', '#E8632A'],
|
||||
'quiver-ai': '#B6B6B6',
|
||||
recraft: '#B6B6B6',
|
||||
reve: '#B6B6B6',
|
||||
rodin: '#F7F7F7',
|
||||
|
||||
49
src/utils/test-utils.ts
Normal file
49
src/utils/test-utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { RenderResult } from '@testing-library/vue'
|
||||
import { render } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ComponentMountingOptions } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
function createDefaultPlugins() {
|
||||
return [
|
||||
PrimeVue,
|
||||
createTestingPinia({ stubActions: false }),
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
function renderWithDefaults<C>(
|
||||
component: C,
|
||||
options?: ComponentMountingOptions<C> & { setupUser?: boolean }
|
||||
): RenderResult & { user: ReturnType<typeof userEvent.setup> | undefined } {
|
||||
const { setupUser = true, global: globalOptions, ...rest } = options ?? {}
|
||||
const user = setupUser ? userEvent.setup() : undefined
|
||||
|
||||
const result = render(
|
||||
component as Parameters<typeof render>[0],
|
||||
{
|
||||
global: {
|
||||
plugins: [...createDefaultPlugins(), ...(globalOptions?.plugins ?? [])],
|
||||
stubs: globalOptions?.stubs,
|
||||
directives: globalOptions?.directives
|
||||
},
|
||||
...rest
|
||||
} as Parameters<typeof render>[1]
|
||||
)
|
||||
|
||||
return {
|
||||
...result,
|
||||
user
|
||||
}
|
||||
}
|
||||
|
||||
export { renderWithDefaults as render }
|
||||
export { screen } from '@testing-library/vue'
|
||||
@@ -30,7 +30,11 @@
|
||||
"@tests-ui/*": ["./tests-ui/*"]
|
||||
},
|
||||
"typeRoots": ["src/types", "node_modules/@types", "./node_modules"],
|
||||
"types": ["vitest/globals", "@webgpu/types"],
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"@webgpu/types",
|
||||
"@testing-library/jest-dom/vitest"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { vi } from 'vitest'
|
||||
import 'vue'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user