mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-26 07:57:36 +00:00
Compare commits
44 Commits
austin/fe-
...
jaewon/m1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
752452b905 | ||
|
|
519aefd382 | ||
|
|
0a8f936f9d | ||
|
|
b50510f7d8 | ||
|
|
d8422691cc | ||
|
|
15108848d5 | ||
|
|
076cba9611 | ||
|
|
37f9a14e15 | ||
|
|
bec9e3714d | ||
|
|
d45e64197c | ||
|
|
4b0f6dc293 | ||
|
|
117d011900 | ||
|
|
c244b53d0b | ||
|
|
4837307fa6 | ||
|
|
3051c00254 | ||
|
|
77514a4c82 | ||
|
|
2fd67256fd | ||
|
|
e35bb25d7c | ||
|
|
2184d25dcb | ||
|
|
98642301fe | ||
|
|
55b0329203 | ||
|
|
c9d9ee1ee5 | ||
|
|
9b9b0f2457 | ||
|
|
c579c88e90 | ||
|
|
0797b7af7a | ||
|
|
1d71c93cad | ||
|
|
b38fab4f66 | ||
|
|
ffe8d0f2ee | ||
|
|
ec0711d62e | ||
|
|
e48dcd11f1 | ||
|
|
5b1446a794 | ||
|
|
2a5de94145 | ||
|
|
c3cde8dd6a | ||
|
|
09943f855a | ||
|
|
b34026527a | ||
|
|
fcdc4404eb | ||
|
|
7f6d354a8e | ||
|
|
7f9804e2bd | ||
|
|
5809f55171 | ||
|
|
29a86503fc | ||
|
|
45402c6b82 | ||
|
|
e927025ee3 | ||
|
|
48883269cc | ||
|
|
c5dd38f089 |
@@ -19,5 +19,8 @@ runs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp -r ./tools/devtools/* /ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
cd /ComfyUI && python3 main.py --cpu --multi-user --front-end-root "${{ inputs.front_end_root }}" &
|
||||
# TODO(FE-729): remove --enable-assets once BE-786 lands in the CI ComfyUI image
|
||||
# (BE-786 removes the gate so /api/assets is always on). Until then, FE-729
|
||||
# routes modelStore through assetService, which 503s without this flag.
|
||||
cd /ComfyUI && python3 main.py --cpu --multi-user --enable-assets --front-end-root "${{ inputs.front_end_root }}" &
|
||||
wait-for-it --service 127.0.0.1:8188 -t ${{ inputs.timeout }}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["fe746_photo.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -119,9 +119,22 @@ export class BuilderSelectHelper {
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
const node = this.comfyPage.vueNodes.getNodeLocator(String(nodeRef.id))
|
||||
// Grid-mode widgets (WidgetSelectDefault) and number widgets expose
|
||||
// aria-label on a wrapper/input. Asset-mode widgets (WidgetSelectDropdown)
|
||||
// do not — the widget name lives in a sibling
|
||||
// [data-testid="widget-layout-field-label"] div, so fall back to clicking
|
||||
// the dropdown trigger button in the same row.
|
||||
const byAriaLabel = node.getByLabel(widgetName, { exact: true })
|
||||
const widgetLocator =
|
||||
(await byAriaLabel.count()) > 0
|
||||
? byAriaLabel
|
||||
: node
|
||||
.getByTestId('widget-layout-field-label')
|
||||
.filter({ hasText: widgetName })
|
||||
.locator('..')
|
||||
.locator('button')
|
||||
.first()
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
* FE-230: Deleting an asset must clear the Load Image node preview, widget
|
||||
* value, and mark the workflow dirty.
|
||||
*
|
||||
* Local run (requires cloud build of the frontend):
|
||||
* pnpm build:cloud
|
||||
* pnpm exec playwright test --project=cloud \
|
||||
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
|
||||
* FE-732: Input-asset deletion is no longer gated on `isCloud`; the same
|
||||
* teardown flow now applies to both Cloud and OSS builds. Cloud and OSS
|
||||
* variants below cover both Playwright projects against the shared mock.
|
||||
*
|
||||
* The cloud project is required because input-asset deletion is gated on
|
||||
* `isCloud === true` (see `useMediaAssetActions.deleteAssetApi`).
|
||||
* Local run examples:
|
||||
* pnpm build:cloud && pnpm exec playwright test --project=cloud \
|
||||
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
|
||||
* pnpm build && pnpm exec playwright test --project=chromium \
|
||||
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
|
||||
*/
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
@@ -119,81 +121,88 @@ const baseTest = comfyPageFixture.extend<{ assetMock: AssetMockApi }>({
|
||||
}
|
||||
})
|
||||
|
||||
function registerDeleteFlowTest() {
|
||||
baseTest(
|
||||
'deleting an input asset clears widget value, preview cache, and marks workflow modified',
|
||||
async ({ comfyPage, assetMock }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
// Drive the production drag-and-drop flow to point the Load Image
|
||||
// widget at the asset we are about to delete and populate the preview
|
||||
// cache. FE-230 is asserting that the deletion tears these down.
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, {
|
||||
dropPosition: { x, y },
|
||||
waitForUpload: true
|
||||
})
|
||||
const imageWidget = await loadImageNode.getWidget(0)
|
||||
await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE)
|
||||
|
||||
// Re-baseline the change tracker so the deletion-side mutation is the
|
||||
// only thing that can flip `isModified` later.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const tracker =
|
||||
window.app?.extensionManager?.workflow?.activeWorkflow?.changeTracker
|
||||
tracker?.reset?.()
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(false)
|
||||
|
||||
// Drive the real production flow: assets sidebar → Imported tab →
|
||||
// right-click asset card → Delete → confirm dialog.
|
||||
const sidebar = comfyPage.menu.assetsTab
|
||||
// The default `open()` waits for assets on the Generated tab; we seed
|
||||
// only an input asset, so skip that wait and let `waitForAssets(1)`
|
||||
// gate on the Imported tab instead.
|
||||
await sidebar.open({ waitForAssets: false })
|
||||
await sidebar.switchToImported()
|
||||
await sidebar.waitForAssets(1)
|
||||
await sidebar.rightClickAsset(TARGET_CARD_TEXT)
|
||||
|
||||
const deleteMenuItem = sidebar.contextMenuItem('Delete')
|
||||
await expect(deleteMenuItem).toBeVisible()
|
||||
await deleteMenuItem.click()
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
// Mocked DELETE was issued.
|
||||
await expect
|
||||
.poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id))
|
||||
.toBe(true)
|
||||
|
||||
// Widget value was cleared.
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('')
|
||||
|
||||
// Preview cache was cleared.
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
return node?.imgs?.length ?? 0
|
||||
}, loadImageNode.id)
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// Workflow was marked dirty by changeTracker.captureCanvasState().
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
baseTest.describe(
|
||||
'FE-230 asset delete clears Load Image preview',
|
||||
'FE-230 asset delete clears Load Image preview (cloud)',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
baseTest(
|
||||
'deleting an input asset clears widget value, preview cache, and marks workflow modified',
|
||||
async ({ comfyPage, assetMock }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
// Drive the production drag-and-drop flow to point the Load Image
|
||||
// widget at the asset we are about to delete and populate the preview
|
||||
// cache. FE-230 is asserting that the deletion tears these down.
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, {
|
||||
dropPosition: { x, y },
|
||||
waitForUpload: true
|
||||
})
|
||||
const imageWidget = await loadImageNode.getWidget(0)
|
||||
await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE)
|
||||
|
||||
// Re-baseline the change tracker so the deletion-side mutation is the
|
||||
// only thing that can flip `isModified` later.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const tracker =
|
||||
window.app?.extensionManager?.workflow?.activeWorkflow
|
||||
?.changeTracker
|
||||
tracker?.reset?.()
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(false)
|
||||
|
||||
// Drive the real production flow: assets sidebar → Imported tab →
|
||||
// right-click asset card → Delete → confirm dialog.
|
||||
const sidebar = comfyPage.menu.assetsTab
|
||||
// The default `open()` waits for assets on the Generated tab; we seed
|
||||
// only an input asset, so skip that wait and let `waitForAssets(1)`
|
||||
// gate on the Imported tab instead.
|
||||
await sidebar.open({ waitForAssets: false })
|
||||
await sidebar.switchToImported()
|
||||
await sidebar.waitForAssets(1)
|
||||
await sidebar.rightClickAsset(TARGET_CARD_TEXT)
|
||||
|
||||
const deleteMenuItem = sidebar.contextMenuItem('Delete')
|
||||
await expect(deleteMenuItem).toBeVisible()
|
||||
await deleteMenuItem.click()
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
// Mocked DELETE was issued.
|
||||
await expect
|
||||
.poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id))
|
||||
.toBe(true)
|
||||
|
||||
// Widget value was cleared.
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('')
|
||||
|
||||
// Preview cache was cleared.
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
return node?.imgs?.length ?? 0
|
||||
}, loadImageNode.id)
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// Workflow was marked dirty by changeTracker.captureCanvasState().
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(true)
|
||||
}
|
||||
)
|
||||
}
|
||||
registerDeleteFlowTest
|
||||
)
|
||||
|
||||
baseTest.describe(
|
||||
'FE-230 asset delete clears Load Image preview (oss)',
|
||||
{ tag: '@oss' },
|
||||
registerDeleteFlowTest
|
||||
)
|
||||
|
||||
@@ -27,7 +27,6 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
const sidebarTabs = [
|
||||
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
|
||||
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
|
||||
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
|
||||
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
|
||||
] as const
|
||||
|
||||
|
||||
@@ -218,21 +218,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-mask-${maskUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadCount++
|
||||
return route.fulfill({
|
||||
@@ -252,20 +239,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
|
||||
// through the unified /upload/image endpoint.
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
imageUploadCount,
|
||||
'save should upload all four layers via /upload/image'
|
||||
).toBe(4)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
|
||||
197
browser_tests/tests/missingMediaAssetUnion.spec.ts
Normal file
197
browser_tests/tests/missingMediaAssetUnion.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
// BE-933 / BE-934 add `file_path` (and BE-933 also `display_name`) to the asset
|
||||
// wire shape. `@comfyorg/ingest-types` is not yet regenerated from the updated
|
||||
// OpenAPI (tracked under BE-932); extend the local type so mocks can carry the
|
||||
// post-BE field without an `any` cast.
|
||||
type PostBEAsset = Asset & {
|
||||
file_path?: string | null
|
||||
display_name?: string | null
|
||||
}
|
||||
|
||||
const WORKFLOW_WIDGET_VALUE = 'fe746_photo.png'
|
||||
|
||||
async function mockAssetListing(
|
||||
page: Page,
|
||||
assets: PostBEAsset[]
|
||||
): Promise<void> {
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
const response: ListAssetsResponse = {
|
||||
assets: assets as Asset[],
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function mockAssetListingFailure(
|
||||
page: Page,
|
||||
status: number
|
||||
): Promise<void> {
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ detail: `forced ${status} for FE-746 test` })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Missing media detection — file_path union (FE-746)',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.use({
|
||||
initialSettings: {
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': true
|
||||
}
|
||||
})
|
||||
|
||||
test('does not surface missing media when a post-BE asset emits file_path that diverges from the workflow widget value (Case B regression)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// BE-933 / BE-934 post-deploy shape: asset emits a namespace-rooted
|
||||
// file_path that differs from the bare `name` the user originally chose.
|
||||
// The workflow widget value (`fe746_photo.png`) predates the rollout, so
|
||||
// it must still match via the `name` arm of the detection-key union.
|
||||
// Case A (file_path-only early return) would mark this as missing.
|
||||
await mockAssetListing(comfyPage.page, [
|
||||
{
|
||||
id: 'fe746-asset-1',
|
||||
name: WORKFLOW_WIDGET_VALUE,
|
||||
asset_hash: 'blake3:fe7460000000000000000000000000000',
|
||||
file_path: 'input/sub/fe746_photo.png',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-22T00:00:00Z',
|
||||
updated_at: '2026-05-22T00:00:00Z',
|
||||
last_access_time: '2026-05-22T00:00:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/fe746_load_image_bare_filename'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([])
|
||||
})
|
||||
|
||||
test('matches via legacy `name` fallback when the asset has no file_path (BE-933 hash-only registration)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// BE-933 hash-only null case: asset registered via POST /assets/from-hash
|
||||
// has no on-disk path, so `file_path` (and `display_name`) come back null.
|
||||
// Detection must still succeed via the legacy `name` arm.
|
||||
await mockAssetListing(comfyPage.page, [
|
||||
{
|
||||
id: 'fe746-asset-hash-only',
|
||||
name: WORKFLOW_WIDGET_VALUE,
|
||||
asset_hash: 'blake3:fe7460000000000000000000000000001',
|
||||
file_path: null,
|
||||
display_name: null,
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-22T00:00:00Z',
|
||||
updated_at: '2026-05-22T00:00:00Z',
|
||||
last_access_time: '2026-05-22T00:00:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/fe746_load_image_bare_filename'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([])
|
||||
})
|
||||
|
||||
test('surfaces missing media when no asset in the listing covers the widget value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Sanity: with the union still in place, an asset listing that does not
|
||||
// include the widget value via any key (file_path / asset_hash / name)
|
||||
// must still report missing. Guards against accidental "match
|
||||
// everything" regressions when the early-return was removed.
|
||||
await mockAssetListing(comfyPage.page, [
|
||||
{
|
||||
id: 'fe746-unrelated-asset',
|
||||
name: 'unrelated.png',
|
||||
asset_hash: 'blake3:fe7460000000000000000000000000002',
|
||||
file_path: 'input/unrelated.png',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-22T00:00:00Z',
|
||||
updated_at: '2026-05-22T00:00:00Z',
|
||||
last_access_time: '2026-05-22T00:00:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/fe746_load_image_bare_filename'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaNames(comfyPage))
|
||||
.toContain(WORKFLOW_WIDGET_VALUE)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('soft-degrades when /api/assets fails so verification does not deadlock pending candidates', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Promise.allSettled + per-branch soft-degrade (Finding 2): when the
|
||||
// input-asset oracle fails (pre-BE-786 OSS without /api/assets, partial
|
||||
// BE-934 deploys, transient network errors), the verifier must finish
|
||||
// — marking the candidate missing — rather than leaving isMissing
|
||||
// stuck at undefined behind a silent toast.
|
||||
await mockAssetListingFailure(comfyPage.page, 500)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/fe746_load_image_bare_filename'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaNames(comfyPage))
|
||||
.toContain(WORKFLOW_WIDGET_VALUE)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -115,42 +115,5 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should clear resolved missing model when Refresh is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await comfyPage.page.route(/\/object_info$/, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,13 +8,14 @@ import type {
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's media-type filter menu only renders in cloud mode
|
||||
// (`MediaAssetFilterBar.vue` gates `MediaAssetFilterButton` behind `isCloud`).
|
||||
// We tag tests `@cloud` so they run against the cloud Playwright project,
|
||||
// and register both `/api/assets` and `/api/jobs` route handlers as auto
|
||||
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
|
||||
// internal `setup()`, so the page first-loads with mocks already in place.
|
||||
// See cloud-asset-default.spec.ts for the same pattern.
|
||||
// Post-FE-732 the media-type filter menu renders unconditionally on both
|
||||
// Cloud and OSS builds. These tests keep the `@cloud` tag because the
|
||||
// `/api/jobs` dependency is still cloud-only; once OSS exposes equivalent
|
||||
// jobs data we can drop the tag. Auto fixtures register `/api/assets` and
|
||||
// `/api/jobs` route handlers — Playwright runs auto fixtures before the
|
||||
// `comfyPage` fixture's internal `setup()`, so the page first-loads with
|
||||
// mocks already in place. See cloud-asset-default.spec.ts for the same
|
||||
// pattern.
|
||||
|
||||
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ import type {
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's sort options live inside the settings popover and are
|
||||
// only rendered in cloud mode (`MediaAssetFilterBar.vue`:
|
||||
// `:show-sort-options="isCloud"`). We tag tests `@cloud` so they run against
|
||||
// the cloud Playwright project, and register `/api/assets`, `/api/jobs`, and
|
||||
// `/internal/files/input` route handlers as auto fixtures — Playwright runs
|
||||
// auto fixtures before the `comfyPage` fixture's internal `setup()`, so the
|
||||
// page first-loads with mocks already in place.
|
||||
// Post-FE-732 the sort options inside the settings popover render
|
||||
// unconditionally on both Cloud and OSS builds. These tests keep the
|
||||
// `@cloud` tag because the `/api/jobs` dependency (used by the generation-
|
||||
// time sort) is still cloud-only; once OSS exposes equivalent jobs data we
|
||||
// can drop the tag. Auto fixtures register `/api/assets`, `/api/jobs`, and
|
||||
// `/internal/files/input` route handlers — Playwright runs auto fixtures
|
||||
// before the `comfyPage` fixture's internal `setup()`, so the page first-
|
||||
// loads with mocks already in place.
|
||||
|
||||
// Three jobs whose `(create_time, duration)` axes are intentionally
|
||||
// misaligned so newest/oldest and longest/fastest sorts produce *different*
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MOCK_FOLDERS: Record<string, string[]> = {
|
||||
checkpoints: [
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVision_v51.safetensors'
|
||||
],
|
||||
loras: ['detail_tweaker_xl.safetensors', 'add_brightness.safetensors'],
|
||||
vae: ['sdxl_vae.safetensors']
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Tab open/close
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Opens model library tab and shows tree', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.modelTree).toBeVisible()
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows refresh and load all folders buttons', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.refreshButton).toBeVisible()
|
||||
await expect(tab.loadAllFoldersButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Folder display
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - folders', () => {
|
||||
// Mocks are set up before setup(), so app.ts's loadModelFolders()
|
||||
// call during initialization hits the mock and populates the store.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays model folders after opening tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('vae')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Expanding a folder loads and shows models', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Click the folder to expand it
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
|
||||
// Models should appear as leaf nodes
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('realisticVision_v51')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Expanding a different folder shows its models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.getFolderByLabel('loras').click()
|
||||
|
||||
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('add_brightness')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Search
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Search filters models by filename', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
|
||||
// Wait for debounce (300ms) + load + render
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
|
||||
// Other models should not be visible
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Clearing search restores folder view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
|
||||
// Clear the search
|
||||
await tab.searchInput.fill('')
|
||||
|
||||
// Folders should be visible again (collapsed)
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty tree', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Expand a folder and verify models are present before searching
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.leafNodes).not.toHaveCount(0)
|
||||
|
||||
await tab.searchInput.fill('nonexistent_model_xyz')
|
||||
|
||||
// Wait for debounce, then verify no leaf nodes
|
||||
await expect.poll(() => tab.leafNodes.count()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. Refresh and load all
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - refresh', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Refresh button reloads folder list', async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({
|
||||
checkpoints: ['model_a.safetensors']
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
|
||||
// Update mock to include a new folder
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({
|
||||
checkpoints: ['model_a.safetensors'],
|
||||
loras: ['lora_b.safetensors']
|
||||
})
|
||||
|
||||
// Wait for the refresh request to complete
|
||||
const refreshRequest = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().endsWith('/experiment/models'),
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
await tab.refreshButton.click()
|
||||
await refreshRequest
|
||||
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Load all folders button triggers loading all model data', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Wait for a per-folder model files request triggered by load all
|
||||
const folderRequest = comfyPage.page.waitForRequest(
|
||||
(req) =>
|
||||
/\/api\/experiment\/models\/[^/]+$/.test(req.url()) &&
|
||||
req.method() === 'GET',
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
await tab.loadAllFoldersButton.click()
|
||||
await folderRequest
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Empty state
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - empty state', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty tree when no model folders exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.modelTree).toBeVisible()
|
||||
await expect(tab.folderNodes).toHaveCount(0)
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -141,7 +141,6 @@
|
||||
<template v-if="isCompact">
|
||||
<!-- Compact mode: Icon only -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
@@ -159,7 +158,6 @@
|
||||
<template v-else>
|
||||
<!-- Normal mode: Icon + Text -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
@@ -190,7 +188,6 @@
|
||||
:asset="contextMenuAsset"
|
||||
:asset-type="contextMenuAssetType"
|
||||
:file-kind="contextMenuFileKind"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@zoom="handleZoomClick(contextMenuAsset)"
|
||||
@@ -249,7 +246,6 @@ import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import {
|
||||
@@ -281,13 +277,6 @@ const isListView = computed(() => viewMode.value === 'list')
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
|
||||
// Determine if delete button should be shown
|
||||
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||
const shouldShowDeleteButton = computed(() => {
|
||||
if (activeTab.value === 'input' && !isCloud) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const contextMenuAssetType = computed(() =>
|
||||
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
|
||||
)
|
||||
|
||||
@@ -989,12 +989,8 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode, false)
|
||||
expect(mediaScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
innerSubgraphNode,
|
||||
false
|
||||
)
|
||||
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode)
|
||||
expect(mediaScanSpy).not.toHaveBeenCalledWith(rootGraph, innerSubgraphNode)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ function scanSingleNodeMedia(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
if (!getActiveExecutionId(node)) return
|
||||
|
||||
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
|
||||
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node)
|
||||
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
|
||||
if (confirmedMedia.length) {
|
||||
useMissingMediaStore().addMissingMedia(confirmedMedia)
|
||||
@@ -302,7 +302,7 @@ async function verifyAndAddPendingMedia(
|
||||
): Promise<void> {
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyMediaCandidates(pending, { isCloud })
|
||||
await verifyMediaCandidates(pending, { allowCompactSuffix: isCloud })
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -6,7 +8,6 @@ import type {
|
||||
EditorOutputLayer,
|
||||
ImageRef
|
||||
} from '@/stores/maskEditorDataStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
@@ -209,18 +210,11 @@ export function useMaskEditorSaver() {
|
||||
}
|
||||
|
||||
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
|
||||
const sourceRef = dataStore.inputData!.sourceRef
|
||||
|
||||
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
|
||||
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
|
||||
const actualPaintedRef = await uploadImage(
|
||||
outputData.paintedImage,
|
||||
sourceRef
|
||||
)
|
||||
|
||||
const actualPaintedMaskedRef = await uploadMask(
|
||||
outputData.paintedMaskedImage,
|
||||
actualPaintedRef
|
||||
const actualMaskedRef = await uploadLayer(outputData.maskedImage)
|
||||
const actualPaintRef = await uploadLayer(outputData.paintLayer)
|
||||
const actualPaintedRef = await uploadLayer(outputData.paintedImage)
|
||||
const actualPaintedMaskedRef = await uploadLayer(
|
||||
outputData.paintedMaskedImage
|
||||
)
|
||||
|
||||
outputData.maskedImage.ref = actualMaskedRef
|
||||
@@ -229,50 +223,10 @@ export function useMaskEditorSaver() {
|
||||
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
|
||||
}
|
||||
|
||||
async function uploadMask(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
async function uploadLayer(layer: EditorOutputLayer): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/mask', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -280,23 +234,31 @@ export function useMaskEditorSaver() {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
|
||||
throw new Error(`Failed to upload: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
let data: UploadImageResponse
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
data = await response.json()
|
||||
} catch (error) {
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
throw new Error(
|
||||
`Invalid upload response for ${layer.ref.filename}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
if (!data?.name) {
|
||||
throw new Error(
|
||||
`Upload response missing 'name' for ${layer.ref.filename}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || '',
|
||||
type: data.type || 'input'
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNodePreview(
|
||||
@@ -322,19 +284,8 @@ export function useMaskEditorSaver() {
|
||||
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
// Widget value format differs between Cloud and OSS:
|
||||
// - Cloud: JUST the filename (subfolder handled by backend)
|
||||
// - OSS: subfolder/filename (traditional format)
|
||||
let widgetValue: string
|
||||
if (isCloud) {
|
||||
widgetValue =
|
||||
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
} else {
|
||||
widgetValue =
|
||||
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
|
||||
mainRef.filename +
|
||||
(mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
}
|
||||
const widgetValue =
|
||||
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
|
||||
imageWidget.value = widgetValue
|
||||
|
||||
|
||||
@@ -384,7 +384,63 @@ describe('usePainter', () => {
|
||||
'/upload/image',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
expect(result).toBe('painter/uploaded.png [temp]')
|
||||
expect(result).toBe('uploaded.png [input]')
|
||||
|
||||
const [, init] = fetchApiMock.mock.calls[0]
|
||||
const body = init?.body as FormData
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect(body.get('type')).toBe('input')
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
})
|
||||
|
||||
it('throws when the upload response is missing a name', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => ({})
|
||||
} as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter('test-node', '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/painter\.uploadError/)
|
||||
})
|
||||
|
||||
it('throws when the upload response body is not valid JSON', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token')
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter('test-node', '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/painter\.uploadError/)
|
||||
})
|
||||
|
||||
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
@@ -12,7 +13,6 @@ import { hexToRgb } from '@/utils/colorUtil'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -631,8 +631,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
const name = `painter-${nodeId}-${Date.now()}.png`
|
||||
const body = new FormData()
|
||||
body.append('image', blob, name)
|
||||
if (!isCloud) body.append('subfolder', 'painter')
|
||||
body.append('type', isCloud ? 'input' : 'temp')
|
||||
body.append('type', 'input')
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
@@ -658,7 +657,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
let data: { name: string }
|
||||
let data: UploadImageResponse
|
||||
try {
|
||||
data = await resp.json()
|
||||
} catch (e) {
|
||||
@@ -670,9 +669,16 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
const result = isCloud
|
||||
? `${data.name} [input]`
|
||||
: `painter/${data.name} [temp]`
|
||||
if (!data?.name) {
|
||||
const err = t('painter.uploadError', {
|
||||
status: resp.status,
|
||||
statusText: "missing 'name' in response"
|
||||
})
|
||||
toastStore.addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
const result = `${data.name} [input]`
|
||||
modelValue.value = result
|
||||
isDirty.value = false
|
||||
return result
|
||||
|
||||
@@ -1283,23 +1283,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
{
|
||||
id: 'Comfy.BrowseModelAssets',
|
||||
icon: 'pi pi-folder-open',
|
||||
label: 'Experimental: Browse Model Assets',
|
||||
label: 'Browse Model Assets',
|
||||
versionAdded: '1.28.3',
|
||||
function: async () => {
|
||||
if (!useSettingStore().get('Comfy.Assets.UseAssetAPI')) {
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: 'Enable Asset API',
|
||||
message:
|
||||
'The Asset API is currently disabled. Would you like to enable it?',
|
||||
type: 'default'
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
await settingStore.set('Comfy.Assets.UseAssetAPI', true)
|
||||
await workflowService.reloadCurrentWorkflow()
|
||||
}
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
await assetBrowserDialog.browse({
|
||||
assetType: 'models',
|
||||
@@ -1318,22 +1304,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleAssetAPI',
|
||||
icon: 'pi pi-database',
|
||||
label: () =>
|
||||
`Experimental: ${
|
||||
useSettingStore().get('Comfy.Assets.UseAssetAPI')
|
||||
? 'Disable'
|
||||
: 'Enable'
|
||||
} AssetAPI`,
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const current = settingStore.get('Comfy.Assets.UseAssetAPI') ?? false
|
||||
await settingStore.set('Comfy.Assets.UseAssetAPI', !current)
|
||||
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleQPOV2',
|
||||
icon: 'pi pi-list',
|
||||
|
||||
@@ -43,6 +43,12 @@ function resolveFlag<T>(
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive feature-flag shape returned by {@link useFeatureFlags}.
|
||||
* Exported so test mocks can stay in sync with the real shape.
|
||||
*/
|
||||
export type FeatureFlags = ReturnType<typeof useFeatureFlags>['flags']
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
|
||||
@@ -75,7 +75,6 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
|
||||
isAssetPreviewSupported: vi.fn(() => false),
|
||||
persistThumbnail: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
@@ -1483,22 +1482,9 @@ describe('useLoad3d', () => {
|
||||
expect(composable).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not call captureThumbnail when asset preview is unsupported', async () => {
|
||||
const { isAssetPreviewSupported } =
|
||||
it('captures thumbnail and persists it when a model_file widget has a value', async () => {
|
||||
const { persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(false)
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
handler()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures thumbnail and persists it when asset preview is supported and a model_file widget has a value', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'',
|
||||
'cube.glb'
|
||||
@@ -1523,9 +1509,8 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('skips persistence when the model widget has no value', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
const { persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
mockNode.widgets = [
|
||||
{ name: 'model_file', value: '' } as unknown as IWidget
|
||||
]
|
||||
@@ -1539,9 +1524,8 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('swallows captureThumbnail rejections silently', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
const { persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'',
|
||||
'broken.glb'
|
||||
|
||||
@@ -8,10 +8,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import type {
|
||||
AnimationItem,
|
||||
CameraConfig,
|
||||
@@ -862,7 +859,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isFirstModelLoad = false
|
||||
},
|
||||
modelReady: () => {
|
||||
if (!load3d || !isAssetPreviewSupported()) return
|
||||
if (!load3d) return
|
||||
|
||||
const node = nodeRef.value
|
||||
const modelWidget = node?.widgets?.find(
|
||||
|
||||
@@ -50,7 +50,6 @@ vi.mock('@/scripts/domWidget', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
|
||||
isAssetPreviewSupported: vi.fn(() => false),
|
||||
persistThumbnail: vi.fn()
|
||||
}))
|
||||
|
||||
|
||||
@@ -17,10 +17,7 @@ type SaveMeshOutput = NodeOutputWith<{
|
||||
'3d'?: ResultItem[]
|
||||
}>
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
@@ -60,15 +57,13 @@ function applySaveGLBOutput(node: LGraphNode, fileInfo: ResultItem): void {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
if (isAssetPreviewSupported()) {
|
||||
const filename = fileInfo.filename ?? ''
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => load3d.captureThumbnail(256, 256))
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(filename, blob))
|
||||
.catch(() => {})
|
||||
}
|
||||
const filename = fileInfo.filename ?? ''
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => load3d.captureThumbnail(256, 256))
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(filename, blob))
|
||||
.catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -194,16 +189,14 @@ useExtensionService().registerExtension({
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
if (isAssetPreviewSupported()) {
|
||||
const filename = fileInfo.filename ?? ''
|
||||
const filename = fileInfo.filename ?? ''
|
||||
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => load3d.captureThumbnail(256, 256))
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(filename, blob))
|
||||
.catch(() => {})
|
||||
}
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => load3d.captureThumbnail(256, 256))
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(filename, blob))
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "حذف هذا الأصل؟",
|
||||
"deleteSelectedDescription": "سيتم إزالة {count} أصل(أصول) بشكل دائم.",
|
||||
"deleteSelectedTitle": "حذف الأصول المحددة؟",
|
||||
"deletingImportedFilesCloudOnly": "حذف الملفات المستوردة مدعوم فقط في النسخة السحابية",
|
||||
"failedToCreateNode": "فشل في إنشاء العقدة",
|
||||
"failedToDeleteAsset": "فشل في حذف الأصل",
|
||||
"failedToExportWorkflow": "فشل في تصدير سير العمل",
|
||||
|
||||
@@ -3111,7 +3111,6 @@
|
||||
"deleteSelectedTitle": "Delete selected assets?",
|
||||
"deleteSelectedDescription": "{count} asset(s) will be permanently removed.",
|
||||
"assetDeletedSuccessfully": "Asset deleted successfully",
|
||||
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
|
||||
"failedToDeleteAsset": "Failed to delete asset",
|
||||
"actions": {
|
||||
"inspect": "Inspect asset",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "¿Eliminar este recurso?",
|
||||
"deleteSelectedDescription": "{count} recurso(s) será(n) eliminado(s) permanentemente.",
|
||||
"deleteSelectedTitle": "¿Eliminar los recursos seleccionados?",
|
||||
"deletingImportedFilesCloudOnly": "La eliminación de archivos importados solo es compatible en la versión cloud",
|
||||
"failedToCreateNode": "No se pudo crear el nodo",
|
||||
"failedToDeleteAsset": "Error al eliminar el recurso",
|
||||
"failedToExportWorkflow": "No se pudo exportar el flujo de trabajo",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "حذف این دارایی؟",
|
||||
"deleteSelectedDescription": "{count} دارایی به طور دائمی حذف خواهد شد.",
|
||||
"deleteSelectedTitle": "حذف داراییهای انتخابشده؟",
|
||||
"deletingImportedFilesCloudOnly": "حذف فایلهای واردشده فقط در نسخه ابری پشتیبانی میشود",
|
||||
"failedToCreateNode": "ایجاد node ناموفق بود",
|
||||
"failedToDeleteAsset": "حذف دارایی ناموفق بود",
|
||||
"failedToExportWorkflow": "خروجی گرفتن از workflow ناموفق بود",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "Supprimer cet élément ?",
|
||||
"deleteSelectedDescription": "{count} élément(s) sera(ont) définitivement supprimé(s).",
|
||||
"deleteSelectedTitle": "Supprimer les éléments sélectionnés ?",
|
||||
"deletingImportedFilesCloudOnly": "La suppression des fichiers importés n'est prise en charge que dans la version cloud",
|
||||
"failedToCreateNode": "Échec de la création du nœud",
|
||||
"failedToDeleteAsset": "Échec de la suppression de l'élément",
|
||||
"failedToExportWorkflow": "Échec de l’exportation du flux de travail",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "このアセットを削除しますか?",
|
||||
"deleteSelectedDescription": "{count} 個のアセットが完全に削除されます。",
|
||||
"deleteSelectedTitle": "選択したアセットを削除しますか?",
|
||||
"deletingImportedFilesCloudOnly": "インポートしたファイルの削除はクラウド版でのみサポートされています",
|
||||
"failedToCreateNode": "ノードの作成に失敗しました",
|
||||
"failedToDeleteAsset": "アセットの削除に失敗しました",
|
||||
"failedToExportWorkflow": "ワークフローのエクスポートに失敗しました",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "이 에셋을 삭제하시겠습니까?",
|
||||
"deleteSelectedDescription": "{count}개의 에셋이 영구적으로 제거됩니다.",
|
||||
"deleteSelectedTitle": "선택한 에셋을 삭제하시겠습니까?",
|
||||
"deletingImportedFilesCloudOnly": "가져온 파일 삭제는 클라우드 버전에서만 지원됩니다",
|
||||
"failedToCreateNode": "노드 생성에 실패했습니다",
|
||||
"failedToDeleteAsset": "에셋 삭제 실패",
|
||||
"failedToExportWorkflow": "워크플로우 내보내기에 실패했습니다",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "Excluir este recurso?",
|
||||
"deleteSelectedDescription": "{count} recurso(s) será(ão) removido(s) permanentemente.",
|
||||
"deleteSelectedTitle": "Excluir recursos selecionados?",
|
||||
"deletingImportedFilesCloudOnly": "A exclusão de arquivos importados é suportada apenas na versão em nuvem",
|
||||
"failedToCreateNode": "Falha ao criar nó",
|
||||
"failedToDeleteAsset": "Falha ao excluir o recurso",
|
||||
"failedToExportWorkflow": "Falha ao exportar fluxo de trabalho",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "Удалить этот ресурс?",
|
||||
"deleteSelectedDescription": "{count} ресурс(ов) будет удален безвозвратно.",
|
||||
"deleteSelectedTitle": "Удалить выбранные ресурсы?",
|
||||
"deletingImportedFilesCloudOnly": "Удаление импортированных файлов поддерживается только в облачной версии",
|
||||
"failedToCreateNode": "Не удалось создать узел",
|
||||
"failedToDeleteAsset": "Не удалось удалить ресурс",
|
||||
"failedToExportWorkflow": "Не удалось экспортировать рабочий процесс",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "Bu varlık silinsin mi?",
|
||||
"deleteSelectedDescription": "{count} varlık kalıcı olarak kaldırılacak.",
|
||||
"deleteSelectedTitle": "Seçilen varlıklar silinsin mi?",
|
||||
"deletingImportedFilesCloudOnly": "İçe aktarılan dosyaların silinmesi yalnızca bulut sürümünde desteklenir",
|
||||
"failedToCreateNode": "Düğüm oluşturulamadı",
|
||||
"failedToDeleteAsset": "Varlık silinemedi",
|
||||
"failedToExportWorkflow": "İş akışı dışa aktarılamadı",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "刪除此資源?",
|
||||
"deleteSelectedDescription": "{count} 個資源將被永久移除。",
|
||||
"deleteSelectedTitle": "刪除選取的資源?",
|
||||
"deletingImportedFilesCloudOnly": "僅雲端版本支援刪除匯入的檔案",
|
||||
"failedToCreateNode": "建立節點失敗",
|
||||
"failedToDeleteAsset": "刪除資源失敗",
|
||||
"failedToExportWorkflow": "匯出工作流程失敗",
|
||||
|
||||
@@ -2002,7 +2002,6 @@
|
||||
"deleteAssetTitle": "删除此资产?",
|
||||
"deleteSelectedDescription": "{count} 项资产将被永久删除。",
|
||||
"deleteSelectedTitle": "删除所选资产?",
|
||||
"deletingImportedFilesCloudOnly": "删除导入文件仅支持云版本",
|
||||
"failedToCreateNode": "创建节点失败",
|
||||
"failedToDeleteAsset": "删除资产失败",
|
||||
"failedToExportWorkflow": "工作流导出失败",
|
||||
|
||||
@@ -128,7 +128,7 @@ const props = defineProps<{
|
||||
showLeftPanel?: boolean
|
||||
title?: string
|
||||
/**
|
||||
* Storybook/test seam: when provided, bypasses the cloud-only
|
||||
* Storybook/test seam: when provided, bypasses the
|
||||
* `assetsStore.getAssets(cacheKey)` fetch and renders this list directly.
|
||||
* Production callers should leave this undefined and rely on the store.
|
||||
*/
|
||||
|
||||
@@ -5,15 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import Media3DTop from './Media3DTop.vue'
|
||||
|
||||
const {
|
||||
mockUseIntersectionObserver,
|
||||
mockFindServerPreviewUrl,
|
||||
mockIsAssetPreviewSupported
|
||||
} = vi.hoisted(() => ({
|
||||
mockUseIntersectionObserver: vi.fn(),
|
||||
mockFindServerPreviewUrl: vi.fn(),
|
||||
mockIsAssetPreviewSupported: vi.fn(() => true)
|
||||
}))
|
||||
const { mockUseIntersectionObserver, mockFindServerPreviewUrl } = vi.hoisted(
|
||||
() => ({
|
||||
mockUseIntersectionObserver: vi.fn(),
|
||||
mockFindServerPreviewUrl: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUseCore>()
|
||||
@@ -24,8 +21,7 @@ vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('../utils/assetPreviewUtil', () => ({
|
||||
findServerPreviewUrl: mockFindServerPreviewUrl,
|
||||
isAssetPreviewSupported: mockIsAssetPreviewSupported
|
||||
findServerPreviewUrl: mockFindServerPreviewUrl
|
||||
}))
|
||||
|
||||
function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
|
||||
@@ -66,7 +62,6 @@ const globalConfig = { mocks: { $t: (key: string) => key } }
|
||||
describe('Media3DTop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsAssetPreviewSupported.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('renders the placeholder when no thumbnail has loaded', () => {
|
||||
@@ -117,18 +112,6 @@ describe('Media3DTop', () => {
|
||||
expect(img).toHaveAttribute('src', 'http://server/from-name.png')
|
||||
})
|
||||
|
||||
it('skips the server query when isAssetPreviewSupported is false', async () => {
|
||||
fireObserverIntersecting()
|
||||
mockIsAssetPreviewSupported.mockReturnValue(false)
|
||||
render(Media3DTop, {
|
||||
props: { asset: makeAsset() },
|
||||
global: globalConfig
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('picks up a patched preview_url after the IntersectionObserver gate has closed', async () => {
|
||||
// Initial render: observer fires, server has no preview yet — hasAttempted=true
|
||||
fireObserverIntersecting()
|
||||
|
||||
@@ -23,10 +23,7 @@ import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import {
|
||||
findServerPreviewUrl,
|
||||
isAssetPreviewSupported
|
||||
} from '../utils/assetPreviewUtil'
|
||||
import { findServerPreviewUrl } from '../utils/assetPreviewUtil'
|
||||
|
||||
const { asset } = defineProps<{ asset: AssetMeta }>()
|
||||
|
||||
@@ -49,7 +46,7 @@ async function loadThumbnail() {
|
||||
|
||||
if (!asset?.src) return
|
||||
|
||||
if (asset.name && isAssetPreviewSupported()) {
|
||||
if (asset.name) {
|
||||
const serverPreviewUrl = await findServerPreviewUrl(asset.name)
|
||||
if (serverPreviewUrl) {
|
||||
thumbnailSrc.value = serverPreviewUrl
|
||||
|
||||
@@ -144,7 +144,6 @@ import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import {
|
||||
formatDuration,
|
||||
@@ -158,7 +157,10 @@ 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'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
getAssetMetadataDimensions
|
||||
} from '../utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
@@ -279,12 +281,15 @@ const formattedDuration = computed(() => {
|
||||
return formatDuration(Number(duration))
|
||||
})
|
||||
|
||||
const displayImageDimensions = computed(
|
||||
() => getAssetMetadataDimensions(asset) ?? imageDimensions.value
|
||||
)
|
||||
|
||||
// Get metadata info based on file kind
|
||||
const metaInfo = computed(() => {
|
||||
if (!asset) return ''
|
||||
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
|
||||
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
|
||||
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
|
||||
if (fileKind.value === 'image' && displayImageDimensions.value) {
|
||||
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
|
||||
}
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
return formatSize(asset.size)
|
||||
|
||||
@@ -105,7 +105,13 @@ interface MediaAssetContextMenuExposed {
|
||||
|
||||
let capturedRef: MediaAssetContextMenuExposed | null = null
|
||||
|
||||
function mountComponent() {
|
||||
interface MountOptions {
|
||||
assetType?: 'output' | 'input'
|
||||
showDeleteButton?: boolean
|
||||
}
|
||||
|
||||
function mountComponent(opts: MountOptions = {}) {
|
||||
const { assetType = 'output', showDeleteButton } = opts
|
||||
const onHide = vi.fn()
|
||||
const { container, unmount } = render(
|
||||
defineComponent({
|
||||
@@ -115,10 +121,10 @@ function mountComponent() {
|
||||
onMounted(() => {
|
||||
capturedRef = menuRef.value
|
||||
})
|
||||
return { menuRef, asset, onHide }
|
||||
return { menuRef, asset, onHide, assetType, showDeleteButton }
|
||||
},
|
||||
template:
|
||||
'<MediaAssetContextMenu ref="menuRef" :asset="asset" asset-type="output" file-kind="image" @hide="onHide" />'
|
||||
'<MediaAssetContextMenu ref="menuRef" :asset="asset" :asset-type="assetType" :show-delete-button="showDeleteButton" file-kind="image" @hide="onHide" />'
|
||||
}),
|
||||
{
|
||||
global: {
|
||||
@@ -198,4 +204,33 @@ describe('MediaAssetContextMenu', () => {
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('shows Delete for input assets regardless of cloud mode (FE-732)', async () => {
|
||||
const { container, unmount } = mountComponent({ assetType: 'input' })
|
||||
await showMenu(container)
|
||||
|
||||
const deleteItem = capturedMenu.model.find(
|
||||
(item) => item.label === 'mediaAsset.actions.delete'
|
||||
)
|
||||
|
||||
expect(deleteItem).toBeDefined()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('hides Delete when parent explicitly passes showDeleteButton: false', async () => {
|
||||
const { container, unmount } = mountComponent({
|
||||
assetType: 'input',
|
||||
showDeleteButton: false
|
||||
})
|
||||
await showMenu(container)
|
||||
|
||||
const deleteItem = capturedMenu.model.find(
|
||||
(item) => item.label === 'mediaAsset.actions.delete'
|
||||
)
|
||||
|
||||
expect(deleteItem).toBeUndefined()
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,7 +37,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { isPreviewableMediaType } from '@/utils/formatUtil'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
@@ -51,7 +50,7 @@ const {
|
||||
asset,
|
||||
assetType,
|
||||
fileKind,
|
||||
showDeleteButton,
|
||||
showDeleteButton = true,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
} = defineProps<{
|
||||
@@ -122,11 +121,8 @@ const showCopyJobId = computed(() => {
|
||||
})
|
||||
|
||||
const shouldShowDeleteButton = computed(() => {
|
||||
const propAllows = showDeleteButton ?? true
|
||||
const typeAllows =
|
||||
assetType === 'output' || (assetType === 'input' && isCloud)
|
||||
|
||||
return propAllows && typeAllows
|
||||
const typeAllows = assetType === 'output' || assetType === 'input'
|
||||
return showDeleteButton && typeAllows
|
||||
})
|
||||
|
||||
// Context menu items
|
||||
|
||||
114
src/platform/assets/components/MediaAssetFilterBar.test.ts
Normal file
114
src/platform/assets/components/MediaAssetFilterBar.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
const filterButtonStub = defineComponent({
|
||||
name: 'MediaAssetFilterButton',
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-testid': 'filter-button' },
|
||||
slots.default ? slots.default({ close: () => {} }) : []
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const settingsButtonStub = defineComponent({
|
||||
name: 'MediaAssetSettingsButton',
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-testid': 'settings-button' },
|
||||
slots.default ? slots.default() : []
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const settingsMenuCapture = { showSortOptions: undefined as unknown }
|
||||
const settingsMenuStub = defineComponent({
|
||||
name: 'MediaAssetSettingsMenu',
|
||||
props: {
|
||||
sortBy: { type: String, default: 'newest' },
|
||||
viewMode: { type: String, default: 'grid' },
|
||||
showSortOptions: { type: Boolean, default: false },
|
||||
showGenerationTimeSort: { type: Boolean, default: false }
|
||||
},
|
||||
setup(props) {
|
||||
settingsMenuCapture.showSortOptions = props.showSortOptions
|
||||
return () => h('div', { 'data-testid': 'settings-menu' })
|
||||
}
|
||||
})
|
||||
|
||||
const sidebarTopAreaStub = defineComponent({
|
||||
name: 'SidebarTopArea',
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h('div', { 'data-testid': 'sidebar-top-area' }, [
|
||||
slots.default?.(),
|
||||
slots.actions?.()
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
const searchInputStub = defineComponent({
|
||||
name: 'SearchInput',
|
||||
setup() {
|
||||
return () => h('input', { 'data-testid': 'search-input' })
|
||||
}
|
||||
})
|
||||
|
||||
function mountFilterBar() {
|
||||
return render(
|
||||
defineComponent({
|
||||
components: { MediaAssetFilterBar },
|
||||
setup() {
|
||||
const sortBy = ref<'newest' | 'oldest'>('newest')
|
||||
const viewMode = ref<'grid' | 'list'>('grid')
|
||||
return { sortBy, viewMode }
|
||||
},
|
||||
template:
|
||||
'<MediaAssetFilterBar :search-query="\'\'" :media-type-filters="[]" v-model:sort-by="sortBy" v-model:view-mode="viewMode" />'
|
||||
}),
|
||||
{
|
||||
global: {
|
||||
stubs: {
|
||||
MediaAssetFilterButton: filterButtonStub,
|
||||
MediaAssetFilterMenu: { template: '<div />' },
|
||||
MediaAssetSettingsButton: settingsButtonStub,
|
||||
MediaAssetSettingsMenu: settingsMenuStub,
|
||||
SidebarTopArea: sidebarTopAreaStub,
|
||||
SearchInput: searchInputStub
|
||||
},
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
},
|
||||
directives: {
|
||||
tooltip: { mounted() {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('MediaAssetFilterBar', () => {
|
||||
it('renders the filter button unconditionally (FE-732)', () => {
|
||||
mountFilterBar()
|
||||
|
||||
expect(screen.getByTestId('filter-button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('passes show-sort-options=true to the settings menu (FE-732)', () => {
|
||||
settingsMenuCapture.showSortOptions = undefined
|
||||
mountFilterBar()
|
||||
|
||||
expect(settingsMenuCapture.showSortOptions).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,6 @@
|
||||
/>
|
||||
<template #actions>
|
||||
<MediaAssetFilterButton
|
||||
v-if="isCloud"
|
||||
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
@@ -27,7 +26,7 @@
|
||||
<MediaAssetSettingsMenu
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:sort-by="sortBy"
|
||||
:show-sort-options="isCloud"
|
||||
:show-sort-options="true"
|
||||
:show-generation-time-sort
|
||||
/>
|
||||
</template>
|
||||
@@ -39,7 +38,6 @@
|
||||
<script setup lang="ts">
|
||||
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
|
||||
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mapInputFileToAssetItem } from './assetMappers'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `/api${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: vi.fn()
|
||||
}))
|
||||
|
||||
describe('mapInputFileToAssetItem', () => {
|
||||
it('preserves a clean filename', () => {
|
||||
const asset = mapInputFileToAssetItem('photo.png', 0, 'input')
|
||||
|
||||
expect(asset.name).toBe('photo.png')
|
||||
expect(asset.id).toBe('input-0-photo.png')
|
||||
expect(asset.preview_url).toBe('/api/view?filename=photo.png&type=input')
|
||||
})
|
||||
|
||||
it.for([
|
||||
['photo.png [input]', 'photo.png'],
|
||||
['photo.png [output]', 'photo.png'],
|
||||
['photo.png [temp]', 'photo.png'],
|
||||
['clip.mp4[input]', 'clip.mp4'],
|
||||
['MyFile.WEBP [Input]', 'MyFile.WEBP']
|
||||
])(
|
||||
'strips ComfyUI directory annotation: %s -> %s',
|
||||
([input, expectedName]) => {
|
||||
const asset = mapInputFileToAssetItem(input, 1, 'input')
|
||||
|
||||
expect(asset.name).toBe(expectedName)
|
||||
expect(asset.id).toBe(`input-1-${expectedName}`)
|
||||
expect(asset.preview_url).toBe(
|
||||
`/api/view?filename=${encodeURIComponent(expectedName)}&type=input`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves non-annotation brackets in the filename intact', () => {
|
||||
const asset = mapInputFileToAssetItem('my [draft] image.png', 0, 'input')
|
||||
|
||||
expect(asset.name).toBe('my [draft] image.png')
|
||||
})
|
||||
|
||||
it('uses the directory passed in for the type query param', () => {
|
||||
const asset = mapInputFileToAssetItem('clip.mp4 [output]', 0, 'output')
|
||||
|
||||
expect(asset.preview_url).toBe('/api/view?filename=clip.mp4&type=output')
|
||||
expect(asset.tags).toEqual(['output'])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
@@ -50,42 +48,3 @@ export function mapTaskOutputToAssetItem(
|
||||
user_metadata: metadata
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips ComfyUI's trailing directory-type annotation (e.g. ` [input]`,
|
||||
* ` [output]`, `[temp]`) from a filename returned by the OSS internal
|
||||
* `/internal/files/{type}` endpoint. The annotation is part of the wire
|
||||
* format LoadImage-style widgets expect, but for the assets sidebar we
|
||||
* want the canonical on-disk filename so type detection / titles work.
|
||||
*/
|
||||
function stripDirectoryAnnotation(filename: string): string {
|
||||
return filename.replace(/\s*\[(?:input|output|temp)\]\s*$/i, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps input directory file to AssetItem format
|
||||
* @param filename The filename
|
||||
* @param index File index for unique ID
|
||||
* @param directory The directory type
|
||||
* @returns AssetItem formatted object
|
||||
*/
|
||||
export function mapInputFileToAssetItem(
|
||||
filename: string,
|
||||
index: number,
|
||||
directory: 'input' | 'output' = 'input'
|
||||
): AssetItem {
|
||||
const cleanName = stripDirectoryAnnotation(filename)
|
||||
const params = new URLSearchParams({ filename: cleanName, type: directory })
|
||||
const preview_url = api.apiURL(`/view?${params}`)
|
||||
appendCloudResParam(params, cleanName)
|
||||
|
||||
return {
|
||||
id: `${directory}-${index}-${cleanName}`,
|
||||
name: cleanName,
|
||||
size: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: [directory],
|
||||
thumbnail_url: api.apiURL(`/view?${params}`),
|
||||
preview_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,6 +898,49 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets - input asset cross-backend support (FE-732)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAssetType.mockReturnValue('input')
|
||||
mockDeleteAsset.mockReset()
|
||||
mockDeleteAsset.mockResolvedValue(undefined)
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes input assets in OSS mode (isCloud = false)', async () => {
|
||||
mockIsCloud.value = false
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const inputAsset = createMockAsset({
|
||||
id: 'input-oss-1',
|
||||
name: 'local-input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
await actions.deleteAssets(inputAsset)
|
||||
|
||||
expect(mockDeleteAsset).toHaveBeenCalledWith('input-oss-1')
|
||||
})
|
||||
|
||||
it('deletes input assets in Cloud mode (isCloud = true)', async () => {
|
||||
mockIsCloud.value = true
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const inputAsset = createMockAsset({
|
||||
id: 'input-cloud-1',
|
||||
name: 'cloud-input.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
await actions.deleteAssets(inputAsset)
|
||||
|
||||
expect(mockDeleteAsset).toHaveBeenCalledWith('input-cloud-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets - confirmation dialog item names', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
|
||||
@@ -17,7 +17,10 @@ import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
getAssetStoredFilename
|
||||
} from '../utils/assetMetadataUtils'
|
||||
import { getAssetType } from '../utils/assetTypeUtil'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
|
||||
@@ -92,10 +95,6 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
await api.deleteItem('history', jobId)
|
||||
} else {
|
||||
// Input assets can only be deleted in cloud environment
|
||||
if (!isCloud) {
|
||||
throw new Error(t('mediaAsset.deletingImportedFilesCloudOnly'))
|
||||
}
|
||||
await assetService.deleteAsset(asset.id)
|
||||
}
|
||||
}
|
||||
@@ -296,12 +295,7 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const assetType = getAssetType(targetAsset, 'input')
|
||||
|
||||
// In Cloud mode, use asset_hash (the actual stored filename)
|
||||
// In OSS mode, use the original name
|
||||
const filename =
|
||||
isCloud && targetAsset.asset_hash
|
||||
? targetAsset.asset_hash
|
||||
: targetAsset.name
|
||||
const filename = getAssetStoredFilename(targetAsset)
|
||||
|
||||
// Create annotated path for the asset
|
||||
const annotated = createAnnotatedPath(
|
||||
@@ -440,10 +434,7 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const assetType = getAssetType(asset, 'input')
|
||||
|
||||
// In Cloud mode, use asset_hash (the actual stored filename)
|
||||
// In OSS mode, use the original name
|
||||
const filename =
|
||||
isCloud && asset.asset_hash ? asset.asset_hash : asset.name
|
||||
const filename = getAssetStoredFilename(asset)
|
||||
|
||||
const annotated = createAnnotatedPath(
|
||||
{
|
||||
|
||||
@@ -6,6 +6,13 @@ const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
asset_hash: z.string().nullish(),
|
||||
// BE-933 / BE-934 (RFC: BE-808 Asset Identity Semantics v2): namespace-rooted
|
||||
// locator/display string, e.g. `input/sub/image.png` or
|
||||
// `models/checkpoints/flux.safetensors`. Emitted on a BEST EFFORT basis
|
||||
// (MAY, nullable). Identity is `id`, not `file_path`. Consumers MUST NOT
|
||||
// assume `file_path` is populated and MUST degrade gracefully — see
|
||||
// missingMediaAssetResolver.getAssetDetectionNames.
|
||||
file_path: z.string().nullish(),
|
||||
size: z.number().optional(), // TBD: Will be provided by history API in the future
|
||||
mime_type: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
|
||||
@@ -10,21 +10,6 @@ import {
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockDistributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: mockSettingStoreGet
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => {
|
||||
const registeredNodeTypes: Record<string, string> = {
|
||||
CheckpointLoaderSimple: 'ckpt_name',
|
||||
@@ -85,59 +70,27 @@ function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
describe(assetService.shouldUseAssetBrowser, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDistributionState.isCloud = false
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns false when not on cloud', () => {
|
||||
mockDistributionState.isCloud = false
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when asset API setting is disabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when node type is not eligible', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser('UnknownNode', 'some_input')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when cloud, setting enabled, and node is eligible', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
|
||||
it('returns true when the node is eligible', () => {
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when nodeType is undefined', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
|
||||
expect(assetService.shouldUseAssetBrowser(undefined, 'ckpt_name')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when widget name does not match registered input', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser(
|
||||
'CheckpointLoaderSimple',
|
||||
|
||||
@@ -20,8 +20,6 @@ import type {
|
||||
ModelFolder,
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
@@ -175,7 +173,6 @@ function getLocalizedErrorMessage(errorCode: string): string {
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
|
||||
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
const DEFAULT_LIMIT = 500
|
||||
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
|
||||
|
||||
@@ -231,9 +228,7 @@ function validateAssetResponse(data: unknown): AssetResponse {
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
throw new Error(
|
||||
`${EXPERIMENTAL_WARNING}Invalid asset response against zod schema:\n${error}`
|
||||
)
|
||||
throw new Error(`Invalid asset response against zod schema:\n${error}`)
|
||||
}
|
||||
|
||||
function validateUploadedAssetResponse(
|
||||
@@ -311,7 +306,7 @@ function createAssetService() {
|
||||
: await api.fetchApi(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`
|
||||
`Unable to load ${context}: Server returned ${res.status}. Please try again.`
|
||||
)
|
||||
}
|
||||
const data = await res.json()
|
||||
@@ -380,17 +375,8 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the asset API is enabled (cloud environment + user setting).
|
||||
*/
|
||||
function isAssetAPIEnabled(): boolean {
|
||||
if (!isCloud) return false
|
||||
return !!useSettingStore().get('Comfy.Assets.UseAssetAPI')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the asset browser should be used for a given node input.
|
||||
* Combines the cloud environment check, user setting, and eligibility check.
|
||||
*
|
||||
* @param nodeType - The ComfyUI node comfyClass
|
||||
* @param widgetName - The name of the widget to check
|
||||
@@ -400,7 +386,7 @@ function createAssetService() {
|
||||
nodeType: string | undefined,
|
||||
widgetName: string
|
||||
): boolean {
|
||||
return isAssetAPIEnabled() && isAssetBrowserEligible(nodeType, widgetName)
|
||||
return isAssetBrowserEligible(nodeType, widgetName)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,7 +436,7 @@ function createAssetService() {
|
||||
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`${EXPERIMENTAL_WARNING}Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
|
||||
`Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
|
||||
)
|
||||
}
|
||||
const data = await res.json()
|
||||
@@ -461,9 +447,7 @@ function createAssetService() {
|
||||
const error = result.error
|
||||
? fromZodError(result.error)
|
||||
: 'Unknown validation error'
|
||||
throw new Error(
|
||||
`${EXPERIMENTAL_WARNING}Invalid asset response against zod schema:\n${error}`
|
||||
)
|
||||
throw new Error(`Invalid asset response against zod schema:\n${error}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -941,7 +925,6 @@ function createAssetService() {
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
isAssetAPIEnabled,
|
||||
isAssetBrowserEligible,
|
||||
shouldUseAssetBrowser,
|
||||
getAssetsForNodeType,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
@@ -10,13 +10,25 @@ import {
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetMetadataDimensions,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetStoredFilename,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription,
|
||||
getSourceName
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
const { isCloudRef } = vi.hoisted(() => ({
|
||||
isCloudRef: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return isCloudRef.value
|
||||
}
|
||||
}))
|
||||
|
||||
describe('assetMetadataUtils', () => {
|
||||
const mockAsset: AssetItem = {
|
||||
id: 'test-id',
|
||||
@@ -295,6 +307,28 @@ describe('assetMetadataUtils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetStoredFilename', () => {
|
||||
afterEach(() => {
|
||||
isCloudRef.value = true
|
||||
})
|
||||
|
||||
it('returns asset_hash on cloud when present', () => {
|
||||
isCloudRef.value = true
|
||||
expect(getAssetStoredFilename(mockAsset)).toBe('hash123')
|
||||
})
|
||||
|
||||
it('falls back to name on cloud when asset_hash is missing', () => {
|
||||
isCloudRef.value = true
|
||||
const asset = { ...mockAsset, asset_hash: undefined }
|
||||
expect(getAssetStoredFilename(asset)).toBe('test-model')
|
||||
})
|
||||
|
||||
it('returns name on OSS regardless of asset_hash', () => {
|
||||
isCloudRef.value = false
|
||||
expect(getAssetStoredFilename(mockAsset)).toBe('test-model')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetFilename', () => {
|
||||
it('returns user_metadata.filename when present', () => {
|
||||
const asset = {
|
||||
@@ -383,4 +417,80 @@ describe('assetMetadataUtils', () => {
|
||||
expect(getAssetCardTitle(asset)).toBe('pretty.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetMetadataDimensions', () => {
|
||||
it('returns dimensions when width/height are positive integers', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 1024, height: 768 } }
|
||||
expect(getAssetMetadataDimensions(asset)).toEqual({
|
||||
width: 1024,
|
||||
height: 768
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: 'NaN width', width: Number.NaN, height: 768 },
|
||||
{
|
||||
name: 'Infinity height',
|
||||
width: 1024,
|
||||
height: Number.POSITIVE_INFINITY
|
||||
},
|
||||
{ name: 'zero width', width: 0, height: 768 },
|
||||
{ name: 'negative height', width: 1024, height: -1 },
|
||||
{ name: 'fractional width', width: 1024.5, height: 768 },
|
||||
{ name: 'string width', width: '1024', height: 768 },
|
||||
{ name: 'missing width', width: undefined, height: 768 }
|
||||
])('returns undefined for invalid shape: $name', ({ width, height }) => {
|
||||
const asset = { ...mockAsset, metadata: { width, height } }
|
||||
expect(getAssetMetadataDimensions(asset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when metadata is absent', () => {
|
||||
expect(getAssetMetadataDimensions(mockAsset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when asset itself is undefined', () => {
|
||||
expect(getAssetMetadataDimensions(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unified asset response shape (BE-808 RFC)', () => {
|
||||
// Cloud asset: `asset.name` is a content hash; `display_name` carries
|
||||
// the user-facing label.
|
||||
const cloudShape: AssetItem = {
|
||||
...mockAsset,
|
||||
id: 'cloud-asset-id',
|
||||
name: 'blake3:abc1234567890def.png',
|
||||
asset_hash: 'blake3:abc1234567890def.png',
|
||||
display_name: 'sunset.png'
|
||||
}
|
||||
|
||||
// OSS asset: `asset.name` is already the filename; `display_name` is
|
||||
// nullable per BE-1045 spec — clients fall back to `asset.name`.
|
||||
const ossShape: AssetItem = {
|
||||
...mockAsset,
|
||||
id: 'oss-asset-id',
|
||||
name: 'sunset.png',
|
||||
asset_hash: null,
|
||||
display_name: undefined
|
||||
}
|
||||
|
||||
it('renders the same label for the Cloud and OSS shapes via getAssetDisplayFilename', () => {
|
||||
expect(getAssetDisplayFilename(cloudShape)).toBe('sunset.png')
|
||||
expect(getAssetDisplayFilename(ossShape)).toBe('sunset.png')
|
||||
})
|
||||
|
||||
it('renders the same label via getAssetCardTitle', () => {
|
||||
expect(getAssetCardTitle(cloudShape)).toBe('sunset.png')
|
||||
expect(getAssetCardTitle(ossShape)).toBe('sunset.png')
|
||||
})
|
||||
|
||||
it('honours OSS-emitted display_name when present', () => {
|
||||
const ossWithDisplayName: AssetItem = {
|
||||
...ossShape,
|
||||
name: 'sunset.png',
|
||||
display_name: 'Curated Sunset'
|
||||
}
|
||||
expect(getAssetDisplayFilename(ossWithDisplayName)).toBe('Curated Sunset')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isCivitaiUrl } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
@@ -172,13 +173,27 @@ export function getAssetFilename(asset: AssetItem): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the human-readable filename to render in UI surfaces.
|
||||
* Fallback chain: user_metadata.filename → metadata.filename →
|
||||
* asset.display_name → asset.name.
|
||||
* Resolves the *stored* filename for an asset — the filename used to
|
||||
* construct asset paths (for /view URLs, widget values), not the
|
||||
* user-facing display name.
|
||||
*
|
||||
* `display_name` is populated by queue output mappers in Cloud where
|
||||
* `asset.name` is a content hash. Use this helper for labels/titles only;
|
||||
* for serialized identifiers use {@link getAssetFilename}.
|
||||
* Cloud stores assets with `asset_hash` as the filename (content-
|
||||
* addressed); OSS uses `name` (filesystem-backed). After BE-933/934
|
||||
* emit `file_path` on both backends and the cloud spec sync brings
|
||||
* the field into generated types, this collapses to
|
||||
* `asset.file_path ?? asset.name` (no isCloud branch).
|
||||
*
|
||||
* For display use {@link getAssetDisplayFilename}; for serialized
|
||||
* identifiers use {@link getAssetFilename}.
|
||||
*/
|
||||
export function getAssetStoredFilename(asset: AssetItem): string {
|
||||
return isCloud && asset.asset_hash ? asset.asset_hash : asset.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable filename for UI labels.
|
||||
* Fallback: user_metadata.filename → metadata.filename → display_name → asset.name.
|
||||
* For serialized identifiers use {@link getAssetFilename}.
|
||||
*/
|
||||
export function getAssetDisplayFilename(asset: AssetItem): string {
|
||||
return (
|
||||
@@ -191,7 +206,7 @@ export function getAssetDisplayFilename(asset: AssetItem): string {
|
||||
* Prefers a user-curated name (user_metadata.name / metadata.name) when it
|
||||
* actually differs from asset.name, so a user-renamed model keeps its
|
||||
* display name. Falls through to {@link getAssetDisplayFilename} when the
|
||||
* curated name is absent or equal to asset.name (Cloud hash case).
|
||||
* curated name is absent or equal to asset.name (hash-keyed asset case).
|
||||
*/
|
||||
export function getAssetCardTitle(asset: AssetItem): string {
|
||||
const curatedName = getStringProperty(asset, 'name')
|
||||
@@ -208,3 +223,30 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
export function getAssetUrlFilename(asset: AssetItem): string {
|
||||
return asset.asset_hash || asset.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: a pixel dimension is a finite positive integer. `metadata` is
|
||||
* typed as `Record<string, unknown>`, so `typeof === 'number'` alone admits
|
||||
* NaN, Infinity, 0, negatives, and fractional values.
|
||||
*/
|
||||
function isValidDimension(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original image dimensions from `asset.metadata.{width,height}`
|
||||
* when both pass shape validation, otherwise `undefined`. Callers should fall
|
||||
* back to the locally-computed `<img>.naturalWidth/Height`, which is correct
|
||||
* on runtimes that serve the original file but reports preview size on
|
||||
* runtimes that serve a downscaled preview.
|
||||
*/
|
||||
export function getAssetMetadataDimensions(
|
||||
asset: AssetItem | undefined
|
||||
): { width: number; height: number } | undefined {
|
||||
const w = asset?.metadata?.width
|
||||
const h = asset?.metadata?.height
|
||||
if (isValidDimension(w) && isValidDimension(h)) {
|
||||
return { width: w, height: h }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
findOutputAsset,
|
||||
findServerPreviewUrl,
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
|
||||
@@ -11,8 +10,6 @@ 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())
|
||||
const mockSetAssetPreview = vi.hoisted(() => vi.fn())
|
||||
@@ -21,14 +18,12 @@ vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: mockFetchApi,
|
||||
apiURL: mockApiURL,
|
||||
api_base: '',
|
||||
getServerFeature: mockGetServerFeature
|
||||
api_base: ''
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
isAssetAPIEnabled: mockIsAssetAPIEnabled,
|
||||
uploadAssetFromBase64: mockUploadAssetFromBase64,
|
||||
updateAsset: mockUpdateAsset
|
||||
}
|
||||
@@ -81,26 +76,6 @@ const localAssetWithPreview = {
|
||||
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())
|
||||
|
||||
|
||||
@@ -10,12 +10,6 @@ interface AssetRecord {
|
||||
preview_id?: string | null
|
||||
}
|
||||
|
||||
export function isAssetPreviewSupported(): boolean {
|
||||
return (
|
||||
assetService.isAssetAPIEnabled() || api.getServerFeature('assets', false)
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchAssets(
|
||||
params: Record<string, string>
|
||||
): Promise<AssetRecord[]> {
|
||||
|
||||
@@ -6,10 +6,6 @@ import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
|
||||
import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/missingMedia/missingMediaScan', () => ({
|
||||
scanNodeMediaCandidates: mockScanNodeMediaCandidates
|
||||
@@ -94,8 +90,7 @@ describe('FE-230 markDeletedAssetsAsMissingMedia', () => {
|
||||
expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
inputNode,
|
||||
true
|
||||
inputNode
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
@@ -38,7 +37,7 @@ export function markDeletedAssetsAsMissingMedia(
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) {
|
||||
for (const candidate of scanNodeMediaCandidates(rootGraph, node)) {
|
||||
if (!deletedValues.has(candidate.name)) continue
|
||||
candidates.push({ ...candidate, isMissing: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import {
|
||||
getMediaDisplayName,
|
||||
useMissingMediaInteractions
|
||||
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
|
||||
|
||||
const mockInputAssetsByFilename = new Map<string, AssetItem>()
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
inputAssetsByFilename: mockInputAssetsByFilename,
|
||||
updateInputs: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/missingMedia/missingMediaStore', () => ({
|
||||
useMissingMediaStore: () => ({
|
||||
expandState: {},
|
||||
pendingSelection: {},
|
||||
uploadState: {},
|
||||
missingMediaCandidates: null,
|
||||
removeMissingMediaByName: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const mockGetNodeByExecutionId = vi.fn()
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: (...args: unknown[]) =>
|
||||
mockGetNodeByExecutionId(...args)
|
||||
}))
|
||||
|
||||
const mockResolveComboValues = vi.fn()
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveComboValues: (widget: unknown) => mockResolveComboValues(widget),
|
||||
addToComboValues: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: { id: 'mock-graph' }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { fetchApi: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: vi.fn() })
|
||||
}))
|
||||
|
||||
const baseAsset: AssetItem = {
|
||||
id: 'asset-1',
|
||||
name: '',
|
||||
tags: ['input'],
|
||||
size: 1024
|
||||
}
|
||||
|
||||
describe('getMediaDisplayName', () => {
|
||||
beforeEach(() => {
|
||||
mockInputAssetsByFilename.clear()
|
||||
})
|
||||
|
||||
it('returns the input string when no matching asset is in the store (OSS pass-through)', () => {
|
||||
expect(getMediaDisplayName('sunset.png')).toBe('sunset.png')
|
||||
})
|
||||
|
||||
it('returns display_name when the matched asset carries one (Cloud unified shape)', () => {
|
||||
const hash = 'blake3:abc1234567890def.png'
|
||||
mockInputAssetsByFilename.set(hash, {
|
||||
...baseAsset,
|
||||
name: hash,
|
||||
asset_hash: hash,
|
||||
display_name: 'sunset.png'
|
||||
})
|
||||
expect(getMediaDisplayName(hash)).toBe('sunset.png')
|
||||
})
|
||||
|
||||
it('falls back to asset.name when display_name is absent (legacy Cloud asset)', () => {
|
||||
const hash = 'blake3:def4567890abc1234.png'
|
||||
mockInputAssetsByFilename.set(hash, {
|
||||
...baseAsset,
|
||||
name: 'beach.png',
|
||||
asset_hash: hash
|
||||
})
|
||||
expect(getMediaDisplayName(hash)).toBe('beach.png')
|
||||
})
|
||||
|
||||
it('prefers metadata.filename over display_name and asset.name (shared helper chain)', () => {
|
||||
const hash = 'blake3:fff1111222.png'
|
||||
mockInputAssetsByFilename.set(hash, {
|
||||
...baseAsset,
|
||||
name: hash,
|
||||
asset_hash: hash,
|
||||
display_name: 'from_display.png',
|
||||
metadata: { filename: 'from_metadata.png' }
|
||||
})
|
||||
expect(getMediaDisplayName(hash)).toBe('from_metadata.png')
|
||||
})
|
||||
|
||||
it('falls back to display_name when filename metadata is absent (Cloud hash-keyed asset)', () => {
|
||||
const hash = 'blake3:aaa2222333.png'
|
||||
mockInputAssetsByFilename.set(hash, {
|
||||
...baseAsset,
|
||||
name: hash,
|
||||
asset_hash: hash,
|
||||
display_name: 'pretty.png'
|
||||
})
|
||||
expect(getMediaDisplayName(hash)).toBe('pretty.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLibraryOptions (integration with getMediaDisplayName)', () => {
|
||||
const makeCandidate = (
|
||||
overrides: Partial<MissingMediaCandidate> = {}
|
||||
): MissingMediaCandidate => ({
|
||||
nodeId: 1,
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'missing.png',
|
||||
isMissing: true,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const makeNode = (widgetType: string = 'combo') => ({
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
type: widgetType,
|
||||
value: '',
|
||||
options: {}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockInputAssetsByFilename.clear()
|
||||
mockGetNodeByExecutionId.mockReset()
|
||||
mockResolveComboValues.mockReset()
|
||||
})
|
||||
|
||||
it('returns empty array when the combo widget cannot be resolved', () => {
|
||||
mockGetNodeByExecutionId.mockReturnValue(null)
|
||||
const { getLibraryOptions } = useMissingMediaInteractions()
|
||||
|
||||
expect(getLibraryOptions(makeCandidate())).toEqual([])
|
||||
expect(mockResolveComboValues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps Cloud hash combo values to display_name via the shared helper chain', () => {
|
||||
const candidateName = 'blake3:missing.png'
|
||||
const hashA = 'blake3:aaa.png'
|
||||
const hashB = 'blake3:bbb.png'
|
||||
mockInputAssetsByFilename.set(hashA, {
|
||||
...baseAsset,
|
||||
name: hashA,
|
||||
asset_hash: hashA,
|
||||
display_name: 'sunset.png'
|
||||
})
|
||||
mockInputAssetsByFilename.set(hashB, {
|
||||
...baseAsset,
|
||||
name: hashB,
|
||||
asset_hash: hashB,
|
||||
metadata: { filename: 'beach.png' }
|
||||
})
|
||||
mockGetNodeByExecutionId.mockReturnValue(makeNode())
|
||||
mockResolveComboValues.mockReturnValue([hashA, hashB, candidateName])
|
||||
|
||||
const { getLibraryOptions } = useMissingMediaInteractions()
|
||||
const options = getLibraryOptions(makeCandidate({ name: candidateName }))
|
||||
|
||||
expect(options).toEqual([
|
||||
{ name: 'sunset.png', value: hashA },
|
||||
{ name: 'beach.png', value: hashB }
|
||||
])
|
||||
})
|
||||
|
||||
it('passes OSS filename combo values through when no matching asset exists', () => {
|
||||
mockGetNodeByExecutionId.mockReturnValue(makeNode())
|
||||
mockResolveComboValues.mockReturnValue([
|
||||
'kitten.png',
|
||||
'puppy.png',
|
||||
'missing.png'
|
||||
])
|
||||
|
||||
const { getLibraryOptions } = useMissingMediaInteractions()
|
||||
const options = getLibraryOptions(makeCandidate({ name: 'missing.png' }))
|
||||
|
||||
expect(options).toEqual([
|
||||
{ name: 'kitten.png', value: 'kitten.png' },
|
||||
{ name: 'puppy.png', value: 'puppy.png' }
|
||||
])
|
||||
})
|
||||
|
||||
it('filters out the candidate name from the alternatives list', () => {
|
||||
mockGetNodeByExecutionId.mockReturnValue(makeNode())
|
||||
mockResolveComboValues.mockReturnValue([
|
||||
'other.png',
|
||||
'missing.png',
|
||||
'extra.png'
|
||||
])
|
||||
|
||||
const { getLibraryOptions } = useMissingMediaInteractions()
|
||||
const options = getLibraryOptions(makeCandidate({ name: 'missing.png' }))
|
||||
|
||||
expect(options.map((o) => o.value)).toEqual(['other.png', 'extra.png'])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { getAssetDisplayFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type {
|
||||
@@ -8,7 +9,6 @@ import type {
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
@@ -85,13 +85,13 @@ export function getNodeDisplayLabel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve display name for a media file.
|
||||
* Cloud widgets store asset hashes as values; this resolves them to
|
||||
* human-readable names via assetsStore.getInputName().
|
||||
* Resolve a media widget value (hash or filename) to a display label via the
|
||||
* shared {@link getAssetDisplayFilename} fallback chain. Returns the input
|
||||
* unchanged when no asset matches.
|
||||
*/
|
||||
export function getMediaDisplayName(name: string): string {
|
||||
if (!isCloud) return name
|
||||
return useAssetsStore().getInputName(name)
|
||||
const asset = useAssetsStore().inputAssetsByFilename.get(name)
|
||||
return asset ? getAssetDisplayFilename(asset) : name
|
||||
}
|
||||
|
||||
export function useMissingMediaInteractions() {
|
||||
|
||||
@@ -20,6 +20,18 @@ const { mockFetchHistoryPage } = vi.hoisted(() => ({
|
||||
mockFetchHistoryPage: vi.fn()
|
||||
}))
|
||||
|
||||
// Mutable holder so each test can flip the runtime `isCloud` to drive the
|
||||
// resolver's generated-assets oracle selection (Cloud /api/assets vs OSS
|
||||
// job history). The named-import binding into the resolver re-reads the
|
||||
// getter on each access (ESM live binding semantics).
|
||||
const isCloudHolder = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return isCloudHolder.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', async () => {
|
||||
const actual = await vi.importActual<typeof AssetServiceModule>(
|
||||
'@/platform/assets/services/assetService'
|
||||
@@ -102,17 +114,17 @@ function makeAssetPage(
|
||||
describe('resolveMissingMediaAssetSources', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isCloudHolder.value = false
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
|
||||
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
|
||||
mockFetchHistoryPage.mockResolvedValue(makeHistoryPage([]))
|
||||
})
|
||||
|
||||
it('loads cloud input assets when requested', async () => {
|
||||
it('loads input assets from the unified listing on both backends', async () => {
|
||||
const inputAsset = makeAsset('photo.png')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue([inputAsset])
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: false,
|
||||
generatedMatchNames: new Set(),
|
||||
allowCompactSuffix: true
|
||||
@@ -127,11 +139,11 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
})
|
||||
|
||||
it('loads cloud output assets by tag when generated candidates need verification', async () => {
|
||||
isCloudHolder.value = true
|
||||
const outputAsset = makeAsset('output.png')
|
||||
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([outputAsset]))
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set(['output.png']),
|
||||
allowCompactSuffix: true
|
||||
@@ -151,13 +163,13 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
})
|
||||
|
||||
it('stops reading cloud output asset pages once all requested names are found', async () => {
|
||||
isCloudHolder.value = true
|
||||
const target = 'target-output.png'
|
||||
mockGetAssetsPageByTag.mockResolvedValueOnce(
|
||||
makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
|
||||
)
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([target]),
|
||||
allowCompactSuffix: true
|
||||
@@ -167,43 +179,42 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('aborts cloud output asset loading when input asset loading fails', async () => {
|
||||
const inputError = new Error('input failed')
|
||||
let rejectInputAssets!: (err: Error) => void
|
||||
let resolveOutputAssets!: (page: ReturnType<typeof makeAssetPage>) => void
|
||||
mockGetInputAssetsIncludingPublic.mockReturnValueOnce(
|
||||
new Promise<AssetItem[]>((_, reject) => {
|
||||
rejectInputAssets = reject
|
||||
})
|
||||
)
|
||||
mockGetAssetsPageByTag.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOutputAssets = resolve
|
||||
})
|
||||
it('returns empty inputAssets and keeps generated fetch alive when input fails (soft degrade)', async () => {
|
||||
isCloudHolder.value = true
|
||||
const inputError = new Error('GET /api/assets 404')
|
||||
mockGetInputAssetsIncludingPublic.mockRejectedValueOnce(inputError)
|
||||
mockGetAssetsPageByTag.mockResolvedValueOnce(
|
||||
makeAssetPage([makeAsset('survivor.png')])
|
||||
)
|
||||
|
||||
const promise = resolveMissingMediaAssetSources({
|
||||
isCloud: true,
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set(['target.png']),
|
||||
generatedMatchNames: new Set(['survivor.png']),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
|
||||
|
||||
rejectInputAssets(inputError)
|
||||
await expect(promise).rejects.toBe(inputError)
|
||||
|
||||
resolveOutputAssets(makeAssetPage([makeAsset('other.png')]))
|
||||
await Promise.resolve()
|
||||
|
||||
const outputSignal = mockGetAssetsPageByTag.mock.calls[0]?.[2]?.signal
|
||||
expect(outputSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(outputSignal.aborted).toBe(true)
|
||||
// Input oracle failed: degrade to empty. Generated oracle is independent
|
||||
// and must keep running so output candidates can still verify.
|
||||
expect(result.inputAssets).toEqual([])
|
||||
expect(result.generatedAssets).toEqual([makeAsset('survivor.png')])
|
||||
expect(mockFetchHistoryPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty generatedAssets when history fetch fails but inputs succeed', async () => {
|
||||
const inputAsset = makeAsset('local-photo.png')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValueOnce([inputAsset])
|
||||
mockFetchHistoryPage.mockRejectedValueOnce(new Error('500 history'))
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set(['rendered.png']),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(result.inputAssets).toEqual([inputAsset])
|
||||
expect(result.generatedAssets).toEqual([])
|
||||
})
|
||||
|
||||
it('stops reading generated history once all requested names are found', async () => {
|
||||
const target = 'target.png'
|
||||
mockFetchHistoryPage.mockResolvedValueOnce(
|
||||
@@ -214,7 +225,6 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
)
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([target]),
|
||||
allowCompactSuffix: true
|
||||
@@ -245,7 +255,6 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
)
|
||||
|
||||
await resolveMissingMediaAssetSources({
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([target]),
|
||||
allowCompactSuffix: true
|
||||
@@ -271,7 +280,6 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
)
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set(['missing.png']),
|
||||
allowCompactSuffix: true
|
||||
@@ -292,7 +300,6 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
)
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set(['missing.png']),
|
||||
allowCompactSuffix: true
|
||||
@@ -301,8 +308,69 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
expect(result.generatedAssets).toHaveLength(1)
|
||||
expect(mockFetchHistoryPage).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('includes slash and backslash subfolder identifiers for detection', () => {
|
||||
describe('getAssetDetectionNames', () => {
|
||||
it('unions file_path with legacy keys so deprecation-window widget values keep matching', () => {
|
||||
const names = getAssetDetectionNames(
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'legacy.png',
|
||||
asset_hash: 'blake3:abc',
|
||||
file_path: 'input/sub/photo.png',
|
||||
mime_type: null,
|
||||
tags: ['input'],
|
||||
user_metadata: { subfolder: 'old-subfolder' }
|
||||
},
|
||||
{ allowCompactSuffix: true }
|
||||
)
|
||||
|
||||
// A widget value in any of these legacy shapes (or the new file_path
|
||||
// shape) must match — BE-808 RFC §4 says file_path is a locator, not the
|
||||
// identity, and workflow widget values do not auto-upgrade.
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'input/sub/photo.png',
|
||||
'blake3:abc',
|
||||
'legacy.png',
|
||||
'old-subfolder/legacy.png'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the legacy union when file_path is null', () => {
|
||||
const names = getAssetDetectionNames(
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'legacy.png',
|
||||
asset_hash: 'blake3:abc',
|
||||
file_path: null,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
},
|
||||
{ allowCompactSuffix: true }
|
||||
)
|
||||
|
||||
expect(names).toEqual(expect.arrayContaining(['legacy.png', 'blake3:abc']))
|
||||
})
|
||||
|
||||
it('returns an empty list when file_path, asset_hash, and name are all absent', () => {
|
||||
const names = getAssetDetectionNames(
|
||||
{
|
||||
id: 'a1',
|
||||
name: '',
|
||||
asset_hash: null,
|
||||
file_path: null,
|
||||
mime_type: null,
|
||||
tags: []
|
||||
},
|
||||
{ allowCompactSuffix: true }
|
||||
)
|
||||
|
||||
expect(names).toEqual([])
|
||||
})
|
||||
|
||||
it('includes slash and backslash subfolder identifiers when file_path is null', () => {
|
||||
const names = getAssetDetectionNames(
|
||||
{
|
||||
...makeAsset('child\\photo.png', 'hash.png'),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { fetchHistoryPage } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getFilePathSeparatorVariants, joinFilePath } from '@/utils/formatUtil'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
import { getMediaPathDetectionNames } from './mediaPathDetectionUtil'
|
||||
|
||||
const HISTORY_MEDIA_ASSETS_PAGE_SIZE = 200
|
||||
@@ -20,7 +22,6 @@ export interface MissingMediaAssetSources {
|
||||
|
||||
export interface ResolveMissingMediaAssetSourcesOptions {
|
||||
signal?: AbortSignal
|
||||
isCloud: boolean
|
||||
includeGeneratedAssets: boolean
|
||||
generatedMatchNames: ReadonlySet<string>
|
||||
allowCompactSuffix: boolean
|
||||
@@ -32,7 +33,6 @@ export type MissingMediaAssetResolver = (
|
||||
|
||||
export async function resolveMissingMediaAssetSources({
|
||||
signal,
|
||||
isCloud,
|
||||
includeGeneratedAssets,
|
||||
generatedMatchNames,
|
||||
allowCompactSuffix
|
||||
@@ -48,43 +48,73 @@ export async function resolveMissingMediaAssetSources({
|
||||
}
|
||||
|
||||
try {
|
||||
const [inputAssets, generatedAssets] = await Promise.all([
|
||||
abortSiblingsOnFailure(
|
||||
isCloud
|
||||
? assetService.getInputAssetsIncludingPublic(controller.signal)
|
||||
: Promise.resolve<AssetItem[]>([]),
|
||||
controller
|
||||
),
|
||||
abortSiblingsOnFailure(
|
||||
includeGeneratedAssets
|
||||
? fetchGeneratedAssets(controller.signal, {
|
||||
isCloud,
|
||||
generatedMatchNames,
|
||||
pathOptions
|
||||
})
|
||||
: Promise.resolve<AssetItem[]>([]),
|
||||
controller
|
||||
)
|
||||
// Input assets (`/api/assets`) and generated assets (Cloud asset API or
|
||||
// OSS `/history`) are independent oracles. Use `allSettled` so a failure
|
||||
// in one — e.g. `/api/assets` 404 on a pre-BE-786 OSS instance, or zod
|
||||
// schema skew during a BE-934 partial deploy — doesn't take down the
|
||||
// other path. Each branch soft-degrades to an empty list; the caller
|
||||
// then marks affected candidates missing instead of swallowing the
|
||||
// whole verification with a toast.
|
||||
const [inputResult, generatedResult] = await Promise.allSettled([
|
||||
assetService.getInputAssetsIncludingPublic(controller.signal),
|
||||
includeGeneratedAssets
|
||||
? fetchGeneratedAssets(controller.signal, {
|
||||
generatedMatchNames,
|
||||
pathOptions
|
||||
})
|
||||
: Promise.resolve<AssetItem[]>([])
|
||||
])
|
||||
|
||||
return { inputAssets, generatedAssets }
|
||||
return {
|
||||
inputAssets: unwrapAssetFetchResult(inputResult, 'inputAssets'),
|
||||
generatedAssets: unwrapAssetFetchResult(
|
||||
generatedResult,
|
||||
'generatedAssets'
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abortFromCaller)
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapAssetFetchResult(
|
||||
result: PromiseSettledResult<AssetItem[]>,
|
||||
label: 'inputAssets' | 'generatedAssets'
|
||||
): AssetItem[] {
|
||||
if (result.status === 'fulfilled') return result.value
|
||||
if (isAbortError(result.reason)) return []
|
||||
console.warn(
|
||||
`[missingMedia] ${label} fetch failed; degrading to empty list.`,
|
||||
result.reason
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
interface FetchGeneratedAssetsOptions {
|
||||
isCloud: boolean
|
||||
generatedMatchNames: ReadonlySet<string>
|
||||
pathOptions: MediaPathDetectionOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive comparison keys for matching workflow widget values against an asset.
|
||||
*
|
||||
* Per RFC BE-808 v2 (Asset Identity Semantics), `id` is the identity field;
|
||||
* `file_path` is a namespace-rooted locator/display string emitted on a
|
||||
* BEST EFFORT basis by BE-933 / BE-934. Workflow widget values predate the
|
||||
* `file_path` rollout and may still be bare filenames, hashes, or annotated
|
||||
* paths, so detection keys union `file_path`, `asset_hash`, `name`, and
|
||||
* `subfolder + name` variants — a widget value in any of those legacy
|
||||
* shapes must keep matching once an asset starts emitting `file_path`.
|
||||
* Both backends round-trip `name` through the BE-792 deprecation window,
|
||||
* so the legacy keys stay valid.
|
||||
*/
|
||||
export function getAssetDetectionNames(
|
||||
asset: AssetItem,
|
||||
options: MediaPathDetectionOptions
|
||||
): string[] {
|
||||
const names = new Set<string>()
|
||||
// Treat names and hashes as opaque match keys because Cloud may use either in widget values.
|
||||
|
||||
addPathDetectionNames(names, asset.file_path, options)
|
||||
addPathDetectionNames(names, asset.asset_hash, options)
|
||||
addPathDetectionNames(names, asset.name, options)
|
||||
|
||||
@@ -96,9 +126,16 @@ export function getAssetDetectionNames(
|
||||
return Array.from(names)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the generated-assets oracle by runtime. Cloud queries
|
||||
* `/api/assets?include_tags=output`; Core synthesizes `AssetItem` shells
|
||||
* from job-execution history because OSS does not auto-register output
|
||||
* files as assets (pre-BE-786). Unifying this oracle is a separate
|
||||
* concern — track as a follow-up to FE-746.
|
||||
*/
|
||||
async function fetchGeneratedAssets(
|
||||
signal: AbortSignal | undefined,
|
||||
{ isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
|
||||
{ generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
|
||||
): Promise<AssetItem[]> {
|
||||
if (isCloud) {
|
||||
return await fetchCloudGeneratedAssets(
|
||||
@@ -212,18 +249,6 @@ async function fetchGeneratedHistoryAssets(
|
||||
}
|
||||
}
|
||||
|
||||
async function abortSiblingsOnFailure<T>(
|
||||
promise: Promise<T>,
|
||||
controller: AbortController
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await promise
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) controller.abort(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function addPathDetectionNames(
|
||||
names: Set<string>,
|
||||
value: string | null | undefined,
|
||||
|
||||
@@ -28,6 +28,17 @@ const { mockFetchHistoryPage } = vi.hoisted(() => ({
|
||||
mockFetchHistoryPage: vi.fn()
|
||||
}))
|
||||
|
||||
// Mutable runtime `isCloud` holder for tests that exercise the default
|
||||
// resolver's generated-assets oracle (Cloud /api/assets vs OSS history).
|
||||
// Tests with their own `resolveAssetSources` mock can ignore this.
|
||||
const isCloudHolder = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return isCloudHolder.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
|
||||
getExecutionIdByNode: (
|
||||
@@ -159,7 +170,7 @@ function makeHistoryJob(
|
||||
}
|
||||
|
||||
describe('scanNodeMediaCandidates', () => {
|
||||
it('returns candidate for a LoadImage node with missing image', () => {
|
||||
it('returns a candidate for a LoadImage node and defers missingness to the verifier', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
@@ -168,17 +179,18 @@ describe('scanNodeMediaCandidates', () => {
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
const result = scanNodeMediaCandidates(graph, node)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo.png',
|
||||
isMissing: true
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty for non-media node types', () => {
|
||||
@@ -190,39 +202,30 @@ describe('scanNodeMediaCandidates', () => {
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(scanNodeMediaCandidates(graph, node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty for node with no widgets', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(1, 'LoadImage', [], 0)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(scanNodeMediaCandidates(graph, node)).toEqual([])
|
||||
})
|
||||
|
||||
it.for([false, true])(
|
||||
'returns empty while a media upload is pending on the node (isCloud: %s)',
|
||||
(isCloud) => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadVideo',
|
||||
[makeMediaCombo('file', 'clip.mp4', [])],
|
||||
0
|
||||
)
|
||||
node.isUploading = true
|
||||
it('returns empty while a media upload is pending on the node', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadVideo',
|
||||
[makeMediaCombo('file', 'clip.mp4', [])],
|
||||
0
|
||||
)
|
||||
node.isUploading = true
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, isCloud)
|
||||
expect(scanNodeMediaCandidates(graph, node)).toEqual([])
|
||||
})
|
||||
|
||||
expect(result).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('detects missing media again after upload state clears', () => {
|
||||
it('emits the candidate again after upload state clears', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
@@ -232,16 +235,16 @@ describe('scanNodeMediaCandidates', () => {
|
||||
)
|
||||
|
||||
node.isUploading = true
|
||||
expect(scanNodeMediaCandidates(graph, node, false)).toEqual([])
|
||||
expect(scanNodeMediaCandidates(graph, node)).toEqual([])
|
||||
|
||||
node.isUploading = false
|
||||
expect(scanNodeMediaCandidates(graph, node, false)).toEqual([
|
||||
expect(scanNodeMediaCandidates(graph, node)).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
name: 'clip.mp4',
|
||||
isMissing: true
|
||||
isMissing: undefined
|
||||
})
|
||||
])
|
||||
})
|
||||
@@ -250,126 +253,51 @@ describe('scanNodeMediaCandidates', () => {
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
value: 'photo.png [input]',
|
||||
option: 'photo.png'
|
||||
mediaType: 'image' as const,
|
||||
value: 'photo.png [input]'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadImageMask',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
value: 'mask.png [input]',
|
||||
option: 'mask.png'
|
||||
mediaType: 'image' as const,
|
||||
value: 'mask.png [input]'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
value: 'clip.mp4 [input]',
|
||||
option: 'clip.mp4'
|
||||
mediaType: 'video' as const,
|
||||
value: 'clip.mp4 [input]'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadAudio',
|
||||
widgetName: 'audio',
|
||||
mediaType: 'audio',
|
||||
value: 'sound.wav [input]',
|
||||
option: 'sound.wav'
|
||||
mediaType: 'audio' as const,
|
||||
value: 'sound.wav [input]'
|
||||
}
|
||||
])(
|
||||
'matches annotated $nodeType values against clean OSS options',
|
||||
({ nodeType, widgetName, mediaType, value, option }) => {
|
||||
'passes annotated $nodeType values through unchanged for async verification',
|
||||
({ nodeType, widgetName, mediaType, value }) => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
nodeType,
|
||||
[makeMediaCombo(widgetName, value, [option])],
|
||||
[makeMediaCombo(widgetName, value, [])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
const result = scanNodeMediaCandidates(graph, node)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
nodeType,
|
||||
widgetName,
|
||||
mediaType,
|
||||
name: value,
|
||||
isMissing: false
|
||||
})
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeType,
|
||||
widgetName,
|
||||
mediaType,
|
||||
name: value,
|
||||
isMissing: undefined
|
||||
})
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
value: 'photo.png [output]'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
value: 'clip.mp4 [output]'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadAudio',
|
||||
widgetName: 'audio',
|
||||
value: 'sound.wav [output]'
|
||||
}
|
||||
])(
|
||||
'leaves OSS $nodeType output annotations pending when not in options',
|
||||
({ nodeType, widgetName, value }) => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
nodeType,
|
||||
[makeMediaCombo(widgetName, value, ['other-file.png', value])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
nodeType,
|
||||
widgetName,
|
||||
name: value,
|
||||
isMissing: undefined
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('marks OSS input annotations missing when the clean option is absent', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png [input]', ['other.png'])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'photo.png [input]',
|
||||
isMissing: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat compact Cloud annotations as valid OSS options', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png[input]', ['photo.png'])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'photo.png[input]',
|
||||
isMissing: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanAllMediaCandidates', () => {
|
||||
@@ -380,8 +308,7 @@ describe('scanAllMediaCandidates', () => {
|
||||
[makeMediaCombo('image', 'photo.png', ['other.png'])],
|
||||
2 // NEVER
|
||||
)
|
||||
const result = scanAllMediaCandidates(makeGraph([node]), false)
|
||||
expect(result).toHaveLength(0)
|
||||
expect(scanAllMediaCandidates(makeGraph([node]))).toEqual([])
|
||||
})
|
||||
|
||||
it('skips bypassed nodes (mode === BYPASS)', () => {
|
||||
@@ -391,20 +318,25 @@ describe('scanAllMediaCandidates', () => {
|
||||
[makeMediaCombo('image', 'photo.png', ['other.png'])],
|
||||
4 // BYPASS
|
||||
)
|
||||
const result = scanAllMediaCandidates(makeGraph([node]), false)
|
||||
expect(result).toHaveLength(0)
|
||||
expect(scanAllMediaCandidates(makeGraph([node]))).toEqual([])
|
||||
})
|
||||
|
||||
it('includes active nodes (mode === ALWAYS)', () => {
|
||||
it('includes active nodes (mode === ALWAYS) with isMissing deferred to the verifier', () => {
|
||||
const node = makeMediaNode(
|
||||
3,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png', ['other.png'])],
|
||||
0 // ALWAYS
|
||||
)
|
||||
const result = scanAllMediaCandidates(makeGraph([node]), false)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].isMissing).toBe(true)
|
||||
const result = scanAllMediaCandidates(makeGraph([node]))
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
name: 'photo.png',
|
||||
isMissing: undefined
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -494,6 +426,7 @@ describe('verifyMediaCandidates', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isCloudHolder.value = false
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
|
||||
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
|
||||
mockFetchHistoryPage.mockResolvedValue({
|
||||
@@ -516,7 +449,7 @@ describe('verifyMediaCandidates', () => {
|
||||
])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
@@ -525,7 +458,6 @@ describe('verifyMediaCandidates', () => {
|
||||
expect(candidates[2].isMissing).toBe(true)
|
||||
expect(resolveAssetSources).toHaveBeenCalledWith({
|
||||
signal: undefined,
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: false,
|
||||
generatedMatchNames: new Set(),
|
||||
allowCompactSuffix: true
|
||||
@@ -542,7 +474,7 @@ describe('verifyMediaCandidates', () => {
|
||||
])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
@@ -550,6 +482,58 @@ describe('verifyMediaCandidates', () => {
|
||||
expect(candidates[1].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('matches widget values against file_path when the asset emits it (post BE-933 / BE-934)', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'input/sub/photo.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'input/sub/missing.png', { isMissing: undefined })
|
||||
]
|
||||
const assetWithFilePath: AssetItem = {
|
||||
id: 'asset-1',
|
||||
// Legacy `name` and `asset_hash` deliberately diverge from the
|
||||
// widget value; `file_path` is the sole reason the match succeeds.
|
||||
name: 'unrelated.png',
|
||||
asset_hash: 'blake3:abc',
|
||||
file_path: 'input/sub/photo.png',
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
const resolveAssetSources = makeAssetResolver([assetWithFilePath])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(candidates[1].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('matches a bare-filename widget value against a file_path-emitting asset (BE-808 deprecation window)', async () => {
|
||||
// Pre-BE-933/934 workflow: widget value is the bare filename the user
|
||||
// originally picked. Post-BE-933/934 asset: emits a namespace-rooted
|
||||
// `file_path`. The two shapes must still match — workflow JSON does
|
||||
// not auto-upgrade when the backend response shape changes.
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
const assetPostBE: AssetItem = {
|
||||
id: 'asset-1',
|
||||
name: 'photo.png',
|
||||
asset_hash: null,
|
||||
file_path: 'input/sub/photo.png',
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
const resolveAssetSources = makeAssetResolver([assetPostBE])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('matches annotated candidate names against clean asset names', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png [input]', { isMissing: undefined }),
|
||||
@@ -572,7 +556,7 @@ describe('verifyMediaCandidates', () => {
|
||||
)
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
@@ -610,13 +594,12 @@ describe('verifyMediaCandidates', () => {
|
||||
)
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(resolveAssetSources).toHaveBeenCalledWith({
|
||||
signal: undefined,
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
@@ -636,7 +619,7 @@ describe('verifyMediaCandidates', () => {
|
||||
const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
@@ -650,14 +633,14 @@ describe('verifyMediaCandidates', () => {
|
||||
const resolveAssetSources = makeAssetResolver([], [makeAsset('photo.png')])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('verifies OSS output candidates against generated history without cloud assets', async () => {
|
||||
it('verifies OSS output candidates against generated history alongside the unified input listing', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'subfolder/photo.png [output]', {
|
||||
isMissing: undefined
|
||||
@@ -672,9 +655,11 @@ describe('verifyMediaCandidates', () => {
|
||||
hasMore: false
|
||||
})
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: false })
|
||||
await verifyMediaCandidates(candidates, { allowCompactSuffix: false })
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mockFetchHistoryPage).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
200,
|
||||
@@ -693,13 +678,12 @@ describe('verifyMediaCandidates', () => {
|
||||
const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: false,
|
||||
allowCompactSuffix: false,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(resolveAssetSources).toHaveBeenCalledWith({
|
||||
signal: undefined,
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: false,
|
||||
generatedMatchNames: new Set(),
|
||||
allowCompactSuffix: false
|
||||
@@ -717,7 +701,7 @@ describe('verifyMediaCandidates', () => {
|
||||
)
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
@@ -730,7 +714,7 @@ describe('verifyMediaCandidates', () => {
|
||||
]
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
resolveAssetSources: makeAssetResolver([])
|
||||
})
|
||||
|
||||
@@ -745,7 +729,7 @@ describe('verifyMediaCandidates', () => {
|
||||
makeAsset('stored-photo.png', existingHash)
|
||||
])
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
@@ -755,6 +739,7 @@ describe('verifyMediaCandidates', () => {
|
||||
})
|
||||
|
||||
it('reads cloud output assets by tag for output candidates', async () => {
|
||||
isCloudHolder.value = true
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const candidates = [
|
||||
@@ -764,7 +749,7 @@ describe('verifyMediaCandidates', () => {
|
||||
makeAssetPage([makeAsset(outputHash)])
|
||||
)
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
expect.any(AbortSignal)
|
||||
@@ -806,7 +791,7 @@ describe('verifyMediaCandidates', () => {
|
||||
hasMore: false
|
||||
})
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: false })
|
||||
await verifyMediaCandidates(candidates, { allowCompactSuffix: false })
|
||||
|
||||
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -839,7 +824,7 @@ describe('verifyMediaCandidates', () => {
|
||||
hasMore: false
|
||||
})
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: false })
|
||||
await verifyMediaCandidates(candidates, { allowCompactSuffix: false })
|
||||
|
||||
expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
@@ -854,7 +839,7 @@ describe('verifyMediaCandidates', () => {
|
||||
]
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
@@ -876,7 +861,7 @@ describe('verifyMediaCandidates', () => {
|
||||
})
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
signal: controller.signal,
|
||||
resolveAssetSources
|
||||
})
|
||||
@@ -887,7 +872,7 @@ describe('verifyMediaCandidates', () => {
|
||||
it('skips candidates already resolved as true', async () => {
|
||||
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
@@ -896,7 +881,7 @@ describe('verifyMediaCandidates', () => {
|
||||
it('skips candidates already resolved as false', async () => {
|
||||
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
@@ -905,7 +890,7 @@ describe('verifyMediaCandidates', () => {
|
||||
it('skips entirely when no pending candidates', async () => {
|
||||
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -920,7 +905,7 @@ describe('verifyMediaCandidates', () => {
|
||||
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
expect.any(AbortSignal)
|
||||
@@ -942,7 +927,7 @@ describe('verifyMediaCandidates', () => {
|
||||
|
||||
await expect(
|
||||
verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
signal: controller.signal,
|
||||
resolveAssetSources
|
||||
})
|
||||
@@ -969,7 +954,7 @@ describe('verifyMediaCandidates', () => {
|
||||
|
||||
await expect(
|
||||
verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
allowCompactSuffix: true,
|
||||
signal: controller.signal
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
import {
|
||||
@@ -49,13 +48,13 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
/**
|
||||
* Scan combo widgets on media nodes for file values that may be missing.
|
||||
*
|
||||
* OSS: `isMissing` is resolved immediately via widget options unless an
|
||||
* output annotation needs generated-history verification.
|
||||
* Cloud: `isMissing` left `undefined` for async verification.
|
||||
* Candidates leave `isMissing` as `undefined`; resolution happens
|
||||
* asynchronously in `verifyMediaCandidates` against the unified asset
|
||||
* listing. Both backends consult the same oracle (per RFC BE-808 v2 /
|
||||
* BE-933 + BE-934).
|
||||
*/
|
||||
export function scanAllMediaCandidates(
|
||||
rootGraph: LGraph,
|
||||
isCloud: boolean
|
||||
rootGraph: LGraph
|
||||
): MissingMediaCandidate[] {
|
||||
if (!rootGraph) return []
|
||||
|
||||
@@ -71,17 +70,16 @@ export function scanAllMediaCandidates(
|
||||
)
|
||||
continue
|
||||
|
||||
candidates.push(...scanNodeMediaCandidates(rootGraph, node, isCloud))
|
||||
candidates.push(...scanNodeMediaCandidates(rootGraph, node))
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
/** Scan a single node for missing media candidates (OSS immediate resolution). */
|
||||
/** Scan a single node for missing media candidates (async resolution). */
|
||||
export function scanNodeMediaCandidates(
|
||||
rootGraph: LGraph,
|
||||
node: LGraphNode,
|
||||
isCloud: boolean
|
||||
node: LGraphNode
|
||||
): MissingMediaCandidate[] {
|
||||
if (!node.widgets?.length) return []
|
||||
|
||||
@@ -100,30 +98,13 @@ export function scanNodeMediaCandidates(
|
||||
const value = widget.value
|
||||
if (typeof value !== 'string' || !value.trim()) continue
|
||||
|
||||
let isMissing: boolean | undefined
|
||||
if (isCloud) {
|
||||
isMissing = undefined
|
||||
} else {
|
||||
const type = getAnnotatedMediaPathTypeForDetection(value)
|
||||
if (type === 'output') {
|
||||
isMissing = undefined
|
||||
} else {
|
||||
const options = resolveComboValues(widget)
|
||||
const detectionNames = getMediaPathDetectionNames(value)
|
||||
const existsInOptions = detectionNames.some((name) =>
|
||||
options.includes(name)
|
||||
)
|
||||
isMissing = !existsInOptions
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
nodeId: executionId as NodeId,
|
||||
nodeType: node.type,
|
||||
widgetName: widget.name,
|
||||
mediaType: mediaInfo.mediaType,
|
||||
name: value,
|
||||
isMissing
|
||||
isMissing: undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,27 +112,32 @@ export function scanNodeMediaCandidates(
|
||||
}
|
||||
|
||||
interface MediaVerificationOptions {
|
||||
isCloud: boolean
|
||||
/**
|
||||
* Whether to accept compact `file.png[input]` suffix annotations in
|
||||
* addition to the canonical spaced `file.png [input]` form. Cloud emits
|
||||
* compact annotations on legacy widget values. Tracked as N1 in the
|
||||
* RFC; retained until widget values stop being filenames.
|
||||
*/
|
||||
allowCompactSuffix: boolean
|
||||
signal?: AbortSignal
|
||||
resolveAssetSources?: MissingMediaAssetResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify media candidates against assets available to the current runtime.
|
||||
* Verify media candidates against the unified asset listing.
|
||||
*
|
||||
* A candidate's `name` may be either a filename or an opaque asset hash.
|
||||
* Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
|
||||
* match against the union of `asset.name` and `asset.asset_hash`. Output
|
||||
* candidates are matched against Cloud output assets or Core generated-history
|
||||
* assets because Core resolves those annotations against output folders, not
|
||||
* input files.
|
||||
* Cloud accepts compact annotated media paths, so only Cloud verification
|
||||
* normalizes compact suffixes.
|
||||
* A candidate's `name` is the widget-value string (filename or annotated
|
||||
* path). It is matched against each asset's `file_path` (canonical key
|
||||
* per RFC BE-808 v2) and, for assets where `file_path` is null
|
||||
* (hash-only Core registrations, tagless Cloud rows, legacy data), the
|
||||
* legacy union of `asset_hash` / `name` / `subfolder + name`. Output
|
||||
* candidates match against generated assets; everything else against
|
||||
* input assets.
|
||||
*/
|
||||
export async function verifyMediaCandidates(
|
||||
candidates: MissingMediaCandidate[],
|
||||
{
|
||||
isCloud,
|
||||
allowCompactSuffix,
|
||||
signal,
|
||||
resolveAssetSources = resolveMissingMediaAssetSources
|
||||
}: MediaVerificationOptions
|
||||
@@ -161,9 +147,7 @@ export async function verifyMediaCandidates(
|
||||
const pending = candidates.filter((c) => c.isMissing === undefined)
|
||||
if (pending.length === 0) return
|
||||
|
||||
// Core stores spaced annotations such as `file.png [output]`; Cloud also
|
||||
// accepts compact forms such as `file.png[output]`.
|
||||
const pathOptions = { allowCompactSuffix: isCloud }
|
||||
const pathOptions = { allowCompactSuffix }
|
||||
const generatedMatchNames = getGeneratedCandidateMatchNames(
|
||||
pending,
|
||||
pathOptions
|
||||
@@ -174,10 +158,9 @@ export async function verifyMediaCandidates(
|
||||
try {
|
||||
const assetSources = await resolveAssetSources({
|
||||
signal,
|
||||
isCloud,
|
||||
includeGeneratedAssets: generatedMatchNames.size > 0,
|
||||
generatedMatchNames,
|
||||
allowCompactSuffix: isCloud
|
||||
allowCompactSuffix
|
||||
})
|
||||
inputAssets = assetSources.inputAssets
|
||||
generatedAssets = assetSources.generatedAssets
|
||||
|
||||
@@ -1218,14 +1218,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: true,
|
||||
versionAdded: '1.30.3'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Assets.UseAssetAPI',
|
||||
name: 'Use Asset API for model library',
|
||||
type: 'hidden',
|
||||
tooltip: 'Use new Asset API for model browsing',
|
||||
defaultValue: isCloud ? true : false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.VersionCompatibility.DisableWarnings',
|
||||
name: 'Disable version compatibility warnings',
|
||||
|
||||
@@ -18,8 +18,7 @@ const flushPromises = () =>
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
shouldUseAssetBrowser: vi.fn(() => true),
|
||||
isAssetAPIEnabled: vi.fn(() => true)
|
||||
shouldUseAssetBrowser: vi.fn(() => true)
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -17,12 +17,10 @@ const i18n = createI18n({
|
||||
|
||||
// Mock state for asset service
|
||||
const mockShouldUseAssetBrowser = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
shouldUseAssetBrowser: mockShouldUseAssetBrowser,
|
||||
isAssetAPIEnabled: mockIsAssetAPIEnabled
|
||||
shouldUseAssetBrowser: mockShouldUseAssetBrowser
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -68,7 +66,6 @@ const globalConfig = {
|
||||
describe('WidgetSelect Value Binding', () => {
|
||||
beforeEach(() => {
|
||||
mockShouldUseAssetBrowser.mockReturnValue(false)
|
||||
mockIsAssetAPIEnabled.mockReturnValue(false)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ const specDescriptor = computed<{
|
||||
const isAssetMode = computed(
|
||||
() =>
|
||||
assetService.shouldUseAssetBrowser(props.nodeType, props.widget.name) ||
|
||||
(assetService.isAssetAPIEnabled() && props.widget.type === 'asset')
|
||||
props.widget.type === 'asset'
|
||||
)
|
||||
|
||||
const assetKind = computed(() => specDescriptor.value.kind)
|
||||
|
||||
@@ -12,14 +12,12 @@ import { AssetKindKey } from './types'
|
||||
import type { FormDropdownMenuItemProps } from './types'
|
||||
|
||||
const mockFindServerPreviewUrl = vi.hoisted(() => vi.fn())
|
||||
const mockIsAssetPreviewSupported = vi.hoisted(() => vi.fn(() => true))
|
||||
const intersectionCallbacks = vi.hoisted(
|
||||
() => [] as Array<(entries: Array<{ isIntersecting: boolean }>) => void>
|
||||
)
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
|
||||
findServerPreviewUrl: (name: string) => mockFindServerPreviewUrl(name),
|
||||
isAssetPreviewSupported: () => mockIsAssetPreviewSupported()
|
||||
findServerPreviewUrl: (name: string) => mockFindServerPreviewUrl(name)
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
@@ -83,7 +81,6 @@ describe('FormDropdownMenuItem', () => {
|
||||
beforeEach(() => {
|
||||
intersectionCallbacks.length = 0
|
||||
mockFindServerPreviewUrl.mockReset()
|
||||
mockIsAssetPreviewSupported.mockReset().mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('Label and name', () => {
|
||||
@@ -167,14 +164,6 @@ describe('FormDropdownMenuItem', () => {
|
||||
expect(img.getAttribute('src')).toBe('/api/preview/resolved.png')
|
||||
})
|
||||
|
||||
it('skips lookup when asset preview is unsupported', async () => {
|
||||
mockIsAssetPreviewSupported.mockReturnValue(false)
|
||||
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
|
||||
fireIntersection(true)
|
||||
await flushPromises()
|
||||
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only looks up once for repeated intersection events', async () => {
|
||||
mockFindServerPreviewUrl.mockResolvedValue(null)
|
||||
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
|
||||
|
||||
@@ -5,10 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import {
|
||||
findServerPreviewUrl,
|
||||
isAssetPreviewSupported
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import { findServerPreviewUrl } from '@/platform/assets/utils/assetPreviewUtil'
|
||||
|
||||
import { AssetKindKey } from './types'
|
||||
import type { FormDropdownMenuItemProps } from './types'
|
||||
@@ -40,7 +37,6 @@ function toLookupName(name: string): string {
|
||||
}
|
||||
|
||||
async function resolveMeshPreview() {
|
||||
if (!isAssetPreviewSupported()) return
|
||||
const url = await findServerPreviewUrl(toLookupName(props.name))
|
||||
if (url) resolvedMeshPreview.value = url
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const mockUpdateModelsForNodeType = vi.fn()
|
||||
const mockGetCategoryForNodeType = vi.fn()
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
getAssets: () => [],
|
||||
isModelLoading: () => false,
|
||||
getError: () => undefined,
|
||||
hasAssetKey: () => false,
|
||||
updateModelsForNodeType: mockUpdateModelsForNodeType
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getCategoryForNodeType: mockGetCategoryForNodeType
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
|
||||
it('returns empty/default values without calling stores', () => {
|
||||
const nodeType = ref('CheckpointLoaderSimple')
|
||||
const { category, assets, isLoading, error } = useAssetWidgetData(nodeType)
|
||||
|
||||
expect(category.value).toBeUndefined()
|
||||
expect(assets.value).toEqual([])
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||
expect(mockGetCategoryForNodeType).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,6 @@ import { nextTick, ref } from 'vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const mockAssetsByKey = new Map<string, AssetItem[]>()
|
||||
const mockLoadingByKey = new Map<string, boolean>()
|
||||
const mockErrorByKey = new Map<string, Error | undefined>()
|
||||
@@ -31,7 +27,7 @@ vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
||||
describe('useAssetWidgetData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAssetsByKey.clear()
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
@@ -11,8 +10,6 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
* Provides reactive asset data based on node type with automatic category detection.
|
||||
* Uses store-based caching to avoid duplicate fetches across multiple instances.
|
||||
*
|
||||
* Cloud-only composable - returns empty data when not in cloud environment.
|
||||
*
|
||||
* @param nodeType - ComfyUI node type (ref, getter, or plain value). Can be undefined.
|
||||
* Accepts: ref('CheckpointLoaderSimple'), () => 'CheckpointLoaderSimple', or 'CheckpointLoaderSimple'
|
||||
* @returns Reactive data including category, assets, dropdown items, loading state, and errors
|
||||
@@ -20,61 +17,52 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
export function useAssetWidgetData(
|
||||
nodeType: MaybeRefOrGetter<string | undefined>
|
||||
) {
|
||||
if (isCloud) {
|
||||
const assetsStore = useAssetsStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
const category = computed(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType
|
||||
? modelToNodeStore.getCategoryForNodeType(resolvedType)
|
||||
: undefined
|
||||
})
|
||||
const category = computed(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType
|
||||
? modelToNodeStore.getCategoryForNodeType(resolvedType)
|
||||
: undefined
|
||||
})
|
||||
|
||||
const assets = computed<AssetItem[]>(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : []
|
||||
})
|
||||
const assets = computed<AssetItem[]>(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : []
|
||||
})
|
||||
|
||||
const isLoading = computed(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType ? assetsStore.isModelLoading(resolvedType) : false
|
||||
})
|
||||
const isLoading = computed(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType ? assetsStore.isModelLoading(resolvedType) : false
|
||||
})
|
||||
|
||||
const error = computed<Error | null>(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
|
||||
})
|
||||
const error = computed<Error | null>(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => toValue(nodeType),
|
||||
async (currentNodeType) => {
|
||||
if (!currentNodeType) {
|
||||
return
|
||||
}
|
||||
watch(
|
||||
() => toValue(nodeType),
|
||||
async (currentNodeType) => {
|
||||
if (!currentNodeType) {
|
||||
return
|
||||
}
|
||||
|
||||
const isLoading = assetsStore.isModelLoading(currentNodeType)
|
||||
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
|
||||
const isLoading = assetsStore.isModelLoading(currentNodeType)
|
||||
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
|
||||
|
||||
if (!isLoading && !hasBeenInitialized) {
|
||||
await assetsStore.updateModelsForNodeType(currentNodeType)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return {
|
||||
category,
|
||||
assets,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
}
|
||||
if (!isLoading && !hasBeenInitialized) {
|
||||
await assetsStore.updateModelsForNodeType(currentNodeType)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return {
|
||||
category: computed(() => undefined),
|
||||
assets: computed<AssetItem[]>(() => []),
|
||||
isLoading: computed(() => false),
|
||||
error: computed(() => null)
|
||||
category,
|
||||
assets,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
}
|
||||
|
||||
// Use vi.hoisted() to ensure mock state is initialized before mocks
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
|
||||
const mockGetAssets = vi.hoisted(() => vi.fn(() => [] as AssetItem[]))
|
||||
@@ -41,12 +40,6 @@ vi.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockDistributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: vi.fn(() => ({
|
||||
get inputAssets() {
|
||||
@@ -147,7 +140,6 @@ describe('useComboWidget', () => {
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(false)
|
||||
vi.mocked(useAssetBrowserDialog).mockClear()
|
||||
mockDistributionState.isCloud = false
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
mockUpdateInputs.mockClear()
|
||||
@@ -174,8 +166,7 @@ describe('useComboWidget', () => {
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create normal combo widget when asset API is disabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
it('should create normal combo widget when asset browser is not eligible', () => {
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(false)
|
||||
|
||||
@@ -201,15 +192,14 @@ describe('useComboWidget', () => {
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
describe('cloud asset browser widget', () => {
|
||||
describe('asset browser widget', () => {
|
||||
// "Select model" is the fallback from t('widgets.selectModel')
|
||||
// in createAssetWidget when defaultValue is undefined.
|
||||
const PLACEHOLDER = 'Select model'
|
||||
|
||||
function setupCloudAssetWidget(
|
||||
function setupAssetBrowserWidget(
|
||||
inputSpecOverrides: Partial<InputSpec> = {}
|
||||
) {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
@@ -238,7 +228,7 @@ describe('useComboWidget', () => {
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
const { mockNode } = setupAssetBrowserWidget({
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
@@ -254,38 +244,37 @@ describe('useComboWidget', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should use first cloud asset as default instead of server combo options', () => {
|
||||
it('should use first asset as default instead of server combo options', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
createMockAssetItem({ name: 'asset_model.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
const { mockNode } = setupAssetBrowserWidget({
|
||||
options: ['local_only_model.safetensors']
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
|
||||
expect(getWidgetDefault(mockNode)).toBe('asset_model.safetensors')
|
||||
})
|
||||
|
||||
it('should fallback to assets[0] when inputSpec.default not in cloud assets', () => {
|
||||
it('should fallback to assets[0] when inputSpec.default not in assets', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
createMockAssetItem({ name: 'asset_model.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
default: 'not_in_cloud.safetensors'
|
||||
const { mockNode } = setupAssetBrowserWidget({
|
||||
default: 'not_in_assets.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
|
||||
expect(getWidgetDefault(mockNode)).toBe('asset_model.safetensors')
|
||||
})
|
||||
|
||||
it('should prefer inputSpec.default when it exists in cloud assets', () => {
|
||||
it('should prefer inputSpec.default when it exists in assets', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'other_model.safetensors' }),
|
||||
createMockAssetItem({ name: 'fallback.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
// Note: no options array provided
|
||||
const { mockNode } = setupAssetBrowserWidget({
|
||||
default: 'fallback.safetensors'
|
||||
})
|
||||
|
||||
@@ -295,18 +284,17 @@ describe('useComboWidget', () => {
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockGetAssets.mockReturnValue([])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
// Note: no options array provided
|
||||
const { mockNode } = setupAssetBrowserWidget({
|
||||
default: 'fallback.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('should fallback to placeholder when cloud assets not loaded', () => {
|
||||
it('should fallback to placeholder when assets not loaded', () => {
|
||||
mockGetAssets.mockReturnValue([])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
const { mockNode } = setupAssetBrowserWidget({
|
||||
options: ['local_model.safetensors']
|
||||
})
|
||||
|
||||
@@ -315,7 +303,6 @@ describe('useComboWidget', () => {
|
||||
})
|
||||
|
||||
it('should show Select model when asset widget has undefined current value', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
@@ -343,7 +330,7 @@ describe('useComboWidget', () => {
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
describe('cloud input asset mapping', () => {
|
||||
describe('input asset mapping', () => {
|
||||
const HASH_FILENAME =
|
||||
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
|
||||
const HASH_FILENAME_2 =
|
||||
@@ -354,10 +341,8 @@ describe('useComboWidget', () => {
|
||||
{ nodeClass: 'LoadVideo', inputName: 'video' },
|
||||
{ nodeClass: 'LoadAudio', inputName: 'audio' }
|
||||
])(
|
||||
'should create combo widget with getOptionLabel for $nodeClass in cloud',
|
||||
'should create combo widget with getOptionLabel for $nodeClass',
|
||||
({ nodeClass, inputName }) => {
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'combo',
|
||||
@@ -387,9 +372,7 @@ describe('useComboWidget', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('should keep the original options object for cloud input mappings', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
it('should keep the original options object for input mappings', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
const inputSpec = createMockInputSpec({
|
||||
@@ -405,7 +388,6 @@ describe('useComboWidget', () => {
|
||||
})
|
||||
|
||||
it("should format option labels using store's getInputName function", () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockGetInputName.mockReturnValue('Beautiful Sunset.png')
|
||||
|
||||
const constructor = useComboWidget()
|
||||
@@ -445,9 +427,7 @@ describe('useComboWidget', () => {
|
||||
expect(result).toBe('Beautiful Sunset.png')
|
||||
})
|
||||
|
||||
it('should create normal combo widget for non-input nodes in cloud', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
it('should create normal combo widget for non-input nodes', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode('SomeOtherNode')
|
||||
@@ -469,34 +449,7 @@ describe('useComboWidget', () => {
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create normal combo widget for LoadImage in OSS', () => {
|
||||
mockDistributionState.isCloud = false
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'image',
|
||||
HASH_FILENAME,
|
||||
expect.any(Function),
|
||||
{
|
||||
values: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
}
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should trigger lazy load for cloud input nodes', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
it('should trigger lazy load for input nodes', () => {
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
|
||||
@@ -515,7 +468,6 @@ describe('useComboWidget', () => {
|
||||
})
|
||||
|
||||
it('should not trigger lazy load if assets already loading', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
mockAssetsStoreState.inputLoading = true
|
||||
|
||||
@@ -534,7 +486,6 @@ describe('useComboWidget', () => {
|
||||
})
|
||||
|
||||
it('should not trigger lazy load if assets already loaded', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockAssetsStoreState.inputAssets = [
|
||||
createMockAssetItem({
|
||||
id: 'asset-123',
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
InputSpec
|
||||
@@ -106,11 +105,11 @@ const addMultiSelectWidget = (
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default value for a cloud asset widget.
|
||||
* Priority: inputSpec.default (if present in cloud assets) → first cloud
|
||||
* asset → undefined (shows placeholder).
|
||||
* Resolve the default value for an asset widget.
|
||||
* Priority: inputSpec.default (if present in assets) → first asset → undefined
|
||||
* (shows placeholder).
|
||||
*/
|
||||
function resolveCloudDefault(
|
||||
function resolveAssetDefault(
|
||||
nodeType: string,
|
||||
specDefault: string | undefined
|
||||
): string | undefined {
|
||||
@@ -119,7 +118,6 @@ function resolveCloudDefault(
|
||||
const inAssets = assets.some((a) => getAssetFilename(a) === specDefault)
|
||||
if (inAssets) return specDefault
|
||||
}
|
||||
// empty filename → undefined (shows placeholder)
|
||||
const filename = assets.length ? getAssetFilename(assets[0]) : undefined
|
||||
return filename || undefined
|
||||
}
|
||||
@@ -213,21 +211,19 @@ const addComboWidget = (
|
||||
): IBaseWidget => {
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
|
||||
if (isCloud) {
|
||||
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
|
||||
// Default from cloud assets, not from server combo options.
|
||||
// Server options list local files that may not exist in the user's
|
||||
// cloud asset library, leading to missing-model errors on undo/reload.
|
||||
const cloudDefault = resolveCloudDefault(
|
||||
node.comfyClass ?? '',
|
||||
inputSpec.default
|
||||
)
|
||||
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
|
||||
}
|
||||
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
|
||||
// Default from asset library, not from server combo options.
|
||||
// Server options list local files that may not exist in the user's
|
||||
// asset library, leading to missing-model errors on undo/reload.
|
||||
const assetDefault = resolveAssetDefault(
|
||||
node.comfyClass ?? '',
|
||||
inputSpec.default
|
||||
)
|
||||
return createAssetBrowserWidget(node, inputSpec, assetDefault)
|
||||
}
|
||||
|
||||
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
|
||||
return createInputMappingWidget(node, inputSpec, defaultValue)
|
||||
}
|
||||
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
|
||||
return createInputMappingWidget(node, inputSpec, defaultValue)
|
||||
}
|
||||
|
||||
// Standard combo widget
|
||||
|
||||
@@ -425,7 +425,6 @@ const zSettings = z.object({
|
||||
'Comfy.VueNodes.Enabled': z.boolean(),
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed': z.boolean(),
|
||||
'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
|
||||
'Comfy.Assets.UseAssetAPI': z.boolean(),
|
||||
'Comfy.Queue.QPOV2': z.boolean(),
|
||||
'Comfy.Queue.ShowRunProgressBar': z.boolean(),
|
||||
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
||||
|
||||
@@ -1505,7 +1505,7 @@ export class ComfyApp {
|
||||
): Promise<void> {
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
const allCandidates = scanAllMediaCandidates(this.rootGraph, isCloud)
|
||||
const allCandidates = scanAllMediaCandidates(this.rootGraph)
|
||||
// Drop candidates whose enclosing subgraph is muted/bypassed.
|
||||
const candidates = allCandidates.filter((c) =>
|
||||
isAncestorPathActive(this.rootGraph, String(c.nodeId))
|
||||
@@ -1520,7 +1520,7 @@ export class ComfyApp {
|
||||
if (pending) {
|
||||
const controller = missingMediaStore.createVerificationAbortController()
|
||||
void verifyMediaCandidates(candidates, {
|
||||
isCloud,
|
||||
allowCompactSuffix: isCloud,
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -36,12 +36,8 @@ vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
OUTPUT_TAG: 'output'
|
||||
}))
|
||||
|
||||
// Mock distribution type - hoisted so it can be changed per test
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
// Mock modelToNodeStore with proper node providers and category lookups
|
||||
@@ -155,14 +151,6 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
|
||||
// Mock asset mappers - add unique timestamps
|
||||
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
mapInputFileToAssetItem: vi.fn((name, index, type) => ({
|
||||
id: `${type}-${index}`,
|
||||
name,
|
||||
size: 0,
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(),
|
||||
tags: [type],
|
||||
preview_url: `http://test.com/${name}`
|
||||
})),
|
||||
mapTaskOutputToAssetItem: vi.fn((task, output) => {
|
||||
const index = parseInt(task.jobId.split('_')[1]) || 0
|
||||
return {
|
||||
@@ -770,17 +758,12 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
describe('assetsStore - Model Assets Cache', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockIsCloud.value = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
const createMockAsset = (id: string, tags: string[] = ['models']) => ({
|
||||
id,
|
||||
name: `asset-${id}`,
|
||||
@@ -1454,25 +1437,20 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
|
||||
describe('getInputName', () => {
|
||||
it('resolves a hashed filename to the human-readable name when the input asset is in the cache', async () => {
|
||||
mockIsCloud.value = true
|
||||
try {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const store = useAssetsStore()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const store = useAssetsStore()
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'input-1',
|
||||
name: 'cute-puppy.png',
|
||||
asset_hash: 'abc123def.png',
|
||||
tags: ['input']
|
||||
}
|
||||
])
|
||||
await store.updateInputs()
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'input-1',
|
||||
name: 'cute-puppy.png',
|
||||
asset_hash: 'abc123def.png',
|
||||
tags: ['input']
|
||||
}
|
||||
])
|
||||
await store.updateInputs()
|
||||
|
||||
expect(store.getInputName('abc123def.png')).toBe('cute-puppy.png')
|
||||
} finally {
|
||||
mockIsCloud.value = false
|
||||
}
|
||||
expect(store.getInputName('abc123def.png')).toBe('cute-puppy.png')
|
||||
})
|
||||
|
||||
it('falls back to the original filename when the input asset is not cached', () => {
|
||||
@@ -1481,27 +1459,22 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInputs cloud routing', () => {
|
||||
it('reads from assetService.getAssetsByTag with limit 100 when isCloud is true', async () => {
|
||||
mockIsCloud.value = true
|
||||
try {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const store = useAssetsStore()
|
||||
describe('updateInputs', () => {
|
||||
it('reads from assetService.getAssetsByTag with limit 100', async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const store = useAssetsStore()
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([])
|
||||
await store.updateInputs()
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([])
|
||||
await store.updateInputs()
|
||||
|
||||
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledWith(
|
||||
'input',
|
||||
false,
|
||||
{ limit: 100 }
|
||||
)
|
||||
expect(
|
||||
assetService.invalidateInputAssetsIncludingPublic
|
||||
).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
mockIsCloud.value = false
|
||||
}
|
||||
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledWith(
|
||||
'input',
|
||||
false,
|
||||
{ limit: 100 }
|
||||
)
|
||||
expect(
|
||||
assetService.invalidateInputAssetsIncludingPublic
|
||||
).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,7 @@ import { useAsyncState, whenever } from '@vueuse/core'
|
||||
import { difference } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref, shallowReactive } from 'vue'
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
mapTaskOutputToAssetItem
|
||||
} from '@/platform/assets/composables/media/assetMappers'
|
||||
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
|
||||
import type {
|
||||
AssetItem,
|
||||
TagsOperationResult
|
||||
@@ -16,7 +13,6 @@ import {
|
||||
assetService
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import type { PaginationOptions } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -26,30 +22,7 @@ import { useModelToNodeStore } from './modelToNodeStore'
|
||||
|
||||
const INPUT_LIMIT = 100
|
||||
|
||||
/**
|
||||
* Fetch input files from the internal API (OSS version)
|
||||
*/
|
||||
async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
|
||||
const response = await fetch(api.internalURL('/files/input'), {
|
||||
headers: {
|
||||
'Comfy-User': api.user
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch input files')
|
||||
}
|
||||
|
||||
const filenames: string[] = await response.json()
|
||||
return filenames.map((name, index) =>
|
||||
mapInputFileToAssetItem(name, index, 'input')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch input files from cloud service
|
||||
*/
|
||||
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
||||
async function fetchInputFiles(): Promise<AssetItem[]> {
|
||||
return await assetService.getAssetsByTag(INPUT_TAG, false, {
|
||||
limit: INPUT_LIMIT
|
||||
})
|
||||
@@ -123,10 +96,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
const loadedIds = shallowReactive(new Set<string>())
|
||||
|
||||
const fetchInputFiles = isCloud
|
||||
? fetchInputFilesFromCloud
|
||||
: fetchInputFilesFromAPI
|
||||
|
||||
const {
|
||||
state: inputAssets,
|
||||
isLoading: inputLoading,
|
||||
@@ -385,418 +354,400 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
* Cloud-only feature - empty Maps in desktop builds
|
||||
*/
|
||||
const getModelState = () => {
|
||||
if (isCloud) {
|
||||
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
|
||||
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
|
||||
|
||||
const assetsArrayCache = new Map<
|
||||
string,
|
||||
{ source: Map<string, AssetItem>; array: AssetItem[] }
|
||||
>()
|
||||
const assetsArrayCache = new Map<
|
||||
string,
|
||||
{ source: Map<string, AssetItem>; array: AssetItem[] }
|
||||
>()
|
||||
|
||||
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
|
||||
const pendingPromiseByCategory = new Map<string, Promise<void>>()
|
||||
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
|
||||
const pendingPromiseByCategory = new Map<string, Promise<void>>()
|
||||
|
||||
function createState(
|
||||
existingAssets?: Map<string, AssetItem>
|
||||
): ModelPaginationState {
|
||||
const assets = new Map(existingAssets)
|
||||
return reactive({
|
||||
assets,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
isLoading: true
|
||||
})
|
||||
function createState(
|
||||
existingAssets?: Map<string, AssetItem>
|
||||
): ModelPaginationState {
|
||||
const assets = new Map(existingAssets)
|
||||
return reactive({
|
||||
assets,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
isLoading: true
|
||||
})
|
||||
}
|
||||
|
||||
function isStale(category: string, state: ModelPaginationState): boolean {
|
||||
const committed = modelStateByCategory.value.get(category)
|
||||
const pending = pendingRequestByCategory.get(category)
|
||||
return committed !== state && pending !== state
|
||||
}
|
||||
|
||||
const EMPTY_ASSETS: AssetItem[] = []
|
||||
|
||||
/**
|
||||
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
* @returns The category or undefined if not resolvable
|
||||
*/
|
||||
function resolveCategory(key: string): string | undefined {
|
||||
if (key.startsWith('tag:')) {
|
||||
return key
|
||||
}
|
||||
return modelToNodeStore.getCategoryForNodeType(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by nodeType or tag key.
|
||||
* Translates nodeType to category internally for cache lookup.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
*/
|
||||
function getAssets(key: string): AssetItem[] {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return EMPTY_ASSETS
|
||||
|
||||
const state = modelStateByCategory.value.get(category)
|
||||
const assetsMap = state?.assets
|
||||
if (!assetsMap) return EMPTY_ASSETS
|
||||
|
||||
const cached = assetsArrayCache.get(category)
|
||||
if (cached && cached.source === assetsMap) {
|
||||
return cached.array
|
||||
}
|
||||
|
||||
function isStale(category: string, state: ModelPaginationState): boolean {
|
||||
const committed = modelStateByCategory.value.get(category)
|
||||
const pending = pendingRequestByCategory.get(category)
|
||||
return committed !== state && pending !== state
|
||||
const array = Array.from(assetsMap.values())
|
||||
assetsArrayCache.set(category, { source: assetsMap, array })
|
||||
return array
|
||||
}
|
||||
|
||||
function isLoading(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.isLoading ?? false
|
||||
}
|
||||
|
||||
function getError(key: string): Error | undefined {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return undefined
|
||||
return modelStateByCategory.value.get(category)?.error
|
||||
}
|
||||
|
||||
function hasMore(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.hasMore ?? false
|
||||
}
|
||||
|
||||
function hasAssetKey(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category exists in the cache.
|
||||
* Checks both direct category keys and tag-prefixed keys.
|
||||
* @param category The category to check (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function hasCategory(category: string): boolean {
|
||||
return (
|
||||
modelStateByCategory.value.has(category) ||
|
||||
modelStateByCategory.value.has(`tag:${category}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
* Keeps existing data visible until new data is successfully fetched.
|
||||
*
|
||||
* Concurrent calls for the same category are short-circuited: if a request
|
||||
* is already in progress (tracked via pendingRequestByCategory), subsequent
|
||||
* calls return immediately to avoid redundant work.
|
||||
*/
|
||||
async function updateModelsForCategory(
|
||||
category: string,
|
||||
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
|
||||
): Promise<void> {
|
||||
if (pendingPromiseByCategory.has(category)) {
|
||||
return pendingPromiseByCategory.get(category)!
|
||||
}
|
||||
|
||||
const EMPTY_ASSETS: AssetItem[] = []
|
||||
const existingState = modelStateByCategory.value.get(category)
|
||||
const state = createState(existingState?.assets)
|
||||
|
||||
/**
|
||||
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
* @returns The category or undefined if not resolvable
|
||||
*/
|
||||
function resolveCategory(key: string): string | undefined {
|
||||
if (key.startsWith('tag:')) {
|
||||
return key
|
||||
}
|
||||
return modelToNodeStore.getCategoryForNodeType(key)
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const hasExistingData = modelStateByCategory.value.has(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.set(category, state)
|
||||
} else {
|
||||
// Also track in pending map for initial loads to prevent concurrent calls
|
||||
pendingRequestByCategory.set(category, state)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by nodeType or tag key.
|
||||
* Translates nodeType to category internally for cache lookup.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
*/
|
||||
function getAssets(key: string): AssetItem[] {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return EMPTY_ASSETS
|
||||
async function loadBatches(): Promise<void> {
|
||||
while (state.hasMore) {
|
||||
try {
|
||||
const newAssets = await fetcher({
|
||||
limit: MODEL_BATCH_SIZE,
|
||||
offset: state.offset
|
||||
})
|
||||
|
||||
const state = modelStateByCategory.value.get(category)
|
||||
const assetsMap = state?.assets
|
||||
if (!assetsMap) return EMPTY_ASSETS
|
||||
if (isStale(category, state)) return
|
||||
|
||||
const cached = assetsArrayCache.get(category)
|
||||
if (cached && cached.source === assetsMap) {
|
||||
return cached.array
|
||||
}
|
||||
|
||||
const array = Array.from(assetsMap.values())
|
||||
assetsArrayCache.set(category, { source: assetsMap, array })
|
||||
return array
|
||||
}
|
||||
|
||||
function isLoading(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.isLoading ?? false
|
||||
}
|
||||
|
||||
function getError(key: string): Error | undefined {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return undefined
|
||||
return modelStateByCategory.value.get(category)?.error
|
||||
}
|
||||
|
||||
function hasMore(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.hasMore ?? false
|
||||
}
|
||||
|
||||
function hasAssetKey(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category exists in the cache.
|
||||
* Checks both direct category keys and tag-prefixed keys.
|
||||
* @param category The category to check (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function hasCategory(category: string): boolean {
|
||||
return (
|
||||
modelStateByCategory.value.has(category) ||
|
||||
modelStateByCategory.value.has(`tag:${category}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
* Keeps existing data visible until new data is successfully fetched.
|
||||
*
|
||||
* Concurrent calls for the same category are short-circuited: if a request
|
||||
* is already in progress (tracked via pendingRequestByCategory), subsequent
|
||||
* calls return immediately to avoid redundant work.
|
||||
*/
|
||||
async function updateModelsForCategory(
|
||||
category: string,
|
||||
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
|
||||
): Promise<void> {
|
||||
if (pendingPromiseByCategory.has(category)) {
|
||||
return pendingPromiseByCategory.get(category)!
|
||||
}
|
||||
|
||||
const existingState = modelStateByCategory.value.get(category)
|
||||
const state = createState(existingState?.assets)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const hasExistingData = modelStateByCategory.value.has(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.set(category, state)
|
||||
} else {
|
||||
// Also track in pending map for initial loads to prevent concurrent calls
|
||||
pendingRequestByCategory.set(category, state)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
async function loadBatches(): Promise<void> {
|
||||
while (state.hasMore) {
|
||||
try {
|
||||
const newAssets = await fetcher({
|
||||
limit: MODEL_BATCH_SIZE,
|
||||
offset: state.offset
|
||||
})
|
||||
|
||||
if (isStale(category, state)) return
|
||||
|
||||
const isFirstBatch = state.offset === 0
|
||||
if (isFirstBatch) {
|
||||
assetsArrayCache.delete(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.delete(category)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
const isFirstBatch = state.offset === 0
|
||||
if (isFirstBatch) {
|
||||
assetsArrayCache.delete(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.delete(category)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
// Merge new assets into existing map and track seen IDs
|
||||
for (const asset of newAssets) {
|
||||
seenIds.add(asset.id)
|
||||
state.assets.set(asset.id, asset)
|
||||
}
|
||||
state.assets = new Map(state.assets)
|
||||
|
||||
state.offset += newAssets.length
|
||||
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
|
||||
|
||||
if (isFirstBatch) {
|
||||
state.isLoading = false
|
||||
}
|
||||
|
||||
if (state.hasMore) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStale(category, state)) return
|
||||
console.error(`Error loading batch for ${category}:`, err)
|
||||
|
||||
state.error = err instanceof Error ? err : new Error(String(err))
|
||||
state.hasMore = false
|
||||
state.isLoading = false
|
||||
pendingRequestByCategory.delete(category)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const staleIds = [...state.assets.keys()].filter(
|
||||
(id) => !seenIds.has(id)
|
||||
)
|
||||
for (const id of staleIds) {
|
||||
state.assets.delete(id)
|
||||
// Merge new assets into existing map and track seen IDs
|
||||
for (const asset of newAssets) {
|
||||
seenIds.add(asset.id)
|
||||
state.assets.set(asset.id, asset)
|
||||
}
|
||||
state.assets = new Map(state.assets)
|
||||
|
||||
state.offset += newAssets.length
|
||||
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
|
||||
|
||||
if (isFirstBatch) {
|
||||
state.isLoading = false
|
||||
}
|
||||
|
||||
if (state.hasMore) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStale(category, state)) return
|
||||
console.error(`Error loading batch for ${category}:`, err)
|
||||
|
||||
state.error = err instanceof Error ? err : new Error(String(err))
|
||||
state.hasMore = false
|
||||
state.isLoading = false
|
||||
pendingRequestByCategory.delete(category)
|
||||
|
||||
return
|
||||
}
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
}
|
||||
|
||||
const promise = loadBatches().finally(() => {
|
||||
pendingPromiseByCategory.delete(category)
|
||||
})
|
||||
pendingPromiseByCategory.set(category, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache model assets for a specific node type.
|
||||
* Translates nodeType to category internally - multiple node types
|
||||
* sharing the same category will share the same cache entry.
|
||||
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
|
||||
*/
|
||||
async function updateModelsForNodeType(nodeType: string): Promise<void> {
|
||||
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
|
||||
if (!category) return
|
||||
|
||||
// Use category as cache key but fetch using nodeType for API compatibility
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsForNodeType(nodeType, opts)
|
||||
const staleIds = [...state.assets.keys()].filter(
|
||||
(id) => !seenIds.has(id)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache model assets for a specific tag
|
||||
* @param tag The tag to fetch assets for (e.g., 'models')
|
||||
*/
|
||||
async function updateModelsForTag(tag: string): Promise<void> {
|
||||
const category = `tag:${tag}`
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsByTag(tag, true, opts)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache for a specific category.
|
||||
* Forces a refetch on next access.
|
||||
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function invalidateCategory(category: string): void {
|
||||
modelStateByCategory.value.delete(category)
|
||||
for (const id of staleIds) {
|
||||
state.assets.delete(id)
|
||||
}
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
}
|
||||
|
||||
const promise = loadBatches().finally(() => {
|
||||
pendingPromiseByCategory.delete(category)
|
||||
}
|
||||
})
|
||||
pendingPromiseByCategory.set(category, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically update an asset in the cache
|
||||
* @param assetId The asset ID to update
|
||||
* @param updates Partial asset data to merge
|
||||
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
|
||||
*/
|
||||
function updateAssetInCache(
|
||||
assetId: string,
|
||||
updates: Partial<AssetItem>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const category = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (cacheKey && !category) return
|
||||
/**
|
||||
* Fetch and cache model assets for a specific node type.
|
||||
* Translates nodeType to category internally - multiple node types
|
||||
* sharing the same category will share the same cache entry.
|
||||
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
|
||||
*/
|
||||
async function updateModelsForNodeType(nodeType: string): Promise<void> {
|
||||
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
|
||||
if (!category) return
|
||||
|
||||
const categoriesToCheck = category
|
||||
? [category]
|
||||
: Array.from(modelStateByCategory.value.keys())
|
||||
// Use category as cache key but fetch using nodeType for API compatibility
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsForNodeType(nodeType, opts)
|
||||
)
|
||||
}
|
||||
|
||||
for (const cat of categoriesToCheck) {
|
||||
const state = modelStateByCategory.value.get(cat)
|
||||
if (!state?.assets) continue
|
||||
/**
|
||||
* Fetch and cache model assets for a specific tag
|
||||
* @param tag The tag to fetch assets for (e.g., 'models')
|
||||
*/
|
||||
async function updateModelsForTag(tag: string): Promise<void> {
|
||||
const category = `tag:${tag}`
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsByTag(tag, true, opts)
|
||||
)
|
||||
}
|
||||
|
||||
const existingAsset = state.assets.get(assetId)
|
||||
if (existingAsset) {
|
||||
const updatedAsset = { ...existingAsset, ...updates }
|
||||
state.assets.set(assetId, updatedAsset)
|
||||
assetsArrayCache.delete(cat)
|
||||
if (cacheKey) return
|
||||
}
|
||||
/**
|
||||
* Invalidate the cache for a specific category.
|
||||
* Forces a refetch on next access.
|
||||
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function invalidateCategory(category: string): void {
|
||||
modelStateByCategory.value.delete(category)
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
pendingPromiseByCategory.delete(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically update an asset in the cache
|
||||
* @param assetId The asset ID to update
|
||||
* @param updates Partial asset data to merge
|
||||
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
|
||||
*/
|
||||
function updateAssetInCache(
|
||||
assetId: string,
|
||||
updates: Partial<AssetItem>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const category = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (cacheKey && !category) return
|
||||
|
||||
const categoriesToCheck = category
|
||||
? [category]
|
||||
: Array.from(modelStateByCategory.value.keys())
|
||||
|
||||
for (const cat of categoriesToCheck) {
|
||||
const state = modelStateByCategory.value.get(cat)
|
||||
if (!state?.assets) continue
|
||||
|
||||
const existingAsset = state.assets.get(assetId)
|
||||
if (existingAsset) {
|
||||
const updatedAsset = { ...existingAsset, ...updates }
|
||||
state.assets.set(assetId, updatedAsset)
|
||||
assetsArrayCache.delete(cat)
|
||||
if (cacheKey) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset metadata with optimistic cache update
|
||||
* @param asset The asset to update
|
||||
* @param userMetadata The user_metadata to save
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetMetadata(
|
||||
asset: AssetItem,
|
||||
userMetadata: Record<string, unknown>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalMetadata = asset.user_metadata
|
||||
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
|
||||
|
||||
try {
|
||||
const updatedAsset = await assetService.updateAsset(asset.id, {
|
||||
user_metadata: userMetadata
|
||||
})
|
||||
updateAssetInCache(asset.id, updatedAsset, cacheKey)
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset metadata:', error)
|
||||
updateAssetInCache(
|
||||
asset.id,
|
||||
{ user_metadata: originalMetadata },
|
||||
cacheKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset tags using add/remove endpoints
|
||||
* @param asset The asset to update (used to read current tags)
|
||||
* @param newTags The desired tags array
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetTags(
|
||||
asset: AssetItem,
|
||||
newTags: string[],
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalTags = asset.tags
|
||||
const tagsToAdd = difference(newTags, originalTags)
|
||||
const tagsToRemove = difference(originalTags, newTags)
|
||||
|
||||
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
|
||||
|
||||
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
|
||||
|
||||
let removedTagsOnServer: string[] = []
|
||||
try {
|
||||
let removeResult: TagsOperationResult | undefined
|
||||
if (tagsToRemove.length > 0) {
|
||||
removeResult = await assetService.removeAssetTags(
|
||||
asset.id,
|
||||
tagsToRemove
|
||||
)
|
||||
removedTagsOnServer = removeResult.removed ?? tagsToRemove
|
||||
}
|
||||
|
||||
const addResult =
|
||||
tagsToAdd.length > 0
|
||||
? await assetService.addAssetTags(asset.id, tagsToAdd)
|
||||
: undefined
|
||||
|
||||
const finalTags = (addResult ?? removeResult)?.total_tags
|
||||
if (finalTags) {
|
||||
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset tags:', error)
|
||||
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
|
||||
|
||||
if (removedTagsOnServer.length > 0) {
|
||||
try {
|
||||
await assetService.addAssetTags(asset.id, removedTagsOnServer)
|
||||
} catch (compensationError) {
|
||||
console.error(
|
||||
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
|
||||
compensationError
|
||||
)
|
||||
const categoriesToInvalidate = new Set<string>()
|
||||
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (resolved) {
|
||||
categoriesToInvalidate.add(resolved)
|
||||
}
|
||||
for (const [
|
||||
category,
|
||||
state
|
||||
] of modelStateByCategory.value.entries()) {
|
||||
if (state.assets?.has(asset.id)) {
|
||||
categoriesToInvalidate.add(category)
|
||||
}
|
||||
}
|
||||
for (const category of categoriesToInvalidate) {
|
||||
invalidateCategory(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
|
||||
* Clears the category cache and tag-based caches so next access triggers refetch
|
||||
* @param category The model category to invalidate (e.g., 'checkpoints')
|
||||
*/
|
||||
function invalidateModelsForCategory(category: string): void {
|
||||
invalidateCategory(category)
|
||||
invalidateCategory(`tag:${category}`)
|
||||
invalidateCategory('tag:models')
|
||||
}
|
||||
|
||||
return {
|
||||
getAssets,
|
||||
isLoading,
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
}
|
||||
|
||||
const emptyAssets: AssetItem[] = []
|
||||
/**
|
||||
* Update asset metadata with optimistic cache update
|
||||
* @param asset The asset to update
|
||||
* @param userMetadata The user_metadata to save
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetMetadata(
|
||||
asset: AssetItem,
|
||||
userMetadata: Record<string, unknown>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalMetadata = asset.user_metadata
|
||||
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
|
||||
|
||||
try {
|
||||
const updatedAsset = await assetService.updateAsset(asset.id, {
|
||||
user_metadata: userMetadata
|
||||
})
|
||||
updateAssetInCache(asset.id, updatedAsset, cacheKey)
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset metadata:', error)
|
||||
updateAssetInCache(
|
||||
asset.id,
|
||||
{ user_metadata: originalMetadata },
|
||||
cacheKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset tags using add/remove endpoints
|
||||
* @param asset The asset to update (used to read current tags)
|
||||
* @param newTags The desired tags array
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetTags(
|
||||
asset: AssetItem,
|
||||
newTags: string[],
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalTags = asset.tags
|
||||
const tagsToAdd = difference(newTags, originalTags)
|
||||
const tagsToRemove = difference(originalTags, newTags)
|
||||
|
||||
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
|
||||
|
||||
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
|
||||
|
||||
let removedTagsOnServer: string[] = []
|
||||
try {
|
||||
let removeResult: TagsOperationResult | undefined
|
||||
if (tagsToRemove.length > 0) {
|
||||
removeResult = await assetService.removeAssetTags(
|
||||
asset.id,
|
||||
tagsToRemove
|
||||
)
|
||||
removedTagsOnServer = removeResult.removed ?? tagsToRemove
|
||||
}
|
||||
|
||||
const addResult =
|
||||
tagsToAdd.length > 0
|
||||
? await assetService.addAssetTags(asset.id, tagsToAdd)
|
||||
: undefined
|
||||
|
||||
const finalTags = (addResult ?? removeResult)?.total_tags
|
||||
if (finalTags) {
|
||||
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset tags:', error)
|
||||
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
|
||||
|
||||
if (removedTagsOnServer.length > 0) {
|
||||
try {
|
||||
await assetService.addAssetTags(asset.id, removedTagsOnServer)
|
||||
} catch (compensationError) {
|
||||
console.error(
|
||||
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
|
||||
compensationError
|
||||
)
|
||||
const categoriesToInvalidate = new Set<string>()
|
||||
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (resolved) {
|
||||
categoriesToInvalidate.add(resolved)
|
||||
}
|
||||
for (const [
|
||||
category,
|
||||
state
|
||||
] of modelStateByCategory.value.entries()) {
|
||||
if (state.assets?.has(asset.id)) {
|
||||
categoriesToInvalidate.add(category)
|
||||
}
|
||||
}
|
||||
for (const category of categoriesToInvalidate) {
|
||||
invalidateCategory(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
|
||||
* Clears the category cache and tag-based caches so next access triggers refetch
|
||||
* @param category The model category to invalidate (e.g., 'checkpoints')
|
||||
*/
|
||||
function invalidateModelsForCategory(category: string): void {
|
||||
invalidateCategory(category)
|
||||
invalidateCategory(`tag:${category}`)
|
||||
invalidateCategory('tag:models')
|
||||
}
|
||||
|
||||
return {
|
||||
getAssets: () => emptyAssets,
|
||||
isLoading: () => false,
|
||||
getError: () => undefined,
|
||||
hasMore: () => false,
|
||||
hasAssetKey: () => false,
|
||||
hasCategory: () => false,
|
||||
updateModelsForNodeType: async () => {},
|
||||
invalidateCategory: () => {},
|
||||
updateModelsForTag: async () => {},
|
||||
updateAssetMetadata: async () => {},
|
||||
updateAssetTags: async () => {},
|
||||
invalidateModelsForCategory: () => {}
|
||||
getAssets,
|
||||
isLoading,
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,12 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
|
||||
// Mock the api
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getModels: vi.fn(),
|
||||
getModelFolders: vi.fn(),
|
||||
viewMetadata: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
|
||||
addEventListener: vi.fn(),
|
||||
@@ -27,37 +24,8 @@ vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the settingStore
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn()
|
||||
}))
|
||||
|
||||
function enableMocks(useAssetAPI = false) {
|
||||
// Mock settingStore to return the useAssetAPI setting
|
||||
const mockSettingStore = {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Assets.UseAssetAPI') {
|
||||
return useAssetAPI
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
vi.mocked(useSettingStore, { partial: true }).mockReturnValue(
|
||||
mockSettingStore
|
||||
)
|
||||
|
||||
// Mock experimental API - returns objects with name and folders properties
|
||||
vi.mocked(api.getModels).mockResolvedValue([
|
||||
{ name: 'sdxl.safetensors', pathIndex: 0 },
|
||||
{ name: 'sdv15.safetensors', pathIndex: 0 },
|
||||
{ name: 'noinfo.safetensors', pathIndex: 0 }
|
||||
])
|
||||
vi.mocked(api.getModelFolders).mockResolvedValue([
|
||||
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
|
||||
{ name: 'vae', folders: ['/path/to/vae'] }
|
||||
])
|
||||
|
||||
// Mock asset API - also returns objects with name and folders properties
|
||||
function enableMocks() {
|
||||
// Mock asset API - returns objects with name and folders properties
|
||||
vi.mocked(assetService.getAssetModelFolders).mockResolvedValue([
|
||||
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
|
||||
{ name: 'vae', folders: ['/path/to/vae'] }
|
||||
@@ -141,11 +109,11 @@ describe('useModelStore', () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
expect(api.getModels).toHaveBeenCalledTimes(0)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(0)
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
expect(api.getModels).toHaveBeenCalledTimes(1)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
expect(api.getModels).toHaveBeenCalledTimes(1)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('refreshModelFolder', () => {
|
||||
@@ -154,9 +122,9 @@ describe('useModelStore', () => {
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
expect(api.getModels).toHaveBeenCalledTimes(1)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.mocked(api.getModels).mockResolvedValueOnce([
|
||||
vi.mocked(assetService.getAssetModels).mockResolvedValueOnce([
|
||||
{ name: 'sdxl.safetensors', pathIndex: 0 },
|
||||
{ name: 'sdv15.safetensors', pathIndex: 0 },
|
||||
{ name: 'noinfo.safetensors', pathIndex: 0 },
|
||||
@@ -165,7 +133,7 @@ describe('useModelStore', () => {
|
||||
|
||||
await store.refreshModelFolder('checkpoints')
|
||||
|
||||
expect(api.getModels).toHaveBeenCalledTimes(2)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(2)
|
||||
const folder = await store.getLoadedModelFolder('checkpoints')
|
||||
expect(Object.keys(folder!.models)).toHaveLength(4)
|
||||
expect(folder!.models['0/new-upload.safetensors']).toBeDefined()
|
||||
@@ -175,12 +143,12 @@ describe('useModelStore', () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(1)
|
||||
|
||||
await store.refreshModelFolder('loras')
|
||||
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(api.getModels).not.toHaveBeenCalled()
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(assetService.getAssetModels).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -190,13 +158,15 @@ describe('useModelStore', () => {
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
expect(api.getModels).toHaveBeenCalledTimes(1)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)
|
||||
|
||||
await store.refresh()
|
||||
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(api.getModels).toHaveBeenCalledTimes(2)
|
||||
expect(api.getModels).toHaveBeenLastCalledWith('checkpoints')
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(2)
|
||||
expect(assetService.getAssetModels).toHaveBeenLastCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not load folders that were never opened', async () => {
|
||||
@@ -206,38 +176,20 @@ describe('useModelStore', () => {
|
||||
|
||||
await store.refresh()
|
||||
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(api.getModels).not.toHaveBeenCalled()
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(assetService.getAssetModels).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API switching functionality', () => {
|
||||
it('should use experimental API for complete workflow when UseAssetAPI setting is false', async () => {
|
||||
enableMocks(false) // useAssetAPI = false
|
||||
describe('asset API usage', () => {
|
||||
it('uses the asset API for model folders and models', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folderStore = await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
|
||||
expect(api.getModels).toHaveBeenCalledWith('checkpoints')
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(0)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(0)
|
||||
expect(folderStore).toBeDefined()
|
||||
expect(Object.keys(folderStore!.models)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should use asset API for complete workflow when UseAssetAPI setting is true', async () => {
|
||||
enableMocks(true) // useAssetAPI = true
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folderStore = await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(1)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledWith('checkpoints')
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(0)
|
||||
expect(api.getModels).toHaveBeenCalledTimes(0)
|
||||
expect(folderStore).toBeDefined()
|
||||
expect(Object.keys(folderStore!.models)).toHaveLength(3)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import type { ModelFile } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/** (Internal helper) finds a value in a metadata object from any of a list of keys. */
|
||||
@@ -193,7 +192,6 @@ export class ModelFolder {
|
||||
|
||||
/** Model store handler, wraps individual per-folder model stores */
|
||||
export const useModelStore = defineStore('models', () => {
|
||||
const settingStore = useSettingStore()
|
||||
const modelFolderNames = ref<string[]>([])
|
||||
const modelFolderByName = ref<Record<string, ModelFolder>>({})
|
||||
const modelFolders = computed<ModelFolder[]>(() =>
|
||||
@@ -206,21 +204,14 @@ export const useModelStore = defineStore('models', () => {
|
||||
)
|
||||
|
||||
function createGetModelsFunc(): (folder: string) => Promise<ModelFile[]> {
|
||||
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
return useAssetAPI
|
||||
? (folder) => assetService.getAssetModels(folder)
|
||||
: (folder) => api.getModels(folder)
|
||||
return (folder) => assetService.getAssetModels(folder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the model folders from the server
|
||||
*/
|
||||
async function loadModelFolders() {
|
||||
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
|
||||
const resData = useAssetAPI
|
||||
? await assetService.getAssetModelFolders()
|
||||
: await api.getModelFolders()
|
||||
const resData = await assetService.getAssetModelFolders()
|
||||
modelFolderNames.value = resData.map((folder) => folder.name)
|
||||
modelFolderByName.value = {}
|
||||
const getModelsFunc = createGetModelsFunc()
|
||||
|
||||
@@ -73,13 +73,9 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
versionAdded: '1.3.9',
|
||||
category: 'view-controls' as const,
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
if (
|
||||
tab.id === 'model-library' &&
|
||||
settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
) {
|
||||
if (tab.id === 'model-library') {
|
||||
await commandStore.commands
|
||||
.find((cmd) => cmd.id === 'Comfy.BrowseModelAssets')
|
||||
?.function?.()
|
||||
|
||||
48
src/test-utils/mockFeatureFlags.ts
Normal file
48
src/test-utils/mockFeatureFlags.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
FeatureFlags,
|
||||
useFeatureFlags
|
||||
} from '@/composables/useFeatureFlags'
|
||||
|
||||
/**
|
||||
* Mock implementation of `useFeatureFlags()` for unit/component tests.
|
||||
*
|
||||
* All flags default to their production opt-in default (mostly `false`);
|
||||
* pass overrides to enable specific ones for the test.
|
||||
*
|
||||
* @example
|
||||
* vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
* useFeatureFlags: () => mockFeatureFlags({ assetRenameEnabled: true })
|
||||
* }))
|
||||
*/
|
||||
export function mockFeatureFlags(
|
||||
overrides: Partial<FeatureFlags> = {}
|
||||
): ReturnType<typeof useFeatureFlags> {
|
||||
const flags: FeatureFlags = {
|
||||
supportsPreviewMetadata: false,
|
||||
maxUploadSize: 0,
|
||||
supportsManagerV4: false,
|
||||
modelUploadButtonEnabled: false,
|
||||
assetRenameEnabled: false,
|
||||
privateModelsEnabled: false,
|
||||
onboardingSurveyEnabled: false,
|
||||
linearToggleEnabled: false,
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
nodeReplacementsEnabled: false,
|
||||
nodeLibraryEssentialsEnabled: false,
|
||||
workflowSharingEnabled: false,
|
||||
comfyHubUploadEnabled: false,
|
||||
comfyHubProfileGateEnabled: false,
|
||||
showSignInButton: undefined,
|
||||
...overrides
|
||||
}
|
||||
|
||||
return {
|
||||
flags,
|
||||
featureFlag: vi.fn() as unknown as ReturnType<
|
||||
typeof useFeatureFlags
|
||||
>['featureFlag']
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user