Compare commits

...

18 Commits

Author SHA1 Message Date
Alexander Brown
816e375a05 fix: resolve all lint warnings (#9972)
Resolve all lint warnings (3 oxlint + 1 eslint).

## Changes

- Replace `it.todo` with `it.skip` in subgraph tests (`warn-todo`)
- Move `vi.mock` to top-level in `firebaseAuthStore.test.ts`
(`hoisted-apis-on-top`)
- Rename `DOMPurify` default import to `dompurify`
(`no-named-as-default`)

---

### The Villager Who Ignored the Warnings

Once there lived a villager whose compiler whispered of lint. "They are
only *warnings*," she said, and went about her day. One warning became
three. Three became thirty. The yellow text grew like ivy across the
terminal, until no one could tell the warnings from the errors. One
morning a real error appeared — a misplaced mock, a shadowed import —
but nobody noticed, for the village had long since learned to stop
reading. The build shipped. The users wept. And the warning, faithful to
the last, sat quietly in the log where it had always been.

*Moral: Today's warning is tomorrow's incident report.*

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9972-fix-resolve-all-lint-warnings-3246d73d3650810a89cde5d05e79d948)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-20 00:14:51 +00:00
Comfy Org PR Bot
8de7795219 [backport cloud/1.42] fix: configure nested subgraph definitions in dependency order (#10318)
Backport of #10314 to `cloud/1.42`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-19 15:46:15 -07:00
Comfy Org PR Bot
57a1d6cfa3 [backport cloud/1.42] fix: prevent nested SubgraphNode input slots from doubling on reload (#10285)
Backport of #10187 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10285-backport-cloud-1-42-fix-prevent-nested-SubgraphNode-input-slots-from-doubling-on-relo-3286d73d3650812e826ed1ec9f2ec4bd)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-19 09:24:05 +09:00
Comfy Org PR Bot
5718e584a9 [backport cloud/1.42] fix: resync slot layouts when switching between app mode and graph mode (#10283)
Backport of #10273 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10283-backport-cloud-1-42-fix-resync-slot-layouts-when-switching-between-app-mode-and-graph-3276d73d365081b8a329e48f54cc0cc9)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 17:01:58 -07:00
Comfy Org PR Bot
25a353aa16 [backport cloud/1.42] fix: App mode - handle socket/response race when tracking jobs (#10268)
Backport of #10244 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10268-backport-cloud-1-42-fix-App-mode-handle-socket-response-race-when-tracking-jobs-3276d73d3650816ba4c5c827d27f9e67)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-18 12:58:53 -07:00
Comfy Org PR Bot
9f904b1e44 [backport cloud/1.42] feat: App mode - update keybindings (#10251)
Backport of #9794 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10251-backport-cloud-1-42-feat-App-mode-update-keybindings-3276d73d3650819cb710c922a29ccead)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-18 12:39:30 -07:00
Comfy Org PR Bot
55710dfbba [backport cloud/1.42] fix: resync vue node layout store after legacy normalization (#10264)
Backport of #10256 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10264-backport-cloud-1-42-fix-resync-vue-node-layout-store-after-legacy-normalization-3276d73d3650813099dfe0f5105d9424)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 19:13:59 +00:00
Comfy Org PR Bot
ed96ffba93 [backport cloud/1.42] fix: track nodePreviewImages in usePromotedPreviews (#10200)
Backport of #10165 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10200-backport-cloud-1-42-fix-track-nodePreviewImages-in-usePromotedPreviews-3266d73d365081e2bc82f557e254d54a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-17 14:38:19 -07:00
Comfy Org PR Bot
32801ccc21 [backport cloud/1.42] fix: replace stale-request guard with single-flight coalescing in queueStore.update() (#10217)
Backport of #10203 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10217-backport-cloud-1-42-fix-replace-stale-request-guard-with-single-flight-coalescing-in--3266d73d365081729194e5f9151b92ac)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 14:37:43 -07:00
Comfy Org PR Bot
cfadc35b18 [backport cloud/1.42] fix: enable 3D thumbnail support for cloud environments (#10207)
Backport of #10121 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10207-backport-cloud-1-42-fix-enable-3D-thumbnail-support-for-cloud-environments-3266d73d365081e2bb07fe887f3f327d)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-17 13:52:01 -07:00
Comfy Org PR Bot
6fc9d1c0e3 [backport cloud/1.42] feat: migrate 13 priority events from Mixpanel-only to GA4 via GTM (#10183)
Backport of #9770 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10183-backport-cloud-1-42-feat-migrate-13-priority-events-from-Mixpanel-only-to-GA4-via-GTM-3266d73d3650812da265caae234f4b1b)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-17 07:18:43 -07:00
Comfy Org PR Bot
e11d5548a1 [backport cloud/1.42] fix: resolve nodes in subgraphs for image copy/paste and display (#10185)
Backport of #10009 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10185-backport-cloud-1-42-fix-resolve-nodes-in-subgraphs-for-image-copy-paste-and-display-3266d73d365081b5a776c60758acfb81)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 14:14:04 +00:00
Comfy Org PR Bot
ccb3e33eb8 [backport cloud/1.42] feat: resolveVirtualOutput for cross-subgraph virtual nodes (eg. Set/Get) (#10182)
Backport of #10111 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10182-backport-cloud-1-42-feat-resolveVirtualOutput-for-cross-subgraph-virtual-nodes-eg-S-3266d73d36508133b43bda8a89aeaf7f)
by [Unito](https://www.unito.io)

Co-authored-by: Jukka Seppänen <40791699+kijai@users.noreply.github.com>
2026-03-17 14:07:16 +00:00
Comfy Org PR Bot
ed40779cdc [backport cloud/1.42] feat: add linear interpolation type to CURVE widget (#10173)
Backport of #10118 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10173-backport-cloud-1-42-feat-add-linear-interpolation-type-to-CURVE-widget-3266d73d365081fc80b1edd8e33b7d68)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-17 04:14:30 -07:00
Comfy Org PR Bot
d809f51831 [backport cloud/1.42] feat: improve essentials tab blueprint support and display names (#10160)
Backport of #10113 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10160-backport-cloud-1-42-feat-improve-essentials-tab-blueprint-support-and-display-names-3266d73d365081e78530df79d35f73cf)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-17 01:51:00 -07:00
Comfy Org PR Bot
a20258abd1 [backport cloud/1.42] fix: prune orphaned SubgraphNode inputs after configure (#10150)
Backport of #10020 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10150-backport-cloud-1-42-fix-prune-orphaned-SubgraphNode-inputs-after-configure-3266d73d3650817493ebe1144f6450d3)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 01:24:12 -07:00
Comfy Org PR Bot
53458b5ada [backport cloud/1.42] fix: show webcam capture button in Vue renderer (#10144)
Backport of #9936 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10144-backport-cloud-1-42-fix-show-webcam-capture-button-in-Vue-renderer-3266d73d365081038006f3dd80dd6a00)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 01:24:01 -07:00
Comfy Org PR Bot
08b501b18f [backport cloud/1.42] Feat/3d thumbnail inline rendering (#10047)
Backport of #9471 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10047-backport-cloud-1-42-Feat-3d-thumbnail-inline-rendering-3256d73d36508198bf09c25c9489b089)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-16 06:46:51 -07:00
87 changed files with 2843 additions and 788 deletions

View File

@@ -0,0 +1,110 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
comfyPage
}) => {
const warnings: string[] = []
comfyPage.page.on('console', (msg) => {
const text = msg.text()
if (
text.includes('No link found') ||
text.includes('Failed to resolve legacy -1') ||
text.includes('No inner link found')
) {
warnings.push(text)
}
})
await comfyPage.workflow.loadWorkflow(WORKFLOW)
expect(warnings).toEqual([])
})
test('All three subgraph levels resolve promoted widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const results = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
return allGraphs.flatMap((g) =>
g._nodes
.filter(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((hostNode) => {
const proxyWidgets = Array.isArray(
hostNode.properties?.proxyWidgets
)
? hostNode.properties.proxyWidgets
: []
const widgetEntries = proxyWidgets
.filter(
(e: unknown): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string'
)
.map(([interiorNodeId, widgetName]: [string, string]) => {
const sg = hostNode.isSubgraphNode() ? hostNode.subgraph : null
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
return {
interiorNodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return {
hostNodeId: String(hostNode.id),
widgetEntries
}
})
)
})
expect(
results.length,
'Should have subgraph host nodes at multiple nesting levels'
).toBeGreaterThanOrEqual(2)
for (const { hostNodeId, widgetEntries } of results) {
expect(
widgetEntries.length,
`Host node ${hostNodeId} should have promoted widgets`
).toBeGreaterThan(0)
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
expect(widgetName).toBeTruthy()
expect(
resolved,
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
).toBe(true)
}
}
})
test('Prompt execution succeeds without 400 error', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const response = await responsePromise
expect(response.status()).not.toBe(400)
})
})

View File

@@ -2,9 +2,116 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../fixtures/ComfyPage'
const CREATE_GROUP_HOTKEY = 'Control+g'
type NodeGroupCenteringError = {
horizontal: number
vertical: number
}
type NodeGroupCenteringErrors = {
innerGroup: NodeGroupCenteringError
outerGroup: NodeGroupCenteringError
}
const LEGACY_VUE_CENTERING_BASELINE: NodeGroupCenteringErrors = {
innerGroup: {
horizontal: 16.308832840862777,
vertical: 17.390899314547084
},
outerGroup: {
horizontal: 20.30164329441476,
vertical: 42.196324096481476
}
} as const
const CENTERING_TOLERANCE = {
innerGroup: 6,
outerGroup: 12
} as const
function expectWithinBaseline(
actual: number,
baseline: number,
tolerance: number
) {
expect(Math.abs(actual - baseline)).toBeLessThan(tolerance)
}
async function getNodeGroupCenteringErrors(
comfyPage: ComfyPage
): Promise<NodeGroupCenteringErrors> {
return comfyPage.page.evaluate(() => {
type GraphNode = {
id: number | string
pos: ReadonlyArray<number>
}
type GraphGroup = {
title: string
pos: ReadonlyArray<number>
size: ReadonlyArray<number>
}
const app = window.app!
const node = app.graph.nodes[0] as GraphNode | undefined
if (!node) {
throw new Error('Expected a node in the loaded workflow')
}
const nodeElement = document.querySelector<HTMLElement>(
`[data-node-id="${node.id}"]`
)
if (!nodeElement) {
throw new Error(`Vue node element not found for node ${node.id}`)
}
const groups = app.graph.groups as GraphGroup[]
const innerGroup = groups.find((group) => group.title === 'Inner Group')
const outerGroup = groups.find((group) => group.title === 'Outer Group')
if (!innerGroup || !outerGroup) {
throw new Error('Expected both Inner Group and Outer Group in graph')
}
const nodeRect = nodeElement.getBoundingClientRect()
const getCenteringError = (group: GraphGroup): NodeGroupCenteringError => {
const [groupStartX, groupStartY] = app.canvasPosToClientPos([
group.pos[0],
group.pos[1]
])
const [groupEndX, groupEndY] = app.canvasPosToClientPos([
group.pos[0] + group.size[0],
group.pos[1] + group.size[1]
])
const groupLeft = Math.min(groupStartX, groupEndX)
const groupRight = Math.max(groupStartX, groupEndX)
const groupTop = Math.min(groupStartY, groupEndY)
const groupBottom = Math.max(groupStartY, groupEndY)
const leftGap = nodeRect.left - groupLeft
const rightGap = groupRight - nodeRect.right
const topGap = nodeRect.top - groupTop
const bottomGap = groupBottom - nodeRect.bottom
return {
horizontal: Math.abs(leftGap - rightGap),
vertical: Math.abs(topGap - bottomGap)
}
}
return {
innerGroup: getCenteringError(innerGroup),
outerGroup: getCenteringError(outerGroup)
}
})
}
test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
@@ -74,4 +181,45 @@ test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
}).toPass({ timeout: 5000 })
})
test('should keep groups aligned after loading legacy Vue workflows', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.vueNodes.waitForNodes(1)
const workflowRendererVersion = await comfyPage.page.evaluate(() => {
const extra = window.app!.graph.extra as
| { workflowRendererVersion?: string }
| undefined
return extra?.workflowRendererVersion
})
expect(workflowRendererVersion).toMatch(/^Vue/)
await expect(async () => {
const centeringErrors = await getNodeGroupCenteringErrors(comfyPage)
expectWithinBaseline(
centeringErrors.innerGroup.horizontal,
LEGACY_VUE_CENTERING_BASELINE.innerGroup.horizontal,
CENTERING_TOLERANCE.innerGroup
)
expectWithinBaseline(
centeringErrors.innerGroup.vertical,
LEGACY_VUE_CENTERING_BASELINE.innerGroup.vertical,
CENTERING_TOLERANCE.innerGroup
)
expectWithinBaseline(
centeringErrors.outerGroup.horizontal,
LEGACY_VUE_CENTERING_BASELINE.outerGroup.horizontal,
CENTERING_TOLERANCE.outerGroup
)
expectWithinBaseline(
centeringErrors.outerGroup.vertical,
LEGACY_VUE_CENTERING_BASELINE.outerGroup.vertical,
CENTERING_TOLERANCE.outerGroup
)
}).toPass({ timeout: 5000 })
})
})

View File

@@ -18,7 +18,7 @@
@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}]");
/* 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,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent}]");
@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}]");
@custom-variant touch (@media (hover: none));

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 10H20.6667C21.403 10 22 10.597 22 11.3333V20.6667C22 21.403 21.403 22 20.6667 22H11.3333C10.597 22 10 21.403 10 20.6667V17M14 15.5V18.6667L18 16L15 14M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 19C3 20.1046 3.79594 21 4.77778 21H11V3H4.77778C3.79594 3 3 3.89543 3 5M3 19V5M3 19C3 19.5304 3.21071 20.0391 3.58579 20.4142C3.96086 20.7893 4.46957 21 5 21M3 5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3M11 1L11 23M21 15L17.9 11.9C17.5237 11.5312 17.017 11.3258 16.4901 11.3284C15.9632 11.331 15.4586 11.5415 15.086 11.914L14 13M11 16L6 21M14 3H19.1538C20.1734 3 21 3.89543 21 5V19C21 20.1046 20.1734 21 19.1538 21H14V3ZM11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 14.9999L17.914 11.9139C17.5389 11.539 17.0303 11.3284 16.5 11.3284C15.9697 11.3284 15.4611 11.539 15.086 11.9139L12.6935 14.3064M14.5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5C3.89543 3 3 3.89543 3 5V13M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM2.03125 18.6735C1.98958 18.5613 1.98958 18.4378 2.03125 18.3255C2.43708 17.3415 3.12595 16.5001 4.01054 15.9081C4.89512 15.3161 5.93558 15 7 15C8.06442 15 9.10488 15.3161 9.98946 15.9081C10.874 16.5001 11.5629 17.3415 11.9687 18.3255C12.0104 18.4378 12.0104 18.5613 11.9687 18.6735C11.5629 19.6575 10.874 20.4989 9.98946 21.0909C9.10488 21.683 8.06442 21.999 7 21.999C5.93558 21.999 4.89512 21.683 4.01054 21.0909C3.12595 20.4989 2.43708 19.6575 2.03125 18.6735ZM8.49992 18.4995C8.49992 19.3278 7.82838 19.9994 6.99999 19.9994C6.17161 19.9994 5.50007 19.3278 5.50007 18.4995C5.50007 17.6711 6.17161 16.9995 6.99999 16.9995C7.82838 16.9995 8.49992 17.6711 8.49992 18.4995Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.5 13.7503L20.5 15.7503M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM21.5871 14.6562C21.8514 14.3919 22 14.0334 22 13.6596C22 13.2858 21.8516 12.9273 21.5873 12.6629C21.323 12.3986 20.9645 12.25 20.5907 12.25C20.2169 12.25 19.8584 12.3984 19.594 12.6627L12.921 19.3373C12.8049 19.453 12.719 19.5955 12.671 19.7523L12.0105 21.9283C11.9975 21.9715 11.9966 22.0175 12.0076 22.0612C12.0187 22.105 12.0414 22.1449 12.0734 22.1768C12.1053 22.2087 12.1453 22.2313 12.189 22.2423C12.2328 22.2533 12.2787 22.2523 12.322 22.2393L14.4985 21.5793C14.6551 21.5317 14.7976 21.4463 14.9135 21.3308L21.5871 14.6562Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9.5M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 15H4.33333C3.59695 15 3 14.403 3 13.6667V4.33333C3 3.59695 3.59695 3 4.33333 3H13.6667C14.403 3 15 3.59695 15 4.33333V11L12.9427 8.94263C12.6926 8.69267 12.3536 8.55225 12 8.55225C11.6464 8.55225 11.3074 8.69267 11.0573 8.94263L5 15M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M18 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M10 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V18M8.33333 7C8.33333 7.73638 7.73638 8.33333 7 8.33333C6.26362 8.33333 5.66667 7.73638 5.66667 7C5.66667 6.26362 6.26362 5.66667 7 5.66667C7.73638 5.66667 8.33333 6.26362 8.33333 7ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 17.9999V20.6666C10 21.403 10.597 21.9999 11.3333 21.9999H20.6667C21.403 21.9999 22 21.403 22 20.6666V11.3333C22 10.5969 21.403 9.99994 20.6667 9.99994H18M14 16.9999V18.6666L18 15.9999L16.6579 15.1052M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 21.9999L7 19.9999M7 19.9999H4C3.46957 19.9999 2.96086 19.7892 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 17.9999V16.9999M7 19.9999L5 17.9999M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M8 4.63322L8.87155 4.08134C8.95314 4.02967 9.05313 4.01593 9.14563 4.04367L10 4.29989M8 4.63322L7.12845 4.08134C7.04686 4.02967 6.94687 4.01593 6.85437 4.04367L6 4.29989M8 4.63322V6.63322M8 8.96655L6.74997 9.90408C6.69573 9.94476 6.65518 10.001 6.63374 10.0653L6 11.9666M8 8.96655L9.25003 9.90408C9.30427 9.94476 9.34482 10.001 9.36626 10.0653L10 11.9666M8 8.96655V6.63322M8 6.63322H9.86193C9.95033 6.63322 10.0351 6.66834 10.0976 6.73085L10.9489 7.58216C10.9826 7.61581 11.0086 7.65627 11.0254 7.70083L11.5 8.96655M8 6.63322H6.13807C6.04967 6.63322 5.96488 6.66834 5.90237 6.73085L5.05205 7.58117C5.01776 7.61546 4.99137 7.65681 4.97471 7.70235L4.5 9M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M8 4.63322L8.87155 4.08134C8.95314 4.02967 9.05313 4.01593 9.14563 4.04367L10 4.29989M8 4.63322L7.12845 4.08134C7.04686 4.02967 6.94687 4.01593 6.85437 4.04367L6 4.29989M8 4.63322V6.63322M8 8.96655L6.74997 9.90408C6.69573 9.94476 6.65518 10.001 6.63374 10.0653L6 11.9666M8 8.96655L9.25003 9.90408C9.30427 9.94476 9.34482 10.001 9.36626 10.0653L10 11.9666M8 8.96655V6.63322M8 6.63322H9.86193C9.95033 6.63322 10.0351 6.66834 10.0976 6.73085L10.9489 7.58216C10.9826 7.61581 11.0086 7.65627 11.0254 7.70083L11.5 8.96655M8 6.63322H6.13807C6.04967 6.63322 5.96488 6.66834 5.90237 6.73085L5.05205 7.58117C5.01776 7.61546 4.99137 7.65681 4.97471 7.70235L4.5 9M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -1,5 +0,0 @@
<svg width="49" height="24" viewBox="0 0 49 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5243 11.7208C26.7584 11.955 26.7583 12.3351 26.5243 12.5694L23.9794 15.1153C23.7452 15.3495 23.3651 15.3493 23.1308 15.1153C22.8965 14.881 22.8965 14.501 23.1308 14.2667L24.6474 12.7501L17.0995 12.7501C16.7683 12.7499 16.4999 12.4807 16.4999 12.1495C16.5001 11.8184 16.7685 11.5501 17.0995 11.5499L24.6571 11.5499L23.1308 10.0235C22.8965 9.7892 22.8965 9.40919 23.1308 9.17489C23.3651 8.94127 23.7453 8.94083 23.9794 9.17489L26.5243 11.7208Z" fill="#8A8A8A"/>
<path d="M4.50779 3.61371C4.6735 3.55748 4.85537 3.56703 5.0156 3.64203L6.14353 4.16938L6.9199 3.685L6.99509 3.64496C7.17632 3.56122 7.38691 3.56099 7.57029 3.64692L8.95798 4.29633C9.2306 4.42419 9.34839 4.74924 9.22068 5.02192C9.09287 5.29443 8.76771 5.4121 8.49509 5.28461L7.30857 4.72797L6.5449 5.20551V6.97211H9.32127C9.55299 6.97221 9.76687 7.08954 9.89158 7.27973L9.93943 7.36567L11.2588 10.1918C11.2714 10.2189 11.282 10.2474 11.291 10.2758L11.3125 10.3627L11.9902 14.2407C12.042 14.5373 11.8435 14.8206 11.5469 14.8725C11.2505 14.924 10.9681 14.7254 10.916 14.4291L10.247 10.6049L9.06052 8.06293H6.5449V11.6079C6.54489 11.6553 6.53579 11.7007 6.52439 11.7446H7.03318C7.26977 11.7446 7.48688 11.8665 7.61033 12.0629L7.6572 12.1518L8.89548 14.9711C8.9258 15.0402 8.94492 15.1138 8.95115 15.1889L9.26951 19.0629C9.29388 19.3627 9.07125 19.6259 8.77146 19.6508C8.47137 19.6755 8.20837 19.4519 8.18357 19.1518L7.86912 15.3471L6.7656 12.8344H5.52048L4.12693 15.3715L3.81541 19.1518C3.79062 19.4519 3.52762 19.6755 3.22752 19.6508C2.92759 19.626 2.70508 19.3628 2.72947 19.0629L3.04685 15.1957L3.05662 15.1245C3.06994 15.0542 3.09436 14.9862 3.12888 14.9233L4.68064 12.0981L4.73045 12.02C4.85779 11.8482 5.05991 11.7448 5.27732 11.7446H5.47361C5.46224 11.7007 5.45409 11.6553 5.45408 11.6079V8.06293H3.24412L1.74509 10.6323L1.08103 14.1127C1.02439 14.4084 0.738116 14.6018 0.44236 14.5454C0.146978 14.4885 -0.0465728 14.2032 0.00974259 13.9077L0.687477 10.3598L0.718727 10.2485C0.732186 10.2126 0.748186 10.1772 0.767555 10.144L2.42088 7.31C2.54303 7.10073 2.76744 6.97221 3.00974 6.97211H5.45408V5.05121L4.72654 4.71039L3.50388 5.28461C3.23123 5.41225 2.90614 5.2945 2.7783 5.02192C2.65052 4.74922 2.76835 4.4242 3.04099 4.29633L4.43748 3.64203L4.50779 3.61371Z" fill="#8A8A8A"/>
<path d="M44.7027 5C46.7971 5 48.5097 6.71671 48.5097 8.84956V14.3804C48.5097 16.5133 46.7971 18.23 44.7027 18.23H35.3067C33.2122 18.23 31.4997 16.5133 31.4997 14.3804V8.84956C31.4997 6.71671 33.2122 5 35.3067 5H44.7027ZM35.3067 6.37812C33.9312 6.37812 32.8496 7.48086 32.8496 8.84956V14.3804C32.8496 15.7491 33.9312 16.8519 35.3067 16.8519H44.7027C46.0781 16.8519 47.1597 15.7491 47.1597 14.3804V8.84956C47.1597 7.48086 46.0781 6.37812 44.7027 6.37812H35.3067ZM38.0595 8.12949C38.1749 8.13322 38.2881 8.16994 38.3859 8.23544L42.6927 11.0009C42.9191 11.1419 43.0022 11.403 43.0022 11.615C43.0022 11.8269 42.9187 12.0878 42.6924 12.2288L38.3862 14.9946C38.1806 15.132 37.9132 15.1341 37.706 15.002L37.7043 15.0009C37.4997 14.8683 37.3782 14.6208 37.3845 14.3727V8.84956C37.3797 8.49772 37.6466 8.14891 38.0086 8.13007L38.0595 8.12949ZM38.7038 13.1249L41.0623 11.6144L38.7038 10.0996V13.1249ZM42.675 11.8255C42.6603 11.8574 42.6421 11.887 42.6201 11.913L42.6503 11.8717C42.6595 11.8571 42.6677 11.8414 42.675 11.8255Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 14.5V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17.5M11.3333 2H2M14 6H2M10.0667 9.93327H2M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V14M7 20L5 18M14 13.3333L18 16L14 18.6667V13.3333Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 14.5V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17.5M11.3333 2H2M14 6H2M10.0667 9.93327H2M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V14M7 20L5 18M14 13.3333L18 16L14 18.6667V13.3333Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -8,7 +8,7 @@
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 min-w-0 p-0 pb-6"
class="m-0 min-w-0 p-0 pb-2"
>
<TreeVirtualizer
v-slot="{ item }"

View File

@@ -12,6 +12,7 @@ import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -23,6 +24,7 @@ const { source, align = 'start' } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const dropdownOpen = ref(false)
const { menuItems } = useWorkflowActionsMenu(
@@ -43,6 +45,16 @@ function handleOpen(open: boolean) {
}
}
function toggleModeTooltip() {
const label = canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
}
function toggleLinearMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
@@ -52,7 +64,14 @@ function toggleLinearMode() {
const tooltipPt = {
root: {
style: { transform: 'translateX(calc(50% - 16px))' }
style: {
transform: 'translateX(calc(50% - 16px))',
whiteSpace: 'nowrap',
maxWidth: 'none'
}
},
text: {
style: { whiteSpace: 'nowrap' }
},
arrow: {
class: '!left-[16px]'
@@ -68,9 +87,7 @@ const tooltipPt = {
>
<Button
v-tooltip.bottom="{
value: canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode'),
value: toggleModeTooltip(),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt

View File

@@ -82,23 +82,25 @@
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { computed, toRef, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import { cn } from '@/utils/tailwindUtil'
import type { CurvePoint } from './types'
import type { CurveInterpolation, CurvePoint } from './types'
import { histogramToPath } from './curveUtils'
const {
curveColor = 'white',
histogram,
disabled = false
disabled = false,
interpolation = 'monotone_cubic'
} = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
disabled?: boolean
interpolation?: CurveInterpolation
}>()
const modelValue = defineModel<CurvePoint[]>({
@@ -109,7 +111,8 @@ const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
svgRef,
modelValue
modelValue,
interpolation: toRef(() => interpolation)
})
function onSvgPointerDown(e: PointerEvent) {

View File

@@ -1,9 +1,30 @@
<template>
<CurveEditor
:model-value="effectivePoints"
:disabled="isDisabled"
@update:model-value="modelValue = $event"
/>
<div class="flex flex-col gap-1">
<Select
v-if="!isDisabled"
:model-value="modelValue.interpolation"
@update:model-value="onInterpolationChange"
>
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="interp in CURVE_INTERPOLATIONS"
:key="interp"
:value="interp"
>
{{ $t(`curveWidget.${interp}`) }}
</SelectItem>
</SelectContent>
</Select>
<CurveEditor
:model-value="effectiveCurve.points"
:disabled="isDisabled"
:interpolation="effectiveCurve.interpolation"
@update:model-value="onPointsChange"
/>
</div>
</template>
<script setup lang="ts">
@@ -15,31 +36,53 @@ import {
} from '@/composables/useUpstreamValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import CurveEditor from './CurveEditor.vue'
import { isCurvePointArray } from './curveUtils'
import type { CurvePoint } from './types'
import { isCurveData } from './curveUtils'
import { CURVE_INTERPOLATIONS } from './types'
import type { CurveData, CurveInterpolation, CurvePoint } from './types'
const { widget } = defineProps<{
widget: SimplifiedWidget
}>()
const modelValue = defineModel<CurvePoint[]>({
default: () => [
[0, 0],
[1, 1]
]
const modelValue = defineModel<CurveData>({
default: () => ({
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
})
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isCurvePointArray)
singleValueExtractor(isCurveData)
)
const effectivePoints = computed(() =>
const effectiveCurve = computed(() =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value
)
function onPointsChange(points: CurvePoint[]) {
modelValue.value = { ...modelValue.value, points }
}
function onInterpolationChange(value: unknown) {
if (typeof value !== 'string') return
modelValue.value = {
...modelValue.value,
interpolation: value as CurveInterpolation
}
}
</script>

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import {
createLinearInterpolator,
createMonotoneInterpolator,
curvesToLUT,
histogramToPath
@@ -73,6 +74,64 @@ describe('createMonotoneInterpolator', () => {
})
})
describe('createLinearInterpolator', () => {
it('returns 0 for empty points', () => {
const interpolate = createLinearInterpolator([])
expect(interpolate(0.5)).toBe(0)
})
it('returns constant for single point', () => {
const interpolate = createLinearInterpolator([[0.5, 0.7]])
expect(interpolate(0)).toBe(0.7)
expect(interpolate(1)).toBe(0.7)
})
it('passes through control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.8],
[1, 1]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0)
expect(interpolate(0.5)).toBeCloseTo(0.8, 10)
expect(interpolate(1)).toBe(1)
})
it('linearly interpolates between points', () => {
const points: CurvePoint[] = [
[0, 0],
[1, 1]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0.25)).toBeCloseTo(0.25, 10)
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
expect(interpolate(0.75)).toBeCloseTo(0.75, 10)
})
it('clamps to endpoint values outside range', () => {
const points: CurvePoint[] = [
[0.2, 0.3],
[0.8, 0.9]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0.3)
expect(interpolate(1)).toBe(0.9)
})
it('handles unsorted input points', () => {
const points: CurvePoint[] = [
[1, 1],
[0, 0],
[0.5, 0.5]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0)
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
expect(interpolate(1)).toBe(1)
})
})
describe('curvesToLUT', () => {
it('returns a 256-entry Uint8Array', () => {
const lut = curvesToLUT([

View File

@@ -1,19 +1,70 @@
import type { CurvePoint } from './types'
import { CURVE_INTERPOLATIONS } from './types'
import type { CurveData, CurveInterpolation, CurvePoint } from './types'
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
export function isCurveData(value: unknown): value is CurveData {
if (typeof value !== 'object' || value === null || Array.isArray(value))
return false
const v = value as Record<string, unknown>
return (
Array.isArray(value) &&
value.length >= 2 &&
value.every(
(p) =>
Array.isArray(v.points) &&
v.points.every(
(p: unknown) =>
Array.isArray(p) &&
p.length === 2 &&
typeof p[0] === 'number' &&
typeof p[1] === 'number'
)
) &&
typeof v.interpolation === 'string' &&
CURVE_INTERPOLATIONS.includes(v.interpolation as CurveInterpolation)
)
}
/**
* Piecewise linear interpolation through sorted control points.
* Returns a function that evaluates y for any x in [0, 1].
*/
export function createLinearInterpolator(
points: CurvePoint[]
): (x: number) => number {
if (points.length === 0) return () => 0
if (points.length === 1) return () => points[0][1]
const sorted = [...points].sort((a, b) => a[0] - b[0])
const n = sorted.length
const xs = sorted.map((p) => p[0])
const ys = sorted.map((p) => p[1])
return (x: number): number => {
if (x <= xs[0]) return ys[0]
if (x >= xs[n - 1]) return ys[n - 1]
let lo = 0
let hi = n - 1
while (lo < hi - 1) {
const mid = (lo + hi) >> 1
if (xs[mid] <= x) lo = mid
else hi = mid
}
const dx = xs[hi] - xs[lo]
if (dx === 0) return ys[lo]
const t = (x - xs[lo]) / dx
return ys[lo] + t * (ys[hi] - ys[lo])
}
}
/**
* Factory that dispatches to the correct interpolator based on type.
*/
export function createInterpolator(
points: CurvePoint[],
interpolation: CurveInterpolation
): (x: number) => number {
return interpolation === 'linear'
? createLinearInterpolator(points)
: createMonotoneInterpolator(points)
}
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points
@@ -120,9 +171,12 @@ export function histogramToPath(histogram: Uint32Array): string {
return parts.join(' ')
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
export function curvesToLUT(
points: CurvePoint[],
interpolation: CurveInterpolation = 'monotone_cubic'
): Uint8Array {
const lut = new Uint8Array(256)
const interpolate = createMonotoneInterpolator(points)
const interpolate = createInterpolator(points, interpolation)
for (let i = 0; i < 256; i++) {
const x = i / 255

View File

@@ -1 +1,10 @@
export type CurvePoint = [x: number, y: number]
export const CURVE_INTERPOLATIONS = ['monotone_cubic', 'linear'] as const
export type CurveInterpolation = (typeof CURVE_INTERPOLATIONS)[number]
export interface CurveData {
points: CurvePoint[]
interpolation: CurveInterpolation
}

View File

@@ -164,9 +164,11 @@ import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { UnauthorizedError } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -207,6 +209,7 @@ const workspaceStore = useWorkspaceStore()
const { isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const { linearMode } = storeToRefs(canvasStore)
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const toastStore = useToastStore()
@@ -279,6 +282,22 @@ watch(
const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
watch(
() => linearMode.value,
(isLinearMode) => {
if (!shouldRenderVueNodes.value) return
if (isLinearMode) {
layoutStore.clearAllSlotLayouts()
} else {
// App mode hides the graph canvas with `display: none`, so slot connectors
// need a fresh DOM measurement pass before links can render correctly.
requestSlotLayoutSyncForAllNodes()
}
layoutStore.setPendingSlotSync(true)
}
)
function onLinkOverlayReady(el: HTMLCanvasElement) {
if (!canvasStore.canvas) return

View File

@@ -80,7 +80,7 @@ import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { app } from '@/scripts/app'
import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -101,7 +101,7 @@ if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
node.value = resolveNode(props.nodeId!) ?? null
})
}

View File

@@ -167,7 +167,10 @@ import {
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import {
resolveBlueprintSuffix,
resolveEssentialsDisplayName
} from '@/constants/essentialsDisplayNames'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import TabPanel from '@/components/tab/TabPanel.vue'
@@ -371,11 +374,38 @@ const essentialSections = computed(() => {
)
})
function disambiguateBlueprintLabels(
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
if (!root.children) return root
return {
...root,
children: root.children.map((folder) => {
if (folder.type !== 'folder' || !folder.children) return folder
const labelCounts = new Map<string, number>()
for (const node of folder.children) {
if (node.label)
labelCounts.set(node.label, (labelCounts.get(node.label) ?? 0) + 1)
}
return {
...folder,
children: folder.children.map((node) => {
if ((labelCounts.get(node.label ?? '') ?? 0) <= 1) return node
const suffix = resolveBlueprintSuffix(node.data?.name ?? '')
if (!suffix) return node
return { ...node, label: `${node.label} (${suffix})` }
})
}
})
}
}
const renderedEssentialRoot = computed(() => {
const section = essentialSections.value[0]
return section
const root = section
? fillNodeInfo(applySorting(section.tree), { useEssentialsLabels: true })
: fillNodeInfo({ key: 'root', label: '', children: [] })
return disambiguateBlueprintLabels(root)
})
function flattenRenderedLeaves(

View File

@@ -39,6 +39,7 @@ import { computed, inject } from 'vue'
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { resolveBlueprintIcon } from '@/constants/essentialsDisplayNames'
import { ESSENTIALS_ICON_OVERRIDES } from '@/constants/essentialsNodes'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -73,6 +74,10 @@ const nodeIcon = computed(() => {
const nodeName = node.data?.name
if (nodeName && nodeName in ESSENTIALS_ICON_OVERRIDES)
return ESSENTIALS_ICON_OVERRIDES[nodeName]
if (nodeName) {
const blueprintIcon = resolveBlueprintIcon(nodeName)
if (blueprintIcon) return blueprintIcon
}
const iconName = nodeName ? kebabCase(nodeName) : 'node'
return `icon-[comfy--${iconName}]`
})

View File

@@ -0,0 +1,12 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
}

View File

@@ -1,16 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
}
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
/**
* Composable for handling canvas image previews in nodes

View File

@@ -16,7 +16,7 @@ import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
'nodeOutputs' | 'getNodeImageUrls'
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
>
const getNodeImageUrls = vi.hoisted(() =>
@@ -35,6 +35,7 @@ vi.mock('@/stores/nodeOutputStore', () => {
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
}
}
@@ -71,12 +72,24 @@ function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
}
}
function seedPreviewImages(
subgraphId: string,
entries: Array<{ nodeId: number | string; urls: string[] }>
) {
const store = useNodeOutputStore()
for (const { nodeId, urls } of entries) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
store.nodePreviewImages[locatorId] = urls
}
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
getNodeImageUrls.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
@@ -119,7 +132,7 @@ describe(usePromotedPreviews, () => {
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
@@ -143,9 +156,7 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.webm'
])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('video')
@@ -162,9 +173,7 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.mp3'
])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('audio')
@@ -194,13 +203,11 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10, 20])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
}
)
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
})
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(2)
@@ -208,6 +215,58 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value[1].urls).toEqual(['/view?b=2'])
})
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
)
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
@@ -253,7 +312,7 @@ describe(usePromotedPreviews, () => {
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(1)

View File

@@ -39,16 +39,18 @@ export function usePromotedPreviews(
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
if (!interiorNode) continue
// Read from the reactive nodeOutputs ref to establish Vue
// dependency tracking. getNodeImageUrls reads from the
// non-reactive app.nodeOutputs, so without this access the
// computed would never re-evaluate when outputs change.
// Read from both reactive refs to establish Vue dependency
// tracking. getNodeImageUrls reads from non-reactive
// app.nodeOutputs / app.nodePreviewImages, so without this
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.interiorNodeId
)
const _reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
if (!_reactiveOutputs?.images?.length) continue
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
continue
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
if (!urls?.length) continue

View File

@@ -1,25 +1,37 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
import type { CurvePoint } from '@/components/curve/types'
import { createInterpolator } from '@/components/curve/curveUtils'
import type { CurveInterpolation, CurvePoint } from '@/components/curve/types'
interface UseCurveEditorOptions {
svgRef: Ref<SVGSVGElement | null>
modelValue: Ref<CurvePoint[]>
interpolation: Ref<CurveInterpolation>
}
export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
export function useCurveEditor({
svgRef,
modelValue,
interpolation
}: UseCurveEditorOptions) {
const dragIndex = ref(-1)
let cleanupDrag: (() => void) | null = null
const curvePath = computed(() => {
const points = modelValue.value
if (points.length < 2) return ''
const sorted = [...points].sort((a, b) => a[0] - b[0])
const interpolate = createMonotoneInterpolator(points)
const xMin = points[0][0]
const xMax = points[points.length - 1][0]
if (interpolation.value === 'linear') {
return sorted
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0]},${1 - p[1]}`)
.join('')
}
const interpolate = createInterpolator(sorted, interpolation.value)
const xMin = sorted[0][0]
const xMax = sorted[sorted.length - 1][0]
const segments = 128
const range = xMax - xMin
const parts: string[] = []

View File

@@ -4,8 +4,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Bounds } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
type ResizeDirection =
| 'top'
@@ -558,10 +558,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const initialize = () => {
if (nodeId != null) {
node.value =
app.canvas?.graph?.getNodeById(nodeId) ||
app.rootGraph?.getNodeById(nodeId) ||
null
node.value = resolveNode(nodeId) ?? null
}
updateImageUrl()

View File

@@ -35,7 +35,8 @@ vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
removeEventListener: vi.fn(),
getServerFeature: vi.fn(() => false)
}
}))

View File

@@ -5,6 +5,10 @@ import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import type {
AnimationItem,
CameraConfig,
@@ -514,19 +518,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
if (load3d) {
if (load3d && isAssetPreviewSupported()) {
const node = nodeRef.value
const modelWidget = node?.widgets?.find(
(w) => w.name === 'model_file' || w.name === 'image'
)
const value = modelWidget?.value
if (typeof value === 'string') {
void Load3dUtils.generateThumbnailIfNeeded(
load3d,
value,
isPreview.value ? 'output' : 'input'
)
if (typeof value === 'string' && value) {
const filename = value.trim().replace(/\s*\[output\]$/, '')
const modelName = Load3dUtils.splitFilePath(filename)[1]
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(modelName, blob))
.catch(() => {})
}
}
},

View File

@@ -1,3 +1,4 @@
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -59,22 +60,26 @@ const EXACT_NAME_MAP: Record<string, string> = {
* (after removing the SubgraphBlueprint. prefix) starts with the key.
* Ordered longest-first so more specific prefixes match before shorter ones.
*/
const BLUEPRINT_PREFIX_MAP: [prefix: string, displayNameKey: string][] = [
const BLUEPRINT_PREFIX_MAP: [
prefix: string,
displayNameKey: string,
category: EssentialsCategory
][] = [
// Image Generation
['image_inpainting_', 'essentials.inpaintImage'],
['image_outpainting_', 'essentials.outpaintImage'],
['image_edit', 'essentials.imageToImage'],
['text_to_image', 'essentials.textToImage'],
['pose_to_image', 'essentials.poseToImage'],
['canny_to_image', 'essentials.cannyToImage'],
['depth_to_image', 'essentials.depthToImage'],
['image_inpainting_', 'essentials.inpaintImage', 'image generation'],
['image_outpainting_', 'essentials.outpaintImage', 'image generation'],
['image_edit', 'essentials.imageToImage', 'image generation'],
['text_to_image', 'essentials.textToImage', 'image generation'],
['pose_to_image', 'essentials.poseToImage', 'image generation'],
['canny_to_image', 'essentials.cannyToImage', 'image generation'],
['depth_to_image', 'essentials.depthToImage', 'image generation'],
// Video Generation
['text_to_video', 'essentials.textToVideo'],
['image_to_video', 'essentials.imageToVideo'],
['pose_to_video', 'essentials.poseToVideo'],
['canny_to_video', 'essentials.cannyToVideo'],
['depth_to_video', 'essentials.depthToVideo']
['text_to_video', 'essentials.textToVideo', 'video generation'],
['image_to_video', 'essentials.imageToVideo', 'video generation'],
['pose_to_video', 'essentials.poseToVideo', 'video generation'],
['canny_to_video', 'essentials.cannyToVideo', 'video generation'],
['depth_to_video', 'essentials.depthToVideo', 'video generation']
]
function resolveBlueprintDisplayName(
@@ -88,6 +93,59 @@ function resolveBlueprintDisplayName(
return undefined
}
/**
* Resolves the icon class for a blueprint node based on its prefix.
* E.g. `SubgraphBlueprint.canny_to_image_flux` → `"icon-[comfy--canny-to-image]"`
*/
export function resolveBlueprintIcon(nodeName: string): string | undefined {
if (!nodeName.startsWith(BLUEPRINT_PREFIX)) return undefined
const blueprintName = nodeName.slice(BLUEPRINT_PREFIX.length)
for (const [prefix] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
const iconName = prefix.replace(/_$/, '').replaceAll('_', '-')
return `icon-[comfy--${iconName}]`
}
}
return undefined
}
/**
* Extracts the provider/model suffix from a blueprint name for disambiguation.
* E.g. `SubgraphBlueprint.text_to_image_flux_1` → `"Flux 1"`
*/
export function resolveBlueprintSuffix(nodeName: string): string | undefined {
if (!nodeName.startsWith(BLUEPRINT_PREFIX)) return undefined
const blueprintName = nodeName.slice(BLUEPRINT_PREFIX.length)
for (const [prefix] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
const raw = blueprintName.slice(prefix.length).replace(/^_/, '')
if (!raw) return undefined
return raw
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
}
return undefined
}
/**
* Returns the essentials category for a blueprint node based on its name,
* or `undefined` if the blueprint doesn't belong in the essentials tab.
*/
export function resolveBlueprintEssentialsCategory(
nodeName: string
): EssentialsCategory | undefined {
if (!nodeName.startsWith(BLUEPRINT_PREFIX)) return undefined
const blueprintName = nodeName.slice(BLUEPRINT_PREFIX.length)
for (const [prefix, , category] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
return category
}
}
return undefined
}
/**
* Resolves the Essentials tab display name for a given node definition.
* Returns `undefined` if the node has no Essentials display name mapping.

View File

@@ -264,4 +264,28 @@ describe('promoteRecommendedWidgets', () => {
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the
// promotion system, or old workflow save).
const subgraph = createTestSubgraph()
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
// Create subgraphNode — constructor calls configure → _internalConfigureAfterSlots
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
).toBe(true)
})
})

View File

@@ -11,7 +11,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/useNodeCanvasImagePreview'
} from '@/composables/node/canvasImagePreviewTypes'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'

View File

@@ -20,6 +20,25 @@ import {
type UpDirection
} from './interfaces'
function positionThumbnailCamera(
camera: THREE.PerspectiveCamera,
model: THREE.Object3D
) {
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const distance = maxDim * 1.5
camera.position.set(
center.x + distance * 0.7,
center.y + distance * 0.5,
center.z + distance * 0.7
)
camera.lookAt(center)
camera.updateProjectionMatrix()
}
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
@@ -781,25 +800,18 @@ class Load3d {
this.cameraManager.toggleCamera('perspective')
}
const box = new THREE.Box3().setFromObject(this.modelManager.currentModel)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const distance = maxDim * 1.5
const cameraPosition = new THREE.Vector3(
center.x - distance * 0.8,
center.y + distance * 0.4,
center.z + distance * 0.3
positionThumbnailCamera(
this.cameraManager.perspectiveCamera,
this.modelManager.currentModel
)
this.cameraManager.perspectiveCamera.position.copy(cameraPosition)
this.cameraManager.perspectiveCamera.lookAt(center)
this.cameraManager.perspectiveCamera.updateProjectionMatrix()
if (this.controlsManager.controls) {
this.controlsManager.controls.target.copy(center)
const box = new THREE.Box3().setFromObject(
this.modelManager.currentModel
)
this.controlsManager.controls.target.copy(
box.getCenter(new THREE.Vector3())
)
this.controlsManager.controls.update()
}

View File

@@ -1,34 +1,9 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
class Load3dUtils {
static async generateThumbnailIfNeeded(
load3d: Load3d,
modelPath: string,
folderType: 'input' | 'output'
): Promise<void> {
const [subfolder, filename] = this.splitFilePath(modelPath)
const thumbnailFilename = this.getThumbnailFilename(filename)
const exists = await this.fileExists(
subfolder,
thumbnailFilename,
folderType
)
if (exists) return
const imageData = await load3d.captureThumbnail(256, 256)
await this.uploadThumbnail(
imageData,
subfolder,
thumbnailFilename,
folderType
)
}
static async uploadTempImage(
imageData: string,
prefix: string,
@@ -147,46 +122,6 @@ class Load3dUtils {
await Promise.all(uploadPromises)
}
static getThumbnailFilename(modelFilename: string): string {
return `${modelFilename}.png`
}
static async fileExists(
subfolder: string,
filename: string,
type: string = 'input'
): Promise<boolean> {
try {
const url = api.apiURL(this.getResourceURL(subfolder, filename, type))
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch {
return false
}
}
static async uploadThumbnail(
imageData: string,
subfolder: string,
filename: string,
type: string = 'input'
): Promise<boolean> {
const blob = await fetch(imageData).then((r) => r.blob())
const file = new File([blob], filename, { type: 'image/png' })
const body = new FormData()
body.append('image', file)
body.append('subfolder', subfolder)
body.append('type', type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
return resp.status === 200
}
}
export default Load3dUtils

View File

@@ -4,7 +4,6 @@ import Load3D from '@/components/load3d/Load3D.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
@@ -14,6 +13,10 @@ type SaveMeshOutput = NodeOutputWith<{
'3d'?: ResultItem[]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
@@ -100,17 +103,20 @@ useExtensionService().registerExtension({
const loadFolder = fileInfo.type as 'input' | 'output'
const onModelLoaded = () => {
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
void Load3dUtils.generateThumbnailIfNeeded(
load3d,
filePath,
loadFolder
)
}
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
config.configureForSaveMesh(loadFolder, filePath)
if (isAssetPreviewSupported()) {
const filename = fileInfo.filename ?? ''
const onModelLoaded = () => {
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(filename, blob))
.catch(() => {})
}
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
}
}
})
}

View File

@@ -108,7 +108,7 @@ app.registerExtension({
'waiting for camera...',
'capture',
capture,
{ canvasOnly: true }
{}
)
btn.disabled = true
btn.serializeValue = () => undefined

View File

@@ -1,4 +1,4 @@
import DOMPurify from 'dompurify'
import dompurify from 'dompurify'
import type {
ContextMenuDivElement,
@@ -16,7 +16,7 @@ const ALLOWED_STYLE_PROPS = new Set([
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
dompurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
@@ -33,7 +33,7 @@ DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
return dompurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})

View File

@@ -78,7 +78,10 @@ import type {
SerialisableReroute
} from './types/serialisation'
import { getAllNestedItems } from './utils/collections'
import { deduplicateSubgraphNodeIds } from './utils/subgraphDeduplication'
import {
deduplicateSubgraphNodeIds,
topologicalSortSubgraphs
} from './subgraph/subgraphDeduplication'
export type {
LGraphTriggerAction,
@@ -2561,7 +2564,12 @@ export class LGraph
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
for (const subgraph of finalSubgraphs) this.createSubgraph(subgraph)
for (const subgraph of finalSubgraphs)
// Configure in leaf-first order so that when a SubgraphNode is
// configured, its referenced subgraph definition already has its
// nodes/links/inputs populated.
const configureOrder = topologicalSortSubgraphs(finalSubgraphs)
for (const subgraph of configureOrder)
this.subgraphs.get(subgraph.id)?.configure(subgraph)
}
@@ -2854,6 +2862,10 @@ export class Subgraph
}
}
// Repair IO slot linkIds that reference links removed by
// _removeDuplicateLinks during super.configure().
this._repairIOSlotLinkIds()
if (widgets) {
this.widgets.length = 0
for (const widget of widgets) {
@@ -2878,6 +2890,50 @@ export class Subgraph
return r
}
/**
* Repairs SubgraphInput/Output `linkIds` that reference links removed
* by `_removeDuplicateLinks` during `super.configure()`.
*
* For each stale link ID, finds the surviving link that connects to the
* same IO node and slot index, and substitutes it.
*/
private _repairIOSlotLinkIds(): void {
for (const [slotIndex, slot] of this.inputs.entries()) {
this._repairSlotLinkIds(slot.linkIds, SUBGRAPH_INPUT_ID, slotIndex)
}
for (const [slotIndex, slot] of this.outputs.entries()) {
this._repairSlotLinkIds(slot.linkIds, SUBGRAPH_OUTPUT_ID, slotIndex)
}
}
private _repairSlotLinkIds(
linkIds: LinkId[],
ioNodeId: number,
slotIndex: number
): void {
const repaired = linkIds.map((id) =>
this._links.has(id)
? id
: (this._findLinkBySlot(ioNodeId, slotIndex)?.id ?? id)
)
repaired.forEach((id, i) => {
linkIds[i] = id
})
}
private _findLinkBySlot(
nodeId: number,
slotIndex: number
): LLink | undefined {
for (const link of this._links.values()) {
if (
(link.origin_id === nodeId && link.origin_slot === slotIndex) ||
(link.target_id === nodeId && link.target_slot === slotIndex)
)
return link
}
}
override attachCanvas(canvas: LGraphCanvas): void {
super.attachCanvas(canvas)
canvas.subgraph = this

View File

@@ -1207,6 +1207,14 @@ export class LGraphNode
: this.inputs[slot]
}
/**
* Resolves the output source for cross-graph virtual nodes (e.g. Set/Get),
* bypassing {@link getInputLink} when the source lives in a different graph.
*/
resolveVirtualOutput?(
slot: number
): { node: LGraphNode; slot: number } | undefined
/**
* Returns the link info in the connection of an input slot
* @returns object or null

View File

@@ -382,6 +382,102 @@ describe('ALWAYS mode node output resolution', () => {
})
})
describe('Virtual node resolveVirtualOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should resolve through resolveVirtualOutput when implemented', () => {
const graph = new LGraph()
const sourceNode = new LGraphNode('Source')
sourceNode.addOutput('out', 'IMAGE')
graph.add(sourceNode)
const virtualNode = new LGraphNode('Virtual Get')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => ({ node: sourceNode, slot: 0 })
graph.add(virtualNode)
const nodeDtoMap = new Map()
const sourceDto = new ExecutableNodeDTO(
sourceNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(sourceDto.id, sourceDto)
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(virtualDto.id, virtualDto)
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(sourceDto)
expect(resolved?.origin_slot).toBe(0)
})
it('should throw when resolveVirtualOutput returns a node with no matching DTO', () => {
const graph = new LGraph()
const unmappedNode = new LGraphNode('Unmapped Source')
unmappedNode.addOutput('out', 'IMAGE')
graph.add(unmappedNode)
const virtualNode = new LGraphNode('Virtual Get')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => ({
node: unmappedNode,
slot: 0
})
graph.add(virtualNode)
const nodeDtoMap = new Map()
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(virtualDto.id, virtualDto)
expect(() => virtualDto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No DTO found for virtual source node'
)
})
it('should fall through to getInputLink when resolveVirtualOutput returns undefined', () => {
const graph = new LGraph()
const virtualNode = new LGraphNode('Virtual Passthrough')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => undefined
graph.add(virtualNode)
const nodeDtoMap = new Map()
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(virtualDto.id, virtualDto)
const spy = vi.spyOn(virtualNode, 'getInputLink')
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeUndefined()
expect(spy).toHaveBeenCalledWith(0)
})
})
describe.skip('ExecutableNodeDTO Properties', () => {
it('should provide access to basic properties', () => {
const graph = new LGraph()

View File

@@ -291,6 +291,20 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
return this._resolveSubgraphOutput(slot, type, visited)
if (node.isVirtualNode) {
// Cross-graph virtual nodes (e.g. Set/Get) resolve their source directly.
const virtualSource = this.node.resolveVirtualOutput?.(slot)
if (virtualSource) {
const inputNodeDto = [...this.nodesByExecutionId.values()].find(
(dto) =>
dto instanceof ExecutableNodeDTO && dto.node === virtualSource.node
)
if (!inputNodeDto)
throw new Error(
`No DTO found for virtual source node [${virtualSource.node.id}]`
)
return inputNodeDto.resolveOutput(virtualSource.slot, type, visited)
}
const virtualLink = this.node.getInputLink(slot)
if (virtualLink) {
const { inputNode } = virtualLink.resolve(this.graph)

View File

@@ -206,7 +206,7 @@ describe.skip('Subgraph Serialization', () => {
})
describe.skip('Subgraph Known Issues', () => {
it.todo('should enforce MAX_NESTED_SUBGRAPHS limit', () => {
it.skip('should enforce MAX_NESTED_SUBGRAPHS limit', () => {
// This test documents that MAX_NESTED_SUBGRAPHS = 1000 is defined
// but not actually enforced anywhere in the code.
//

View File

@@ -48,7 +48,7 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
expect(firstLevel.isSubgraphNode()).toBe(true)
})
it.todo('should use WeakSet for cycle detection', () => {
it.skip('should use WeakSet for cycle detection', () => {
// TODO: This test is currently skipped because cycle detection has a bug
// The fix is to pass 'visited' directly instead of 'new Set(visited)' in SubgraphNode.ts:299
const subgraph = createTestSubgraph({ nodeCount: 1 })

View File

@@ -9,9 +9,9 @@ import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -618,6 +618,135 @@ describe.skip('SubgraphNode Cleanup', () => {
})
})
describe('SubgraphNode duplicate input pruning (#9977)', () => {
it('should prune inputs that have no matching subgraph slot after configure', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'NUMBER' }
]
})
const parentGraph = new LGraph()
const instanceData = {
id: 1 as const,
type: subgraph.id,
pos: [0, 0] as [number, number],
size: [200, 100] as [number, number],
inputs: [
{ name: 'a', type: 'STRING', link: null },
{ name: 'b', type: 'NUMBER', link: null },
{ name: 'a', type: 'STRING', link: null },
{ name: 'b', type: 'NUMBER', link: null }
],
outputs: [],
properties: {},
flags: {},
mode: 0,
order: 0
}
const node = new SubgraphNode(
parentGraph,
subgraph,
instanceData as ExportedSubgraphInstance
)
expect(node.inputs).toHaveLength(2)
expect(node.inputs.every((i) => i._subgraphSlot)).toBe(true)
})
it('should not accumulate duplicate inputs on reconfigure', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'NUMBER' }
]
})
const node = createTestSubgraphNode(subgraph)
expect(node.inputs).toHaveLength(2)
const serialized = node.serialize()
node.configure(serialized)
expect(node.inputs).toHaveLength(2)
const serialized2 = node.serialize()
node.configure(serialized2)
expect(node.inputs).toHaveLength(2)
})
it('should serialize with exactly the subgraph-defined inputs', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'x', type: 'IMAGE' },
{ name: 'y', type: 'VAE' }
]
})
const node = createTestSubgraphNode(subgraph)
const serialized = node.serialize()
expect(serialized.inputs).toHaveLength(2)
expect(serialized.inputs?.map((i) => i.name)).toEqual(['x', 'y'])
})
})
describe('Nested SubgraphNode duplicate input prevention', () => {
it('should not duplicate inputs when the referenced subgraph is reconfigured', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'NUMBER' }
]
})
const node = createTestSubgraphNode(subgraph)
expect(node.inputs).toHaveLength(2)
// Simulate what happens during nested subgraph configure:
// B.configure() calls _configureSubgraph(), which recreates SubgraphInput
// objects and dispatches 'input-added' events with new references.
const serialized = subgraph.asSerialisable()
subgraph.configure(serialized)
// The SubgraphNode's event listener should recognize existing inputs
// by ID and NOT add duplicates.
expect(node.inputs).toHaveLength(2)
expect(node.inputs.every((i) => i._subgraphSlot)).toBe(true)
})
it('should not accumulate inputs across multiple reconfigure cycles', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'x', type: 'IMAGE' },
{ name: 'y', type: 'VAE' }
]
})
const node = createTestSubgraphNode(subgraph)
expect(node.inputs).toHaveLength(2)
for (let i = 0; i < 5; i++) {
const serialized = subgraph.asSerialisable()
subgraph.configure(serialized)
}
expect(node.inputs).toHaveLength(2)
expect(node.inputs.map((i) => i.name)).toEqual(['x', 'y'])
})
})
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -38,6 +38,10 @@ import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetVie
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -562,7 +566,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Legacy -1 entries use the slot name as the widget name.
// Find the input with that name, then trace to the connected interior widget.
const input = this.inputs.find((i) => i.name === widgetName)
if (!input?._widget) return undefined
if (!input?._widget) {
// Fallback: find via subgraph input slot connection
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
const widget = input._widget
if (isPromotedWidgetView(widget)) {
@@ -612,9 +621,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const subgraphInput = e.detail.input
const { name, type } = subgraphInput
const existingInput = this.inputs.find(
(input) => input._subgraphSlot === subgraphInput
(input) =>
input._subgraphSlot === subgraphInput ||
(input._subgraphSlot && input._subgraphSlot.id === subgraphInput.id)
)
if (existingInput) {
// Rebind to the new SubgraphInput object and re-register listeners
// (configure recreates SubgraphInput objects with the same id)
this._addSubgraphInputListeners(subgraphInput, existingInput)
const linkId = subgraphInput.linkIds[0]
if (linkId === undefined) return
@@ -926,6 +940,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override _internalConfigureAfterSlots() {
this._rebindInputSubgraphSlots()
// Prune inputs that don't map to any subgraph slot definition.
// This prevents stale/duplicate serialized inputs from persisting (#9977).
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
// Ensure proxyWidgets is initialized so it serializes
this.properties.proxyWidgets ??= []
@@ -983,6 +1001,25 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
this._syncPromotions()
for (const node of this.subgraph.nodes) {
if (!supportsVirtualCanvasImagePreview(node)) continue
if (
store.isPromoted(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
)
continue
store.promote(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
}
}
private _resolveInputWidget(

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { ExportedSubgraph } from '../types/serialisation'
import { topologicalSortSubgraphs } from './subgraphDeduplication'
function makeSubgraph(id: string, nodeTypes: string[] = []): ExportedSubgraph {
return {
id,
name: id,
version: 1,
revision: 0,
state: { lastNodeId: 0, lastLinkId: 0, lastGroupId: 0, lastRerouteId: 0 },
nodes: nodeTypes.map((type, i) => ({
id: i + 1,
type,
pos: [0, 0] as [number, number],
size: [100, 100] as [number, number],
flags: {},
order: i,
mode: 0,
inputs: [],
outputs: [],
properties: {}
})),
inputNode: { id: -10, bounding: [0, 0, 100, 100] },
outputNode: { id: -20, bounding: [0, 0, 100, 100] }
} as ExportedSubgraph
}
describe('topologicalSortSubgraphs', () => {
it('returns original order when there are no dependencies', () => {
const a = makeSubgraph('a')
const b = makeSubgraph('b')
const result = topologicalSortSubgraphs([a, b])
expect(result).toEqual([a, b])
})
it('sorts leaf dependencies before their parents', () => {
const inner = makeSubgraph('inner', ['StringConcat'])
const outer = makeSubgraph('outer', ['inner'])
const result = topologicalSortSubgraphs([outer, inner])
expect(result.map((s) => s.id)).toEqual(['inner', 'outer'])
})
it('handles three-level nesting', () => {
const leaf = makeSubgraph('leaf', ['StringConcat'])
const mid = makeSubgraph('mid', ['leaf', 'StringConcat'])
const top = makeSubgraph('top', ['mid'])
const result = topologicalSortSubgraphs([top, mid, leaf])
expect(result.map((s) => s.id)).toEqual(['leaf', 'mid', 'top'])
})
it('handles diamond dependencies', () => {
const shared = makeSubgraph('shared')
const left = makeSubgraph('left', ['shared'])
const right = makeSubgraph('right', ['shared'])
const top = makeSubgraph('top', ['left', 'right'])
const result = topologicalSortSubgraphs([top, left, right, shared])
const ids = result.map((s) => s.id)
expect(ids.indexOf('shared')).toBeLessThan(ids.indexOf('left'))
expect(ids.indexOf('shared')).toBeLessThan(ids.indexOf('right'))
expect(ids.indexOf('left')).toBeLessThan(ids.indexOf('top'))
expect(ids.indexOf('right')).toBeLessThan(ids.indexOf('top'))
})
it('returns original order for a single subgraph', () => {
const only = makeSubgraph('only')
const result = topologicalSortSubgraphs([only])
expect(result).toEqual([only])
})
it('returns original order for empty array', () => {
expect(topologicalSortSubgraphs([])).toEqual([])
})
})

View File

@@ -140,6 +140,63 @@ function patchPromotedWidgets(
}
}
/**
* Topologically sorts subgraph definitions so that leaf subgraphs (those
* that no other subgraph depends on) are configured first. This ensures
* that when a SubgraphNode is configured, the subgraph definition it
* references already has its nodes, links, and inputs populated.
*
* Falls back to the original order if no reordering is needed or if the
* dependency graph contains cycles.
*/
export function topologicalSortSubgraphs(
subgraphs: ExportedSubgraph[]
): ExportedSubgraph[] {
const subgraphIds = new Set(subgraphs.map((sg) => sg.id))
const byId = new Map(subgraphs.map((sg) => [sg.id, sg]))
// Build adjacency: dependency → set of dependents (parents that use it).
// Edges go from leaf to parent so Kahn's emits leaves first.
const dependents = new Map<string, Set<string>>()
const inDegree = new Map<string, number>()
for (const id of subgraphIds) {
dependents.set(id, new Set())
inDegree.set(id, 0)
}
for (const sg of subgraphs) {
for (const node of sg.nodes ?? []) {
if (subgraphIds.has(node.type)) {
// sg depends on node.type → edge from node.type to sg.id
dependents.get(node.type)!.add(sg.id)
inDegree.set(sg.id, (inDegree.get(sg.id) ?? 0) + 1)
}
}
}
// Kahn's algorithm — leaves (in-degree 0) are emitted first.
const queue: string[] = []
for (const [id, degree] of inDegree) {
if (degree === 0) queue.push(id)
}
const sorted: ExportedSubgraph[] = []
while (queue.length > 0) {
const id = queue.shift()!
sorted.push(byId.get(id)!)
for (const dependent of dependents.get(id) ?? []) {
const newDegree = (inDegree.get(dependent) ?? 1) - 1
inDegree.set(dependent, newDegree)
if (newDegree === 0) queue.push(dependent)
}
}
// Cycle fallback: return original order
if (sorted.length !== subgraphs.length) return subgraphs
return sorted
}
/** Patches proxyWidgets in root-level SubgraphNode instances. */
function patchProxyWidgets(
rootNodes: ISerialisedNode[],

View File

@@ -1,5 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurvePoint } from '@/components/curve/types'
import type { CurveData } from '@/components/curve/types'
import type {
CanvasColour,
@@ -331,9 +331,9 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
value: Bounds
}
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
export interface ICurveWidget extends IBaseWidget<CurveData, 'curve'> {
type: 'curve'
value: CurvePoint[]
value: CurveData
}
export interface IPainterWidget extends IBaseWidget<string, 'painter'> {

View File

@@ -856,9 +856,9 @@
"alphabeticalDesc": "Sort alphabetically within groups"
},
"sections": {
"favorites": "Favorites",
"favoriteNode": "Favorite Node",
"unfavoriteNode": "Unfavorite Node",
"favorites": "Bookmarks",
"favoriteNode": "Bookmark Node",
"unfavoriteNode": "Unbookmark Node",
"bookmarked": "Bookmarked",
"subgraphBlueprints": "Subgraph Blueprints",
"myBlueprints": "My Blueprints",
@@ -867,7 +867,7 @@
"comfyNodes": "Comfy Nodes",
"extensions": "Extensions"
},
"noBookmarkedNodes": "No favorites yet"
"noBookmarkedNodes": "No bookmarks yet"
},
"modelLibrary": "Model Library",
"downloads": "Downloads",
@@ -1972,6 +1972,10 @@
"width": "Width",
"height": "Height"
},
"curveWidget": {
"monotone_cubic": "Smooth",
"linear": "Linear"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",
@@ -3567,9 +3571,9 @@
"removeBackground": "Remove Background",
"imageCompare": "Image compare",
"extractFrame": "Extract frame",
"loadStyleLora": "Load style (LoRA)",
"loadStyleLora": "Load style",
"lipsync": "Lipsync",
"textGenerationLLM": "Text generation (LLM)",
"textGenerationLLM": "Text generation",
"textTo3DModel": "Text to 3D model",
"imageTo3DModel": "Image to 3D Model",
"musicGeneration": "Music generation",

View File

@@ -1,11 +1,10 @@
<template>
<div class="relative size-full overflow-hidden rounded-sm">
<div ref="containerRef" class="relative size-full overflow-hidden rounded-sm">
<img
v-if="!thumbnailError"
v-if="thumbnailSrc"
:src="thumbnailSrc"
:alt="asset?.name"
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
@error="thumbnailError = true"
/>
<div
v-else
@@ -20,16 +19,60 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import { onBeforeUnmount, ref, watch } from 'vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import {
findServerPreviewUrl,
isAssetPreviewSupported
} from '../utils/assetPreviewUtil'
const { asset } = defineProps<{ asset: AssetMeta }>()
const thumbnailError = ref(false)
const containerRef = ref<HTMLElement>()
const thumbnailSrc = ref<string | null>(null)
const hasAttempted = ref(false)
const thumbnailSrc = computed(() => {
if (!asset?.src) return ''
return asset.src.replace(/([?&]filename=)([^&]*)/, '$1$2.png')
useIntersectionObserver(containerRef, ([entry]) => {
if (entry?.isIntersecting && !hasAttempted.value) {
hasAttempted.value = true
void loadThumbnail()
}
})
async function loadThumbnail() {
if (asset?.preview_id && asset?.preview_url) {
thumbnailSrc.value = asset.preview_url
return
}
if (!asset?.src) return
if (asset.name && isAssetPreviewSupported()) {
const serverPreviewUrl = await findServerPreviewUrl(asset.name)
if (serverPreviewUrl) {
thumbnailSrc.value = serverPreviewUrl
}
}
}
function revokeThumbnail() {
if (thumbnailSrc.value?.startsWith('blob:')) {
URL.revokeObjectURL(thumbnailSrc.value)
}
thumbnailSrc.value = null
}
watch(
() => asset?.src,
() => {
if (hasAttempted.value) {
hasAttempted.value = false
revokeThumbnail()
}
}
)
onBeforeUnmount(revokeThumbnail)
</script>

View File

@@ -150,6 +150,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
@@ -237,7 +238,12 @@ const adaptedAsset = computed(() => {
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.thumbnail_url || asset.preview_url || '',
src:
fileKind.value === '3D'
? getAssetUrl(asset)
: asset.thumbnail_url || asset.preview_url || '',
preview_url: asset.preview_url,
preview_id: asset.preview_id,
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,

View File

@@ -95,7 +95,7 @@ export type ModelFile = z.infer<typeof zModelFile>
/** Payload for updating an asset via PUT /assets/:id */
export type AssetUpdatePayload = Partial<
Pick<AssetItem, 'name' | 'tags' | 'user_metadata'>
Pick<AssetItem, 'name' | 'tags' | 'user_metadata' | 'preview_id'>
>
/** User-editable metadata fields for model assets */

View File

@@ -0,0 +1,267 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
findOutputAsset,
findServerPreviewUrl,
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockApiURL = vi.hoisted(() =>
vi.fn((path: string) => `http://localhost:8188${path}`)
)
const mockGetServerFeature = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
const mockUploadAssetFromBase64 = vi.hoisted(() => vi.fn())
const mockUpdateAsset = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: mockFetchApi,
apiURL: mockApiURL,
api_base: '',
getServerFeature: mockGetServerFeature
}
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetAPIEnabled: mockIsAssetAPIEnabled,
uploadAssetFromBase64: mockUploadAssetFromBase64,
updateAsset: mockUpdateAsset
}
}))
function mockFetchResponse(assets: Record<string, unknown>[]) {
mockFetchApi.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ assets })
})
}
function mockFetchEmpty() {
mockFetchResponse([])
}
function mockFetchError() {
mockFetchApi.mockResolvedValueOnce({ ok: false })
}
const cloudAsset = {
id: '72d169cc-7f9a-40d2-9382-35eadcba0a6a',
name: 'mesh/ComfyUI_00003_.glb',
asset_hash: 'c6cadcee57dd.glb',
preview_id: null,
preview_url: undefined
}
const cloudAssetWithPreview = {
...cloudAsset,
preview_id: 'aaaa-bbbb',
preview_url: '/api/view?type=output&filename=preview.png'
}
const localAsset = {
id: '50bf419e-7ecb-4c96-a0c7-c1eb4dff00cb',
name: 'ComfyUI_00081_.glb',
preview_id: null,
preview_url:
'/api/view?type=output&filename=ComfyUI_00081_.glb&subfolder=mesh'
}
const localAssetWithPreview = {
...localAsset,
preview_id: '3df94ee8-preview',
preview_url: '/api/view?type=output&filename=preview.png'
}
describe('isAssetPreviewSupported', () => {
beforeEach(() => vi.clearAllMocks())
it('returns true when asset API is enabled (cloud)', () => {
mockIsAssetAPIEnabled.mockReturnValue(true)
expect(isAssetPreviewSupported()).toBe(true)
})
it('returns true when server assets feature is enabled (local)', () => {
mockGetServerFeature.mockReturnValue(true)
expect(isAssetPreviewSupported()).toBe(true)
})
it('returns false when neither is enabled', () => {
mockIsAssetAPIEnabled.mockReturnValue(false)
mockGetServerFeature.mockReturnValue(false)
expect(isAssetPreviewSupported()).toBe(false)
})
})
describe('findOutputAsset', () => {
beforeEach(() => vi.clearAllMocks())
it('finds asset by hash (cloud)', async () => {
mockFetchResponse([cloudAsset])
const result = await findOutputAsset('c6cadcee57dd.glb')
expect(mockFetchApi).toHaveBeenCalledOnce()
expect(mockFetchApi.mock.calls[0][0]).toContain(
'asset_hash=c6cadcee57dd.glb'
)
expect(result).toEqual(cloudAsset)
})
it('falls back to name_contains when hash returns empty (local)', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
const result = await findOutputAsset('ComfyUI_00081_.glb')
expect(mockFetchApi).toHaveBeenCalledTimes(2)
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_hash=')
expect(mockFetchApi.mock.calls[1][0]).toContain('name_contains=')
expect(result).toEqual(localAsset)
})
it('returns undefined when no asset matches', async () => {
mockFetchEmpty()
mockFetchEmpty()
const result = await findOutputAsset('nonexistent.glb')
expect(result).toBeUndefined()
})
it('matches exact name from name_contains results', async () => {
mockFetchEmpty()
mockFetchResponse([
{ id: '1', name: 'ComfyUI_00001_.glb_preview.png' },
{ id: '2', name: 'ComfyUI_00001_.glb' }
])
const result = await findOutputAsset('ComfyUI_00001_.glb')
expect(result?.id).toBe('2')
})
it('returns empty array on fetch error', async () => {
mockFetchError()
mockFetchError()
const result = await findOutputAsset('test.glb')
expect(result).toBeUndefined()
})
})
describe('findServerPreviewUrl', () => {
beforeEach(() => vi.clearAllMocks())
it('returns null when asset has no preview_id', async () => {
mockFetchResponse([cloudAsset])
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
expect(result).toBeNull()
})
it('returns preview_url via apiURL when preview_id is set', async () => {
mockFetchResponse([cloudAssetWithPreview])
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
expect(mockApiURL).toHaveBeenCalledWith(cloudAssetWithPreview.preview_url)
expect(result).toBe(
`http://localhost:8188${cloudAssetWithPreview.preview_url}`
)
})
it('constructs URL from preview_id when preview_url is missing', async () => {
mockFetchResponse([{ ...cloudAsset, preview_id: 'aaaa-bbbb' }])
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
expect(result).toBe('http://localhost:8188/assets/aaaa-bbbb/content')
})
it('falls back to asset id when preview_id is null but set', async () => {
// Edge case: asset has preview_id explicitly null, no preview_url
mockFetchEmpty()
mockFetchEmpty()
const result = await findServerPreviewUrl('nonexistent.glb')
expect(result).toBeNull()
})
it('returns null on error', async () => {
mockFetchApi.mockRejectedValueOnce(new Error('network error'))
const result = await findServerPreviewUrl('test.glb')
expect(result).toBeNull()
})
})
describe('persistThumbnail', () => {
beforeEach(() => vi.clearAllMocks())
it('uploads thumbnail and links preview_id', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
mockUpdateAsset.mockResolvedValue({})
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockUploadAssetFromBase64).toHaveBeenCalledOnce()
expect(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
'ComfyUI_00081_.glb_preview.png'
)
expect(mockUpdateAsset).toHaveBeenCalledWith(localAsset.id, {
preview_id: 'new-preview-id'
})
})
it('skips when asset already has preview_id', async () => {
mockFetchEmpty()
mockFetchResponse([localAssetWithPreview])
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
expect(mockUpdateAsset).not.toHaveBeenCalled()
})
it('skips when no asset found', async () => {
mockFetchEmpty()
mockFetchEmpty()
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('nonexistent.glb', blob)
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
})
it('swallows errors silently', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
mockUploadAssetFromBase64.mockRejectedValue(new Error('upload failed'))
const blob = new Blob(['fake-png'], { type: 'image/png' })
await expect(
persistThumbnail('ComfyUI_00081_.glb', blob)
).resolves.toBeUndefined()
})
it('works with cloud hash filename', async () => {
mockFetchResponse([cloudAsset])
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
mockUpdateAsset.mockResolvedValue({})
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('c6cadcee57dd.glb', blob)
expect(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
'mesh/ComfyUI_00003_.glb_preview.png'
)
expect(mockUpdateAsset).toHaveBeenCalledWith(cloudAsset.id, {
preview_id: 'new-preview-id'
})
})
})

