Compare commits

..

12 Commits

Author SHA1 Message Date
PabloWiedemann
714a11872f feat: add outline button variant
Add a new `outline` variant to the Button component with transparent
background, subtle border stroke, and hover state. Automatically
reflected in Storybook via the existing AllVariants story.
2026-03-21 17:35:50 -07:00
Simon Pinfold
aa407e7cd4 fix: show all outputs in FormDropdown for multi-output jobs (#10131)
## Summary

FormDropdown Outputs tab only showed the first output for multi-output
jobs because the Jobs API `/jobs` returns a single `preview_output` per
job.

## Changes

- **What**: When history assets include jobs with `outputs_count > 1`,
lazily fetch full outputs via `getJobDetail` (cached in
`jobOutputCache`) and expand them into individual dropdown items.
Single-output jobs are unaffected. Added in-flight guard to prevent
duplicate fetches.
- This is a consumer-side workaround in `WidgetSelectDropdown.vue` that
becomes a no-op once the backend returns all outputs in the list
response (planned Assets API migration).

## Review Focus

- The `resolvedMultiOutputs` shallowRef + watch pattern for async data
feeding into a computed. Each `getJobDetail` call is cached by
`jobOutputCache` LRU, so no redundant network requests.
- This fix is intentionally temporary — it will be superseded when
OSS/cloud both return full outputs from list endpoints.

## No E2E test

E2E coverage is impractical here: reproducing requires a running ComfyUI
backend executing a workflow that produces multiple outputs, then
inspecting the FormDropdown's Outputs tab. The unit test covers the
lazy-loading logic with mocked `getJobDetail` responses.

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-21 16:40:30 -07:00
Dante
a9dce7aa20 fix: prevent survey popup when surveyed feature is inactive (#10360)
## Summary

Prevent the nightly survey popup from appearing when the feature being
surveyed is currently disabled.

## Root Cause

`useSurveyEligibility` checks the feature usage count from localStorage
but does not verify whether the surveyed feature is currently active.
When a user uses the new node search 3+ times (reaching the survey
threshold), then switches back to legacy search, the usage count
persists in localStorage and the survey popup still appears despite the
feature being off.

## Steps to Reproduce

1. Enable new node search (Settings > Node Search Box Implementation >
"default")
2. Use node search 3+ times (double-click canvas, search for nodes)
3. Switch back to legacy search (Settings > Node Search Box
Implementation > "litegraph (legacy)")
4. Wait 5 seconds on a nightly localhost build
5. **Expected**: No survey popup appears (feature is disabled)
6. **Actual**: Survey popup appears because eligibility only checks
stored usage count, not current feature state

## Changes

1. Added optional `isFeatureActive` callback to `FeatureSurveyConfig`
interface
2. Added `isFeatureActive` guard in `useSurveyEligibility` eligibility
computation
3. Configured `node-search` survey with `isFeatureActive` that checks
the current search box setting

## Red-Green Verification

| Commit | Result | Description |
|---|---|---|
| `99e7d7fe9` `test:` | RED | Asserts `isEligible` is false when
`isFeatureActive` returns false. Fails on current code. |
| `01df9af12` `fix:` | GREEN | Adds `isFeatureActive` check to
eligibility. Test passes. |

Fixes #10333
2026-03-22 07:38:41 +09:00
Alexander Brown
20738b6349 test: add Vue Testing Library infrastructure and pilot migration (#10319)
## Summary

Add Vue Testing Library (VTL) infrastructure and pilot-migrate
ComfyQueueButton.test.ts as Phase 0 of an incremental VTL adoption.

## Changes

- **What**: Install `@testing-library/vue`,
`@testing-library/user-event`, `@testing-library/jest-dom`, and
`eslint-plugin-testing-library`. Configure jest-dom matchers globally
via `vitest.setup.ts` and `tsconfig.json`. Create shared render wrapper
at `src/utils/test-utils.ts` (pre-configures PrimeVue, Pinia, i18n).
Migrate `ComfyQueueButton.test.ts` from `@vue/test-utils` to VTL. Add
warn-level `testing-library/*` ESLint rules for test files.
- **Dependencies**: `@testing-library/vue`,
`@testing-library/user-event`, `@testing-library/jest-dom`,
`eslint-plugin-testing-library`

## Review Focus

- `src/utils/test-utils.ts` — shared render wrapper typing approach
(uses `ComponentMountingOptions` from VTU since VTL's `RenderOptions`
requires a generic parameter)
- ESLint rules are all set to `warn` during migration to avoid breaking
existing VTU tests
- VTL coexists with VTU — no existing tests are broken

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10319-test-add-Vue-Testing-Library-infrastructure-and-pilot-migration-3286d73d3650812793ccd8a839550a04)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-21 13:40:15 -07:00
Alexander Brown
477f9c7631 chore: upgrade nx 22.5.2 → 22.6.1 (#10370)
## Summary

Upgrade Nx from 22.5.2 to 22.6.1.

## Changes

- **What**: Bumped nx, @nx/eslint, @nx/playwright, @nx/storybook, and
@nx/vite from 22.5.2 to 22.6.1.
- **Dependencies**: nx, @nx/eslint, @nx/playwright, @nx/storybook,
@nx/vite updated to 22.6.1.

## Review Focus

All Nx migrations ran with no changes needed — this is a straightforward
version bump.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10370-chore-upgrade-nx-22-5-2-22-6-1-32a6d73d36508191988bdc7376dc5e14)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-21 17:41:36 +00:00
pythongosssss
dee494f019 feat: App mode - Switch to Nodes 2.0 when entering builder (#10337)
## Summary

We've had some reports of issues selecting inputs/nodes when trying to
use the builder in LiteGraph mode and due to the complexity of the
canvas system, we're going to enable Nodes 2.0 when entering the builder
to ensure the best experience.

## Changes

- **What**:  
- When entering builder select mode automatically switch to Nodes 2.0
- Extract reusable component from features toast
- Show popup telling user the mode was changed
- Add hidden setting for storing "don't show again" on the switch popup

## Review Focus
- I have not removed the LiteGraph selection code in case someone still
manages to enter the builder in LiteGraph mode, this should be cleaned
up in future

## Screenshots (if applicable)

<img width="423" height="224" alt="image"
src="https://github.com/user-attachments/assets/cc2591bc-e5dc-47ef-a3c6-91ca7b6066ff"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10337-feat-App-mode-Switch-to-Nodes-2-0-when-entering-builder-3296d73d3650818e9f3cdaac59d15609)
by [Unito](https://www.unito.io)
2026-03-21 10:38:59 -07:00
pythongosssss
39864b67d8 feat: App mode - Show filename on previews (#10364)
## Summary

Shows the name of the file allowing users to update the filename_prefix
widget on nodes to easier identify which output is for which node

## Changes

- **What**: Pass output display name or filename and use as label

## Screenshots (if applicable)

<img width="586" height="203" alt="image"
src="https://github.com/user-attachments/assets/8d989e61-aa47-4644-8738-159dbf4430e0"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10364-feat-App-mode-Show-filename-on-previews-32a6d73d36508133b53df619e8f0bea1)
by [Unito](https://www.unito.io)
2026-03-21 10:28:24 -07:00
Alexander Brown
32bd570855 chore: clean up knip config, upgrade to v6, remove unused exports (#10348)
## Summary

Clean up stale knip configuration, upgrade to v6, and remove unused
exports.

## Changes

- **What**: Upgrade knip 5.75.1 → 6.0.1; remove 13 stale/redundant
config entries; remove custom CSS compiler (replaced by v6 built-in);
move CSS plugin deps to design-system package; fix husky pre-commit
binary detection; remove 3 unused exports (`MaintenanceTaskRunner`,
`ClipspaceDialog`, `Load3dService`)
- **Dependencies**: knip ^5.75.1 → ^6.0.1; tailwindcss-primeui and
tw-animate-css moved from root to packages/design-system

## Review Focus

- The husky pre-commit change from `pnpm exec lint-staged` to `npx
--no-install lint-staged` works around a knip script parser limitation
([knip#743](https://github.com/webpro-nl/knip/issues/743))
- knip v6 drops Node.js 18 support (requires ≥20.19.0) and removes
`classMembers` issue type

Co-authored-by: Amp <amp@ampcode.com>
2026-03-21 09:43:14 -07:00
Yourz
811d58aef7 feat: enable Quiver AI icon for partner nodes (#10366)
## Summary

<!-- One sentence describing what changed and why. -->

Enable Quiver AI icon for partner nodes

## Changes

- **What**: <!-- Core functionality added/modified -->
  - Enable  Quiver AI icon for partner nodes

No Quiver AI nodes now, just mock the data to see how it will look
<img width="1062" height="926" alt="image"
src="https://github.com/user-attachments/assets/1b81a1cc-d72c-413d-bd75-a63925c27a4b"
/>


## Review Focus
When Quiver AI nodes provided, the icon should show at both node library
tree, but also the PreviewCard label.


<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10366-feat-enable-Quiver-AI-icon-for-partner-nodes-32a6d73d365081d4801ec2619bd2c77c)
by [Unito](https://www.unito.io)
2026-03-21 23:29:09 +08:00
Jin Yi
7d9fa2bfc5 feat: add WaveAudioPlayer with waveform visualization and authenticated audio fetch (#10158)
## Summary

Add a waveform-based audio player component (`WaveAudioPlayer`)
replacing the native `<audio>` element, with authenticated API fetch for
cloud audio playback.

## Changes

- **What**:
- Add `useWaveAudioPlayer` composable with waveform visualization from
audio data (Web Audio API `decodeAudioData`), playback controls, and
seek support
- Add `WaveAudioPlayer.vue` component with compact (inline waveform +
time) and expanded (full transport controls) variants
- Replace native `<audio>` in `MediaAudioTop.vue` and `ResultAudio.vue`
with `WaveAudioPlayer`
- Use `api.fetchApi()` instead of bare `fetch()` to include Firebase JWT
auth headers, fixing 401 errors in cloud environments
  - Add Storybook stories and unit tests

## Review Focus

- The audio URL is fetched via `api.fetchApi()` with auth headers,
converted to a Blob URL, then passed to the native `<audio>` element.
This avoids 401 Unauthorized in cloud environments where `/api/view`
requires authentication.
- URL-to-route extraction logic (`url.includes(apiBase)`) handles both
full API URLs and relative paths.


[screen-capture.webm](https://github.com/user-attachments/assets/44e61812-0391-4b47-a199-92927e75f8b4)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10158-feat-add-WaveAudioPlayer-with-waveform-visualization-and-authenticated-audio-fetch-3266d73d365081beab3fc6274c39fcd4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-20 16:48:28 -07:00
AustinMroz
2b51babbcd Don't use reactives for app mode selections (#10342)
- The DraggableList component takes a v-model.
- When a drag is completed, it reassigns to v-model so an update event
fires
  - App Builder sets v-model="appModeStore.selectedInputs"
- Thus a completed drag operation effectively performs
appModeStore.selectedInputs = newList
- In appModeStore, selectedInputs is a reactive. Thus, after a drag/drop
operation occurs, selectedInputs in the store is not the same value as
appModeStore.selectedInputs

When a reliable repro for the issue had not yet been found, an attempted
earlier fix for this was to swap from watchers to directly updating
`selectedInputs`. Since this change makes the code cleaner and still
make the code safer, the change is left in place.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10342-Don-t-use-watcher-for-loading-app-mode-selections-3296d73d365081f7b216e79c4f4f4e8d)
by [Unito](https://www.unito.io)
2026-03-20 16:07:20 -07:00
jaeone94
cc0ba2d471 refactor: extract helpers from _removeDuplicateLinks and add integration tests (#10332) 2026-03-21 08:03:55 +09:00
61 changed files with 3252 additions and 1049 deletions

View File

@@ -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/

View File

@@ -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

View 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
}

View File

@@ -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')

View File

@@ -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 ({

View File

@@ -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])
)
})
})

View File

@@ -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: {

View File

@@ -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']
},

View File

@@ -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

View File

@@ -36,5 +36,6 @@
"targetName": "e2e"
}
}
]
],
"analytics": false
}

View File

@@ -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:",

View File

@@ -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:",

View File

@@ -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}]");

View 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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')

View 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>

View 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')
})
})

View 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>

View 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>'
})
]
}

View 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>

View File

@@ -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'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',

View 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()
})
})

View 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
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}

View 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: {}
}
]
}
}

View 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
}
}
}

View File

@@ -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"

View File

@@ -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<{

View File

@@ -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]>

View File

@@ -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'],

View File

@@ -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)'
)
}
}
}

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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>

View File

@@ -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') {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()

View File

@@ -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

View File

@@ -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
})
/**

View File

@@ -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()

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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() {}

View File

@@ -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()
)
})
})
})

View File

@@ -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
}
})

View File

@@ -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 {

View File

@@ -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
View 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'

View File

@@ -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": "./"
},

View File

@@ -1,3 +1,4 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
import 'vue'