Compare commits

..

5 Commits

Author SHA1 Message Date
Terry Jia
be131f7e9a feat(load3d): register Preview3DAdvanced extension (#12527)
## Summary
Preview3DAdvanced is a Preview3D variant that emits live viewport state
as outputs (model_file, camera_info, model_3d_info, width, height) plus
a width/height pair. Reuses the LOAD_3D capture widget for the viewport
but
skips upload/clear buttons (it has no model_file widget). 

Adds a camera-only + gizmo serializeValue path (no scene/mask/normal
image uploads since no image outputs are emitted) and an onExecuted that
loads the saved preview model via
Load3DConfiguration.configureForSaveMesh and applies the input
camera_info forwarded via PreviewUI3D when one was connected, so a chain
of Preview3DAdvanced nodes propagates camera state in a single run
instead of lagging one hop per execution.

Drops the per-node camera lock toggle (retainViewOnReload) across all 3D
nodes (Load3D, Preview3D, Preview3DAdvanced).
The viewport now always preserves the user's adjusted framing after the
first model load; the toggle was rarely needed and made chained
propagation ambiguous. Saved workflows with retainViewOnReload in their
cameraConfig continue
to load, the field is simply ignored at runtime.

camera_info / model_3d_info resolved by execute: the input when
connected, otherwise the viewport's own live state. Applying them
overrides the preserved viewport in the input case and is a no-op echo
when the viewport already matches. Only the first model_3d_info entry is
applied, single-object viewer currently.

BE: https://github.com/Comfy-Org/ComfyUI/pull/14175

## Screenshots (if applicable)


https://github.com/user-attachments/assets/42536469-e188-492a-9864-cdb6bfa76e97
2026-06-04 18:09:12 -04:00
pythongosssss
daf07a7442 fix: node library drag drop to add node appears in wrong place in firefox (#12419)
## Summary

Firefox can give invalid drag coordinates causing incorrect drop
position (https://bugzilla.mozilla.org/show_bug.cgi?id=1773886)
I was unable to consistently recreate this issue and it only happened in
Firefox, so not a candidate for e2e tests.

## Changes

- **What**: 
- store position on `dragover` which looks to reliably report the
correct position and use that value on drop

## Screenshots (if applicable)



https://github.com/user-attachments/assets/6ff604b7-92fb-4a70-bd9f-c37cdba292ba
2026-06-04 19:50:41 +00:00
Dante
454e124099 feat(graph): add mask icon to "Open in Mask Editor" context menu option (FE-929) (#12642)
## Summary
<img width="1600" height="882" alt="fe-mask-icon-after"
src="https://github.com/user-attachments/assets/7ddd6ae3-4c6a-4c4d-9a61-2059851bd4f9"
/>


Stacked on #12563 (FE-839). Follow-up requested in the FE-839 thread by
@AlexisRolland and @alextov: the media-node context menu gives every
image action an icon **except** `Open in Mask Editor`, leaving its label
misaligned with the rest of the group.

This reuses the existing custom `comfy--mask` icon — the same one shown
on the node image overlay / selection-toolbox `MaskEditorButton` — for
the `Open in Mask Editor` entry. With it, all five image actions (`Open
Image`, `Open in Mask Editor`, `Copy Image`, `Paste Image`, `Save
Image`) now carry an icon and their labels line up.

- Follow-up to FE-839
- Context (FE-839 Slack thread): Alexis — *"it would be nice to have an
icon for open in mask editor. There is one on the image overlay in the
node which could be reused"*; Alex Tov — icon name is `mask`, a custom
icon.

> The broader idea of reserving a placeholder/buffer for options that
have no icon so **all** menu labels align (Alex Tov) is intentionally
out of scope here — this PR only completes the image-action group.

## Implementation

- `useImageMenuOptions.ts`: add `icon: 'icon-[comfy--mask]'` to the
`Open in Mask Editor` option.

No rendering changes are needed — `NodeContextMenu.vue` already renders
the icon via `<i v-if="item.icon" :class="[item.icon, 'size-4']" />`,
and `comfy--mask` is already registered (custom collection loaded from
`packages/design-system/src/icons`, used today by `MaskEditorButton.vue`
and `linearMode/DropZone.vue`).

## Red-Green Verification (local)

| Step | Result |
|------|--------|
| Test-only commit (`d8b7e2c`) | 🔴 Red — `expected undefined to be
'icon-[comfy--mask]'`; image group not fully iconed |
| Fix commit (`feeb505`) | 🟢 Green — 10/10 passing |

## Before / After

Right-click a media node (Load / Preview / Save Image) → image-action
group at the top of the menu:

| Before (base `FE-839`) | After (this PR) |
| --- | --- |
| `Open in Mask Editor` has no icon — its label sits flush-left while
the other four image actions are indented past their icons. | `Open in
Mask Editor` shows the mask icon; all five image actions line up. |

<!-- drag fe-mask-icon-before.png and fe-mask-icon-after.png into the
two cells above -->

Captured locally against a Load Image node with `example.png` (Vue nodes
enabled), dev server proxied to a live backend.


https://comfy-organization.slack.com/archives/C0A7ADM4797/p1780467176353799?thread_ts=1779462362.995709&cid=C0A7ADM4797
## Test Plan

- [x] Local red on the test-only commit
- [x] Local green on the fix commit
- [x] `useImageMenuOptions.test.ts` — 2 new tests (mask icon present on
`Open in Mask Editor`; every image action carries an icon)
- [x] eslint clean on changed files
- [ ] Manual: right-click a media node and confirm the mask icon renders
next to `Open in Mask Editor`
2026-06-04 18:40:10 +00:00
Matt Miller
8a819fa2be refactor(assets): read content hash from the canonical hash field (#12638)
## Summary
The assets API exposes an asset's content hash as `hash`. An older
`asset_hash` field was a deprecated alias carrying the same value. This
PR moves the frontend fully onto `hash` and removes `asset_hash` from
the frontend entirely.

## Changes
- Read `asset.hash` (no `?? asset_hash` fallback) across the asset
consumers:
- `useMediaAssetActions` — widget-value variants + cloud-mode
stored-filename resolution
  - `assetsStore` — input-asset-by-filename map
  - `assetMetadataUtils.getAssetUrlFilename`
  - `missingMedia` resolver/scan and `missingModel` scan hash matching
  - `useComboWidget` / `useWidgetSelectItems`
- `assetPreviewUtil.findOutputAsset` now queries `/assets?hash=` instead
of the deprecated `?asset_hash=` param and matches on `a.hash`.
- Removed `asset_hash` from the zod asset schema and the local
`AssetRecord` type. Responses that still include the alias parse cleanly
— zod strips unknown keys — so the declared field protected nothing once
the reads were gone.
- Purged `asset_hash` from all test fixtures/mocks; tests key on the
canonical `hash`.

## Safety / rollout
The API currently emits **both** `hash` and `asset_hash` with identical
values, so reading `hash` is safe today. This is the frontend half of
retiring the alias; the backend stops emitting `asset_hash` only after
this ships and old bundles age out, so there is no window where the
field the UI reads is absent.

## Verification
- `pnpm typecheck`: clean.
- Affected unit tests pass (asset utils, store, media/model scans,
widget composables).
- `grep -rn asset_hash src/`: zero matches.
2026-06-04 18:18:12 +00:00
Deep Mehta
35157f1af0 feat(telemetry): capture desktop entry props in cloud build (#12647)
## Summary

When a visitor arrives at the cloud product with
`utm_source=comfy.desktop`, register `source_app` and
`desktop_device_id` as PostHog super-properties and persist them onto
the person on identify.

Backend-fired billing events (Stripe webhook →
`billing:subscription_created`) then inherit the desktop attribution via
person-on-events, closing the cross-session gap where browser-side utm
capture and backend webhook events live on different `distinct_id`s.

## Why

The desktop app appends
`?desktop_device_id=<id>&utm_source=comfy.desktop` to its cloud links.
`utm_source` is auto-captured by posthog-js as a session super-property,
but arbitrary query params like `desktop_device_id` are ignored. Without
this, cross-session Desktop→Cloud sub attribution silently shows zero.

Confirmed empirically: across 24h of recent billing events,
`person.$initial_utm_source = 'comfy.desktop'` matched 0 rows, because
users had visited the cloud surface before they ever launched desktop.
Person-property based join via this PR is the durable fix.

## Test plan

- [x] Unit tests for: no utm, wrong utm, utm with device id, utm without
device id, person.set after identify, no-op for non-desktop visitors
- [x] `pnpm vitest run PostHogTelemetryProvider.test.ts` — 28/28 pass
- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [ ] Manual verification post-merge: visit cloud.comfy.org with
`?utm_source=comfy.desktop&desktop_device_id=test-abc`, log in, check
PostHog person profile gains `desktop_device_id`,
`last_seen_via_desktop`, `first_seen_via_desktop`, `source_app`
2026-06-04 18:17:58 +00:00
78 changed files with 1181 additions and 1145 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -123,6 +123,7 @@ export function useImageMenuOptions() {
},
{
label: t('contextMenu.Open in Mask Editor'),
icon: 'icon-[comfy--mask]',
action: () => openMaskEditor()
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,7 +80,6 @@ export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
retainViewOnReload?: boolean
}
export interface LightConfig {

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "جاري إعادة تحميل النموذج...",
"removeBackgroundImage": "إزالة صورة الخلفية",
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
"scene": "المشهد",
"showGrid": "عرض الشبكة",
"showSkeleton": "إظهار الهيكل العظمي",

View File

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

View File

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

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "در حال بارگذاری مجدد مدل...",
"removeBackgroundImage": "حذف تصویر پس‌زمینه",
"resizeNodeMatchOutput": "تغییر اندازه node مطابق خروجی",
"retainViewOnReload": "قفل کردن نمای دوربین هنگام بارگذاری مجدد مدل",
"scene": "صحنه",
"showGrid": "نمایش شبکه",
"showSkeleton": "نمایش اسکلت",

View File

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

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "モデルを再読み込み中...",
"removeBackgroundImage": "背景画像を削除",
"resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ",
"retainViewOnReload": "モデルの再読み込み時にカメラビューを固定する",
"scene": "シーン",
"showGrid": "グリッドを表示",
"showSkeleton": "スケルトンを表示",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "모델 다시 로드 중...",
"removeBackgroundImage": "배경 이미지 제거",
"resizeNodeMatchOutput": "노드 크기를 출력에 맞추기",
"retainViewOnReload": "모델을 다시 불러와도 카메라 뷰 고정",
"scene": "장면",
"showGrid": "그리드 표시",
"showSkeleton": "스켈레톤 표시",

View File

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

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "Перезагрузка модели...",
"removeBackgroundImage": "Удалить фоновое изображение",
"resizeNodeMatchOutput": "Изменить размер узла под вывод",
"retainViewOnReload": "Зафиксировать вид камеры при перезагрузке модели",
"scene": "Сцена",
"showGrid": "Показать сетку",
"showSkeleton": "Показать скелет",

View File

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

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "重新載入模型中...",
"removeBackgroundImage": "移除背景圖片",
"resizeNodeMatchOutput": "調整節點以符合輸出",
"retainViewOnReload": "鎖定相機視角於模型重新載入時保持不變",
"scene": "場景",
"showGrid": "顯示格線",
"showSkeleton": "顯示骨架",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "正在重新加载模型...",
"removeBackgroundImage": "移除背景图片",
"resizeNodeMatchOutput": "调整节点以匹配输出",
"retainViewOnReload": "模型重新加载时锁定相机视角",
"scene": "场景",
"showGrid": "显示网格",
"showSkeleton": "显示骨架",

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ function createVideoAsset(
return {
id: 'video-1',
name: 'clip.mp4',
asset_hash: null,
hash: null,
mime_type: mimeType,
tags: [],
kind: 'video',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,6 @@ export interface MissingMediaCandidate {
export interface MissingMediaViewModel {
name: string
mediaType: MediaType
representative: MissingMediaCandidate
referencingNodes: Array<{
nodeId: NodeId
widgetName: string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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