View File

@@ -0,0 +1,95 @@
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
interface AssetRecord {
id: string
name: string
asset_hash?: string
preview_url?: string
preview_id?: string | null
}
export function isAssetPreviewSupported(): boolean {
return (
assetService.isAssetAPIEnabled() || api.getServerFeature('assets', false)
)
}
async function fetchAssets(
params: Record<string, string>
): Promise<AssetRecord[]> {
const query = new URLSearchParams(params)
const res = await api.fetchApi(`/assets?${query}`)
if (!res.ok) return []
const data = await res.json()
return data.assets ?? []
}
function resolvePreviewUrl(asset: AssetRecord): string {
if (asset.preview_url) return api.apiURL(asset.preview_url)
const contentId = asset.preview_id ?? asset.id
return api.apiURL(`/assets/${contentId}/content`)
}
/**
* Find an output asset record by content hash, falling back to name.
* On cloud, output filenames are content-hashed; use asset_hash to match.
* On local, filenames are not hashed; use name_contains to match.
*/
export async function findOutputAsset(
name: string
): Promise<AssetRecord | undefined> {
const byHash = await fetchAssets({ asset_hash: name })
const hashMatch = byHash.find((a) => a.asset_hash === name)
if (hashMatch) return hashMatch
const byName = await fetchAssets({ name_contains: name })
return byName.find((a) => a.name === name)
}
export async function findServerPreviewUrl(
name: string
): Promise<string | null> {
try {
const asset = await findOutputAsset(name)
if (!asset?.preview_id) return null
return resolvePreviewUrl(asset)
} catch {
return null
}
}
export async function persistThumbnail(
name: string,
blob: Blob
): Promise<void> {
try {
const asset = await findOutputAsset(name)
if (!asset || asset.preview_id) return
const previewFilename = `${asset.name}_preview.png`
const uploaded = await assetService.uploadAssetFromBase64({
data: await blobToDataUrl(blob),
name: previewFilename,
tags: ['output'],
user_metadata: { filename: previewFilename }
})
await assetService.updateAsset(asset.id, {
preview_id: uploaded.id
})
} catch {
// Non-critical — client still shows the rendered thumbnail
}
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}

