mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Merge branch 'main' into drjkl/knipper
This commit is contained in:
169
browser_tests/assets/links/duplicate_links_slot_drift.json
Normal file
169
browser_tests/assets/links/duplicate_links_slot_drift.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"pos": [400, 300],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 120,
|
||||
"lastLinkId": 276,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Slot Drift Duplicate Links",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [0, 300, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 300, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 120,
|
||||
"type": "ComfySwitchNode",
|
||||
"title": "Switch (CFG)",
|
||||
"pos": [100, 100],
|
||||
"size": [200, 80],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "value", "type": "FLOAT", "link": null }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [257, 271, 276]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "ComfySwitchNode" },
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 50],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 276 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 350],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 271 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 257,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 271,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 86,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 276,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] },
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -68,6 +68,41 @@ export class AppModeHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject linearData into the current graph and enter app mode.
|
||||
*
|
||||
* Serializes the graph, injects linearData with the given inputs and
|
||||
* auto-detected output node IDs, then reloads so the appModeStore
|
||||
* picks up the data via its activeWorkflow watcher.
|
||||
*
|
||||
* @param inputs - Widget selections as [nodeId, widgetName] tuples
|
||||
*/
|
||||
async enterAppModeWithInputs(inputs: [string, string][]) {
|
||||
await this.page.evaluate(async (inputTuples) => {
|
||||
const graph = window.app!.graph
|
||||
if (!graph) return
|
||||
|
||||
const outputNodeIds = graph.nodes
|
||||
.filter(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
.map((n: { id: number | string }) => String(n.id))
|
||||
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
}, inputs)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
@@ -125,4 +160,42 @@ export class AppModeHelper {
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getBuilderInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title to trigger
|
||||
* inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.filter({ hasText: title })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export const TestIds = {
|
||||
},
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
},
|
||||
breadcrumb: {
|
||||
|
||||
168
browser_tests/tests/appModeDropdownClipping.spec.ts
Normal file
168
browser_tests/tests/appModeDropdownClipping.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Default workflow widget inputs as [nodeId, widgetName] tuples.
|
||||
* All widgets from the default graph are selected so the panel scrolls,
|
||||
* pushing the last widget's dropdown to the clipping boundary.
|
||||
*/
|
||||
const DEFAULT_INPUTS: [string, string][] = [
|
||||
['4', 'ckpt_name'],
|
||||
['6', 'text'],
|
||||
['7', 'text'],
|
||||
['5', 'width'],
|
||||
['5', 'height'],
|
||||
['5', 'batch_size'],
|
||||
['3', 'seed'],
|
||||
['3', 'steps'],
|
||||
['3', 'cfg'],
|
||||
['3', 'sampler_name'],
|
||||
['3', 'scheduler'],
|
||||
['3', 'denoise'],
|
||||
['9', 'filename_prefix']
|
||||
]
|
||||
|
||||
function isClippedByAnyAncestor(el: Element): boolean {
|
||||
const child = el.getBoundingClientRect()
|
||||
let parent = el.parentElement
|
||||
|
||||
while (parent) {
|
||||
const overflow = getComputedStyle(parent).overflow
|
||||
if (overflow !== 'visible') {
|
||||
const p = parent.getBoundingClientRect()
|
||||
if (
|
||||
child.top < p.top ||
|
||||
child.bottom > p.bottom ||
|
||||
child.left < p.left ||
|
||||
child.right > p.right
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Add a node to the graph by type and return its ID. */
|
||||
async function addNode(page: Page, nodeType: string): Promise<string> {
|
||||
return page.evaluate((type) => {
|
||||
const node = window.app!.graph.add(
|
||||
window.LiteGraph!.createNode(type, undefined, {})
|
||||
)
|
||||
return String(node!.id)
|
||||
}, nodeType)
|
||||
}
|
||||
|
||||
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Select dropdown is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[saveVideoId, 'codec']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the codec widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
|
||||
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
|
||||
await codecSelect.click()
|
||||
|
||||
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await overlay.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
|
||||
test('FormDropdown popup is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[loadImageId, 'image']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the image widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the FormDropdown trigger button for the image widget.
|
||||
// The button emits 'select-click' which toggles the Popover.
|
||||
const imageRow = widgetList.locator(
|
||||
'div:has(> div > span:text-is("image"))'
|
||||
)
|
||||
const dropdownButton = imageRow.locator('button:has(> span)').first()
|
||||
await dropdownButton.click()
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -82,7 +82,9 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
|
||||
test('Rename from builder input-select sidebar via menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
@@ -91,11 +93,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
|
||||
const menu = appMode.getBuilderInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameWidget(menu, 'Builder Input Seed')
|
||||
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input`
|
||||
const workflowName = `${new Date().getTime()} builder-input-menu`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
@@ -104,6 +106,24 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToInputs()
|
||||
|
||||
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
|
||||
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-dblclick`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
@@ -23,4 +23,85 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('links/bad_link')
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
|
||||
})
|
||||
|
||||
// Regression: duplicate links with shifted target_slot (widget-to-input
|
||||
// conversion) caused the wrong link to survive during deduplication.
|
||||
// Switch(CFG) node 120 connects to both KSamplerAdvanced 85 and 86 (2 links).
|
||||
// Links 257 and 276 shared the same tuple (origin=120 → target=85 slot=5).
|
||||
// Node 85's input.link was 276 (valid), but the bug kept 257 (stale) and
|
||||
// removed 276, breaking the cfg connection on KSamplerAdvanced 85.
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/10291
|
||||
test('Deduplicates links without breaking connections on slot-drift workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
// Find cfg inputs by name (slot indices shift due to widget-to-input)
|
||||
const cfgInput85 = ksampler85.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfgInput86 = ksampler86.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfg85Linked = cfgInput85?.link != null
|
||||
const cfg86Linked = cfgInput86?.link != null
|
||||
|
||||
// Verify the surviving links exist in the subgraph link map
|
||||
const cfg85LinkValid =
|
||||
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
|
||||
const cfg86LinkValid =
|
||||
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
|
||||
|
||||
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
|
||||
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
|
||||
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
return {
|
||||
cfg85Linked,
|
||||
cfg86Linked,
|
||||
cfg85LinkValid,
|
||||
cfg86LinkValid,
|
||||
cfg85LinkId: cfgInput85?.link ?? null,
|
||||
cfg86LinkId: cfgInput86?.link ?? null,
|
||||
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Both KSamplerAdvanced nodes must have their cfg input connected
|
||||
expect(result.cfg85Linked).toBe(true)
|
||||
expect(result.cfg86Linked).toBe(true)
|
||||
// Links must exist in the subgraph link map
|
||||
expect(result.cfg85LinkValid).toBe(true)
|
||||
expect(result.cfg86LinkValid).toBe(true)
|
||||
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
|
||||
expect(result.switchOutputLinkCount).toBe(2)
|
||||
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
|
||||
expect(result.cfgLinkToNode85Count).toBe(1)
|
||||
// Output link IDs must match the input link IDs (source/target integrity)
|
||||
expect(result.switchOutputLinkIds).toEqual(
|
||||
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
@@ -11,58 +9,10 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function enterAppMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
// LinearControls requires hasOutputs to be true. Serialize the current
|
||||
// graph, inject linearData with output node IDs, then reload so the
|
||||
// appModeStore picks up the outputs via its activeWorkflow watcher.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const graph = window.app!.graph
|
||||
if (!graph) return
|
||||
|
||||
const outputNodeIds = graph.nodes
|
||||
.filter(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
.map((n: { id: number | string }) => String(n.id))
|
||||
|
||||
// Serialize, inject linearData, and reload to sync stores
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: [], outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Toggle to app mode via the command which sets canvasStore.linearMode
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function enterGraphMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
@@ -70,7 +20,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
@@ -78,7 +28,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
|
||||
@@ -86,13 +36,13 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Returns to graph mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await enterGraphMode(comfyPage)
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
@@ -101,7 +51,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Canvas not visible in app mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
|
||||
@@ -631,3 +631,10 @@ export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
mediaType === '3D'
|
||||
)
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds === 0) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
@@ -63,7 +63,7 @@ const inputsWithState = computed(() =>
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
rename: () => promptRenameWidget(widget, node, t)
|
||||
canRename: true
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -74,6 +74,16 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
function inlineRenameInput(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
newLabel: string
|
||||
) {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) return
|
||||
renameWidget(widget, node, newLabel)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -234,7 +244,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
canRename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="
|
||||
@@ -242,7 +252,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:can-rename="canRename"
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
@@ -250,6 +260,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
@rename="inlineRenameInput(nodeId, widgetName, $event)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -44,6 +45,7 @@ const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
|
||||
@@ -2,31 +2,43 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const titleTooltip = ref<string | null>(null)
|
||||
const subTitleTooltip = ref<string | null>(null)
|
||||
const isEditing = ref(false)
|
||||
|
||||
function isTruncated(e: MouseEvent): boolean {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
return el.scrollWidth > el.clientWidth
|
||||
}
|
||||
const { rename, remove } = defineProps<{
|
||||
const { title, canRename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
rename?: () => void
|
||||
canRename?: boolean
|
||||
remove?: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
rename: [newName: string]
|
||||
}>()
|
||||
|
||||
function onEditComplete(newName: string) {
|
||||
isEditing.value = false
|
||||
const trimmed = newName.trim()
|
||||
if (trimmed && trimmed !== title) emit('rename', trimmed)
|
||||
}
|
||||
|
||||
const entries = computed(() => {
|
||||
const items = []
|
||||
if (rename)
|
||||
if (canRename)
|
||||
items.push({
|
||||
label: t('g.rename'),
|
||||
command: rename,
|
||||
command: () => setTimeout(() => (isEditing.value = true)),
|
||||
icon: 'icon-[lucide--pencil]'
|
||||
})
|
||||
if (remove)
|
||||
@@ -43,13 +55,24 @@ const entries = computed(() => {
|
||||
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
|
||||
data-testid="builder-io-item"
|
||||
>
|
||||
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
|
||||
<div
|
||||
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-sm"
|
||||
<div class="drag-handle mr-auto flex w-full min-w-0 flex-col gap-1">
|
||||
<EditableText
|
||||
:model-value="title"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ class: 'p-1' }"
|
||||
:class="
|
||||
cn(
|
||||
'drag-handle h-5 text-sm',
|
||||
isEditing && 'relative -top-0.5 -left-1 -mt-px mb-px -ml-px',
|
||||
!isEditing && 'truncate'
|
||||
)
|
||||
"
|
||||
data-testid="builder-io-item-title"
|
||||
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
|
||||
v-text="title"
|
||||
label-class="drag-handle"
|
||||
label-type="div"
|
||||
@dblclick="canRename && (isEditing = true)"
|
||||
@edit="onEditComplete"
|
||||
@cancel="isEditing = false"
|
||||
/>
|
||||
<div
|
||||
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="editable-text">
|
||||
<span v-if="!isEditing">
|
||||
<component :is="labelType" v-if="!isEditing" :class="labelClass">
|
||||
{{ modelValue }}
|
||||
</span>
|
||||
</component>
|
||||
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||
<InputText
|
||||
v-else
|
||||
@@ -35,11 +35,15 @@ import { nextTick, ref, watch } from 'vue'
|
||||
const {
|
||||
modelValue,
|
||||
isEditing = false,
|
||||
inputAttrs = {}
|
||||
inputAttrs = {},
|
||||
labelClass = '',
|
||||
labelType = 'span'
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, string>
|
||||
labelClass?: string
|
||||
labelType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['edit', 'cancel'])
|
||||
|
||||
60
src/components/common/WaveAudioPlayer.stories.ts
Normal file
60
src/components/common/WaveAudioPlayer.stories.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import WaveAudioPlayer from './WaveAudioPlayer.vue'
|
||||
|
||||
const meta: Meta<typeof WaveAudioPlayer> = {
|
||||
title: 'Components/Audio/WaveAudioPlayer',
|
||||
component: WaveAudioPlayer,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
src: '/assets/audio/sample.wav',
|
||||
barCount: 40,
|
||||
height: 32
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-80 rounded-lg bg-base-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const BottomAligned: Story = {
|
||||
args: {
|
||||
src: '/assets/audio/sample.wav',
|
||||
barCount: 40,
|
||||
height: 48,
|
||||
align: 'bottom'
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-80 rounded-lg bg-base-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const Expanded: Story = {
|
||||
args: {
|
||||
src: '/assets/audio/sample.wav',
|
||||
variant: 'expanded',
|
||||
barCount: 80,
|
||||
height: 120
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[600px] rounded-2xl bg-base-background/80 p-8 backdrop-blur-sm"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
221
src/components/common/WaveAudioPlayer.vue
Normal file
221
src/components/common/WaveAudioPlayer.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<!-- Compact: [▶] [waveform] [time] -->
|
||||
<div
|
||||
v-if="variant === 'compact'"
|
||||
:class="
|
||||
cn('flex w-full gap-2', align === 'center' ? 'items-center' : 'items-end')
|
||||
"
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
|
||||
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
|
||||
:loading="loading"
|
||||
@click.stop="togglePlayPause"
|
||||
>
|
||||
<i
|
||||
v-if="!isPlaying"
|
||||
class="ml-0.5 icon-[lucide--play] size-3 text-base-foreground"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--pause] size-3 text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
:ref="(el) => (waveformRef = el as HTMLElement)"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-0 flex-1 cursor-pointer gap-px',
|
||||
align === 'center' ? 'items-center' : 'items-end'
|
||||
)
|
||||
"
|
||||
:style="{ height: height + 'px' }"
|
||||
@click="handleWaveformClick"
|
||||
>
|
||||
<div
|
||||
v-for="(bar, index) in bars"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'min-h-0.5 flex-1 rounded-full',
|
||||
loading
|
||||
? 'bg-muted-foreground/20'
|
||||
: index <= playedBarIndex
|
||||
? 'bg-base-foreground'
|
||||
: 'bg-muted-foreground/40'
|
||||
)
|
||||
"
|
||||
:style="{ height: (bar.height / 100) * height + 'px' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{{ formattedCurrentTime }} / {{ formattedDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Expanded: waveform / progress bar + times / transport -->
|
||||
<div v-else class="flex w-full flex-col gap-4" @pointerdown.stop @click.stop>
|
||||
<div
|
||||
class="flex w-full items-center gap-0.5"
|
||||
:style="{ height: height + 'px' }"
|
||||
>
|
||||
<div
|
||||
v-for="(bar, index) in bars"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'min-h-0.5 flex-1 rounded-full',
|
||||
loading ? 'bg-muted-foreground/20' : 'bg-base-foreground'
|
||||
)
|
||||
"
|
||||
:style="{ height: (bar.height / 100) * height + 'px' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
ref="progressRef"
|
||||
class="relative h-1 w-full cursor-pointer rounded-full bg-muted-foreground/20"
|
||||
@click="handleProgressClick"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full rounded-full bg-base-foreground"
|
||||
:style="{ width: progressRatio + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
<span>{{ formattedCurrentTime }}</span>
|
||||
<span>{{ formattedDuration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-20" />
|
||||
|
||||
<div class="flex flex-1 items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 rounded-full"
|
||||
:aria-label="$t('g.skipToStart')"
|
||||
:disabled="loading"
|
||||
@click="seekToStart"
|
||||
>
|
||||
<i class="icon-[lucide--skip-back] size-4 text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-10 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
|
||||
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
|
||||
:loading="loading"
|
||||
@click="togglePlayPause"
|
||||
>
|
||||
<i
|
||||
v-if="!isPlaying"
|
||||
class="ml-0.5 icon-[lucide--play] size-5 text-base-foreground"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--pause] size-5 text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 rounded-full"
|
||||
:aria-label="$t('g.skipToEnd')"
|
||||
:disabled="loading"
|
||||
@click="seekToEnd"
|
||||
>
|
||||
<i class="icon-[lucide--skip-forward] size-4 text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex w-20 items-center gap-1">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 rounded-full"
|
||||
:aria-label="$t('g.volume')"
|
||||
:disabled="loading"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i :class="cn(volumeIcon, 'size-4 text-base-foreground')" />
|
||||
</Button>
|
||||
<Slider
|
||||
:model-value="[volume * 100]"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => (volume = (v?.[0] ?? 100) / 100)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
:ref="(el) => (audioRef = el as HTMLAudioElement)"
|
||||
:src="audioSrc"
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, toRef } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useWaveAudioPlayer } from '@/composables/useWaveAudioPlayer'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
barCount = 40,
|
||||
height = 32,
|
||||
align = 'center',
|
||||
variant = 'compact'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
barCount?: number
|
||||
height?: number
|
||||
align?: 'center' | 'bottom'
|
||||
variant?: 'compact' | 'expanded'
|
||||
}>()
|
||||
|
||||
const progressRef = ref<HTMLElement>()
|
||||
|
||||
const {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying,
|
||||
playedBarIndex,
|
||||
progressRatio,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
togglePlayPause,
|
||||
seekToStart,
|
||||
seekToEnd,
|
||||
volume,
|
||||
volumeIcon,
|
||||
toggleMute,
|
||||
seekToRatio,
|
||||
handleWaveformClick
|
||||
} = useWaveAudioPlayer({
|
||||
src: toRef(() => src),
|
||||
barCount
|
||||
})
|
||||
|
||||
function handleProgressClick(event: MouseEvent) {
|
||||
if (!progressRef.value) return
|
||||
const rect = progressRef.value.getBoundingClientRect()
|
||||
seekToRatio((event.clientX - rect.left) / rect.width)
|
||||
}
|
||||
</script>
|
||||
@@ -1,19 +1,21 @@
|
||||
<template>
|
||||
<audio controls width="100%" height="100%">
|
||||
<source :src="url" :type="htmlAudioType" />
|
||||
{{ $t('g.audioFailedToLoad') }}
|
||||
</audio>
|
||||
<div
|
||||
class="m-auto w-[min(90vw,42rem)] rounded-2xl bg-base-background/80 p-8 backdrop-blur-sm"
|
||||
>
|
||||
<WaveAudioPlayer
|
||||
:src="result.url"
|
||||
variant="expanded"
|
||||
:height="120"
|
||||
:bar-count="80"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import WaveAudioPlayer from '@/components/common/WaveAudioPlayer.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { result } = defineProps<{
|
||||
defineProps<{
|
||||
result: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const url = computed(() => result.url)
|
||||
const htmlAudioType = computed(() => result.htmlAudioType)
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HintedString } from '@primevue/core'
|
||||
import { computed } from 'vue'
|
||||
import type { InjectionKey } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
/**
|
||||
* Options for configuring transform-compatible overlay props
|
||||
@@ -15,6 +16,10 @@ interface TransformCompatOverlayOptions {
|
||||
// autoZIndex?: boolean
|
||||
}
|
||||
|
||||
export const OverlayAppendToKey: InjectionKey<
|
||||
HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
> = Symbol('OverlayAppendTo')
|
||||
|
||||
/**
|
||||
* Composable that provides props to make PrimeVue overlay components
|
||||
* compatible with CSS-transformed parent elements.
|
||||
@@ -41,8 +46,10 @@ interface TransformCompatOverlayOptions {
|
||||
export function useTransformCompatOverlayProps(
|
||||
overrides: TransformCompatOverlayOptions = {}
|
||||
) {
|
||||
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
|
||||
|
||||
return computed(() => ({
|
||||
appendTo: 'self' as const,
|
||||
appendTo: injectedAppendTo ?? ('self' as const),
|
||||
...overrides
|
||||
}))
|
||||
}
|
||||
|
||||
130
src/composables/useWaveAudioPlayer.test.ts
Normal file
130
src/composables/useWaveAudioPlayer.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWaveAudioPlayer } from './useWaveAudioPlayer'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
useMediaControls: () => ({
|
||||
playing: ref(false),
|
||||
currentTime: ref(0),
|
||||
duration: ref(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const mockFetchApi = vi.fn()
|
||||
const originalAudioContext = globalThis.AudioContext
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.AudioContext = originalAudioContext
|
||||
mockFetchApi.mockReset()
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (route: string) => '/api' + route,
|
||||
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useWaveAudioPlayer', () => {
|
||||
it('initializes with default bar count', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src })
|
||||
expect(bars.value).toHaveLength(40)
|
||||
})
|
||||
|
||||
it('initializes with custom bar count', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src, barCount: 20 })
|
||||
expect(bars.value).toHaveLength(20)
|
||||
})
|
||||
|
||||
it('returns playedBarIndex as -1 when duration is 0', () => {
|
||||
const src = ref('')
|
||||
const { playedBarIndex } = useWaveAudioPlayer({ src })
|
||||
expect(playedBarIndex.value).toBe(-1)
|
||||
})
|
||||
|
||||
it('generates bars with heights between 10 and 70', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src })
|
||||
for (const bar of bars.value) {
|
||||
expect(bar.height).toBeGreaterThanOrEqual(10)
|
||||
expect(bar.height).toBeLessThanOrEqual(70)
|
||||
}
|
||||
})
|
||||
|
||||
it('starts in paused state', () => {
|
||||
const src = ref('')
|
||||
const { isPlaying } = useWaveAudioPlayer({ src })
|
||||
expect(isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shows 0:00 for formatted times initially', () => {
|
||||
const src = ref('')
|
||||
const { formattedCurrentTime, formattedDuration } = useWaveAudioPlayer({
|
||||
src
|
||||
})
|
||||
expect(formattedCurrentTime.value).toBe('0:00')
|
||||
expect(formattedDuration.value).toBe('0:00')
|
||||
})
|
||||
|
||||
it('fetches and decodes audio when src changes', async () => {
|
||||
const mockAudioBuffer = {
|
||||
getChannelData: vi.fn(() => new Float32Array(80))
|
||||
}
|
||||
|
||||
const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer))
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined)
|
||||
globalThis.AudioContext = class {
|
||||
decodeAudioData = mockDecodeAudioData
|
||||
close = mockClose
|
||||
} as unknown as typeof AudioContext
|
||||
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
headers: { get: () => 'audio/wav' }
|
||||
})
|
||||
|
||||
const src = ref('/api/view?filename=audio.wav&type=output')
|
||||
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 10 })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/view?filename=audio.wav&type=output'
|
||||
)
|
||||
expect(mockDecodeAudioData).toHaveBeenCalled()
|
||||
expect(bars.value).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('clears blobUrl and shows placeholder bars when fetch fails', async () => {
|
||||
mockFetchApi.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const src = ref('/api/view?filename=audio.wav&type=output')
|
||||
const { bars, loading, audioSrc } = useWaveAudioPlayer({
|
||||
src,
|
||||
barCount: 10
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(bars.value).toHaveLength(10)
|
||||
expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output')
|
||||
})
|
||||
|
||||
it('does not call decodeAudioSource when src is empty', () => {
|
||||
const src = ref('')
|
||||
useWaveAudioPlayer({ src })
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
205
src/composables/useWaveAudioPlayer.ts
Normal file
205
src/composables/useWaveAudioPlayer.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useMediaControls, whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { formatTime } from '@/utils/formatUtil'
|
||||
|
||||
interface WaveformBar {
|
||||
height: number
|
||||
}
|
||||
|
||||
interface UseWaveAudioPlayerOptions {
|
||||
src: Ref<string>
|
||||
barCount?: number
|
||||
}
|
||||
|
||||
export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
const { src, barCount = 40 } = options
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const waveformRef = ref<HTMLElement>()
|
||||
const blobUrl = ref<string>()
|
||||
const loading = ref(false)
|
||||
let decodeRequestId = 0
|
||||
const bars = ref<WaveformBar[]>(generatePlaceholderBars())
|
||||
|
||||
const { playing, currentTime, duration, volume, muted } =
|
||||
useMediaControls(audioRef)
|
||||
|
||||
const playedBarIndex = computed(() => {
|
||||
if (duration.value === 0) return -1
|
||||
return Math.floor((currentTime.value / duration.value) * barCount) - 1
|
||||
})
|
||||
|
||||
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
|
||||
const formattedDuration = computed(() => formatTime(duration.value))
|
||||
|
||||
const audioSrc = computed(() =>
|
||||
src.value ? (blobUrl.value ?? src.value) : ''
|
||||
)
|
||||
|
||||
function generatePlaceholderBars(): WaveformBar[] {
|
||||
return Array.from({ length: barCount }, () => ({
|
||||
height: Math.random() * 60 + 10
|
||||
}))
|
||||
}
|
||||
|
||||
function generateBarsFromBuffer(buffer: AudioBuffer) {
|
||||
const channelData = buffer.getChannelData(0)
|
||||
if (channelData.length === 0) {
|
||||
bars.value = generatePlaceholderBars()
|
||||
return
|
||||
}
|
||||
|
||||
const averages: number[] = []
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const start = Math.floor((i * channelData.length) / barCount)
|
||||
const end = Math.max(
|
||||
start + 1,
|
||||
Math.floor(((i + 1) * channelData.length) / barCount)
|
||||
)
|
||||
let sum = 0
|
||||
for (let j = start; j < end && j < channelData.length; j++) {
|
||||
sum += Math.abs(channelData[j])
|
||||
}
|
||||
averages.push(sum / (end - start))
|
||||
}
|
||||
|
||||
const peak = Math.max(...averages) || 1
|
||||
bars.value = averages.map((avg) => ({
|
||||
height: Math.max(8, (avg / peak) * 100)
|
||||
}))
|
||||
}
|
||||
|
||||
async function decodeAudioSource(url: string) {
|
||||
const requestId = ++decodeRequestId
|
||||
loading.value = true
|
||||
let ctx: AudioContext | undefined
|
||||
try {
|
||||
const apiBase = api.apiURL('/')
|
||||
const route = url.includes(apiBase)
|
||||
? url.slice(url.indexOf(apiBase) + api.apiURL('').length)
|
||||
: url
|
||||
const response = await api.fetchApi(route)
|
||||
if (requestId !== decodeRequestId) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch audio (${response.status})`)
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
|
||||
if (requestId !== decodeRequestId) return
|
||||
|
||||
const blob = new Blob([arrayBuffer.slice(0)], {
|
||||
type: response.headers.get('content-type') ?? 'audio/wav'
|
||||
})
|
||||
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = URL.createObjectURL(blob)
|
||||
|
||||
ctx = new AudioContext()
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
if (requestId !== decodeRequestId) return
|
||||
generateBarsFromBuffer(audioBuffer)
|
||||
} catch {
|
||||
if (requestId === decodeRequestId) {
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
bars.value = generatePlaceholderBars()
|
||||
}
|
||||
} finally {
|
||||
await ctx?.close()
|
||||
if (requestId === decodeRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const progressRatio = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
function togglePlayPause() {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function seekToStart() {
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
function seekToEnd() {
|
||||
currentTime.value = duration.value
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
function seekToRatio(ratio: number) {
|
||||
const clamped = Math.max(0, Math.min(1, ratio))
|
||||
currentTime.value = clamped * duration.value
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
muted.value = !muted.value
|
||||
}
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
if (muted.value || volume.value === 0) return 'icon-[lucide--volume-x]'
|
||||
if (volume.value < 0.5) return 'icon-[lucide--volume-1]'
|
||||
return 'icon-[lucide--volume-2]'
|
||||
})
|
||||
|
||||
function handleWaveformClick(event: MouseEvent) {
|
||||
if (!waveformRef.value || duration.value === 0) return
|
||||
const rect = waveformRef.value.getBoundingClientRect()
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (event.clientX - rect.left) / rect.width)
|
||||
)
|
||||
currentTime.value = ratio * duration.value
|
||||
|
||||
if (!playing.value) {
|
||||
playing.value = true
|
||||
}
|
||||
}
|
||||
|
||||
whenever(
|
||||
src,
|
||||
(url) => {
|
||||
playing.value = false
|
||||
currentTime.value = 0
|
||||
void decodeAudioSource(url)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
decodeRequestId += 1
|
||||
audioRef.value?.pause()
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying: playing,
|
||||
playedBarIndex,
|
||||
progressRatio,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
togglePlayPause,
|
||||
seekToStart,
|
||||
seekToEnd,
|
||||
volume,
|
||||
volumeIcon,
|
||||
toggleMute,
|
||||
seekToRatio,
|
||||
handleWaveformClick
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from './subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
duplicateLinksRoot,
|
||||
duplicateLinksSlotShift,
|
||||
duplicateLinksSubgraph
|
||||
} from './__fixtures__/duplicateLinks'
|
||||
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
|
||||
import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets'
|
||||
import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
|
||||
@@ -560,31 +565,39 @@ describe('_removeDuplicateLinks', () => {
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
}
|
||||
|
||||
it('removes orphaned duplicate links from _links and output.links', () => {
|
||||
function createConnectedGraph() {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
expect(graph._links.size).toBe(1)
|
||||
return { graph, source, target }
|
||||
}
|
||||
|
||||
const existingLink = graph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
}
|
||||
function injectDuplicateLink(
|
||||
graph: LGraph,
|
||||
source: LGraphNode,
|
||||
target: LGraphNode
|
||||
) {
|
||||
const dup = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
return dup
|
||||
}
|
||||
|
||||
it('removes orphaned duplicate links from _links and output.links', () => {
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
|
||||
for (let i = 0; i < 3; i++) injectDuplicateLink(graph, source, target)
|
||||
|
||||
expect(graph._links.size).toBe(4)
|
||||
expect(source.outputs[0].links).toHaveLength(4)
|
||||
@@ -597,27 +610,10 @@ describe('_removeDuplicateLinks', () => {
|
||||
})
|
||||
|
||||
it('keeps the link referenced by input.link', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
const keptLinkId = target.inputs[0].link!
|
||||
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
@@ -628,18 +624,8 @@ describe('_removeDuplicateLinks', () => {
|
||||
})
|
||||
|
||||
it('keeps the valid link when input.link is at a shifted slot index', () => {
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
// Connect source:0 -> target:0, establishing input.link on target
|
||||
source.connect(0, target, 0)
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
const validLinkId = target.inputs[0].link!
|
||||
expect(graph._links.has(validLinkId)).toBe(true)
|
||||
|
||||
// Simulate widget-to-input conversion shifting the slot: insert a new
|
||||
// input BEFORE the connected one, moving it from index 0 to index 1.
|
||||
@@ -647,26 +633,13 @@ describe('_removeDuplicateLinks', () => {
|
||||
const connectedInput = target.inputs[0]
|
||||
target.inputs[0] = target.inputs[1]
|
||||
target.inputs[1] = connectedInput
|
||||
// Now target.inputs[1].link === validLinkId, but target.inputs[0].link is null
|
||||
|
||||
// Add a duplicate link with the same connection tuple (target_slot=0
|
||||
// in the LLink, matching the original slot before the shift).
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
|
||||
expect(graph._links.size).toBe(2)
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
// The valid link (referenced by an actual input) must survive
|
||||
expect(graph._links.size).toBe(1)
|
||||
expect(graph._links.has(validLinkId)).toBe(true)
|
||||
expect(graph._links.has(dupLink.id)).toBe(false)
|
||||
@@ -674,50 +647,22 @@ describe('_removeDuplicateLinks', () => {
|
||||
})
|
||||
|
||||
it('repairs input.link when it points to a removed duplicate', () => {
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
|
||||
// Create a duplicate link
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
// Point input.link to the duplicate (simulating corrupted state)
|
||||
target.inputs[0].link = dupLink.id
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
// input.link must point to whichever link survived
|
||||
const survivingId = graph._links.keys().next().value!
|
||||
expect(target.inputs[0].link).toBe(survivingId)
|
||||
expect(graph._links.has(target.inputs[0].link!)).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op when no duplicates exist', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
const { graph } = createConnectedGraph()
|
||||
const linksBefore = graph._links.size
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
@@ -738,29 +683,56 @@ describe('_removeDuplicateLinks', () => {
|
||||
subgraph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dup = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
}
|
||||
for (let i = 0; i < 3; i++) injectDuplicateLink(subgraph, source, target)
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
// Serialize and reconfigure - should clean up during configure
|
||||
const serialized = subgraph.asSerialisable()
|
||||
subgraph.configure(serialized as never)
|
||||
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
})
|
||||
|
||||
it('removes duplicate links via root graph configure()', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
graph.configure(duplicateLinksRoot)
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
const survivingLink = graph._links.values().next().value!
|
||||
const targetNode = graph.getNodeById(survivingLink.target_id)!
|
||||
expect(targetNode.inputs[0].link).toBe(survivingLink.id)
|
||||
const sourceNode = graph.getNodeById(survivingLink.origin_id)!
|
||||
expect(sourceNode.outputs[0].links).toEqual([survivingLink.id])
|
||||
})
|
||||
|
||||
it('preserves link integrity after configure() with slot-shifted duplicates', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
graph.configure(duplicateLinksSlotShift)
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
|
||||
const link = graph._links.values().next().value!
|
||||
const target = graph.getNodeById(link.target_id)!
|
||||
const linkedInput = target.inputs.find((inp) => inp.link === link.id)
|
||||
expect(linkedInput).toBeDefined()
|
||||
|
||||
const source = graph.getNodeById(link.origin_id)!
|
||||
expect(source.outputs[link.origin_slot].links).toContain(link.id)
|
||||
})
|
||||
|
||||
it('deduplicates links inside subgraph definitions during root configure()', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(duplicateLinksSubgraph)
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value!
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
const link = subgraph._links.values().next().value!
|
||||
const target = subgraph.getNodeById(link.target_id)!
|
||||
expect(target.inputs[0].link).toBe(link.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Unpacking', () => {
|
||||
@@ -790,6 +762,21 @@ describe('Subgraph Unpacking', () => {
|
||||
return rootGraph.createSubgraph(createTestSubgraphData())
|
||||
}
|
||||
|
||||
function duplicateExistingLink(graph: LGraph, source: LGraphNode) {
|
||||
const existingLink = graph._links.values().next().value!
|
||||
const dup = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
graph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
return dup
|
||||
}
|
||||
|
||||
it('deduplicates links when unpacking subgraph with duplicate links', () => {
|
||||
registerTestNodes()
|
||||
const rootGraph = new LGraph()
|
||||
@@ -800,24 +787,9 @@ describe('Subgraph Unpacking', () => {
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create a legitimate link
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
// Manually add duplicate links (simulating the bug)
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
}
|
||||
for (let i = 0; i < 3; i++) duplicateExistingLink(subgraph, sourceNode)
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
@@ -839,21 +811,8 @@ describe('Subgraph Unpacking', () => {
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Connect source output 0 → target input 0
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
|
||||
// Add duplicate links to the same connection
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
duplicateExistingLink(subgraph, sourceNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
@@ -13,6 +13,13 @@ import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import {
|
||||
groupLinksByTuple,
|
||||
purgeOrphanedLinks,
|
||||
repairInputLinks,
|
||||
selectSurvivorLink
|
||||
} from './linkDeduplication'
|
||||
|
||||
import type { DragAndScaleState } from './DragAndScale'
|
||||
import { LGraphCanvas } from './LGraphCanvas'
|
||||
import { LGraphGroup } from './LGraphGroup'
|
||||
@@ -168,11 +175,6 @@ export class LGraph
|
||||
static STATUS_STOPPED = 1
|
||||
static STATUS_RUNNING = 2
|
||||
|
||||
/** Generates a unique string key for a link's connection tuple. */
|
||||
static _linkTupleKey(link: LLink): string {
|
||||
return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}`
|
||||
}
|
||||
|
||||
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
||||
static readonly ConfigureProperties = new Set([
|
||||
'nodes',
|
||||
@@ -1626,68 +1628,21 @@ export class LGraph
|
||||
* (origin_id, origin_slot, target_id, target_slot). Keeps the link
|
||||
* referenced by input.link and removes orphaned duplicates from
|
||||
* output.links and the graph's _links map.
|
||||
*
|
||||
* Three phases: group links by tuple, select the survivor, purge duplicates.
|
||||
*/
|
||||
_removeDuplicateLinks(): void {
|
||||
// Group all link IDs by their connection tuple.
|
||||
const groups = new Map<string, LinkId[]>()
|
||||
for (const [id, link] of this._links) {
|
||||
const key = LGraph._linkTupleKey(link)
|
||||
let group = groups.get(key)
|
||||
if (!group) {
|
||||
group = []
|
||||
groups.set(key, group)
|
||||
}
|
||||
group.push(id)
|
||||
}
|
||||
const groups = groupLinksByTuple(this._links)
|
||||
|
||||
for (const [, ids] of groups) {
|
||||
for (const ids of groups.values()) {
|
||||
if (ids.length <= 1) continue
|
||||
|
||||
const sampleLink = this._links.get(ids[0])!
|
||||
const node = this.getNodeById(sampleLink.target_id)
|
||||
const keepId = selectSurvivorLink(ids, node)
|
||||
|
||||
// Find which link ID is actually referenced by any input on the target
|
||||
// node. Cannot rely on target_slot index because widget-to-input
|
||||
// conversions during configure() can shift slot indices.
|
||||
let keepId: LinkId | undefined
|
||||
if (node) {
|
||||
for (const input of node.inputs ?? []) {
|
||||
const match = ids.find((id) => input.link === id)
|
||||
if (match != null) {
|
||||
keepId = match
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
keepId ??= ids[0]
|
||||
|
||||
for (const id of ids) {
|
||||
if (id === keepId) continue
|
||||
|
||||
const link = this._links.get(id)
|
||||
if (!link) continue
|
||||
|
||||
// Remove from origin node's output.links array
|
||||
const originNode = this.getNodeById(link.origin_id)
|
||||
if (originNode) {
|
||||
const output = originNode.outputs?.[link.origin_slot]
|
||||
if (output?.links) {
|
||||
const idx = output.links.indexOf(id)
|
||||
if (idx !== -1) output.links.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
this._links.delete(id)
|
||||
}
|
||||
|
||||
// Ensure input.link points to the surviving link
|
||||
if (node) {
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (ids.includes(input.link as LinkId) && input.link !== keepId) {
|
||||
input.link = keepId
|
||||
}
|
||||
}
|
||||
}
|
||||
purgeOrphanedLinks(ids, keepId, this._links, (id) => this.getNodeById(id))
|
||||
repairInputLinks(ids, keepId, node)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
240
src/lib/litegraph/src/__fixtures__/duplicateLinks.ts
Normal file
240
src/lib/litegraph/src/__fixtures__/duplicateLinks.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Root graph with two nodes (Source, Target) connected by one valid link
|
||||
* plus two duplicate links sharing the same connection tuple.
|
||||
* Tests that configure() deduplicates to a single link.
|
||||
*/
|
||||
export const duplicateLinksRoot: SerialisableGraph = {
|
||||
id: 'dd000000-0000-4000-8000-000000000001',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: 3,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'input_0', type: 'number', link: null }],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [1, 2, 3] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'input_0', type: 'number', link: 1 }],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [] }],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Root graph with slot-shifted duplicates. Target node has an extra input
|
||||
* (simulating widget-to-input conversion) that shifts the connected input
|
||||
* from slot 0 to slot 1. Link 1 is valid (referenced by input.link),
|
||||
* link 2 is a duplicate with the original (pre-shift) target_slot.
|
||||
*/
|
||||
export const duplicateLinksSlotShift: SerialisableGraph = {
|
||||
id: 'dd000000-0000-4000-8000-000000000002',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: 2,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'input_0', type: 'number', link: null }],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [1, 2] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: 'extra_widget', type: 'number', link: null },
|
||||
{ name: 'input_0', type: 'number', link: 1 }
|
||||
],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [] }],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Root graph containing a SubgraphNode whose subgraph definition has
|
||||
* duplicate links. Tests that configure() deduplicates links inside
|
||||
* subgraph definitions during root-level configure.
|
||||
*/
|
||||
export const duplicateLinksSubgraph: SerialisableGraph = {
|
||||
id: 'dd000000-0000-4000-8000-000000000003',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 1,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'dd111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: 'dd111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: 3,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'Subgraph With Duplicates',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [0, 100, 120, 60] },
|
||||
outputNode: { id: -20, bounding: [500, 100, 120, 60] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test/Source',
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [{ name: 'out', type: 'number', links: [1, 2, 3] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test/Target',
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'in', type: 'number', link: 1 }],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
extra: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
82
src/lib/litegraph/src/linkDeduplication.ts
Normal file
82
src/lib/litegraph/src/linkDeduplication.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { LGraphNode, NodeId } from './LGraphNode'
|
||||
import type { LLink, LinkId } from './LLink'
|
||||
|
||||
/** Generates a unique string key for a link's connection tuple. */
|
||||
function linkTupleKey(link: LLink): string {
|
||||
return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}`
|
||||
}
|
||||
|
||||
/** Groups all link IDs by their connection tuple key. */
|
||||
export function groupLinksByTuple(
|
||||
links: Map<LinkId, LLink>
|
||||
): Map<string, LinkId[]> {
|
||||
const groups = new Map<string, LinkId[]>()
|
||||
for (const [id, link] of links) {
|
||||
const key = linkTupleKey(link)
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(id)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the link ID actually referenced by an input on the target node.
|
||||
* Cannot rely on target_slot index because widget-to-input conversions
|
||||
* during configure() can shift slot indices.
|
||||
*/
|
||||
export function selectSurvivorLink(
|
||||
ids: LinkId[],
|
||||
node: LGraphNode | null
|
||||
): LinkId {
|
||||
if (!node) return ids[0]
|
||||
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (!input) continue
|
||||
const match = ids.find((id) => input.link === id)
|
||||
if (match != null) return match
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
|
||||
/** Removes duplicate links from origin outputs and the graph's link map. */
|
||||
export function purgeOrphanedLinks(
|
||||
ids: LinkId[],
|
||||
keepId: LinkId,
|
||||
links: Map<LinkId, LLink>,
|
||||
getNodeById: (id: NodeId) => LGraphNode | null
|
||||
): void {
|
||||
for (const id of ids) {
|
||||
if (id === keepId) continue
|
||||
|
||||
const link = links.get(id)
|
||||
if (!link) continue
|
||||
|
||||
const originNode = getNodeById(link.origin_id)
|
||||
const output = originNode?.outputs?.[link.origin_slot]
|
||||
if (output?.links) {
|
||||
for (let i = output.links.length - 1; i >= 0; i--) {
|
||||
if (output.links[i] === id) output.links.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
links.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensures input.link on the target node points to the surviving link. */
|
||||
export function repairInputLinks(
|
||||
ids: LinkId[],
|
||||
keepId: LinkId,
|
||||
node: LGraphNode | null
|
||||
): void {
|
||||
if (!node) return
|
||||
|
||||
const duplicateIds = new Set(ids)
|
||||
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (input?.link == null || input.link === keepId) continue
|
||||
if (duplicateIds.has(input.link)) {
|
||||
input.link = keepId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,9 +343,13 @@
|
||||
"frameNodes": "Frame Nodes",
|
||||
"listening": "Listening...",
|
||||
"ready": "Ready",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"playPause": "Play/Pause",
|
||||
"playRecording": "Play Recording",
|
||||
"playing": "Playing",
|
||||
"skipToStart": "Skip to Start",
|
||||
"skipToEnd": "Skip to End",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"playbackSpeed": "Playback Speed",
|
||||
"volume": "Volume",
|
||||
|
||||
@@ -8,16 +8,20 @@
|
||||
$t('assetBrowser.media.audioPlaceholder')
|
||||
}}</span>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
class="absolute bottom-0 left-0 w-full p-2"
|
||||
:src="asset.src"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0 w-full p-2">
|
||||
<WaveAudioPlayer
|
||||
:src="asset.src"
|
||||
:bar-count="40"
|
||||
:height="32"
|
||||
align="bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WaveAudioPlayer from '@/components/common/WaveAudioPlayer.vue'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import {
|
||||
@@ -403,6 +404,7 @@ export const useWorkflowService = () => {
|
||||
// Determine the initial app mode for fresh loads from serialized state.
|
||||
// null means linearMode was never explicitly set (not builder-saved).
|
||||
const freshLoadMode = linearModeToAppMode(workflowData.extra?.linearMode)
|
||||
useAppModeStore().loadSelections(workflowData.extra?.linearData)
|
||||
|
||||
function trackIfEnteringApp(workflow: ComfyWorkflow) {
|
||||
if (!wasAppMode && workflow.initialMode === 'app') {
|
||||
|
||||
@@ -96,7 +96,7 @@ import { useAudioService } from '@/services/audioService'
|
||||
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
|
||||
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
|
||||
import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
|
||||
import { formatTime } from '../utils/audioUtils'
|
||||
import { formatTime } from '@/utils/formatUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { formatTime } from '../../utils/audioUtils'
|
||||
import { formatTime } from '@/utils/formatUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -4,6 +4,7 @@ import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import type {
|
||||
@@ -50,6 +51,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
@@ -209,6 +211,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
ref="popoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type {
|
||||
FilterOption,
|
||||
OwnershipFilterOption,
|
||||
@@ -15,6 +16,7 @@ import FormSearchInput from '../FormSearchInput.vue'
|
||||
import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
@@ -132,6 +134,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -194,6 +197,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="ownershipPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -256,6 +260,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="baseModelPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Format time in MM:SS format
|
||||
*/
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds === 0) return '0:00'
|
||||
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
|
||||
@@ -197,14 +197,12 @@ describe('appModeStore', () => {
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(
|
||||
[
|
||||
store.loadSelections({
|
||||
inputs: [
|
||||
[1, 'prompt'],
|
||||
[99, 'width']
|
||||
],
|
||||
[]
|
||||
)
|
||||
await nextTick()
|
||||
]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([[1, 'prompt']])
|
||||
})
|
||||
@@ -215,14 +213,12 @@ describe('appModeStore', () => {
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(
|
||||
[
|
||||
store.loadSelections({
|
||||
inputs: [
|
||||
[1, 'prompt'],
|
||||
[1, 'deleted_widget']
|
||||
],
|
||||
[]
|
||||
)
|
||||
await nextTick()
|
||||
]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([
|
||||
[1, 'prompt'],
|
||||
@@ -236,8 +232,7 @@ describe('appModeStore', () => {
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData([], [1, 99])
|
||||
await nextTick()
|
||||
store.loadSelections({ outputs: [1, 99] })
|
||||
|
||||
expect(store.selectedOutputs).toEqual([1])
|
||||
})
|
||||
@@ -247,8 +242,11 @@ describe('appModeStore', () => {
|
||||
|
||||
// Initially nodes are not resolvable — pruning removes them
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
workflowStore.activeWorkflow = workflowWithLinearData([[1, 'seed']], [1])
|
||||
const inputs: [number, string][] = [[1, 'seed']]
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(inputs, [1])
|
||||
store.loadSelections({ inputs })
|
||||
await nextTick()
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(store.selectedOutputs).toEqual([])
|
||||
|
||||
@@ -268,8 +266,7 @@ describe('appModeStore', () => {
|
||||
it('hasOutputs is false when all output nodes are deleted', async () => {
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData([], [10, 20])
|
||||
await nextTick()
|
||||
store.loadSelections({ outputs: [10, 20] })
|
||||
|
||||
expect(store.selectedOutputs).toEqual([])
|
||||
expect(store.hasOutputs).toBe(false)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
|
||||
@@ -25,9 +25,9 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
|
||||
const emptyWorkflowDialog = useEmptyWorkflowDialog()
|
||||
|
||||
const selectedInputs = reactive<[NodeId, string][]>([])
|
||||
const selectedOutputs = reactive<NodeId[]>([])
|
||||
const hasOutputs = computed(() => !!selectedOutputs.length)
|
||||
const selectedInputs = ref<[NodeId, string][]>([])
|
||||
const selectedOutputs = ref<NodeId[]>([])
|
||||
const hasOutputs = computed(() => !!selectedOutputs.value.length)
|
||||
const hasNodes = computed(() => {
|
||||
// Nodes are not reactive, so trigger recomputation when workflow changes
|
||||
void workflowStore.activeWorkflow
|
||||
@@ -54,8 +54,8 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
|
||||
function loadSelections(data: Partial<LinearData> | undefined) {
|
||||
const { inputs, outputs } = pruneLinearData(data)
|
||||
selectedInputs.splice(0, selectedInputs.length, ...inputs)
|
||||
selectedOutputs.splice(0, selectedOutputs.length, ...outputs)
|
||||
selectedInputs.value = inputs
|
||||
selectedOutputs.value = outputs
|
||||
}
|
||||
|
||||
function resetSelectedToWorkflow() {
|
||||
@@ -65,20 +65,6 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(newWorkflow) => {
|
||||
if (newWorkflow) {
|
||||
loadSelections(
|
||||
newWorkflow.changeTracker?.activeState?.extra?.linearData
|
||||
)
|
||||
} else {
|
||||
loadSelections(undefined)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'configured',
|
||||
@@ -88,7 +74,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
watch(
|
||||
() =>
|
||||
isBuilderMode.value
|
||||
? { inputs: selectedInputs, outputs: selectedOutputs }
|
||||
? { inputs: selectedInputs.value, outputs: selectedOutputs.value }
|
||||
: null,
|
||||
(data) => {
|
||||
if (!data || ChangeTracker.isLoadingGraph) return
|
||||
@@ -144,10 +130,10 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const index = selectedInputs.findIndex(
|
||||
const index = selectedInputs.value.findIndex(
|
||||
([id, name]) => storeId == id && storeName === name
|
||||
)
|
||||
if (index !== -1) selectedInputs.splice(index, 1)
|
||||
if (index !== -1) selectedInputs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -155,6 +141,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
exitBuilder,
|
||||
hasNodes,
|
||||
hasOutputs,
|
||||
loadSelections,
|
||||
pruneLinearData,
|
||||
removeSelectedInput,
|
||||
resetSelectedToWorkflow,
|
||||
|
||||
Reference in New Issue
Block a user