From 63c36d3f2f5aba5b773c13028aa86926154fcf78 Mon Sep 17 00:00:00 2001 From: Hunter Date: Mon, 9 Mar 2026 01:06:28 -0400 Subject: [PATCH] feat: display original asset names instead of hashes in assets panel (#9626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Output assets in the assets panel show content hashes (e.g., `a1b2c3d4.png`) instead of display names (e.g., `ComfyUI_00001_.png`). ## Root Cause Cloud inference replaces `filename` with the content hash in the output transform pipeline. The hashed filename gets stored in the jobs table's `preview_output` JSONB. The frontend uses this hash as the display name. ## Solution - Add `display_name` field to `AssetItem` schema and `ResultItemImpl` - Backend (cloud PR) joins job→assets table to resolve the original name and injects `display_name` into job responses - Frontend prefers `display_name` over `name` **only for display text and download filenames** - `asset.name` remains unchanged (the hash) for URLs, drag-to-canvas, export filters, and output key dedup ## Backwards Compatible - OSS: `display_name` is undefined, falls back to `asset.name` (which is already the real filename in OSS) - Cloud pre-deploy: `display_name` absent from API, falls back gracefully - Old jobs with no assets: `display_name` not injected, no change ## Cloud PR https://github.com/Comfy-Org/cloud/pull/2747 https://github.com/user-attachments/assets/8a4c9cac-4ade-4ea2-9a70-9af240a56602 --- .../sidebar/tabs/AssetsSidebarListView.vue | 10 ++-- .../sidebar/tabs/AssetsSidebarTab.vue | 2 +- src/platform/assets/components/AssetCard.vue | 2 +- .../assets/components/MediaAssetCard.vue | 5 +- .../assets/components/MediaImageTop.vue | 4 +- .../assets/composables/media/assetMappers.ts | 1 + .../composables/useMediaAssetActions.ts | 4 +- src/platform/assets/schemas/assetSchema.ts | 1 + .../assets/utils/outputAssetUtil.test.ts | 46 ++++++++++++++++++- src/platform/assets/utils/outputAssetUtil.ts | 1 + src/platform/remote/comfyui/jobs/jobTypes.ts | 3 +- src/schemas/apiSchema.ts | 3 +- src/services/jobOutputCache.test.ts | 29 ++++++++++++ src/stores/queueStore.ts | 5 ++ 14 files changed, 102 insertions(+), 14 deletions(-) diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.vue b/src/components/sidebar/tabs/AssetsSidebarListView.vue index 95050adb18..91e9e02478 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.vue +++ b/src/components/sidebar/tabs/AssetsSidebarListView.vue @@ -33,7 +33,7 @@ tabindex="0" :aria-label=" t('assetBrowser.ariaLabel.assetCard', { - name: item.asset.name, + name: getAssetDisplayName(item.asset), type: getAssetMediaType(item.asset) }) " @@ -44,7 +44,7 @@ ) " :preview-url="getAssetPreviewUrl(item.asset)" - :preview-alt="item.asset.name" + :preview-alt="getAssetDisplayName(item.asset)" :icon-name="iconForMediaType(getAssetMediaType(item.asset))" :is-video-preview="isVideoAsset(item.asset)" :primary-text="getAssetPrimaryText(item.asset)" @@ -133,8 +133,12 @@ const listGridStyle = { gap: '0.5rem' } +function getAssetDisplayName(asset: AssetItem): string { + return asset.display_name || asset.name +} + function getAssetPrimaryText(asset: AssetItem): string { - return truncateFilename(asset.name) + return truncateFilename(getAssetDisplayName(asset)) } function getAssetMediaType(asset: AssetItem) { diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 646d025edf..db19624a34 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => { const dialogStore = useDialogStore() dialogStore.showDialog({ key: 'asset-3d-viewer', - title: asset.name, + title: asset.display_name || asset.name, component: Load3dViewerContent, props: { modelUrl: asset.preview_url || '' diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue index 38c271fb38..5f35da07c6 100644 --- a/src/platform/assets/components/AssetCard.vue +++ b/src/platform/assets/components/AssetCard.vue @@ -186,7 +186,7 @@ const tooltipDelay = computed(() => const { isLoading, error } = useImage({ src: asset.preview_url ?? '', - alt: asset.name + alt: asset.display_name || asset.name }) function handleSelect() { diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index 8f05cdfc82..fb38a1139e 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -5,7 +5,7 @@ :aria-label=" asset ? $t('assetBrowser.ariaLabel.assetCard', { - name: asset.name, + name: asset.display_name || asset.name, type: fileKind }) : $t('assetBrowser.ariaLabel.loadingAsset') @@ -225,7 +225,7 @@ const canInspect = computed(() => isPreviewableMediaType(fileKind.value)) // Get filename without extension const fileName = computed(() => { - return getFilenameDetails(asset?.name || '').filename + return getFilenameDetails(asset?.display_name || asset?.name || '').filename }) // Adapt AssetItem to legacy AssetMeta format for existing components @@ -234,6 +234,7 @@ const adaptedAsset = computed(() => { return { id: asset.id, name: asset.name, + display_name: asset.display_name, kind: fileKind.value, src: asset.preview_url || '', size: asset.size, diff --git a/src/platform/assets/components/MediaImageTop.vue b/src/platform/assets/components/MediaImageTop.vue index 2aa6722645..4232777196 100644 --- a/src/platform/assets/components/MediaImageTop.vue +++ b/src/platform/assets/components/MediaImageTop.vue @@ -6,7 +6,7 @@
{ - const filename = asset.name + const filename = asset.display_name || asset.name const downloadUrl = asset.preview_url || getAssetUrl(asset) downloadFile(downloadUrl, filename) }) diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index d6941e18cf..c13fc64528 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -9,6 +9,7 @@ const zAsset = z.object({ mime_type: z.string().nullish(), tags: z.array(z.string()).optional().default([]), preview_id: z.string().nullable().optional(), + display_name: z.string().optional(), preview_url: z.string().optional(), created_at: z.string().optional(), updated_at: z.string().optional(), diff --git a/src/platform/assets/utils/outputAssetUtil.test.ts b/src/platform/assets/utils/outputAssetUtil.test.ts index 649df87fc5..30adb9b613 100644 --- a/src/platform/assets/utils/outputAssetUtil.test.ts +++ b/src/platform/assets/utils/outputAssetUtil.test.ts @@ -20,6 +20,7 @@ type OutputOverrides = Partial<{ subfolder: string nodeId: string url: string + display_name: string }> function createOutput(overrides: OutputOverrides = {}): ResultItemImpl { @@ -32,7 +33,8 @@ function createOutput(overrides: OutputOverrides = {}): ResultItemImpl { } return { ...merged, - previewUrl: merged.url + previewUrl: merged.url, + display_name: merged.display_name } as ResultItemImpl } @@ -125,6 +127,48 @@ describe('resolveOutputAssetItems', () => { ]) }) + it('propagates display_name from output to asset item', async () => { + const output = createOutput({ + filename: 'abc123hash.png', + nodeId: '1', + url: 'https://example.com/abc123hash.png', + display_name: 'ComfyUI_00001_.png' + }) + const metadata: OutputAssetMetadata = { + jobId: 'job-dn', + nodeId: '1', + subfolder: 'sub', + outputCount: 1, + allOutputs: [output] + } + + const results = await resolveOutputAssetItems(metadata) + + expect(results).toHaveLength(1) + expect(results[0].name).toBe('abc123hash.png') + expect(results[0].display_name).toBe('ComfyUI_00001_.png') + }) + + it('omits display_name when not present in output', async () => { + const output = createOutput({ + filename: 'file.png', + nodeId: '1', + url: 'https://example.com/file.png' + }) + const metadata: OutputAssetMetadata = { + jobId: 'job-nodn', + nodeId: '1', + subfolder: 'sub', + outputCount: 1, + allOutputs: [output] + } + + const results = await resolveOutputAssetItems(metadata) + + expect(results).toHaveLength(1) + expect(results[0].display_name).toBeUndefined() + }) + it('keeps root outputs with empty subfolders', async () => { const output = createOutput({ filename: 'root.png', diff --git a/src/platform/assets/utils/outputAssetUtil.ts b/src/platform/assets/utils/outputAssetUtil.ts index 81e4d31fc3..3d25654c60 100644 --- a/src/platform/assets/utils/outputAssetUtil.ts +++ b/src/platform/assets/utils/outputAssetUtil.ts @@ -69,6 +69,7 @@ function mapOutputsToAssetItems({ items.push({ id: `${jobId}-${outputKey}`, name: output.filename, + display_name: output.display_name, size: 0, created_at: createdAtValue, tags: ['output'], diff --git a/src/platform/remote/comfyui/jobs/jobTypes.ts b/src/platform/remote/comfyui/jobs/jobTypes.ts index 48e2bfbc46..5fb9236238 100644 --- a/src/platform/remote/comfyui/jobs/jobTypes.ts +++ b/src/platform/remote/comfyui/jobs/jobTypes.ts @@ -23,7 +23,8 @@ const zPreviewOutput = z.object({ subfolder: z.string(), type: resultItemType, nodeId: z.string(), - mediaType: z.string() + mediaType: z.string(), + display_name: z.string().optional() }) /** diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 228c347c13..fb530f54f5 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -19,7 +19,8 @@ export type CustomNodesI18n = z.infer const zResultItem = z.object({ filename: z.string().optional(), subfolder: z.string().optional(), - type: resultItemType.optional() + type: resultItemType.optional(), + display_name: z.string().optional() }) export type ResultItem = z.infer const zOutputs = z diff --git a/src/services/jobOutputCache.test.ts b/src/services/jobOutputCache.test.ts index d512582cad..292e96e07b 100644 --- a/src/services/jobOutputCache.test.ts +++ b/src/services/jobOutputCache.test.ts @@ -255,6 +255,35 @@ describe('jobOutputCache', () => { expect(video?.mediaType).toBe('video') }) + it('preserves display_name from output items', async () => { + const { getPreviewableOutputsFromJobDetail } = + await import('@/services/jobOutputCache') + const jobDetail: JobDetail = { + id: 'job-display-name', + status: 'completed', + create_time: Date.now(), + priority: 0, + outputs: { + 'node-1': { + images: [ + { + filename: 'abc123hash.png', + subfolder: '', + type: 'output', + display_name: 'ComfyUI_00001_.png' + } + ] + } + } + } + + const result = getPreviewableOutputsFromJobDetail(jobDetail) + + expect(result).toHaveLength(1) + expect(result[0].filename).toBe('abc123hash.png') + expect(result[0].display_name).toBe('ComfyUI_00001_.png') + }) + it('filters non-previewable outputs and non-object items', async () => { const { getPreviewableOutputsFromJobDetail } = await import('@/services/jobOutputCache') diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index b5a2af67d6..d81bffdc90 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -37,6 +37,7 @@ interface ResultItemInit extends ResultItem { mediaType: string format?: string frame_rate?: number + display_name?: string } export class ResultItemImpl { @@ -48,6 +49,8 @@ export class ResultItemImpl { // 'audio' | 'images' | ... mediaType: string + display_name?: string + // VHS output specific fields format?: string frame_rate?: number @@ -60,6 +63,8 @@ export class ResultItemImpl { this.nodeId = obj.nodeId this.mediaType = obj.mediaType + this.display_name = obj.display_name + this.format = obj.format this.frame_rate = obj.frame_rate }