View File

@@ -56,9 +56,8 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
{
combo: {
ctrl: true,
shift: true,
key: 'a'
alt: true,
key: 'm'
},
commandId: 'Comfy.ToggleLinear'
},
@@ -179,7 +178,8 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
{
combo: {
key: 'm',
alt: true
alt: true,
shift: true
},
commandId: 'Comfy.Canvas.ToggleMinimap'
},

View File

@@ -2,6 +2,16 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
function createInitializedProvider(): GtmTelemetryProvider {
window.__CONFIG__ = { gtm_container_id: 'GTM-TEST123' }
return new GtmTelemetryProvider()
}
function lastDataLayerEntry(): Record<string, unknown> | undefined {
const dl = window.dataLayer as unknown[] | undefined
return dl?.[dl.length - 1] as Record<string, unknown> | undefined
}
describe('GtmTelemetryProvider', () => {
beforeEach(() => {
window.__CONFIG__ = {}
@@ -66,4 +76,157 @@ describe('GtmTelemetryProvider', () => {
expect(gtagScripts).toHaveLength(1)
})
describe('event dispatch', () => {
it('pushes subscription modal as view_promotion', () => {
const provider = createInitializedProvider()
provider.trackSubscription('modal_opened')
expect(lastDataLayerEntry()).toMatchObject({ event: 'view_promotion' })
})
it('pushes subscribe click as select_promotion', () => {
const provider = createInitializedProvider()
provider.trackSubscription('subscribe_clicked')
expect(lastDataLayerEntry()).toMatchObject({ event: 'select_promotion' })
})
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
provider.trackRunButton({ trigger_source: 'button' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'run_workflow',
trigger_source: 'button',
subscribe_to_run: false
})
})
it('pushes execution_error with truncated error', () => {
const provider = createInitializedProvider()
const longError = 'x'.repeat(200)
provider.trackExecutionError({
jobId: 'job-1',
nodeType: 'KSampler',
error: longError
})
const entry = lastDataLayerEntry()
expect(entry).toMatchObject({
event: 'execution_error',
node_type: 'KSampler'
})
expect((entry?.error as string).length).toBe(100)
})
it('pushes select_content for template events', () => {
const provider = createInitializedProvider()
provider.trackTemplate({
workflow_name: 'flux-dev',
template_category: 'image-gen'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'select_content',
content_type: 'template',
workflow_name: 'flux-dev',
template_category: 'image-gen'
})
})
it('pushes survey_opened for survey opened stage', () => {
const provider = createInitializedProvider()
provider.trackSurvey('opened')
expect(lastDataLayerEntry()).toMatchObject({ event: 'survey_opened' })
})
it('pushes survey_submitted with responses', () => {
const provider = createInitializedProvider()
provider.trackSurvey('submitted', {
familiarity: 'expert',
industry: 'gaming'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'survey_submitted',
familiarity: 'expert',
industry: 'gaming'
})
})
it('pushes email_verify_opened for opened stage', () => {
const provider = createInitializedProvider()
provider.trackEmailVerification('opened')
expect(lastDataLayerEntry()).toMatchObject({
event: 'email_verify_opened'
})
})
it('pushes email_verify_completed for completed stage', () => {
const provider = createInitializedProvider()
provider.trackEmailVerification('completed')
expect(lastDataLayerEntry()).toMatchObject({
event: 'email_verify_completed'
})
})
it('pushes search for node search (GA4 recommended)', () => {
const provider = createInitializedProvider()
provider.trackNodeSearch({ query: 'KSampler' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'search',
search_term: 'KSampler'
})
})
it('pushes select_item for node search result (GA4 recommended)', () => {
const provider = createInitializedProvider()
provider.trackNodeSearchResultSelected({
node_type: 'KSampler',
last_query: 'sampler'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'select_item',
item_id: 'KSampler',
search_term: 'sampler'
})
})
it('pushes setting_changed with setting_id', () => {
const provider = createInitializedProvider()
provider.trackSettingChanged({ setting_id: 'theme' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'setting_changed',
setting_id: 'theme'
})
})
it('pushes workflow_created with metadata', () => {
const provider = createInitializedProvider()
provider.trackWorkflowCreated({
workflow_type: 'blank',
previous_workflow_had_nodes: true
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'workflow_created',
workflow_type: 'blank',
previous_workflow_had_nodes: true
})
})
it('pushes share_flow with step and source', () => {
const provider = createInitializedProvider()
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'share_flow',
step: 'link_copied',
source: 'app_mode'
})
})
it('does not push events when not initialized', () => {
window.__CONFIG__ = {}
const provider = new GtmTelemetryProvider()
provider.trackSubscription('modal_opened')
expect(window.dataLayer).toBeUndefined()
})
})
})

