mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-04 12:20:50 +00:00
Compare commits
5 Commits
jaeone/fe-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be131f7e9a | ||
|
|
daf07a7442 | ||
|
|
454e124099 | ||
|
|
8a819fa2be | ||
|
|
35157f1af0 |
@@ -1,10 +1,11 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createModelAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-model-001',
|
||||
name: 'model.safetensors',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createInputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-input-001',
|
||||
name: 'input.png',
|
||||
asset_hash:
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createOutputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-output-001',
|
||||
name: 'output_00001.png',
|
||||
asset_hash:
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
|
||||
@@ -54,7 +54,6 @@ export const TestIds = {
|
||||
errorDialogFindIssues: 'error-dialog-find-issues',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
errorGroupDisplayMessage: 'error-group-display-message',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingModelExpand: 'missing-model-expand',
|
||||
|
||||
@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
const defaultInputAsset: Asset & { hash?: string } = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
const importedInputAsset: Asset & { hash?: string } = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getSwapNodesGroup,
|
||||
setupNodeReplacement
|
||||
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const renderModes = [
|
||||
{ name: 'vue nodes', vueNodesEnabled: true },
|
||||
@@ -39,9 +38,6 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await expect(swapGroup).toBeVisible()
|
||||
await expect(
|
||||
swapGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
await expect(swapGroup).toContainText('E2E_OldSampler')
|
||||
await expect(
|
||||
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
|
||||
|
||||
@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset = {
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
name: LOTUS_MODEL_NAME,
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'diffusion_models'],
|
||||
|
||||
@@ -36,10 +36,6 @@ function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
}
|
||||
|
||||
function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -50,24 +46,14 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
const overlay = getErrorOverlay(comfyPage)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toContainText(/Load Image/)
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(overlay).toBeHidden()
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
|
||||
await expect(
|
||||
missingMediaGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
|
||||
@@ -44,10 +44,10 @@ const emptyMediaLoaderNodes = [
|
||||
}
|
||||
]
|
||||
|
||||
const cloudOutputAsset: Asset = {
|
||||
const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-output-hash-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
asset_hash: outputHash,
|
||||
hash: outputHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
@@ -56,10 +56,10 @@ const cloudOutputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset = {
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
asset_hash: plainVideoFileName,
|
||||
hash: plainVideoFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
@@ -70,10 +70,10 @@ const cloudUploadedVideoAsset: Asset = {
|
||||
|
||||
// The Cloud test app starts with a default LoadImage node. Keep that baseline
|
||||
// input resolvable so this spec only observes the media it creates.
|
||||
const cloudDefaultGraphInputAsset: Asset = {
|
||||
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-default-input-001',
|
||||
name: '00000000000000000000000Aexample.png',
|
||||
asset_hash: '00000000000000000000000Aexample.png',
|
||||
hash: '00000000000000000000000Aexample.png',
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
@@ -25,13 +25,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelsGroup).toBeVisible()
|
||||
await expect(
|
||||
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display model name with referencing node count', async ({
|
||||
|
||||
@@ -23,13 +23,9 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
await expect(
|
||||
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -18,32 +18,10 @@
|
||||
v-model="fov"
|
||||
:tooltip-text="$t('load3d.fov')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.retainViewOnReload'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.retainViewOnReload')"
|
||||
:aria-pressed="retainViewOnReload"
|
||||
@click="retainViewOnReload = !retainViewOnReload"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi text-lg text-base-foreground',
|
||||
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
@@ -52,9 +30,6 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
|
||||
default: false
|
||||
})
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
|
||||
@@ -90,6 +90,8 @@ const i18n = createI18n({
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
missingNodePacks: {
|
||||
ossMessage: 'Missing node packs detected. Install them.',
|
||||
cloudMessage: 'Unsupported node packs detected.',
|
||||
ossManagerDisabledHint:
|
||||
'To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.',
|
||||
applyChanges: 'Apply Changes'
|
||||
@@ -157,6 +159,21 @@ describe('MissingNodeCard', () => {
|
||||
})
|
||||
|
||||
describe('Rendering & Props', () => {
|
||||
it('renders cloud message when isCloud is true', () => {
|
||||
mockIsCloud.value = true
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByText('Unsupported node packs detected.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders OSS message when isCloud is false', () => {
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByText('Missing node packs detected. Install them.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct number of MissingPackGroupRow components', () => {
|
||||
renderCard({ missingPackGroups: makePackGroups(3) })
|
||||
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)
|
||||
|
||||
@@ -36,6 +36,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
|
||||
<p
|
||||
class="m-0 text-sm/relaxed text-muted-foreground"
|
||||
:class="showManagerHint ? 'pb-3' : 'pb-5'"
|
||||
>
|
||||
{{
|
||||
isCloud
|
||||
? t('rightSidePanel.missingNodePacks.cloudMessage')
|
||||
: t('rightSidePanel.missingNodePacks.ossMessage')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- Manager disabled hint: shown on OSS when manager is not active -->
|
||||
<i18n-t
|
||||
v-if="showManagerHint"
|
||||
|
||||
@@ -7,8 +7,6 @@ import { createI18n } from 'vue-i18n'
|
||||
import TabErrors from './TabErrors.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -38,16 +36,6 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
|
||||
downloadModel: vi.fn(),
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue({
|
||||
fileSize: null,
|
||||
gatedRepoUrl: null
|
||||
}),
|
||||
isModelDownloadable: vi.fn(() => true),
|
||||
toBrowsableUrl: vi.fn((url: string) => url)
|
||||
}))
|
||||
|
||||
describe('TabErrors.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
@@ -69,18 +57,6 @@ describe('TabErrors.vue', () => {
|
||||
downloadAll: 'Download all',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing missing models.'
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaTitle: 'Missing Inputs',
|
||||
image: 'Images',
|
||||
uploadFile: 'Upload {type}',
|
||||
useFromLibrary: 'Use from Library',
|
||||
confirmSelection: 'Confirm selection',
|
||||
locateNode: 'Locate node',
|
||||
expandNodes: 'Show referencing nodes',
|
||||
collapseNodes: 'Hide referencing nodes',
|
||||
cancelSelection: 'Cancel selection',
|
||||
or: 'OR'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,85 +282,6 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'local-only.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
} satisfies MissingModelCandidate
|
||||
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [missingModel]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing media display message below the section title', () => {
|
||||
const missingMedia = {
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'portrait.png',
|
||||
isMissing: true
|
||||
} satisfies MissingMediaCandidate
|
||||
|
||||
renderComponent({
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [missingMedia]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
nodeId: '1',
|
||||
isReplaceable: true,
|
||||
replacement: {
|
||||
old_node_id: 'OldSampler',
|
||||
new_node_id: 'KSampler',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}
|
||||
} satisfies MissingNodeType
|
||||
|
||||
renderComponent({
|
||||
missingNodesError: {
|
||||
missingNodesError: {
|
||||
message: 'Missing Node Packs',
|
||||
nodeTypes: [swapNode]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('KSampler')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Replace Node/ })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
|
||||
@@ -154,18 +154,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.type !== 'execution' && group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
@@ -178,7 +166,7 @@
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@@ -186,7 +174,7 @@
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
@@ -201,7 +189,7 @@
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
v-else-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
@@ -209,7 +197,7 @@
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
v-else-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
|
||||
@@ -302,24 +302,7 @@ describe('useErrorGroups', () => {
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Cloud copy for missing_node group in Cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
|
||||
'Some nodes are missing and need to be installed'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -723,6 +723,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
kind: 'missing_media',
|
||||
groups: missingMediaGroups.value,
|
||||
count: totalItems,
|
||||
mediaTypes: missingMediaGroups.value.map((group) => group.mediaType),
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
@@ -839,6 +840,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
kind: 'missing_media',
|
||||
groups: filteredMissingMediaGroups.value,
|
||||
count: totalItems,
|
||||
mediaTypes: filteredMissingMediaGroups.value.map(
|
||||
(group) => group.mediaType
|
||||
),
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,6 +101,23 @@ describe('useImageMenuOptions', () => {
|
||||
expect(copyIdx).toBeLessThan(pasteIdx)
|
||||
expect(pasteIdx).toBeLessThan(saveIdx)
|
||||
})
|
||||
|
||||
it('gives the Open in Mask Editor option the mask icon', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const maskOption = options.find((o) => o.label === 'Open in Mask Editor')
|
||||
|
||||
expect(maskOption?.icon).toBe('icon-[comfy--mask]')
|
||||
})
|
||||
|
||||
it('gives every image action option an icon so labels stay aligned', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
|
||||
expect(options.every((o) => !!o.icon)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteImage action', () => {
|
||||
|
||||
@@ -123,6 +123,7 @@ export function useImageMenuOptions() {
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Open in Mask Editor'),
|
||||
icon: 'icon-[comfy--mask]',
|
||||
action: () => openMaskEditor()
|
||||
},
|
||||
{
|
||||
|
||||
@@ -456,4 +456,105 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('native drag position tracking', () => {
|
||||
beforeEach(() => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([300, 300])
|
||||
})
|
||||
|
||||
// happy-dom has no DragEvent constructor; MouseEvent works since the
|
||||
// handler only reads clientX/clientY.
|
||||
function fireDrag(x: number, y: number) {
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('dragover', { clientX: x, clientY: y, bubbles: true })
|
||||
)
|
||||
}
|
||||
|
||||
it('should prefer tracked drag position over dragend coordinates', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
fireDrag(250, 250)
|
||||
// dragend supplies a bad position (the Firefox bug); the tracked one
|
||||
// from the last drag event should win.
|
||||
handleNativeDrop(1505, 102)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
|
||||
clientX: 250,
|
||||
clientY: 250
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore drag events with (0, 0)', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
fireDrag(250, 250)
|
||||
fireDrag(0, 0)
|
||||
handleNativeDrop(1505, 102)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
|
||||
clientX: 250,
|
||||
clientY: 250
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to dragend coordinates when no drag fired', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
|
||||
clientX: 250,
|
||||
clientY: 250
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore dragover events fired before startDrag', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
fireDrag(250, 250)
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
|
||||
clientX: 300,
|
||||
clientY: 300
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear tracked position between drags', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
fireDrag(250, 250)
|
||||
handleNativeDrop(1505, 102)
|
||||
|
||||
// Second drag - no drag events, so we should fall back to args.
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({
|
||||
clientX: 300,
|
||||
clientY: 300
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,16 +11,27 @@ const isDragging = ref(false)
|
||||
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
|
||||
const cursorPosition = ref({ x: 0, y: 0 })
|
||||
const dragMode = ref<DragMode>('click')
|
||||
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
|
||||
let listenersSetup = false
|
||||
|
||||
function updatePosition(e: PointerEvent) {
|
||||
cursorPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
// Firefox dragend can report stale clientX/Y and `drag` can fire with
|
||||
// (0, 0). dragover on the target reliably reports real client coords.
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
|
||||
function trackNativeDragPosition(e: DragEvent) {
|
||||
if (dragMode.value !== 'native') return
|
||||
if (e.clientX === 0 && e.clientY === 0) return
|
||||
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
lastNativeDragPosition.value = undefined
|
||||
}
|
||||
|
||||
function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
@@ -85,6 +96,7 @@ function setupGlobalListeners() {
|
||||
document.addEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.addEventListener('pointerup', endDrag, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
document.addEventListener('dragover', trackNativeDragPosition)
|
||||
}
|
||||
|
||||
function cleanupGlobalListeners() {
|
||||
@@ -95,6 +107,7 @@ function cleanupGlobalListeners() {
|
||||
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.removeEventListener('pointerup', endDrag, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('dragover', trackNativeDragPosition)
|
||||
|
||||
if (isDragging.value && dragMode.value === 'click') {
|
||||
cancelDrag()
|
||||
@@ -110,8 +123,9 @@ export function useNodeDragToCanvas() {
|
||||
|
||||
function handleNativeDrop(clientX: number, clientY: number) {
|
||||
if (dragMode.value !== 'native') return
|
||||
const tracked = lastNativeDragPosition.value
|
||||
try {
|
||||
addNodeAtPosition(clientX, clientY)
|
||||
addNodeAtPosition(tracked?.x ?? clientX, tracked?.y ?? clientY)
|
||||
} finally {
|
||||
cancelDrag()
|
||||
}
|
||||
|
||||
@@ -144,7 +144,6 @@ describe('useLoad3d', () => {
|
||||
setMaterialMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setRetainViewOnReload: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -334,6 +333,20 @@ describe('useLoad3d', () => {
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should set preview mode when comfyClass starts with Preview, even with width/height widgets', async () => {
|
||||
Object.defineProperty(mockNode, 'constructor', {
|
||||
value: { comfyClass: 'Preview3DAdvanced' },
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
@@ -570,21 +583,17 @@ describe('useLoad3d', () => {
|
||||
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
|
||||
vi.mocked(mockLoad3d.setFOV!).mockClear()
|
||||
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
|
||||
|
||||
composable.cameraConfig.value.cameraType = 'orthographic'
|
||||
composable.cameraConfig.value.fov = 90
|
||||
composable.cameraConfig.value.retainViewOnReload = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
|
||||
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
|
||||
expect(mockNode.properties['Camera Config']).toEqual({
|
||||
cameraType: 'orthographic',
|
||||
fov: 90,
|
||||
state: null,
|
||||
retainViewOnReload: true
|
||||
state: null
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -152,7 +152,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (!(widthWidget && heightWidget)) {
|
||||
if (
|
||||
node.constructor.comfyClass?.startsWith('Preview') ||
|
||||
!(widthWidget && heightWidget)
|
||||
) {
|
||||
isPreview.value = true
|
||||
}
|
||||
|
||||
@@ -484,7 +487,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Camera Config'] = newValue
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CameraState } from '@/extensions/core/load3d/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
@@ -9,17 +10,21 @@ const {
|
||||
waitForLoad3dMock,
|
||||
onLoad3dReadyMock,
|
||||
configureMock,
|
||||
configureForSaveMeshMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock,
|
||||
getNodeByLocatorIdMock
|
||||
getNodeByLocatorIdMock,
|
||||
nodeToLoad3dMap
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
onLoad3dReadyMock: vi.fn(),
|
||||
configureMock: vi.fn(),
|
||||
configureForSaveMeshMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn(),
|
||||
getNodeByLocatorIdMock: vi.fn()
|
||||
getNodeByLocatorIdMock: vi.fn(),
|
||||
nodeToLoad3dMap: new Map<object, unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
@@ -38,12 +43,13 @@ vi.mock('@/composables/useLoad3d', () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap: new Map()
|
||||
nodeToLoad3dMap
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configure = configureMock
|
||||
configureForSaveMesh = configureForSaveMeshMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -116,18 +122,21 @@ type ExtCreated = ComfyExtension & {
|
||||
onNodeOutputsUpdated: (
|
||||
nodeOutputs: Record<string, Record<string, unknown>>
|
||||
) => void
|
||||
getCustomWidgets: () => Record<string, (node: LGraphNode) => unknown>
|
||||
}
|
||||
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
preview3DAdvancedExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
|
||||
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +162,22 @@ function makePreview3DNode(
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makePreview3DAdvancedNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeLoad3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
@@ -179,7 +204,14 @@ interface FakeLoad3d {
|
||||
whenLoadIdle: () => Promise<void>
|
||||
setCameraFromMatrices: ReturnType<typeof vi.fn>
|
||||
setBackgroundImage: ReturnType<typeof vi.fn>
|
||||
setCameraState: ReturnType<typeof vi.fn>
|
||||
getCameraState: ReturnType<typeof vi.fn>
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
getModelInfo: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
forceRender: ReturnType<typeof vi.fn>
|
||||
cameraManager: { perspectiveCamera: { fov: number } }
|
||||
currentLoadGeneration: number
|
||||
}
|
||||
|
||||
@@ -188,7 +220,14 @@ function makeLoad3dMock(): FakeLoad3d {
|
||||
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
|
||||
setCameraFromMatrices: vi.fn(),
|
||||
setBackgroundImage: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
getCameraState: vi.fn(() => ({ position: [0, 0, 5], target: [0, 0, 0] })),
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
getModelInfo: vi.fn(() => null),
|
||||
applyModelTransform: vi.fn(),
|
||||
isSplatModel: vi.fn(() => false),
|
||||
forceRender: vi.fn(),
|
||||
cameraManager: { perspectiveCamera: { fov: 35 } },
|
||||
currentLoadGeneration: 0
|
||||
}
|
||||
}
|
||||
@@ -199,6 +238,7 @@ async function flush() {
|
||||
|
||||
function setupBaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
@@ -210,12 +250,14 @@ function setupBaseMocks() {
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -476,6 +518,47 @@ describe('Comfy.Load3D.nodeCreated', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D.getCustomWidgets LOAD_3D', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('adds upload and clear buttons when the node has a model_file widget', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode()
|
||||
const addWidget = node.addWidget as ReturnType<typeof vi.fn>
|
||||
|
||||
load3DExt.getCustomWidgets().LOAD_3D(node)
|
||||
|
||||
const buttonNames = addWidget.mock.calls
|
||||
.filter(([type]) => type === 'button')
|
||||
.map(([, name]) => name)
|
||||
expect(buttonNames).toEqual([
|
||||
'upload 3d model',
|
||||
'upload extra resources',
|
||||
'clear'
|
||||
])
|
||||
})
|
||||
|
||||
it('skips upload and clear buttons when the node has no model_file widget (e.g. Preview3DAdvanced)', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({
|
||||
comfyClass: 'Preview3DAdvanced',
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
})
|
||||
const addWidget = node.addWidget as ReturnType<typeof vi.fn>
|
||||
|
||||
load3DExt.getCustomWidgets().LOAD_3D(node)
|
||||
|
||||
const buttonCalls = addWidget.mock.calls.filter(
|
||||
([type]) => type === 'button'
|
||||
)
|
||||
expect(buttonCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
@@ -610,3 +693,324 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Preview3DAdvanced', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call configureForSaveMesh on creation when no Last Time Model File is persisted', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores via configureForSaveMesh when Last Time Model File is persisted', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
properties: { 'Last Time Model File': 'prev/model.glb' }
|
||||
})
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'prev/model.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the saved camera state after model load when reloading the page', async () => {
|
||||
const persistedCameraState = {
|
||||
position: [1, 2, 3],
|
||||
target: [0, 0, 0]
|
||||
} as unknown as CameraState
|
||||
const load3dInstance = makeLoad3dMock()
|
||||
onLoad3dReadyMock.mockImplementationOnce(
|
||||
(cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(load3dInstance)
|
||||
}
|
||||
)
|
||||
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
properties: {
|
||||
'Last Time Model File': 'prev/model.glb',
|
||||
'Camera Config': {
|
||||
cameraType: 'perspective',
|
||||
fov: 35,
|
||||
state: persistedCameraState
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
await flush()
|
||||
|
||||
expect(load3dInstance.setCameraState).toHaveBeenCalledWith(
|
||||
persistedCameraState
|
||||
)
|
||||
expect(load3dInstance.forceRender).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call setCameraState when no Camera Config state is persisted', async () => {
|
||||
const load3dInstance = makeLoad3dMock()
|
||||
onLoad3dReadyMock.mockImplementationOnce(
|
||||
(cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(load3dInstance)
|
||||
}
|
||||
)
|
||||
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
properties: { 'Last Time Model File': 'prev/model.glb' }
|
||||
})
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
await flush()
|
||||
|
||||
expect(load3dInstance.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches a camera-only serializeValue to the image widget', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(typeof widgets[0].serializeValue).toBe('function')
|
||||
})
|
||||
|
||||
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
const payload = await widgets[0].serializeValue!()
|
||||
|
||||
expect(payload).toEqual({
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: { position: [0, 0, 5], target: [0, 0, 0] },
|
||||
recording: '',
|
||||
model_3d_info: []
|
||||
})
|
||||
})
|
||||
|
||||
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
const modelInfo = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
load3d.getModelInfo = vi.fn(() => modelInfo)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
const payload = (await widgets[0].serializeValue!()) as {
|
||||
model_3d_info: unknown[]
|
||||
}
|
||||
|
||||
expect(payload.model_3d_info).toEqual([modelInfo])
|
||||
})
|
||||
|
||||
it('onExecuted persists Last Time Model File with normalized slashes and calls configureForSaveMesh', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'sub/nested/mesh.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('onExecuted applies the input cameraState when one is forwarded via PreviewUI3D', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const cameraState = { position: [1, 2, 3] }
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', cameraState] })
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).toHaveBeenCalledWith(cameraState)
|
||||
})
|
||||
|
||||
it('onExecuted applies the first model_3d_info entry to the viewport when present', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const transform = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, [transform]]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
})
|
||||
|
||||
it('onExecuted does not call applyModelTransform when model_3d_info is empty', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, []]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.applyModelTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted defensively skips cameraState apply when result[1] is missing', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb'] })
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted skips cameraState apply when load3d generation changes before whenLoadIdle resolves', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
let resolveIdle: () => void = () => {}
|
||||
load3d.whenLoadIdle = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveIdle = resolve
|
||||
})
|
||||
)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', { position: [1, 2, 3] }] })
|
||||
|
||||
load3d.currentLoadGeneration = 6
|
||||
resolveIdle()
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted shows an error toast when no file path is returned', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: [] })
|
||||
|
||||
expect(toastAddAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.unableToGetModelFilePath'
|
||||
)
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('returns [] for non-Preview3DAdvanced nodes', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when no load3d instance exists for the node', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue(null)
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for splat models', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns export menu items for non-splat models', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([
|
||||
{ content: 'Export' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ type Matrix = number[][]
|
||||
type Load3dPreviewOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, string?, Matrix?, Matrix?]
|
||||
}>
|
||||
type Preview3DAdvancedOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, Model3DInfo?]
|
||||
}>
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ComfyApp, app } from '@/scripts/app'
|
||||
@@ -269,7 +272,10 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
if (node.constructor.comfyClass === 'Load3D') {
|
||||
const hasModelFileWidget = node.widgets?.some(
|
||||
(w) => w.name === 'model_file'
|
||||
)
|
||||
if (hasModelFileWidget) {
|
||||
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
|
||||
|
||||
node.properties['Resource Folder'] = ''
|
||||
@@ -651,3 +657,156 @@ useExtensionService().registerExtension({
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAdvanced',
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to restore camera state for Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
|
||||
const currentLoad3d = resolveLoad3d()
|
||||
const config = new Load3DConfiguration(currentLoad3d, node.properties)
|
||||
config.configureForSaveMesh('output', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraState = result?.[1]
|
||||
const modelTransform = result?.[2]?.[0]
|
||||
if (cameraState || modelTransform) {
|
||||
const targetGeneration = currentLoad3d.currentLoadGeneration
|
||||
void currentLoad3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
|
||||
return
|
||||
if (cameraState) currentLoad3d.setCameraState(cameraState)
|
||||
if (modelTransform)
|
||||
currentLoad3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -287,6 +287,41 @@ describe('GizmoManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyModelTransform', () => {
|
||||
it('sets position, quaternion, and scale on target and notifies', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
manager.applyModelTransform({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.92 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
})
|
||||
|
||||
expect(model.position.x).toBeCloseTo(1)
|
||||
expect(model.position.y).toBeCloseTo(2)
|
||||
expect(model.position.z).toBeCloseTo(3)
|
||||
expect(model.quaternion.x).toBeCloseTo(0.1)
|
||||
expect(model.quaternion.y).toBeCloseTo(0.2)
|
||||
expect(model.quaternion.z).toBeCloseTo(0.3)
|
||||
expect(model.quaternion.w).toBeCloseTo(0.92)
|
||||
expect(model.scale.x).toBeCloseTo(2)
|
||||
expect(onTransformChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does nothing without a target', () => {
|
||||
manager.init()
|
||||
expect(() =>
|
||||
manager.applyModelTransform({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransform', () => {
|
||||
it('returns current target transform', () => {
|
||||
manager.init()
|
||||
|
||||
@@ -159,6 +159,27 @@ export class GizmoManager {
|
||||
}
|
||||
}
|
||||
|
||||
applyModelTransform(transform: Model3DTransform): void {
|
||||
if (!this.targetObject) return
|
||||
this.targetObject.position.set(
|
||||
transform.position.x,
|
||||
transform.position.y,
|
||||
transform.position.z
|
||||
)
|
||||
this.targetObject.quaternion.set(
|
||||
transform.quaternion.x,
|
||||
transform.quaternion.y,
|
||||
transform.quaternion.z,
|
||||
transform.quaternion.w
|
||||
)
|
||||
this.targetObject.scale.set(
|
||||
transform.scale.x,
|
||||
transform.scale.y,
|
||||
transform.scale.z
|
||||
)
|
||||
this.onTransformChange?.()
|
||||
}
|
||||
|
||||
getInitialTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
|
||||
@@ -39,6 +39,7 @@ type GizmoStub = {
|
||||
setMode: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
applyTransform: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
getTransform: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
updateCamera: ReturnType<typeof vi.fn>
|
||||
@@ -73,6 +74,7 @@ function makeGizmoStub(): GizmoStub {
|
||||
setMode: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
applyTransform: vi.fn(),
|
||||
applyModelTransform: vi.fn(),
|
||||
getTransform: vi.fn(() => ({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
@@ -203,6 +205,19 @@ describe('Load3d', () => {
|
||||
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
|
||||
})
|
||||
|
||||
it('applyModelTransform forwards the full position/quaternion/scale payload', () => {
|
||||
const transform = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.4 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
|
||||
ctx.load3d.applyModelTransform(transform)
|
||||
|
||||
expect(ctx.gizmo.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('getGizmoTransform returns the gizmoManager transform', () => {
|
||||
const transform = {
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
@@ -772,8 +787,8 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('retainViewOnReload', () => {
|
||||
function setupLoadInternal(initialFlag: boolean) {
|
||||
describe('camera framing across reloads', () => {
|
||||
function setupLoadInternal() {
|
||||
const getCameraState = vi.fn<() => CameraState>(() => ({
|
||||
position: new THREE.Vector3(1, 2, 3),
|
||||
target: new THREE.Vector3(),
|
||||
@@ -802,25 +817,23 @@ describe('Load3d', () => {
|
||||
setupModelAnimations: vi.fn()
|
||||
},
|
||||
handleResize: vi.fn(),
|
||||
retainViewOnReload: initialFlag,
|
||||
hasLoadedModel: false
|
||||
})
|
||||
return { getCameraState, setCameraState, getCurrentCameraType }
|
||||
}
|
||||
|
||||
it('first load uses default framing even with retain enabled', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
it('first load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
|
||||
// hasLoadedModel started false, so retain shouldn't kick in yet.
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('subsequent load captures camera state, skips reset, and restores it', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
it('subsequent load preserves the user-adjusted camera framing', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
@@ -834,23 +847,8 @@ describe('Load3d', () => {
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not retain when the flag is off, even after a prior load', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles to the saved camera type before restoring state when types differ', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
const mocks = setupLoadInternal()
|
||||
mocks.getCameraState.mockImplementation(() => ({
|
||||
position: new THREE.Vector3(0, 0, 5),
|
||||
target: new THREE.Vector3(),
|
||||
@@ -870,7 +868,7 @@ describe('Load3d', () => {
|
||||
})
|
||||
|
||||
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
const mocks = setupLoadInternal()
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.clearModel()
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
@@ -881,22 +879,6 @@ describe('Load3d', () => {
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.setRetainViewOnReload(true)
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
|
||||
expect(mocks.getCameraState).toHaveBeenCalledOnce()
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
|
||||
@@ -105,7 +105,6 @@ class Load3d {
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
private retainViewOnReload: boolean = false
|
||||
private hasLoadedModel: boolean = false
|
||||
|
||||
constructor(
|
||||
@@ -579,17 +578,14 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
public setRetainViewOnReload(value: boolean): void {
|
||||
this.retainViewOnReload = value
|
||||
}
|
||||
|
||||
private async _loadModelInternal(
|
||||
url: string,
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
// First load always uses default framing; retain only applies on reload.
|
||||
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
|
||||
// First load always uses default framing; subsequent reloads preserve
|
||||
// the user's framing.
|
||||
const shouldRetainView = this.hasLoadedModel
|
||||
const savedCameraState = shouldRetainView
|
||||
? this.cameraManager.getCameraState()
|
||||
: null
|
||||
@@ -919,6 +915,12 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public applyModelTransform(transform: Model3DTransform): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.applyModelTransform(transform)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public getGizmoTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
|
||||
@@ -80,7 +80,6 @@ export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
retainViewOnReload?: boolean
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "جاري إعادة تحميل النموذج...",
|
||||
"removeBackgroundImage": "إزالة صورة الخلفية",
|
||||
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
|
||||
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
|
||||
"scene": "المشهد",
|
||||
"showGrid": "عرض الشبكة",
|
||||
"showSkeleton": "إظهار الهيكل العظمي",
|
||||
|
||||
@@ -1963,7 +1963,6 @@
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"retainViewOnReload": "Lock camera view across model reloads",
|
||||
"showGrid": "Show Grid",
|
||||
"backgroundColor": "Background Color",
|
||||
"lightIntensity": "Light Intensity",
|
||||
@@ -3533,6 +3532,7 @@
|
||||
"skipForNow": "Skip for Now",
|
||||
"installMissingNodes": "Install Missing Nodes",
|
||||
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure.",
|
||||
"swapNodesGuide": "The following nodes can be automatically replaced with compatible alternatives.",
|
||||
"willBeReplacedBy": "This node will be replaced by:",
|
||||
"replaceNode": "Replace Node",
|
||||
"replaceAll": "Replace All",
|
||||
@@ -3614,6 +3614,8 @@
|
||||
"missingNodePacks": {
|
||||
"title": "Missing Node Packs",
|
||||
"unsupportedTitle": "Unsupported Node Packs",
|
||||
"cloudMessage": "This workflow requires custom nodes not yet available on Comfy Cloud.",
|
||||
"ossMessage": "This workflow uses custom nodes you haven't installed yet.",
|
||||
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
|
||||
"installAll": "Install All",
|
||||
"installNodePack": "Install node pack",
|
||||
@@ -3683,6 +3685,7 @@
|
||||
"viewDetails": "View details",
|
||||
"missingNodes": "Some nodes are missing and need to be installed",
|
||||
"missingModels": "{count} required model is missing | {count} required models are missing",
|
||||
"swapNodes": "Some nodes can be replaced with alternatives",
|
||||
"missingMedia": "Some nodes are missing required inputs"
|
||||
},
|
||||
"errorCatalog": {
|
||||
@@ -3690,46 +3693,6 @@
|
||||
"nodeName": "This node",
|
||||
"inputName": "unknown input"
|
||||
},
|
||||
"missingErrors": {
|
||||
"missing_node": {
|
||||
"displayMessageCloud": "Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
|
||||
"displayMessageOss": "Install missing packs to use this workflow.",
|
||||
"toastTitleOneCloud": "{nodeType} isn't available on Cloud",
|
||||
"toastTitleOneOss": "Missing node: {nodeType}",
|
||||
"toastTitleManyCloud": "Nodes aren't available on Cloud",
|
||||
"toastTitleManyOss": "Missing nodes",
|
||||
"toastMessageOneCloud": "This node isn't supported on Cloud.",
|
||||
"toastMessageOneOss": "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node.",
|
||||
"toastMessageManyCloud": "This workflow uses nodes that aren't supported on Cloud.",
|
||||
"toastMessageManyOss": "{count} nodes require missing node packs."
|
||||
},
|
||||
"swap_nodes": {
|
||||
"displayMessage": "Some nodes can be replaced with alternatives",
|
||||
"toastTitleOne": "{nodeType} can be replaced",
|
||||
"toastTitleMany": "Nodes can be replaced",
|
||||
"toastMessageOne": "Replace it with {replacementNodeType} from the error panel.",
|
||||
"toastMessageMany": "{count} node types can be replaced with compatible alternatives."
|
||||
},
|
||||
"missing_model": {
|
||||
"displayMessageCloud": "Import a model, or open the node to replace it.",
|
||||
"displayMessageOss": "Download a model, or open the node to replace it.",
|
||||
"toastTitleOneCloud": "{modelName} isn't available on Cloud",
|
||||
"toastTitleOneOss": "{modelName} is missing",
|
||||
"toastTitleMany": "Missing models",
|
||||
"toastTitleManyCloud": "Models aren't available on Cloud",
|
||||
"toastMessageOneCloud": "This model isn't supported. Choose a different one.",
|
||||
"toastMessageOneOss": "{nodeName} is missing a required model file.",
|
||||
"toastMessageManyCloud": "Some models aren't supported. Choose different ones.",
|
||||
"toastMessageManyOss": "{count} model files are missing."
|
||||
},
|
||||
"missing_media": {
|
||||
"displayMessage": "A required media input has no file selected.",
|
||||
"toastTitleOne": "Media input missing",
|
||||
"toastTitleMany": "Missing media inputs",
|
||||
"toastMessageWithNode": "{nodeName} is missing a required media file.",
|
||||
"toastMessageMany": "Please select the missing media inputs before running this workflow."
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"title": "Missing connection",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "Recargando modelo...",
|
||||
"removeBackgroundImage": "Eliminar imagen de fondo",
|
||||
"resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida",
|
||||
"retainViewOnReload": "Bloquear la vista de la cámara al recargar el modelo",
|
||||
"scene": "Escena",
|
||||
"showGrid": "Mostrar cuadrícula",
|
||||
"showSkeleton": "Mostrar esqueleto",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "در حال بارگذاری مجدد مدل...",
|
||||
"removeBackgroundImage": "حذف تصویر پسزمینه",
|
||||
"resizeNodeMatchOutput": "تغییر اندازه node مطابق خروجی",
|
||||
"retainViewOnReload": "قفل کردن نمای دوربین هنگام بارگذاری مجدد مدل",
|
||||
"scene": "صحنه",
|
||||
"showGrid": "نمایش شبکه",
|
||||
"showSkeleton": "نمایش اسکلت",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "Rechargement du modèle...",
|
||||
"removeBackgroundImage": "Supprimer l'image de fond",
|
||||
"resizeNodeMatchOutput": "Redimensionner le nœud pour correspondre à la sortie",
|
||||
"retainViewOnReload": "Verrouiller la vue de la caméra lors du rechargement du modèle",
|
||||
"scene": "Scène",
|
||||
"showGrid": "Afficher la grille",
|
||||
"showSkeleton": "Afficher le squelette",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "モデルを再読み込み中...",
|
||||
"removeBackgroundImage": "背景画像を削除",
|
||||
"resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ",
|
||||
"retainViewOnReload": "モデルの再読み込み時にカメラビューを固定する",
|
||||
"scene": "シーン",
|
||||
"showGrid": "グリッドを表示",
|
||||
"showSkeleton": "スケルトンを表示",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "모델 다시 로드 중...",
|
||||
"removeBackgroundImage": "배경 이미지 제거",
|
||||
"resizeNodeMatchOutput": "노드 크기를 출력에 맞추기",
|
||||
"retainViewOnReload": "모델을 다시 불러와도 카메라 뷰 고정",
|
||||
"scene": "장면",
|
||||
"showGrid": "그리드 표시",
|
||||
"showSkeleton": "스켈레톤 표시",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "Recarregando modelo...",
|
||||
"removeBackgroundImage": "Remover Imagem de Fundo",
|
||||
"resizeNodeMatchOutput": "Redimensionar Nó para corresponder à saída",
|
||||
"retainViewOnReload": "Manter a visão da câmera ao recarregar o modelo",
|
||||
"scene": "Cena",
|
||||
"showGrid": "Mostrar Grade",
|
||||
"showSkeleton": "Mostrar Esqueleto",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "Перезагрузка модели...",
|
||||
"removeBackgroundImage": "Удалить фоновое изображение",
|
||||
"resizeNodeMatchOutput": "Изменить размер узла под вывод",
|
||||
"retainViewOnReload": "Зафиксировать вид камеры при перезагрузке модели",
|
||||
"scene": "Сцена",
|
||||
"showGrid": "Показать сетку",
|
||||
"showSkeleton": "Показать скелет",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "Model yeniden yükleniyor...",
|
||||
"removeBackgroundImage": "Arka Plan Resmini Kaldır",
|
||||
"resizeNodeMatchOutput": "Düğümü çıktıya uyacak şekilde yeniden boyutlandır",
|
||||
"retainViewOnReload": "Model yeniden yüklendiğinde kamera görünümünü kilitle",
|
||||
"scene": "Sahne",
|
||||
"showGrid": "Izgarayı Göster",
|
||||
"showSkeleton": "İskeleti Göster",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "重新載入模型中...",
|
||||
"removeBackgroundImage": "移除背景圖片",
|
||||
"resizeNodeMatchOutput": "調整節點以符合輸出",
|
||||
"retainViewOnReload": "鎖定相機視角於模型重新載入時保持不變",
|
||||
"scene": "場景",
|
||||
"showGrid": "顯示格線",
|
||||
"showSkeleton": "顯示骨架",
|
||||
|
||||
@@ -2077,7 +2077,6 @@
|
||||
"reloadingModel": "正在重新加载模型...",
|
||||
"removeBackgroundImage": "移除背景图片",
|
||||
"resizeNodeMatchOutput": "调整节点以匹配输出",
|
||||
"retainViewOnReload": "模型重新加载时锁定相机视角",
|
||||
"scene": "场景",
|
||||
"showGrid": "显示网格",
|
||||
"showSkeleton": "显示骨架",
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('AssetBrowserModal', () => {
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
asset_hash: `blake3:${id.padEnd(64, '0')}`,
|
||||
hash: `blake3:${id.padEnd(64, '0')}`,
|
||||
size: 1024000,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', category, 'test'],
|
||||
|
||||
@@ -49,10 +49,10 @@ const ORIGINAL_FILENAME = 'sunset_photo.png'
|
||||
function createDisplayAsset(
|
||||
overrides: Partial<AssetDisplayItem> = {}
|
||||
): AssetDisplayItem {
|
||||
return {
|
||||
const base = {
|
||||
id: 'asset-1',
|
||||
name: HASH,
|
||||
asset_hash: HASH,
|
||||
hash: HASH,
|
||||
tags: ['input'],
|
||||
preview_url: '/preview.png',
|
||||
secondaryText: '',
|
||||
@@ -62,6 +62,7 @@ function createDisplayAsset(
|
||||
metadata: { filename: ORIGINAL_FILENAME },
|
||||
...overrides
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function renderCard(asset: AssetDisplayItem) {
|
||||
@@ -97,7 +98,7 @@ describe('AssetCard', () => {
|
||||
})
|
||||
|
||||
describe('FE-228: filename rendering', () => {
|
||||
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
|
||||
it('renders the human-readable filename instead of hash when asset.name equals hash', () => {
|
||||
const asset = createDisplayAsset()
|
||||
|
||||
renderCard(asset)
|
||||
@@ -130,7 +131,7 @@ describe('AssetCard', () => {
|
||||
const asset = createDisplayAsset({
|
||||
id: 'model-1',
|
||||
name: MODEL_FILENAME,
|
||||
asset_hash: undefined,
|
||||
hash: undefined,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: { name: CURATED_NAME },
|
||||
metadata: { filename: MODEL_FILENAME }
|
||||
@@ -146,7 +147,7 @@ describe('AssetCard', () => {
|
||||
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
|
||||
const asset = createDisplayAsset({
|
||||
name: HASH,
|
||||
asset_hash: HASH,
|
||||
hash: HASH,
|
||||
user_metadata: { name: HASH },
|
||||
metadata: { filename: ORIGINAL_FILENAME }
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: 'mesh.glb',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
mime_type: 'model/gltf-binary',
|
||||
tags: [],
|
||||
kind: '3D',
|
||||
|
||||
@@ -13,7 +13,7 @@ function createVideoAsset(
|
||||
return {
|
||||
id: 'video-1',
|
||||
name: 'clip.mp4',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
mime_type: mimeType,
|
||||
tags: [],
|
||||
kind: 'video',
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('ModelInfoPanel', () => {
|
||||
): AssetDisplayItem => ({
|
||||
id: 'test-id',
|
||||
name: 'test-model.safetensors',
|
||||
asset_hash: 'hash123',
|
||||
hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
|
||||
@@ -26,7 +26,7 @@ function makeAsset(index: number): AssetItem {
|
||||
return {
|
||||
id: `asset-${index}`,
|
||||
name: `asset-${index}.safetensors`,
|
||||
asset_hash: `blake3:${index}`,
|
||||
hash: `blake3:${index}`,
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', category],
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('useAssetBrowser', () => {
|
||||
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
|
||||
id: 'test-id',
|
||||
name: 'test-asset.safetensors',
|
||||
asset_hash: 'blake3:abc123',
|
||||
hash: 'blake3:abc123',
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
|
||||
@@ -296,7 +296,7 @@ describe('useMediaAssetActions', () => {
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'my-image.jpeg',
|
||||
asset_hash: 'hash123.jpeg'
|
||||
hash: 'hash123.jpeg'
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -310,12 +310,12 @@ describe('useMediaAssetActions', () => {
|
||||
mockIsCloud.value = true
|
||||
})
|
||||
|
||||
it('should use asset_hash as filename when available', async () => {
|
||||
it('should use hash as filename when available', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'original.jpeg',
|
||||
asset_hash: 'abc123hash.jpeg'
|
||||
hash: 'abc123hash.jpeg'
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -323,12 +323,12 @@ describe('useMediaAssetActions', () => {
|
||||
expect(capturedFilenames.values).toContain('abc123hash.jpeg')
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when asset_hash is not available', async () => {
|
||||
it('should fall back to asset.name when hash is not available', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'fallback-name.jpeg',
|
||||
asset_hash: undefined
|
||||
hash: undefined
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -336,12 +336,12 @@ describe('useMediaAssetActions', () => {
|
||||
expect(capturedFilenames.values).toContain('fallback-name.jpeg')
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when asset_hash is null', async () => {
|
||||
it('should fall back to asset.name when hash is null', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'fallback-null.jpeg',
|
||||
asset_hash: null
|
||||
hash: null
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -357,19 +357,19 @@ describe('useMediaAssetActions', () => {
|
||||
mockIsCloud.value = true
|
||||
})
|
||||
|
||||
it('should use asset_hash for each asset', async () => {
|
||||
it('should use hash for each asset', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assets = [
|
||||
createMockAsset({
|
||||
id: '1',
|
||||
name: 'file1.jpeg',
|
||||
asset_hash: 'hash1.jpeg'
|
||||
hash: 'hash1.jpeg'
|
||||
}),
|
||||
createMockAsset({
|
||||
id: '2',
|
||||
name: 'file2.jpeg',
|
||||
asset_hash: 'hash2.jpeg'
|
||||
hash: 'hash2.jpeg'
|
||||
})
|
||||
]
|
||||
|
||||
@@ -973,7 +973,7 @@ describe('useMediaAssetActions', () => {
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-match',
|
||||
name: 'foo.png',
|
||||
asset_hash: 'abc123.png',
|
||||
hash: 'abc123.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
@@ -1051,7 +1051,7 @@ describe('useMediaAssetActions', () => {
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-failed',
|
||||
name: 'failed.png',
|
||||
asset_hash: 'failhash.png'
|
||||
hash: 'failhash.png'
|
||||
})
|
||||
|
||||
await actions.deleteAssets(asset)
|
||||
|
||||
@@ -43,8 +43,8 @@ const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
|
||||
*
|
||||
* Output assets emit `<name> [output]` (and the subfolder-prefixed form when
|
||||
* present in metadata). Input/temp assets emit the bare name plus the explicit
|
||||
* annotation. `asset_hash` is included whenever present, since cloud-stored
|
||||
* assets can be referenced by hash.
|
||||
* annotation. The content `hash` is included whenever present, since
|
||||
* cloud-stored assets can be referenced by hash.
|
||||
*/
|
||||
function widgetValueVariantsForAsset(asset: AssetItem): string[] {
|
||||
const variants: string[] = []
|
||||
@@ -62,7 +62,7 @@ function widgetValueVariantsForAsset(asset: AssetItem): string[] {
|
||||
variants.push(`${name} [input]`)
|
||||
}
|
||||
}
|
||||
const hash = asset.hash ?? asset.asset_hash
|
||||
const hash = asset.hash
|
||||
if (hash) variants.push(hash)
|
||||
return variants
|
||||
}
|
||||
@@ -300,10 +300,9 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const assetType = getAssetType(targetAsset, 'input')
|
||||
|
||||
// In Cloud mode, use the content hash (the actual stored filename),
|
||||
// preferring hash and falling back to the deprecated asset_hash alias.
|
||||
// In Cloud mode, use the content hash (the actual stored filename).
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = targetAsset.hash ?? targetAsset.asset_hash
|
||||
const cloudHash = targetAsset.hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
|
||||
|
||||
// Create annotated path for the asset
|
||||
@@ -445,10 +444,9 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const assetType = getAssetType(asset, 'input')
|
||||
|
||||
// In Cloud mode, use the content hash (the actual stored filename),
|
||||
// preferring hash and falling back to the deprecated asset_hash alias.
|
||||
// In Cloud mode, use the content hash (the actual stored filename).
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = asset.hash ?? asset.asset_hash
|
||||
const cloudHash = asset.hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : asset.name
|
||||
|
||||
const annotated = createAnnotatedPath(
|
||||
|
||||
@@ -97,11 +97,12 @@ export function createMockAssets(count: number = 20): AssetItem[] {
|
||||
const lastAccessTime = getRandomISODate()
|
||||
|
||||
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
|
||||
const fakeAssetHash = generateFakeAssetHash()
|
||||
|
||||
return {
|
||||
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
|
||||
name: fakeFileName,
|
||||
asset_hash: generateFakeAssetHash(),
|
||||
hash: fakeAssetHash,
|
||||
size: sizeInBytes,
|
||||
mime_type: mimeType,
|
||||
tags: [
|
||||
|
||||
@@ -6,7 +6,6 @@ const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
hash: z.string().nullish(),
|
||||
asset_hash: 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([]),
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('assetMetadataUtils', () => {
|
||||
const mockAsset: AssetItem = {
|
||||
id: 'test-id',
|
||||
name: 'test-model',
|
||||
asset_hash: 'hash123',
|
||||
hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
|
||||
@@ -201,10 +201,10 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
|
||||
/**
|
||||
* Returns the filename component the cloud `/api/view` endpoint resolves
|
||||
* for this asset — `asset_hash` when present (cloud assets are hash-keyed
|
||||
* for this asset — `hash` when present (cloud assets are hash-keyed
|
||||
* in storage), otherwise `asset.name`. Use this when constructing widget
|
||||
* values or media URLs that must round-trip through the view endpoint.
|
||||
*/
|
||||
export function getAssetUrlFilename(asset: AssetItem): string {
|
||||
return asset.hash ?? asset.asset_hash ?? asset.name
|
||||
return asset.hash ?? asset.name
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ function mockFetchError() {
|
||||
const cloudAsset = {
|
||||
id: '72d169cc-7f9a-40d2-9382-35eadcba0a6a',
|
||||
name: 'mesh/ComfyUI_00003_.glb',
|
||||
asset_hash: 'c6cadcee57dd.glb',
|
||||
hash: 'c6cadcee57dd.glb',
|
||||
preview_id: null,
|
||||
preview_url: undefined
|
||||
}
|
||||
@@ -110,9 +110,7 @@ describe('findOutputAsset', () => {
|
||||
const result = await findOutputAsset('c6cadcee57dd.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledOnce()
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain(
|
||||
'asset_hash=c6cadcee57dd.glb'
|
||||
)
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=c6cadcee57dd.glb')
|
||||
expect(result).toEqual(cloudAsset)
|
||||
})
|
||||
|
||||
@@ -123,7 +121,7 @@ describe('findOutputAsset', () => {
|
||||
const result = await findOutputAsset('ComfyUI_00081_.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_hash=')
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=')
|
||||
expect(mockFetchApi.mock.calls[1][0]).toContain('name_contains=')
|
||||
expect(result).toEqual(localAsset)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ interface AssetRecord {
|
||||
id: string
|
||||
name: string
|
||||
hash?: string
|
||||
asset_hash?: string
|
||||
preview_url?: string
|
||||
preview_id?: string | null
|
||||
}
|
||||
@@ -36,14 +35,14 @@ function resolvePreviewUrl(asset: AssetRecord): string {
|
||||
|
||||
/**
|
||||
* Find an output asset record by content hash, falling back to name.
|
||||
* On cloud, output filenames are content-hashed; use asset_hash to match.
|
||||
* On cloud, output filenames are content-hashed; use hash to match.
|
||||
* On local, filenames are not hashed; use name_contains to match.
|
||||
*/
|
||||
export async function findOutputAsset(
|
||||
name: string
|
||||
): Promise<AssetRecord | undefined> {
|
||||
const byHash = await fetchAssets({ asset_hash: name })
|
||||
const hashMatch = byHash.find((a) => (a.hash ?? a.asset_hash) === name)
|
||||
const byHash = await fetchAssets({ hash: name })
|
||||
const hashMatch = byHash.find((a) => a.hash === name)
|
||||
if (hashMatch) return hashMatch
|
||||
|
||||
const byName = await fetchAssets({ name_contains: name })
|
||||
|
||||
@@ -15,7 +15,7 @@ import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
*
|
||||
* Comparison is full-string against the widget value as stored — callers must
|
||||
* provide the canonical widget-value variants for each deleted asset (e.g.
|
||||
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<asset_hash>`). This
|
||||
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<hash>`). This
|
||||
* avoids false matches when two distinct assets share a basename across
|
||||
* input/output sources.
|
||||
*
|
||||
|
||||
@@ -6,9 +6,6 @@ import {
|
||||
} from './errorMessageResolver'
|
||||
import type { NodeValidationError } from './types'
|
||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
function nodeValidationError(
|
||||
@@ -58,59 +55,6 @@ function executionError(
|
||||
}
|
||||
}
|
||||
|
||||
function missingNodeType(
|
||||
type: string,
|
||||
nodeId: string,
|
||||
cnrId?: string
|
||||
): MissingNodeType {
|
||||
return {
|
||||
type,
|
||||
nodeId,
|
||||
cnrId,
|
||||
isReplaceable: false
|
||||
}
|
||||
}
|
||||
|
||||
function replaceableNodeType(
|
||||
type: string,
|
||||
nodeId: string,
|
||||
replacementNodeType: string
|
||||
): MissingNodeType {
|
||||
return {
|
||||
type,
|
||||
nodeId,
|
||||
isReplaceable: true,
|
||||
replacement: {
|
||||
old_node_id: type,
|
||||
new_node_id: replacementNodeType,
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function missingModelGroups(...names: string[]): MissingModelGroup[] {
|
||||
return [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: names.map((name) => ({
|
||||
name,
|
||||
representative: {
|
||||
name,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: []
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('errorMessageResolver', () => {
|
||||
it('resolves required_input_missing to missing connection display copy', () => {
|
||||
const result = resolveRunErrorMessage({
|
||||
@@ -1363,342 +1307,17 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
|
||||
it('resolves missing error group display copy', () => {
|
||||
const missingNodeTypes = [missingNodeType('FooNode', '7', 'foo-pack')]
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: missingNodeTypes,
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Missing Node Packs (1)',
|
||||
displayMessage: 'Install missing packs to use this workflow.',
|
||||
toastTitle: 'Missing node: FooNode',
|
||||
toastMessage:
|
||||
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: missingNodeTypes,
|
||||
count: 1,
|
||||
isCloud: true
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Unsupported Node Packs (1)',
|
||||
displayMessage:
|
||||
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
|
||||
toastTitle: "FooNode isn't available on Cloud",
|
||||
toastMessage: "This node isn't supported on Cloud."
|
||||
})
|
||||
|
||||
const multipleMissingNodeTypes = [
|
||||
missingNodeType('FooNode', '7', 'foo-pack'),
|
||||
missingNodeType('BarNode', '9', 'bar-pack')
|
||||
]
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: multipleMissingNodeTypes,
|
||||
count: 2,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'Missing nodes',
|
||||
toastMessage: '2 nodes require missing node packs.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: multipleMissingNodeTypes,
|
||||
count: 2,
|
||||
isCloud: true
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: "Nodes aren't available on Cloud",
|
||||
toastMessage: "This workflow uses nodes that aren't supported on Cloud."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: [
|
||||
missingNodeType('FooNode', '7', 'foo-pack'),
|
||||
missingNodeType('FooNode', '8', 'foo-pack')
|
||||
],
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'Missing node: FooNode',
|
||||
toastMessage:
|
||||
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
|
||||
})
|
||||
|
||||
const swapNodeTypes = [replaceableNodeType('OldNode', '8', 'NewNode')]
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
nodeTypes: swapNodeTypes,
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: 'Swap Nodes (1)',
|
||||
displayMessage: 'Some nodes can be replaced with alternatives',
|
||||
toastTitle: 'OldNode can be replaced',
|
||||
toastMessage: 'Replace it with NewNode from the error panel.'
|
||||
})
|
||||
|
||||
const multipleSwapNodeTypes = [
|
||||
replaceableNodeType('OldNodeA', '8', 'NewNodeA'),
|
||||
replaceableNodeType('OldNodeB', '9', 'NewNodeB')
|
||||
]
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
nodeTypes: multipleSwapNodeTypes,
|
||||
count: 2,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
displayMessage: 'Some nodes can be replaced with alternatives',
|
||||
toastTitle: 'Nodes can be replaced',
|
||||
toastMessage: '2 node types can be replaced with compatible alternatives.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
nodeTypes: [
|
||||
replaceableNodeType('OldNode', '8', 'NewNode'),
|
||||
replaceableNodeType('OldNode', '9', 'NewNode')
|
||||
],
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'OldNode can be replaced',
|
||||
toastMessage: 'Replace it with NewNode from the error panel.'
|
||||
})
|
||||
|
||||
const groups = missingModelGroups('sdxl.safetensors')
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups,
|
||||
groups: [],
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayMessage: 'Download a model, or open the node to replace it.',
|
||||
toastTitle: 'sdxl.safetensors is missing',
|
||||
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups,
|
||||
count: 1,
|
||||
isCloud: true
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: "sdxl.safetensors isn't available on Cloud",
|
||||
toastMessage: "This model isn't supported. Choose a different one."
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves missing media group display and toast copy', () => {
|
||||
const groups: MissingMediaGroup[] = [
|
||||
{
|
||||
mediaType: 'image',
|
||||
items: [
|
||||
{
|
||||
name: 'portrait.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '4',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'portrait.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '4', widgetName: 'image' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups,
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: 'Missing Inputs (1)',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.'
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
[
|
||||
'image',
|
||||
'LoadImage',
|
||||
'image',
|
||||
'portrait.png',
|
||||
'Media input missing',
|
||||
'Load Image is missing a required media file.'
|
||||
],
|
||||
[
|
||||
'video',
|
||||
'LoadVideo',
|
||||
'file',
|
||||
'clip.mp4',
|
||||
'Media input missing',
|
||||
'Load Video is missing a required media file.'
|
||||
],
|
||||
[
|
||||
'audio',
|
||||
'LoadAudio',
|
||||
'audio',
|
||||
'voice.wav',
|
||||
'Media input missing',
|
||||
'Load Audio is missing a required media file.'
|
||||
]
|
||||
] as const)(
|
||||
'resolves missing %s toast copy from media type and node type',
|
||||
([
|
||||
mediaType,
|
||||
nodeType,
|
||||
widgetName,
|
||||
mediaName,
|
||||
toastTitle,
|
||||
toastMessage
|
||||
]) => {
|
||||
const groups: MissingMediaGroup[] = [
|
||||
{
|
||||
mediaType,
|
||||
items: [
|
||||
{
|
||||
name: mediaName,
|
||||
mediaType,
|
||||
representative: {
|
||||
nodeId: '4',
|
||||
nodeType,
|
||||
widgetName,
|
||||
mediaType,
|
||||
name: mediaName,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '4', widgetName }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups,
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle,
|
||||
toastMessage
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('summarizes multiple missing model and media items', () => {
|
||||
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups: modelGroups,
|
||||
count: 2,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'Missing models',
|
||||
toastMessage: '2 model files are missing.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups: modelGroups,
|
||||
count: 2,
|
||||
isCloud: true
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: "Models aren't available on Cloud",
|
||||
toastMessage: "Some models aren't supported. Choose different ones."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups: [
|
||||
{
|
||||
mediaType: 'image',
|
||||
items: [
|
||||
{
|
||||
name: 'a.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'a.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '1', widgetName: 'image' }]
|
||||
},
|
||||
{
|
||||
name: 'b.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '2',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'b.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '2', widgetName: 'image' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
count: 2,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'Missing media inputs',
|
||||
toastMessage:
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
displayMessage: '1 required model is missing'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,312 +2,19 @@ import type {
|
||||
MissingErrorMessageSource,
|
||||
ResolvedMissingErrorMessage
|
||||
} from './types'
|
||||
import { translateCatalogMessage } from './catalogI18n'
|
||||
import { st } from '@/i18n'
|
||||
import { st, t } from '@/i18n'
|
||||
|
||||
// Resolves pre-run missing-resource groups (nodes, models, media, swaps). These
|
||||
// are grouped catalog messages rather than individual execution error items.
|
||||
function formatCountTitle(title: string, count: number): string {
|
||||
return `${title} (${count})`
|
||||
}
|
||||
|
||||
function formatNodeTypeName(nodeType: string): string | null {
|
||||
const trimmed = nodeType.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
return trimmed
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
type NodeTypeErrorSource = Extract<
|
||||
MissingErrorMessageSource,
|
||||
{ kind: 'missing_node' | 'swap_nodes' }
|
||||
>
|
||||
type NodeTypeErrorItem = NodeTypeErrorSource['nodeTypes'][number]
|
||||
|
||||
function getNodeTypeLabel(nodeType: NodeTypeErrorItem): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function getDistinctNodeTypeLabels(nodeTypes: NodeTypeErrorItem[]): string[] {
|
||||
const labels = new Set<string>()
|
||||
for (const nodeType of nodeTypes) labels.add(getNodeTypeLabel(nodeType))
|
||||
return Array.from(labels)
|
||||
}
|
||||
|
||||
type MissingNodeSource = Extract<
|
||||
MissingErrorMessageSource,
|
||||
{ kind: 'missing_node' }
|
||||
>
|
||||
|
||||
function isMissingNodeType(nodeType: NodeTypeErrorItem): boolean {
|
||||
return typeof nodeType === 'string' || !nodeType.isReplaceable
|
||||
}
|
||||
|
||||
function resolveMissingNodeDisplayMessage(source: MissingNodeSource): string {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.displayMessageCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.displayMessageOss'
|
||||
const fallback = source.isCloud
|
||||
? "Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
|
||||
: 'Install missing packs to use this workflow.'
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
function resolveMissingNodeToastTitle(source: MissingNodeSource): string {
|
||||
const labels = getDistinctNodeTypeLabels(
|
||||
source.nodeTypes.filter(isMissingNodeType)
|
||||
)
|
||||
const [firstLabel] = labels
|
||||
if (labels.length === 1 && firstLabel) {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.toastTitleOneCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.toastTitleOneOss'
|
||||
const fallback = source.isCloud
|
||||
? "{nodeType} isn't available on Cloud"
|
||||
: 'Missing node: {nodeType}'
|
||||
return translateCatalogMessage(key, fallback, { nodeType: firstLabel })
|
||||
}
|
||||
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.toastTitleManyCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.toastTitleManyOss'
|
||||
const fallback = source.isCloud
|
||||
? "Nodes aren't available on Cloud"
|
||||
: 'Missing nodes'
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
function resolveMissingNodeToastMessage(source: MissingNodeSource): string {
|
||||
const labels = getDistinctNodeTypeLabels(
|
||||
source.nodeTypes.filter(isMissingNodeType)
|
||||
)
|
||||
const count = labels.length || source.count
|
||||
|
||||
if (count === 1) {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.toastMessageOneCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.toastMessageOneOss'
|
||||
const fallback = source.isCloud
|
||||
? "This node isn't supported on Cloud."
|
||||
: "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.toastMessageManyCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.toastMessageManyOss'
|
||||
const fallback = source.isCloud
|
||||
? "This workflow uses nodes that aren't supported on Cloud."
|
||||
: '{count} nodes require missing node packs.'
|
||||
return translateCatalogMessage(key, fallback, { count })
|
||||
}
|
||||
|
||||
type SwapNodeSource = Extract<MissingErrorMessageSource, { kind: 'swap_nodes' }>
|
||||
|
||||
function isSwapNodeType(nodeType: NodeTypeErrorItem): nodeType is Exclude<
|
||||
NodeTypeErrorItem,
|
||||
string
|
||||
> & {
|
||||
isReplaceable: true
|
||||
} {
|
||||
return typeof nodeType !== 'string' && nodeType.isReplaceable === true
|
||||
}
|
||||
|
||||
function getSwapNodeTypes(source: SwapNodeSource) {
|
||||
return source.nodeTypes.filter(isSwapNodeType)
|
||||
}
|
||||
|
||||
function resolveSwapNodeToastTitle(source: SwapNodeSource): string {
|
||||
const nodeTypes = getSwapNodeTypes(source)
|
||||
const labels = getDistinctNodeTypeLabels(nodeTypes)
|
||||
const [firstLabel] = labels
|
||||
if (labels.length === 1 && firstLabel) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.toastTitleOne',
|
||||
'{nodeType} can be replaced',
|
||||
{ nodeType: firstLabel }
|
||||
)
|
||||
}
|
||||
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.toastTitleMany',
|
||||
'Nodes can be replaced'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSwapNodeToastMessage(source: SwapNodeSource): string {
|
||||
const nodeTypes = getSwapNodeTypes(source)
|
||||
const labels = getDistinctNodeTypeLabels(nodeTypes)
|
||||
const [firstNodeType] = nodeTypes
|
||||
if (labels.length === 1 && firstNodeType?.replacement?.new_node_id) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.toastMessageOne',
|
||||
'Replace it with {replacementNodeType} from the error panel.',
|
||||
{ replacementNodeType: firstNodeType.replacement.new_node_id }
|
||||
)
|
||||
}
|
||||
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.toastMessageMany',
|
||||
'{count} node types can be replaced with compatible alternatives.',
|
||||
{ count: labels.length || source.count }
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSwapNodeDisplayMessage(): string {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.displayMessage',
|
||||
'Some nodes can be replaced with alternatives'
|
||||
)
|
||||
}
|
||||
|
||||
type MissingModelSource = Extract<
|
||||
MissingErrorMessageSource,
|
||||
{ kind: 'missing_model' }
|
||||
>
|
||||
|
||||
function getMissingModelCount(source: MissingModelSource): number {
|
||||
const count = source.groups.reduce(
|
||||
(total, group) => total + group.models.length,
|
||||
0
|
||||
)
|
||||
return count || source.count
|
||||
}
|
||||
|
||||
function resolveMissingModelDisplayMessage(source: MissingModelSource): string {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_model.displayMessageCloud'
|
||||
: 'errorCatalog.missingErrors.missing_model.displayMessageOss'
|
||||
const fallback = source.isCloud
|
||||
? 'Import a model, or open the node to replace it.'
|
||||
: 'Download a model, or open the node to replace it.'
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
function resolveMissingModelToastTitle(source: MissingModelSource): string {
|
||||
const [firstModel] = source.groups.flatMap((group) => group.models)
|
||||
const count = getMissingModelCount(source)
|
||||
|
||||
if (count === 1 && firstModel) {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_model.toastTitleOneCloud'
|
||||
: 'errorCatalog.missingErrors.missing_model.toastTitleOneOss'
|
||||
const fallback = source.isCloud
|
||||
? "{modelName} isn't available on Cloud"
|
||||
: '{modelName} is missing'
|
||||
return translateCatalogMessage(key, fallback, {
|
||||
modelName: firstModel.name
|
||||
})
|
||||
}
|
||||
|
||||
const useCloudPluralTitle = source.isCloud && count > 1
|
||||
const key = useCloudPluralTitle
|
||||
? 'errorCatalog.missingErrors.missing_model.toastTitleManyCloud'
|
||||
: 'errorCatalog.missingErrors.missing_model.toastTitleMany'
|
||||
const fallback = useCloudPluralTitle
|
||||
? "Models aren't available on Cloud"
|
||||
: 'Missing models'
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
function getMissingModelNodeName(
|
||||
model: MissingModelSource['groups'][number]['models'][number]
|
||||
): string {
|
||||
return (
|
||||
formatNodeTypeName(model.representative.nodeType) ??
|
||||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveMissingModelToastMessage(source: MissingModelSource): string {
|
||||
const [firstModel] = source.groups.flatMap((group) => group.models)
|
||||
const count = getMissingModelCount(source)
|
||||
|
||||
if (!firstModel || count !== 1) {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_model.toastMessageManyCloud'
|
||||
: 'errorCatalog.missingErrors.missing_model.toastMessageManyOss'
|
||||
const fallback = source.isCloud
|
||||
? "Some models aren't supported. Choose different ones."
|
||||
: '{count} model files are missing.'
|
||||
return translateCatalogMessage(key, fallback, { count })
|
||||
}
|
||||
|
||||
if (source.isCloud) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_model.toastMessageOneCloud',
|
||||
"This model isn't supported. Choose a different one."
|
||||
)
|
||||
}
|
||||
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_model.toastMessageOneOss',
|
||||
'{nodeName} is missing a required model file.',
|
||||
{ nodeName: getMissingModelNodeName(firstModel) }
|
||||
)
|
||||
}
|
||||
|
||||
type MissingMediaSource = Extract<
|
||||
MissingErrorMessageSource,
|
||||
{ kind: 'missing_media' }
|
||||
>
|
||||
|
||||
function getMissingMediaItems(source: MissingMediaSource) {
|
||||
return source.groups.flatMap((group) => group.items)
|
||||
}
|
||||
|
||||
function getMissingMediaNodeName(
|
||||
item: ReturnType<typeof getMissingMediaItems>[number]
|
||||
): string | null {
|
||||
return formatNodeTypeName(item.representative.nodeType)
|
||||
}
|
||||
|
||||
function resolveMissingMediaDisplayMessage(): string {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.displayMessage',
|
||||
'A required media input has no file selected.'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
|
||||
const items = getMissingMediaItems(source)
|
||||
if (items.length !== 1) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastTitleMany',
|
||||
'Missing media inputs'
|
||||
)
|
||||
}
|
||||
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastTitleOne',
|
||||
'Media input missing'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
|
||||
const items = getMissingMediaItems(source)
|
||||
const [firstItem] = items
|
||||
if (!firstItem || items.length !== 1) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastMessageMany',
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
)
|
||||
}
|
||||
|
||||
const nodeName = getMissingMediaNodeName(firstItem)
|
||||
const displayNodeName =
|
||||
nodeName ??
|
||||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastMessageWithNode',
|
||||
'{nodeName} is missing a required media file.',
|
||||
{
|
||||
nodeName: displayNodeName
|
||||
}
|
||||
)
|
||||
function translateMissingModelOverlayMessage(count: number): string {
|
||||
const translated = t('errorOverlay.missingModels', { count }, count)
|
||||
return translated === 'errorOverlay.missingModels'
|
||||
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
: translated
|
||||
}
|
||||
|
||||
export function resolveMissingErrorMessage(
|
||||
@@ -326,9 +33,10 @@ export function resolveMissingErrorMessage(
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveMissingNodeDisplayMessage(source),
|
||||
toastTitle: resolveMissingNodeToastTitle(source),
|
||||
toastMessage: resolveMissingNodeToastMessage(source)
|
||||
displayMessage: st(
|
||||
'errorOverlay.missingNodes',
|
||||
'Some nodes are missing and need to be installed'
|
||||
)
|
||||
}
|
||||
case 'swap_nodes':
|
||||
return {
|
||||
@@ -337,9 +45,10 @@ export function resolveMissingErrorMessage(
|
||||
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveSwapNodeDisplayMessage(),
|
||||
toastTitle: resolveSwapNodeToastTitle(source),
|
||||
toastMessage: resolveSwapNodeToastMessage(source)
|
||||
displayMessage: st(
|
||||
'errorOverlay.swapNodes',
|
||||
'Some nodes can be replaced with alternatives'
|
||||
)
|
||||
}
|
||||
case 'missing_model':
|
||||
return {
|
||||
@@ -351,9 +60,7 @@ export function resolveMissingErrorMessage(
|
||||
),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveMissingModelDisplayMessage(source),
|
||||
toastTitle: resolveMissingModelToastTitle(source),
|
||||
toastMessage: resolveMissingModelToastMessage(source)
|
||||
displayMessage: translateMissingModelOverlayMessage(source.count)
|
||||
}
|
||||
case 'missing_media':
|
||||
return {
|
||||
@@ -362,9 +69,10 @@ export function resolveMissingErrorMessage(
|
||||
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveMissingMediaDisplayMessage(),
|
||||
toastTitle: resolveMissingMediaToastTitle(source),
|
||||
toastMessage: resolveMissingMediaToastMessage(source)
|
||||
displayMessage: st(
|
||||
'errorOverlay.missingMedia',
|
||||
'Some nodes are missing required inputs'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type {
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type {
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
@@ -70,5 +73,6 @@ export type MissingErrorMessageSource =
|
||||
kind: 'missing_media'
|
||||
groups: MissingMediaGroup[]
|
||||
count: number
|
||||
mediaTypes: MediaType[]
|
||||
isCloud: boolean
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
asset_hash: assetHash,
|
||||
hash: assetHash,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function getAssetDetectionNames(
|
||||
): 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.hash ?? asset.asset_hash, options)
|
||||
addPathDetectionNames(names, asset.hash, options)
|
||||
addPathDetectionNames(names, asset.name, options)
|
||||
|
||||
const subfolder = asset.user_metadata?.subfolder
|
||||
|
||||
@@ -115,7 +115,7 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
asset_hash: assetHash,
|
||||
hash: assetHash,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
@@ -422,7 +422,6 @@ describe('groupCandidatesByName', () => {
|
||||
const photoGroup = result.find((g) => g.name === 'photo.png')
|
||||
expect(photoGroup?.referencingNodes).toHaveLength(2)
|
||||
expect(photoGroup?.mediaType).toBe('image')
|
||||
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
|
||||
|
||||
const otherGroup = result.find((g) => g.name === 'other.png')
|
||||
expect(otherGroup?.referencingNodes).toHaveLength(1)
|
||||
@@ -533,7 +532,7 @@ describe('verifyMediaCandidates', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('matches asset names when asset_hash is null', async () => {
|
||||
it('matches asset names when hash is null', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'missing-photo.png', { isMissing: undefined })
|
||||
|
||||
@@ -140,8 +140,8 @@ interface MediaVerificationOptions {
|
||||
* Verify media candidates against assets available to the current runtime.
|
||||
*
|
||||
* 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
|
||||
* Cloud-side `hash` is not guaranteed to follow a single shape, so we
|
||||
* match against the union of `asset.name` and `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.
|
||||
@@ -262,7 +262,6 @@ export function groupCandidatesByName(
|
||||
map.set(c.name, {
|
||||
name: c.name,
|
||||
mediaType: c.mediaType,
|
||||
representative: c,
|
||||
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface MissingMediaCandidate {
|
||||
export interface MissingMediaViewModel {
|
||||
name: string
|
||||
mediaType: MediaType
|
||||
representative: MissingMediaCandidate
|
||||
referencingNodes: Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
|
||||
@@ -1445,13 +1445,13 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'other_model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'other_model.safetensors' }
|
||||
}
|
||||
])
|
||||
@@ -1465,7 +1465,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when asset with matching asset_hash exists', async () => {
|
||||
it('should resolve isMissing=false when asset with matching hash exists', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('model.safetensors', {
|
||||
hash: 'abc123',
|
||||
@@ -1473,7 +1473,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
})
|
||||
]
|
||||
mockGetAssets.mockReturnValue([
|
||||
{ id: '1', name: 'model.safetensors', asset_hash: 'sha256:abc123' }
|
||||
{ id: '1', name: 'model.safetensors', hash: 'sha256:abc123' }
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
@@ -1487,7 +1487,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
@@ -1578,7 +1578,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'checkpoint.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'checkpoint.safetensors' }
|
||||
}
|
||||
]
|
||||
@@ -1601,7 +1601,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'model.safetensors' }
|
||||
}
|
||||
])
|
||||
@@ -1617,7 +1617,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'subfolder/my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
@@ -501,8 +501,7 @@ function isAssetInstalled(
|
||||
): boolean {
|
||||
if (candidate.hash && candidate.hashType) {
|
||||
const candidateHash = `${candidate.hashType}:${candidate.hash}`
|
||||
if (assets.some((a) => (a.hash ?? a.asset_hash) === candidateHash))
|
||||
return true
|
||||
if (assets.some((a) => a.hash === candidateHash)) return true
|
||||
}
|
||||
|
||||
const normalizedName = normalizePath(candidate.name)
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
@@ -18,6 +19,14 @@ vi.mock('./SwapNodeGroupRow.vue', () => ({
|
||||
|
||||
import SwapNodesCard from './SwapNodesCard.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function makeGroups(count = 2): SwapNodeGroup[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
type: `Type${i}`,
|
||||
@@ -47,13 +56,19 @@ function mountCard(
|
||||
...(callbacks?.onReplace ? { onReplace: callbacks.onReplace } : {})
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue]
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SwapNodesCard', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders guidance message', () => {
|
||||
const { container } = mountCard()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('p')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders correct number of SwapNodeGroupRow components', () => {
|
||||
const { container } = mountCard({ swapNodeGroups: makeGroups(3) })
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<template>
|
||||
<div class="mt-2 px-4 pb-2">
|
||||
<!-- Sub-label: guidance message shown above all swap groups -->
|
||||
<p class="m-0 pb-5 text-sm/relaxed text-muted-foreground">
|
||||
{{
|
||||
t(
|
||||
'nodeReplacement.swapNodesGuide',
|
||||
'The following nodes can be automatically replaced with compatible alternatives.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<!-- Group Rows -->
|
||||
<SwapNodeGroupRow
|
||||
v-for="group in swapNodeGroups"
|
||||
:key="group.type"
|
||||
@@ -12,9 +22,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
|
||||
swapNodeGroups: SwapNodeGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
@@ -7,6 +7,8 @@ const hoisted = vi.hoisted(() => {
|
||||
const mockInit = vi.fn()
|
||||
const mockIdentify = vi.fn()
|
||||
const mockPeopleSet = vi.fn()
|
||||
const mockPeopleSetOnce = vi.fn()
|
||||
const mockRegister = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockOnUserResolved = vi.fn()
|
||||
const mockOnUserLogout = vi.fn()
|
||||
@@ -16,6 +18,8 @@ const hoisted = vi.hoisted(() => {
|
||||
mockInit,
|
||||
mockIdentify,
|
||||
mockPeopleSet,
|
||||
mockPeopleSetOnce,
|
||||
mockRegister,
|
||||
mockReset,
|
||||
mockOnUserResolved,
|
||||
mockOnUserLogout,
|
||||
@@ -24,7 +28,8 @@ const hoisted = vi.hoisted(() => {
|
||||
init: mockInit,
|
||||
capture: mockCapture,
|
||||
identify: mockIdentify,
|
||||
people: { set: mockPeopleSet },
|
||||
register: mockRegister,
|
||||
people: { set: mockPeopleSet, set_once: mockPeopleSetOnce },
|
||||
reset: mockReset
|
||||
}
|
||||
}
|
||||
@@ -147,6 +152,99 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop entry capture', () => {
|
||||
function setLocation(search: string): void {
|
||||
Object.defineProperty(window.location, 'search', {
|
||||
configurable: true,
|
||||
value: search,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setLocation('')
|
||||
})
|
||||
|
||||
it('does not register desktop props when utm_source is absent', async () => {
|
||||
setLocation('')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
|
||||
setLocation('?utm_source=google&desktop_device_id=should-be-ignored')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers source_app and desktop_device_id when arriving from desktop', async () => {
|
||||
setLocation(
|
||||
'?utm_source=comfy.desktop&utm_medium=app_feature&desktop_device_id=device-abc'
|
||||
)
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
source_app: 'desktop',
|
||||
desktop_device_id: 'device-abc'
|
||||
})
|
||||
})
|
||||
|
||||
it('registers source_app alone when desktop_device_id is missing', async () => {
|
||||
setLocation('?utm_source=comfy.desktop')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
source_app: 'desktop'
|
||||
})
|
||||
})
|
||||
|
||||
it('persists desktop props to the person on identify so backend events inherit them', async () => {
|
||||
setLocation('?utm_source=comfy.desktop&desktop_device_id=device-xyz')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
|
||||
callback({ id: 'user-456' })
|
||||
|
||||
const setCall = hoisted.mockPeopleSet.mock.calls.find(
|
||||
([props]) => props && 'desktop_device_id' in props
|
||||
)
|
||||
expect(setCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
source_app: 'desktop',
|
||||
desktop_device_id: 'device-xyz',
|
||||
last_seen_via_desktop: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockPeopleSetOnce).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ first_seen_via_desktop: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not touch the person profile on identify for non-desktop visitors', async () => {
|
||||
setLocation('')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
|
||||
callback({ id: 'user-789' })
|
||||
|
||||
const desktopSetCall = hoisted.mockPeopleSet.mock.calls.find(
|
||||
([props]) =>
|
||||
props &&
|
||||
('desktop_device_id' in props || 'last_seen_via_desktop' in props)
|
||||
)
|
||||
expect(desktopSetCall).toBeUndefined()
|
||||
expect(hoisted.mockPeopleSetOnce).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event tracking', () => {
|
||||
it('captures events after initialization', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
@@ -72,6 +72,20 @@ interface QueuedEvent {
|
||||
properties?: TelemetryEventProperties
|
||||
}
|
||||
|
||||
interface DesktopEntryProps {
|
||||
source_app: 'desktop'
|
||||
desktop_device_id?: string
|
||||
}
|
||||
|
||||
function readDesktopEntryProps(): DesktopEntryProps | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('utm_source') !== 'comfy.desktop') return null
|
||||
const props: DesktopEntryProps = { source_app: 'desktop' }
|
||||
const deviceId = params.get('desktop_device_id')
|
||||
if (deviceId) props.desktop_device_id = deviceId
|
||||
return props
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog Telemetry Provider - Cloud Build Implementation
|
||||
*
|
||||
@@ -89,6 +103,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
private desktopEntryProps: DesktopEntryProps | null = null
|
||||
|
||||
constructor() {
|
||||
this.configureDisabledEvents(
|
||||
@@ -128,11 +143,13 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
this.registerDesktopEntryProps()
|
||||
|
||||
const currentUser = useCurrentUser()
|
||||
currentUser.onUserResolved((user) => {
|
||||
if (this.posthog && user.id) {
|
||||
this.posthog.identify(user.id)
|
||||
this.setDesktopEntryPersonProperties()
|
||||
this.setSubscriptionProperties()
|
||||
}
|
||||
})
|
||||
@@ -267,6 +284,34 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private registerDesktopEntryProps(): void {
|
||||
if (!this.posthog) return
|
||||
const props = readDesktopEntryProps()
|
||||
if (!props) return
|
||||
this.desktopEntryProps = props
|
||||
try {
|
||||
this.posthog.register(props)
|
||||
} catch (error) {
|
||||
console.error('Failed to register desktop entry props:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Persisted onto the person so backend-fired billing events inherit
|
||||
// desktop_device_id via person-on-events at ingest.
|
||||
private setDesktopEntryPersonProperties(): void {
|
||||
if (!this.posthog || !this.desktopEntryProps) return
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
this.posthog.people.set({
|
||||
...this.desktopEntryProps,
|
||||
last_seen_via_desktop: now
|
||||
})
|
||||
this.posthog.people.set_once({ first_seen_via_desktop: now })
|
||||
} catch (error) {
|
||||
console.error('Failed to set desktop entry person properties:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private setSubscriptionProperties(): void {
|
||||
const { subscriptionTier } = useSubscription()
|
||||
watch(
|
||||
|
||||
@@ -12,7 +12,7 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
name: 'test-image.png',
|
||||
asset_hash: 'hash123',
|
||||
hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -432,7 +432,7 @@ describe('useComboWidget', () => {
|
||||
[
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -463,7 +463,7 @@ describe('useComboWidget', () => {
|
||||
[
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -483,11 +483,11 @@ describe('useComboWidget', () => {
|
||||
[
|
||||
createMockAssetItem({
|
||||
name: `fallback-${scenario.assetName}`,
|
||||
asset_hash: fallbackHash
|
||||
hash: fallbackHash
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -507,11 +507,11 @@ describe('useComboWidget', () => {
|
||||
[
|
||||
createMockAssetItem({
|
||||
name: `fallback-${scenario.assetName}`,
|
||||
asset_hash: fallbackHash
|
||||
hash: fallbackHash
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -531,11 +531,11 @@ describe('useComboWidget', () => {
|
||||
[
|
||||
createMockAssetItem({
|
||||
name: scenario.assetHash,
|
||||
asset_hash: nameMatchHash
|
||||
hash: nameMatchHash
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -575,15 +575,15 @@ describe('useComboWidget', () => {
|
||||
[
|
||||
createMockAssetItem({
|
||||
name: 'wrong-kind.txt',
|
||||
asset_hash: 'wrong-kind.txt'
|
||||
hash: 'wrong-kind.txt'
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: `second-${scenario.assetName}`,
|
||||
asset_hash: `second-${scenario.assetHash}`
|
||||
hash: `second-${scenario.assetHash}`
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -606,7 +606,7 @@ describe('useComboWidget', () => {
|
||||
[
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: ''
|
||||
hash: ''
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -639,7 +639,7 @@ describe('useComboWidget', () => {
|
||||
[
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -667,7 +667,7 @@ describe('useComboWidget', () => {
|
||||
[
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -769,7 +769,7 @@ describe('useComboWidget', () => {
|
||||
mockAssetsStoreState.inputAssets = [
|
||||
createMockAssetItem({
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
})
|
||||
]
|
||||
})
|
||||
@@ -820,7 +820,7 @@ describe('useComboWidget', () => {
|
||||
createMockAssetItem({
|
||||
id: 'asset-123',
|
||||
name: scenario.assetName,
|
||||
asset_hash: scenario.assetHash
|
||||
hash: scenario.assetHash
|
||||
})
|
||||
]
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
|
||||
@@ -137,7 +137,7 @@ function getCloudInputAssets(nodeType: string | undefined): AssetItem[] {
|
||||
}
|
||||
|
||||
function getCloudInputAssetValue(asset: AssetItem): string | undefined {
|
||||
return asset.hash ?? asset.asset_hash ?? undefined
|
||||
return asset.hash ?? undefined
|
||||
}
|
||||
|
||||
function getCloudInputAssetValues(nodeType: string | undefined): string[] {
|
||||
|
||||
@@ -684,15 +684,14 @@ describe('useWidgetSelectItems', () => {
|
||||
|
||||
it('does not expand a hash-keyed asset even if its metadata reports outputCount > 1', async () => {
|
||||
// Defense against future cloud-schema changes: if a flat output row
|
||||
// ever ships with both asset_hash AND multi-output user_metadata, the
|
||||
// ever ships with both hash AND multi-output user_metadata, the
|
||||
// watcher must NOT replace it with synthesized AssetItems lacking the
|
||||
// hash, or select+load reverts to the FE-227 broken state.
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-flat-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-future',
|
||||
@@ -729,13 +728,12 @@ describe('useWidgetSelectItems', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses asset_hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
|
||||
it('uses hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-out-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
preview_url: '/api/view?filename=039b...0b13.png',
|
||||
tags: ['output']
|
||||
}
|
||||
@@ -753,7 +751,7 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
// The value (item.name) — what becomes modelValue on click — must be the
|
||||
// hash-keyed path so /api/view resolves it. Cloud's hash is in
|
||||
// asset_hash, not asset.name (which is the human filename).
|
||||
// asset.hash, not asset.name (which is the human filename).
|
||||
expect(dropdownItems.value[0].name).toBe(
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png [output]'
|
||||
)
|
||||
@@ -761,7 +759,7 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(dropdownItems.value[0].label).toContain('z-image-turbo_00093_.png')
|
||||
})
|
||||
|
||||
it('falls back to asset.name when asset_hash is absent (local/history path)', async () => {
|
||||
it('falls back to asset.name when hash is absent (local/history path)', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'local-1',
|
||||
@@ -973,8 +971,7 @@ describe('useWidgetSelectItems', () => {
|
||||
{
|
||||
id: 'asset-hash-1',
|
||||
name: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5.png',
|
||||
asset_hash:
|
||||
'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
|
||||
hash: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
|
||||
preview_url: '/preview.png',
|
||||
tags: ['output'],
|
||||
metadata: {
|
||||
|
||||
@@ -131,8 +131,8 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
// Hash-keyed assets are leaf rows from the cloud `/assets` API and
|
||||
// already carry their own URL-resolvable filename. Expanding them via
|
||||
// resolveOutputAssetItems would synthesize sibling AssetItems without
|
||||
// an asset_hash and reintroduce the FE-227 hash→name fallback bug.
|
||||
if (asset.hash ?? asset.asset_hash) continue
|
||||
// a hash and reintroduce the FE-227 hash→name fallback bug.
|
||||
if (asset.hash) continue
|
||||
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
@@ -1463,7 +1463,7 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
{
|
||||
id: 'input-1',
|
||||
name: 'cute-puppy.png',
|
||||
asset_hash: 'abc123def.png',
|
||||
hash: 'abc123def.png',
|
||||
tags: ['input']
|
||||
}
|
||||
])
|
||||
@@ -1509,14 +1509,10 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
const FLAT_OUTPUT_PAGE_SIZE = 200
|
||||
|
||||
const makeAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
asset_hash?: string
|
||||
): AssetItem => ({
|
||||
const makeAsset = (id: string, name: string, hash?: string): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
asset_hash,
|
||||
hash,
|
||||
size: 0,
|
||||
tags: ['output']
|
||||
})
|
||||
|
||||
@@ -347,12 +347,12 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
/**
|
||||
* Map of asset hash filename to asset item for O(1) lookup
|
||||
* Cloud assets use asset_hash for the hash-based filename
|
||||
* Cloud assets use hash for the hash-based filename
|
||||
*/
|
||||
const inputAssetsByFilename = computed(() => {
|
||||
const map = new Map<string, AssetItem>()
|
||||
for (const asset of inputAssets.value) {
|
||||
const hash = asset.hash ?? asset.asset_hash
|
||||
const hash = asset.hash
|
||||
if (hash) {
|
||||
map.set(hash, asset)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user