View File

@@ -1,8 +1,32 @@
import type {
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
TelemetryProvider
PageVisibilityMetadata,
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
/**
@@ -84,9 +108,22 @@ export class GtmTelemetryProvider implements TelemetryProvider {
gtag('config', measurementId, { send_page_view: false })
}
private sanitizeProperties(
properties?: Record<string, unknown>
): Record<string, unknown> | undefined {
if (!properties) return undefined
return Object.fromEntries(
Object.entries(properties).map(([key, value]) => [
key,
typeof value === 'string' ? value.slice(0, 100) : value
])
)
}
private pushEvent(event: string, properties?: Record<string, unknown>): void {
if (!this.initialized) return
window.dataLayer?.push({ event, ...properties })
window.dataLayer?.push({ event, ...this.sanitizeProperties(properties) })
}
trackPageView(pageName: string, properties?: PageViewMetadata): void {
@@ -114,4 +151,210 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.pushEvent('begin_checkout', metadata)
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
const ga4EventName =
event === 'modal_opened' ? 'view_promotion' : 'select_promotion'
this.pushEvent(ga4EventName, metadata ? { ...metadata } : undefined)
}
trackSignupOpened(): void {
this.pushEvent('signup_opened')
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
this.pushEvent('run_workflow', {
subscribe_to_run: options?.subscribe_to_run ?? false,
trigger_source: options?.trigger_source ?? 'unknown'
})
}
trackWorkflowExecution(): void {
this.pushEvent('execution_start')
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.pushEvent('execution_error', {
node_type: metadata.nodeType,
error: metadata.error
})
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.pushEvent('execution_success', {
job_id: metadata.jobId
})
}
trackTemplate(metadata: TemplateMetadata): void {
this.pushEvent('select_content', {
content_type: 'template',
workflow_name: metadata.workflow_name,
template_category: metadata.template_category
})
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.pushEvent('template_library_opened', {
source: metadata.source
})
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
this.pushEvent('template_library_closed', {
template_selected: metadata.template_selected,
time_spent_seconds: metadata.time_spent_seconds
})
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.pushEvent('workflow_import', {
missing_node_count: metadata.missing_node_count,
open_source: metadata.open_source
})
}
trackUserLoggedIn(): void {
this.pushEvent('user_logged_in')
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
const ga4EventName =
stage === 'opened' ? 'survey_opened' : 'survey_submitted'
this.pushEvent(ga4EventName, responses ? { ...responses } : undefined)
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
const eventMap = {
opened: 'email_verify_opened',
requested: 'email_verify_requested',
completed: 'email_verify_completed'
} as const
this.pushEvent(eventMap[stage])
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.pushEvent('workflow_opened', {
missing_node_count: metadata.missing_node_count,
open_source: metadata.open_source
})
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.pushEvent('workflow_saved', {
is_app: metadata.is_app,
is_new: metadata.is_new
})
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.pushEvent('default_view_set', {
default_view: metadata.default_view
})
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.pushEvent('app_mode_opened', {
source: metadata.source
})
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.pushEvent('share_flow', {
step: metadata.step,
source: metadata.source
})
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.pushEvent('page_visibility', {
visibility_state: metadata.visibility_state
})
}
trackTabCount(metadata: TabCountMetadata): void {
this.pushEvent('tab_count', {
tab_count: metadata.tab_count
})
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.pushEvent('search', {
search_term: metadata.query
})
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.pushEvent('select_item', {
item_id: metadata.node_type,
search_term: metadata.last_query
})
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.pushEvent('template_filter', {
search_query: metadata.search_query,
sort_by: metadata.sort_by,
filtered_count: metadata.filtered_count,
total_count: metadata.total_count
})
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.pushEvent('setting_changed', {
setting_id: metadata.setting_id
})
}
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.pushEvent('ui_button_click', {
button_id: metadata.button_id
})
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
this.pushEvent('help_center_opened', {
source: metadata.source
})
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
this.pushEvent('help_resource_click', {
resource_type: metadata.resource_type,
is_external: metadata.is_external,
source: metadata.source
})
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
this.pushEvent('help_center_closed', {
time_spent_seconds: metadata.time_spent_seconds
})
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
this.pushEvent('workflow_created', {
workflow_type: metadata.workflow_type,
previous_workflow_had_nodes: metadata.previous_workflow_had_nodes
})
}
trackAddApiCreditButtonClicked(): void {
this.pushEvent('add_credit_clicked')
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
this.pushEvent('credit_topup_clicked', {
credit_amount: amount
})
}
}

View File

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { syncLayoutStoreNodeBoundsFromGraph } from './syncLayoutStoreFromGraph'
function createGraph(nodes: LGraphNode[]): LGraph {
return {
nodes
} as LGraph
}
function createNode(
id: number,
pos: [number, number],
size: [number, number]
): LGraphNode {
return {
id,
pos,
size
} as LGraphNode
}
describe('syncLayoutStoreNodeBoundsFromGraph', () => {
beforeEach(() => {
vi.restoreAllMocks()
LiteGraph.vueNodesMode = false
})
it('syncs node bounds to layout store when Vue nodes mode is enabled', () => {
LiteGraph.vueNodesMode = true
const batchUpdateNodeBounds = vi
.spyOn(layoutStore, 'batchUpdateNodeBounds')
.mockImplementation(() => {})
const graph = createGraph([
createNode(1, [100, 200], [320, 140]),
createNode(2, [450, 300], [225, 96])
])
syncLayoutStoreNodeBoundsFromGraph(graph)
expect(batchUpdateNodeBounds).toHaveBeenCalledWith([
{
nodeId: '1',
bounds: {
x: 100,
y: 200,
width: 320,
height: 140
}
},
{
nodeId: '2',
bounds: {
x: 450,
y: 300,
width: 225,
height: 96
}
}
])
})
it('does nothing when Vue nodes mode is disabled', () => {
const batchUpdateNodeBounds = vi
.spyOn(layoutStore, 'batchUpdateNodeBounds')
.mockImplementation(() => {})
const graph = createGraph([createNode(1, [100, 200], [320, 140])])
syncLayoutStoreNodeBoundsFromGraph(graph)
expect(batchUpdateNodeBounds).not.toHaveBeenCalled()
})
it('does nothing when graph has no nodes', () => {
LiteGraph.vueNodesMode = true
const batchUpdateNodeBounds = vi
.spyOn(layoutStore, 'batchUpdateNodeBounds')
.mockImplementation(() => {})
const graph = createGraph([])
syncLayoutStoreNodeBoundsFromGraph(graph)
expect(batchUpdateNodeBounds).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,23 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { NodeBoundsUpdate } from '@/renderer/core/layout/types'
export function syncLayoutStoreNodeBoundsFromGraph(graph: LGraph): void {
if (!LiteGraph.vueNodesMode) return
const nodes = graph.nodes ?? []
if (nodes.length === 0) return
const updates: NodeBoundsUpdate[] = nodes.map((node) => ({
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
}))
layoutStore.batchUpdateNodeBounds(updates)
}

View File

@@ -1312,4 +1312,96 @@ describe('linearOutputStore', () => {
).toHaveLength(2)
})
})
describe('deferred path mapping (WebSocket/HTTP race)', () => {
it('starts tracking when path mapping arrives after activeJobId', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
// activeJobId set before path mapping exists (WebSocket race)
activeJobIdRef.value = 'job-1'
await nextTick()
// No skeleton yet — path mapping is missing
expect(store.inProgressItems).toHaveLength(0)
// Path mapping arrives (HTTP response from queuePrompt)
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
await nextTick()
// Now onJobStart should have fired
expect(store.inProgressItems).toHaveLength(1)
expect(store.inProgressItems[0].state).toBe('skeleton')
expect(store.inProgressItems[0].jobId).toBe('job-1')
})
it('processes executed events after deferred start', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
activeJobIdRef.value = 'job-1'
await nextTick()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
await nextTick()
// Executed event arrives — should create an image item
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
const imageItems = store.inProgressItems.filter(
(i) => i.state === 'image'
)
expect(imageItems).toHaveLength(1)
})
it('does not double-start if path mapping is already available', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
// Path mapping set before activeJobId (normal case, no race)
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
expect(store.inProgressItems).toHaveLength(1)
// Trigger path mapping update again — should not create a second skeleton
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
await nextTick()
expect(store.inProgressItems).toHaveLength(1)
})
it('ignores deferred mapping if activeJobId changed', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
activeJobIdRef.value = 'job-1'
await nextTick()
// Job changes before path mapping arrives
activeJobIdRef.value = null
await nextTick()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
await nextTick()
// Should not have started job-1
expect(store.inProgressItems).toHaveLength(0)
})
it('ignores deferred mapping for a different workflow', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
activeJobIdRef.value = 'job-1'
await nextTick()
// Path maps to a different workflow than the active one
setJobWorkflowPath('job-1', 'workflows/other-workflow.json')
await nextTick()
expect(store.inProgressItems).toHaveLength(0)
})
})
})

View File

@@ -262,17 +262,27 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
onNodeExecuted(jobId, detail)
}
// Watch both activeJobId and the path mapping together. The path mapping
// may arrive after activeJobId due to a race between WebSocket
// (execution_start) and the HTTP response (queuePrompt > storeJob).
// Watching both ensures onJobStart fires once the mapping is available.
watch(
() => executionStore.activeJobId,
(jobId, oldJobId) => {
[
() => executionStore.activeJobId,
() => executionStore.jobIdToSessionWorkflowPath
],
([jobId], [oldJobId]) => {
if (!isAppMode.value) return
if (oldJobId && oldJobId !== jobId) {
onJobComplete(oldJobId)
}
// Start tracking only if the job belongs to this workflow.
// Jobs from other workflows are picked up by reconcileOnEnter
// when the user switches to that workflow's tab.
if (jobId && isJobForActiveWorkflow(jobId)) {
// Guard with trackedJobId to avoid double-starting when the
// path mapping arrives after activeJobId was already set.
if (
jobId &&
trackedJobId.value !== jobId &&
isJobForActiveWorkflow(jobId)
) {
onJobStart(jobId)
}
}

View File

@@ -131,8 +131,8 @@ import { downloadFile } from '@/base/common/downloadUtil'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
interface ImagePreviewProps {
@@ -227,7 +227,7 @@ const handleImageError = () => {
const handleEditMask = () => {
if (!props.nodeId) return
const node = app.rootGraph?.getNodeById(Number(props.nodeId))
const node = resolveNode(Number(props.nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
@@ -246,7 +246,7 @@ const handleDownload = () => {
const handleRemove = () => {
if (!props.nodeId) return
const node = app.rootGraph?.getNodeById(Number(props.nodeId))
const node = resolveNode(Number(props.nodeId))
nodeOutputStore.removeNodeOutputs(props.nodeId)
if (node) {
node.imgs = undefined

View File

@@ -14,6 +14,7 @@ import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/
import {
syncNodeSlotLayoutsFromDOM,
flushScheduledSlotLayoutSync,
requestSlotLayoutSyncForAllNodes,
useSlotElementTracking
} from './useSlotElementTracking'
@@ -55,7 +56,10 @@ function createWrapperComponent(type: 'input' | 'output') {
*/
async function mountAndRegisterSlot(type: 'input' | 'output') {
const wrapper = mount(createWrapperComponent(type))
wrapper.vm.el = document.createElement('div')
const slotEl = document.createElement('div')
slotEl.getBoundingClientRect = vi.fn(() => new DOMRect(100, 200, 16, 16))
document.body.append(slotEl)
wrapper.vm.el = slotEl
await nextTick()
flushScheduledSlotLayoutSync()
return wrapper
@@ -64,6 +68,7 @@ async function mountAndRegisterSlot(type: 'input' | 'output') {
describe('useSlotElementTracking', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
document.body.innerHTML = ''
layoutStore.initializeFromLiteGraph([])
layoutStore.applyOperation({
type: 'createNode',
@@ -134,9 +139,55 @@ describe('useSlotElementTracking', () => {
expect(layoutStore.pendingSlotSync).toBe(true)
})
it('keeps pendingSlotSync when all registered slots are hidden', () => {
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
const hiddenSlot = document.createElement('div')
const registryStore = useNodeSlotRegistryStore()
const node = registryStore.ensureNode(NODE_ID)
node.slots.set(slotKey, {
el: hiddenSlot,
index: SLOT_INDEX,
type: 'input'
})
layoutStore.setPendingSlotSync(true)
requestSlotLayoutSyncForAllNodes()
expect(layoutStore.pendingSlotSync).toBe(true)
expect(layoutStore.getSlotLayout(slotKey)).toBeNull()
})
it('removes stale slot layouts when slot element is hidden', () => {
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
const hiddenSlot = document.createElement('div')
const staleLayout: SlotLayout = {
nodeId: NODE_ID,
index: SLOT_INDEX,
type: 'input',
position: { x: 10, y: 20 },
bounds: { x: 6, y: 16, width: 8, height: 8 }
}
layoutStore.batchUpdateSlotLayouts([{ key: slotKey, layout: staleLayout }])
const registryStore = useNodeSlotRegistryStore()
const node = registryStore.ensureNode(NODE_ID)
node.slots.set(slotKey, {
el: hiddenSlot,
index: SLOT_INDEX,
type: 'input'
})
syncNodeSlotLayoutsFromDOM(NODE_ID)
expect(layoutStore.getSlotLayout(slotKey)).toBeNull()
})
it('skips slot layout writeback when measured slot geometry is unchanged', () => {
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
const slotEl = document.createElement('div')
document.body.append(slotEl)
slotEl.getBoundingClientRect = vi.fn(() => new DOMRect(100, 200, 16, 16))
const registryStore = useNodeSlotRegistryStore()

View File

@@ -33,6 +33,38 @@ function scheduleSlotLayoutSync(nodeId: string) {
raf.schedule()
}
function shouldWaitForSlotLayouts(): boolean {
const graph = app.canvas?.graph
const hasNodes = Boolean(graph && graph._nodes && graph._nodes.length > 0)
return hasNodes && !layoutStore.hasSlotLayouts
}
function completePendingSlotSync(): void {
layoutStore.setPendingSlotSync(false)
app.canvas?.setDirty(true, true)
}
function getSlotElementRect(el: HTMLElement): DOMRect | null {
if (!el.isConnected) return null
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
return rect
}
export function requestSlotLayoutSyncForAllNodes(): void {
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
for (const nodeId of nodeSlotRegistryStore.getNodeIds()) {
scheduleSlotLayoutSync(nodeId)
}
// If no slots are currently registered, run the completion check immediately
// so pendingSlotSync can be cleared when the graph has no nodes.
if (pendingNodes.size === 0) {
flushScheduledSlotLayoutSync()
}
}
function createSlotLayout(options: {
nodeId: string
index: number
@@ -60,17 +92,14 @@ function createSlotLayout(options: {
export function flushScheduledSlotLayoutSync() {
if (pendingNodes.size === 0) {
// No pending nodes - check if we should wait for Vue components to mount
const graph = app.canvas?.graph
const hasNodes = graph && graph._nodes && graph._nodes.length > 0
if (hasNodes && !layoutStore.hasSlotLayouts) {
if (shouldWaitForSlotLayouts()) {
// Graph has nodes but no slot layouts yet - Vue hasn't mounted.
// Keep flag set so late mounts can re-assert via scheduleSlotLayoutSync()
return
}
// Either no nodes (nothing to wait for) or slot layouts already exist
// (undo/redo preserved them). Clear the flag so links can render.
layoutStore.setPendingSlotSync(false)
app.canvas?.setDirty(true, true)
completePendingSlotSync()
return
}
const conv = useSharedCanvasPositionConversion()
@@ -78,10 +107,12 @@ export function flushScheduledSlotLayoutSync() {
pendingNodes.delete(nodeId)
syncNodeSlotLayoutsFromDOM(nodeId, conv)
}
// Clear the pending sync flag - slots are now synced
layoutStore.setPendingSlotSync(false)
// Trigger canvas redraw now that links can render with correct positions
app.canvas?.setDirty(true, true)
// Keep pending sync active until at least one measurable slot layout has
// been captured for the current graph.
if (shouldWaitForSlotLayouts()) return
completePendingSlotSync()
}
export function syncNodeSlotLayoutsFromDOM(
@@ -99,7 +130,14 @@ export function syncNodeSlotLayoutsFromDOM(
const positionConv = conv ?? useSharedCanvasPositionConversion()
for (const [slotKey, entry] of node.slots) {
const rect = entry.el.getBoundingClientRect()
const rect = getSlotElementRect(entry.el)
if (!rect) {
// Drop stale layout values while the slot is hidden so we don't render
// links with off-screen coordinates from a previous graph/tab state.
layoutStore.deleteSlotLayout(slotKey)
continue
}
const screenCenter: [number, number] = [
rect.left + rect.width / 2,
rect.top + rect.height / 2

View File

@@ -41,10 +41,15 @@ export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => {
registry.clear()
}
function getNodeIds(): string[] {
return Array.from(registry.keys())
}
return {
getNode,
ensureNode,
deleteNode,
clear
clear,
getNodeIds
}
})

View File

@@ -193,8 +193,8 @@ import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -374,7 +374,7 @@ function selectFromGrid(index: number) {
function handleEditMask() {
if (!nodeId) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
const node = resolveNode(Number(nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
@@ -395,7 +395,7 @@ function handleDownload() {
function handleRemove() {
if (!nodeId) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
const node = resolveNode(Number(nodeId))
nodeOutputStore.removeNodeOutputs(nodeId)
if (node) {
node.imgs = undefined

View File

@@ -1,3 +1,4 @@
import type { CurveData } from '@/components/curve/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ICurveWidget } from '@/lib/litegraph/src/types/widgets'
import type {
@@ -6,20 +7,22 @@ import type {
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
const DEFAULT_CURVE_DATA: CurveData = {
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
}
export const useCurveWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ICurveWidget => {
const spec = inputSpec as CurveInputSpec
const defaultValue = spec.default ?? [
[0, 0],
[1, 1]
]
const defaultValue: CurveData = spec.default
? { ...spec.default, points: [...spec.default.points] }
: { ...DEFAULT_CURVE_DATA, points: [...DEFAULT_CURVE_DATA.points] }
const rawWidget = node.addWidget(
'curve',
spec.name,
[...defaultValue],
() => {}
)
const rawWidget = node.addWidget('curve', spec.name, defaultValue, () => {})
if (rawWidget.type !== 'curve') {
throw new Error(`Unexpected widget type: ${rawWidget.type}`)

View File

@@ -128,11 +128,16 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
const zCurvePoint = z.tuple([z.number(), z.number()])
const zCurveData = z.object({
points: z.array(zCurvePoint),
interpolation: z.enum(['monotone_cubic', 'linear'])
})
const zCurveInputSpec = zBaseInputOptions.extend({
type: z.literal('CURVE'),
name: z.string(),
isOptional: z.boolean().optional(),
default: z.array(zCurvePoint).optional()
default: zCurveData.optional()
})
const zCustomInputSpec = zBaseInputOptions.extend({

View File

@@ -379,6 +379,7 @@ export class ComfyApi extends EventTarget {
}
apiURL(route: string): string {
if (route.startsWith('/api')) return this.api_base + route
return this.api_base + '/api' + route
}

View File

@@ -6,6 +6,7 @@ import { shallowRef } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { syncLayoutStoreNodeBoundsFromGraph } from '@/renderer/core/layout/sync/syncLayoutStoreFromGraph'
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { st, t } from '@/i18n'
@@ -1281,6 +1282,7 @@ export class ComfyApp {
ChangeTracker.isLoadingGraph = true
try {
let normalizedMainGraph = false
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.rootGraph.configure(graphData)
@@ -1290,7 +1292,10 @@ export class ComfyApp {
this.rootGraph.extra.workflowRendererVersion
// Scale main graph
ensureCorrectLayoutScale(originalMainGraphRenderer, this.rootGraph)
normalizedMainGraph = ensureCorrectLayoutScale(
originalMainGraphRenderer,
this.rootGraph
)
// Scale all subgraphs that were loaded with the workflow
// Use original main graph renderer as fallback (not the modified one)
@@ -1368,6 +1373,10 @@ export class ComfyApp {
useExtensionService().invokeExtensions('loadedGraphNode', node)
})
if (normalizedMainGraph) {
syncLayoutStoreNodeBoundsFromGraph(this.rootGraph)
}
await useExtensionService().invokeExtensionsAsync(
'afterConfigureGraph',
missingNodeTypes

View File

@@ -1,5 +1,9 @@
import { resolveBlueprintEssentialsCategory } from '@/constants/essentialsDisplayNames'
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import { ESSENTIALS_NODES } from '@/constants/essentialsNodes'
import {
ESSENTIALS_CATEGORIES,
ESSENTIALS_NODES
} from '@/constants/essentialsNodes'
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { buildNodeDefTree } from '@/stores/nodeDefStore'
@@ -144,11 +148,16 @@ class NodeOrganizationService {
private organizeEssentials(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const essentialNodes = nodes.filter(
(nodeDef) => !!nodeDef.essentials_category
(nodeDef) =>
!!nodeDef.essentials_category ||
!!resolveBlueprintEssentialsCategory(nodeDef.name)
)
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: (nodeDef) => {
const folder = nodeDef.essentials_category || ''
const folder =
nodeDef.essentials_category ||
resolveBlueprintEssentialsCategory(nodeDef.name) ||
''
return folder ? [folder, nodeDef.name] : [nodeDef.name]
}
})
@@ -158,6 +167,14 @@ class NodeOrganizationService {
private sortEssentialsFolders(tree: TreeNode): void {
if (!tree.children) return
const catLen = ESSENTIALS_CATEGORIES.length
tree.children.sort((a, b) => {
const ai = ESSENTIALS_CATEGORIES.indexOf(a.label as EssentialsCategory)
const bi = ESSENTIALS_CATEGORIES.indexOf(b.label as EssentialsCategory)
return (ai === -1 ? catLen : ai) - (bi === -1 ? catLen : bi)
})
for (const folder of tree.children) {
if (!folder.children) continue
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]

View File

@@ -98,6 +98,7 @@ vi.mock('@/stores/toastStore', () => ({
// Mock useDialogService
vi.mock('@/services/dialogService')
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
// Mock apiKeyAuthStore
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
@@ -185,7 +186,6 @@ describe('useFirebaseAuthStore', () => {
describe('token refresh events', () => {
beforeEach(async () => {
vi.resetModules()
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation(
(_auth, callback) => {

View File

@@ -9,9 +9,12 @@ import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import * as litegraphUtil from '@/utils/litegraphUtil'
const mockResolveNode = vi.fn()
vi.mock('@/utils/litegraphUtil', () => ({
isAnimatedOutput: vi.fn(),
isVideoNode: vi.fn()
isVideoNode: vi.fn(),
resolveNode: (...args: unknown[]) => mockResolveNode(...args)
}))
const mockGetNodeById = vi.fn()
@@ -337,7 +340,7 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(1, mockImg, 0)
@@ -351,7 +354,7 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(1, mockImg, 0)
@@ -365,7 +368,7 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 42 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(42, mockImg, 3)
@@ -379,11 +382,11 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 123 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs('123', mockImg, 0)
expect(mockGetNodeById).toHaveBeenCalledWith(123)
expect(mockResolveNode).toHaveBeenCalledWith(123)
expect(mockNode.imgs).toEqual([mockImg])
})
@@ -392,7 +395,7 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const store = useNodeOutputStore()
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(undefined)
mockResolveNode.mockReturnValue(undefined)
expect(() => store.syncLegacyNodeImgs(999, mockImg, 0)).not.toThrow()
})
@@ -403,10 +406,27 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(1, mockImg)
expect(mockNode.imageIndex).toBe(0)
})
it('should sync node.imgs when node is inside a subgraph', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 5 })
const mockImg = document.createElement('img')
// Node NOT in root graph (returns null)
mockGetNodeById.mockReturnValue(null)
// But found by resolveNode (in a subgraph)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(5, mockImg, 0)
expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(0)
})
})

View File

@@ -15,7 +15,11 @@ import { app } from '@/scripts/app'
import { clone } from '@/scripts/utils'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseFilePath } from '@/utils/formatUtil'
import { isAnimatedOutput, isVideoNode } from '@/utils/litegraphUtil'
import {
isAnimatedOutput,
isVideoNode,
resolveNode
} from '@/utils/litegraphUtil'
import {
releaseSharedObjectUrl,
retainSharedObjectUrl
@@ -464,7 +468,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
if (!LiteGraph.vueNodesMode) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
const node = resolveNode(Number(nodeId))
if (!node) return
node.imgs = [element]

View File

@@ -350,6 +350,119 @@ describe('useQueueStore', () => {
})
})
describe('update() - single-flight coalescing', () => {
it('should coalesce concurrent calls into one re-fetch', async () => {
let resolveQueue!: QueueResolver
mockGetQueue.mockImplementation(
() =>
new Promise<QueueResponse>((resolve) => {
resolveQueue = resolve
})
)
mockGetHistory.mockResolvedValue([])
// First call starts the in-flight request
const first = store.update()
expect(mockGetQueue).toHaveBeenCalledTimes(1)
// These calls arrive while the first is in flight — they should coalesce
void store.update()
void store.update()
void store.update()
// No additional HTTP requests fired
expect(mockGetQueue).toHaveBeenCalledTimes(1)
// Resolve the in-flight request
resolveQueue({ Running: [], Pending: [] })
await first
// A single re-fetch should fire because dirty was set
expect(mockGetQueue).toHaveBeenCalledTimes(2)
})
it('should apply every response (no starvation)', async () => {
const firstRunning = createRunningJob(1, 'run-1')
const secondRunning = createRunningJob(2, 'run-2')
let resolveQueue!: QueueResolver
mockGetQueue.mockImplementation(
() =>
new Promise<QueueResponse>((resolve) => {
resolveQueue = resolve
})
)
mockGetHistory.mockResolvedValue([])
// First call
const first = store.update()
// Second call coalesces
void store.update()
// Resolve first — data should be applied (not discarded)
resolveQueue({ Running: [firstRunning], Pending: [] })
await first
expect(store.runningTasks).toHaveLength(1)
expect(store.runningTasks[0].jobId).toBe('run-1')
// The coalesced re-fetch fires; resolve it with new data
resolveQueue({ Running: [secondRunning], Pending: [] })
// Wait for the re-fetch to complete
await new Promise((r) => setTimeout(r, 0))
expect(store.runningTasks).toHaveLength(1)
expect(store.runningTasks[0].jobId).toBe('run-2')
})
it('should not fire duplicate requests when no calls arrive during flight', async () => {
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
mockGetHistory.mockResolvedValue([])
await store.update()
expect(mockGetQueue).toHaveBeenCalledTimes(1)
expect(mockGetHistory).toHaveBeenCalledTimes(1)
})
it('should clear loading state after coalesced re-fetch completes', async () => {
let resolveQueue!: QueueResolver
mockGetQueue.mockImplementation(
() =>
new Promise<QueueResponse>((resolve) => {
resolveQueue = resolve
})
)
mockGetHistory.mockResolvedValue([])
const first = store.update()
void store.update() // coalesce
resolveQueue({ Running: [], Pending: [] })
await first
// isLoading should be true again for the re-fetch
expect(store.isLoading).toBe(true)
resolveQueue({ Running: [], Pending: [] })
await new Promise((r) => setTimeout(r, 0))
expect(store.isLoading).toBe(false)
})
it('should allow new requests after coalesced re-fetch completes', async () => {
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
mockGetHistory.mockResolvedValue([])
await store.update()
expect(mockGetQueue).toHaveBeenCalledTimes(1)
await store.update()
expect(mockGetQueue).toHaveBeenCalledTimes(2)
})
})
describe('update() - sorting', () => {
it('should sort tasks by job.priority descending', async () => {
const job1 = createHistoryJob(1, 'hist-1')
@@ -826,101 +939,94 @@ describe('useQueueStore', () => {
})
})
describe('update deduplication', () => {
it('should discard stale responses when newer request completes first', async () => {
let resolveFirst: QueueResolver
let resolveSecond: QueueResolver
const firstQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveFirst = resolve
})
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
})
describe('update deduplication (coalescing)', () => {
it('should coalesce concurrent calls — second call does not fire its own request', async () => {
let resolveQueue!: QueueResolver
mockGetQueue.mockImplementation(
() =>
new Promise<QueueResponse>((resolve) => {
resolveQueue = resolve
})
)
mockGetHistory.mockResolvedValue([])
mockGetQueue
.mockReturnValueOnce(firstQueuePromise)
.mockReturnValueOnce(secondQueuePromise)
const firstUpdate = store.update()
const secondUpdate = store.update()
resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
// Only one HTTP request should have been made
expect(mockGetQueue).toHaveBeenCalledTimes(1)
// Second call returns immediately (coalesced)
await secondUpdate
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].jobId).toBe('new-job')
resolveFirst!({
Running: [],
Pending: [createPendingJob(1, 'stale-job')]
})
// Resolve the in-flight request
resolveQueue({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
await firstUpdate
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].jobId).toBe('new-job')
// A re-fetch fires because dirty was set
expect(mockGetQueue).toHaveBeenCalledTimes(2)
})
it('should set isLoading to false only for the latest request', async () => {
let resolveFirst: QueueResolver
let resolveSecond: QueueResolver
const firstQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveFirst = resolve
})
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
})
it('should clear isLoading after in-flight request completes', async () => {
let resolveQueue!: QueueResolver
mockGetQueue.mockImplementation(
() =>
new Promise<QueueResponse>((resolve) => {
resolveQueue = resolve
})
)
mockGetHistory.mockResolvedValue([])
mockGetQueue
.mockReturnValueOnce(firstQueuePromise)
.mockReturnValueOnce(secondQueuePromise)
const firstUpdate = store.update()
expect(store.isLoading).toBe(true)
const secondUpdate = store.update()
expect(store.isLoading).toBe(true)
// Second call coalesces and returns immediately
void store.update()
resolveSecond!({ Running: [], Pending: [] })
await secondUpdate
expect(store.isLoading).toBe(false)
resolveFirst!({ Running: [], Pending: [] })
resolveQueue({ Running: [], Pending: [] })
await firstUpdate
// isLoading is true again because re-fetch was triggered
expect(store.isLoading).toBe(true)
// Resolve the re-fetch
resolveQueue({ Running: [], Pending: [] })
await new Promise((r) => setTimeout(r, 0))
expect(store.isLoading).toBe(false)
})
it('should handle stale request failure without affecting latest state', async () => {
let resolveSecond: QueueResolver
it('should handle in-flight failure and still trigger coalesced re-fetch', async () => {
let callCount = 0
let resolveSecond!: QueueResolver
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
mockGetQueue.mockImplementation(() => {
callCount++
if (callCount === 1) {
return Promise.reject(new Error('network error'))
}
return new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
})
})
mockGetHistory.mockResolvedValue([])
mockGetQueue
.mockRejectedValueOnce(new Error('stale network error'))
.mockReturnValueOnce(secondQueuePromise)
const firstUpdate = store.update()
const secondUpdate = store.update()
void store.update() // coalesces, sets dirty
resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
await secondUpdate
// First call rejects — but dirty flag triggers re-fetch
await expect(firstUpdate).rejects.toThrow('network error')
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].jobId).toBe('new-job')
expect(store.isLoading).toBe(false)
// Re-fetch was triggered
expect(mockGetQueue).toHaveBeenCalledTimes(2)
await expect(firstUpdate).rejects.toThrow('stale network error')
resolveSecond({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
await new Promise((r) => setTimeout(r, 0))
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].jobId).toBe('new-job')

View File

@@ -488,8 +488,13 @@ export const useQueueStore = defineStore('queue', () => {
const maxHistoryItems = ref(64)
const isLoading = ref(false)
// Scoped per-store instance; incremented to dedupe concurrent update() calls
let updateRequestId = 0
// Single-flight coalescing: at most one fetch in flight at a time.
// If update() is called while a fetch is running, the call is coalesced
// and a single re-fetch fires after the current one completes.
// This prevents both request spam and UI starvation (where a rapid stream
// of calls causes every response to be discarded by a stale-request guard).
let inFlight = false
let dirty = false
const tasks = computed<TaskItemImpl[]>(
() =>
@@ -514,7 +519,13 @@ export const useQueueStore = defineStore('queue', () => {
)
const update = async () => {
const requestId = ++updateRequestId
if (inFlight) {
dirty = true
return
}
inFlight = true
dirty = false
isLoading.value = true
try {
const [queue, history] = await Promise.all([
@@ -522,8 +533,6 @@ export const useQueueStore = defineStore('queue', () => {
api.getHistory(maxHistoryItems.value)
])
if (requestId !== updateRequestId) return
// API returns pre-sorted data (sort_by=create_time&order=desc)
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
@@ -582,11 +591,10 @@ export const useQueueStore = defineStore('queue', () => {
}
hasFetchedHistorySnapshot.value = true
} finally {
// Only clear loading if this is the latest request.
// A stale request completing (success or error) should not touch loading state
// since a newer request is responsible for it.
if (requestId === updateRequestId) {
isLoading.value = false
isLoading.value = false
inFlight = false
if (dirty) {
void update()
}
}
}

View File

@@ -1,442 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
compressWidgetInputSlots,
createNode,
isAnimatedOutput,
isVideoOutput,
migrateWidgetsValues,
resolveNode
} from '@/utils/litegraphUtil'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => ({
...(await importOriginal()),
LiteGraph: {
createNode: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: null }
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
addAlert: vi.fn(),
add: vi.fn(),
remove: vi.fn()
}))
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) => key)
}))
function createMockCanvas(overrides: Partial<LGraphCanvas> = {}): LGraphCanvas {
const mockGraph = {
add: vi.fn((node) => node),
change: vi.fn()
} as Partial<LGraph> as LGraph
const mockCanvas: Partial<LGraphCanvas> = {
graph_mouse: [100, 200],
graph: mockGraph,
...overrides
}
return mockCanvas as LGraphCanvas
}
describe('createNode', () => {
beforeEach(vi.clearAllMocks)
it('should create a node successfully', async () => {
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
const mockCanvas = createMockCanvas()
const result = await createNode(mockCanvas, 'LoadImage')
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(result).toBe(mockNode)
})
it('should return null when name is empty', async () => {
const mockCanvas = createMockCanvas()
const result = await createNode(mockCanvas, '')
expect(LiteGraph.createNode).not.toHaveBeenCalled()
expect(result).toBeNull()
})
it('should handle graph being null', async () => {
const mockNode = { pos: [0, 0] }
const mockCanvas = createMockCanvas({ graph: null })
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
const result = await createNode(mockCanvas, 'LoadImage')
expect(mockNode.pos).toEqual([0, 0])
expect(result).toBeNull()
})
it('should set position based on canvas graph_mouse', async () => {
const mockCanvas = createMockCanvas({ graph_mouse: [250, 350] })
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
await createNode(mockCanvas, 'LoadAudio')
expect(mockNode.pos).toEqual([250, 350])
})
})
describe('migrateWidgetsValues', () => {
it('should remove widget values for forceInput inputs', () => {
const inputDefs: Record<string, InputSpec> = {
normalInput: {
type: 'INT',
name: 'normalInput'
},
forceInputField: {
type: 'STRING',
name: 'forceInputField',
forceInput: true
},
anotherNormal: {
type: 'FLOAT',
name: 'anotherNormal'
}
}
const widgets = [
{ name: 'normalInput', type: 'number' },
{ name: 'anotherNormal', type: 'number' }
] as Partial<IWidget>[] as IWidget[]
const widgetValues = [42, 'dummy value', 3.14]
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual([42, 3.14])
})
it('should return original values if lengths do not match', () => {
const inputDefs: Record<string, InputSpec> = {
input1: {
type: 'INT',
name: 'input1',
forceInput: true
}
}
const widgets: IWidget[] = []
const widgetValues = [42, 'extra value']
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual(widgetValues)
})
it('should handle empty widgets and values', () => {
const inputDefs: Record<string, InputSpec> = {}
const widgets: IWidget[] = []
const widgetValues: unknown[] = []
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual([])
})
it('should preserve order of non-forceInput widget values', () => {
const inputDefs: Record<string, InputSpec> = {
first: {
type: 'INT',
name: 'first'
},
forced: {
type: 'STRING',
name: 'forced',
forceInput: true
},
last: {
type: 'FLOAT',
name: 'last'
}
}
const widgets = [
{ name: 'first', type: 'number' },
{ name: 'last', type: 'number' }
] as Partial<IWidget>[] as IWidget[]
const widgetValues = ['first value', 'dummy', 'last value']
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual(['first value', 'last value'])
})
it('should correctly handle seed with unexpected value', () => {
const inputDefs: Record<string, InputSpec> = {
normalInput: {
type: 'INT',
name: 'normalInput',
control_after_generate: true
},
forceInputField: {
type: 'STRING',
name: 'forceInputField',
forceInput: true
}
}
const widgets = [
{ name: 'normalInput', type: 'number' },
{ name: 'control_after_generate', type: 'string' }
] as Partial<IWidget>[] as IWidget[]
const widgetValues = [42, 'fixed', 'unexpected widget value']
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual([42, 'fixed'])
})
})
function createOutput(
overrides: Partial<ExecutedWsMessage['output']> = {}
): ExecutedWsMessage['output'] {
return { ...overrides }
}
describe('isAnimatedOutput', () => {
it('returns false for undefined output', () => {
expect(isAnimatedOutput(undefined)).toBe(false)
})
it('returns false when animated array is missing', () => {
expect(isAnimatedOutput(createOutput())).toBe(false)
})
it('returns false when all animated values are false', () => {
expect(isAnimatedOutput(createOutput({ animated: [false, false] }))).toBe(
false
)
})
it('returns true when any animated value is true', () => {
expect(isAnimatedOutput(createOutput({ animated: [false, true] }))).toBe(
true
)
})
})
describe('isVideoOutput', () => {
it('returns false for non-animated output', () => {
expect(
isVideoOutput(
createOutput({
animated: [false],
images: [{ filename: 'video.webm' }]
})
)
).toBe(false)
})
it('returns false for animated webp output', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'anim.webp' }]
})
)
).toBe(false)
})
it('returns false for animated png output', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'anim.png' }]
})
)
).toBe(false)
})
it('returns true for animated webm output', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'output.webm' }]
})
)
).toBe(true)
})
it('returns true for animated mp4 output', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'output.mp4' }]
})
)
).toBe(true)
})
it('returns true for animated output with no images array', () => {
expect(isVideoOutput(createOutput({ animated: [true] }))).toBe(true)
})
it('does not false-positive on filenames containing webp as substring', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'my_webp_file.mp4' }]
})
)
).toBe(true)
})
})
describe('compressWidgetInputSlots', () => {
it('should remove unconnected widget input slots', () => {
// Using partial mock - only including properties needed for test
const graph = {
nodes: [
{
id: 1,
type: 'foo',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [
{ widget: { name: 'foo' }, link: null, type: 'INT', name: 'foo' },
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
{ widget: { name: 'baz' }, link: null, type: 'INT', name: 'baz' }
],
outputs: []
}
],
links: [[2, 1, 0, 1, 0, 'INT']]
} as Partial<ISerialisedGraph> as ISerialisedGraph
compressWidgetInputSlots(graph)
expect(graph.nodes[0].inputs).toEqual([
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' }
])
})
it('should update link target slots correctly', () => {
const graph = {
nodes: [
{
id: 1,
type: 'foo',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [
{ widget: { name: 'foo' }, link: null, type: 'INT', name: 'foo' },
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
{ widget: { name: 'baz' }, link: 3, type: 'INT', name: 'baz' }
],
outputs: []
}
],
links: [
[2, 1, 0, 1, 1, 'INT'],
[3, 1, 0, 1, 2, 'INT']
]
} as Partial<ISerialisedGraph> as ISerialisedGraph
compressWidgetInputSlots(graph)
expect(graph.nodes[0].inputs).toEqual([
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
{ widget: { name: 'baz' }, link: 3, type: 'INT', name: 'baz' }
])
expect(graph.links).toEqual([
[2, 1, 0, 1, 0, 'INT'],
[3, 1, 0, 1, 1, 'INT']
])
})
it('should handle graphs with no nodes gracefully', () => {
// Using partial mock - only including properties needed for test
const graph = {
nodes: [],
links: []
} as Partial<ISerialisedGraph> as ISerialisedGraph
compressWidgetInputSlots(graph)
expect(graph.nodes).toEqual([])
expect(graph.links).toEqual([])
})
})
import { resolveNode } from './litegraphUtil'
describe('resolveNode', () => {
function mockGraph(
nodeList: Partial<LGraphNode>[],
subgraphs?: Map<string, LGraph>
) {
const nodesById: Record<string, LGraphNode> = {}
for (const n of nodeList) {
nodesById[String(n.id)] = n as LGraphNode
}
return {
nodes: nodeList as LGraphNode[],
getNodeById(id: unknown) {
return id != null ? (nodesById[String(id)] ?? null) : null
},
subgraphs: subgraphs ?? new Map()
} as unknown as LGraph
}
it('returns undefined when graph is nullish', () => {
it('returns undefined when graph is null', () => {
expect(resolveNode(1, null)).toBeUndefined()
})
it('returns undefined when graph is undefined', () => {
expect(resolveNode(1, undefined)).toBeUndefined()
})
it('finds a node in the main graph', () => {
const node = { id: 5 } as LGraphNode
const graph = mockGraph([node])
expect(resolveNode(5, graph)).toBe(node)
it('finds a node in the root graph', () => {
const graph = new LGraph()
const node = new LGraphNode('TestNode')
graph.add(node)
expect(resolveNode(node.id, graph)).toBe(node)
})
it('finds a node in a subgraph', () => {
const subNode = { id: 10 } as LGraphNode
const subgraph = mockGraph([subNode])
const graph = mockGraph([], new Map([['sg-1', subgraph]]))
expect(resolveNode(10, graph)).toBe(subNode)
})
it('returns undefined when node does not exist anywhere', () => {
const graph = new LGraph()
it('returns undefined when node is not found anywhere', () => {
const graph = mockGraph([{ id: 1 } as LGraphNode])
expect(resolveNode(999, graph)).toBeUndefined()
})
it('prefers main graph over subgraph', () => {
const mainNode = { id: 1, title: 'main' } as LGraphNode
const subNode = { id: 1, title: 'sub' } as LGraphNode
const subgraph = mockGraph([subNode])
const graph = mockGraph([mainNode], new Map([['sg-1', subgraph]]))
expect(resolveNode(1, graph)).toBe(mainNode)
it('finds a node inside a subgraph', () => {
const subgraph = createTestSubgraph({ nodeCount: 1 })
const rootGraph = subgraph.rootGraph
rootGraph._subgraphs.set(subgraph.id, subgraph)
const subgraphNode = subgraph._nodes[0]
// Node should NOT be found directly on root graph
expect(rootGraph.getNodeById(subgraphNode.id)).toBeFalsy()
// But resolveNode should find it via subgraph search
expect(resolveNode(subgraphNode.id, rootGraph)).toBe(subgraphNode)
})
it('prefers root graph node over subgraph node with same id', () => {
const subgraph = createTestSubgraph()
const rootGraph = subgraph.rootGraph
const rootNode = new LGraphNode('RootNode')
rootGraph.add(rootNode)
// Add a different node to the subgraph
const sgNode = new LGraphNode('SubgraphNode')
subgraph.add(sgNode)
// resolveNode should return the root graph node first
expect(resolveNode(rootNode.id, rootGraph)).toBe(rootNode)
})
it('searches across multiple subgraphs', () => {
const sg1 = createTestSubgraph({ name: 'SG1' })
const rootGraph = sg1.rootGraph
const sg2 = createTestSubgraph({ name: 'SG2', nodeCount: 1 })
// Put sg2 under the same root graph
rootGraph._subgraphs.set(sg2.id, sg2)
const targetNode = sg2._nodes[0]
expect(resolveNode(targetNode.id, rootGraph)).toBe(targetNode)
})
})