mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-11 08:00:21 +00:00
feat: display original asset names instead of hashes in assets panel (#9626)
## 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
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -186,7 +186,7 @@ const tooltipDelay = computed<number>(() =>
|
||||
|
||||
const { isLoading, error } = useImage({
|
||||
src: asset.preview_url ?? '',
|
||||
alt: asset.name
|
||||
alt: asset.display_name || asset.name
|
||||
})
|
||||
|
||||
function handleSelect() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<img
|
||||
v-if="!error"
|
||||
:src="asset.src"
|
||||
:alt="asset.name"
|
||||
:alt="asset.display_name || asset.name"
|
||||
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
/>
|
||||
<div
|
||||
@@ -34,7 +34,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { state, error, isReady } = useImage({
|
||||
src: asset.src ?? '',
|
||||
alt: asset.name
|
||||
alt: asset.display_name || asset.name
|
||||
})
|
||||
|
||||
whenever(
|
||||
|
||||
@@ -39,6 +39,7 @@ export function mapTaskOutputToAssetItem(
|
||||
return {
|
||||
id: taskItem.jobId,
|
||||
name: output.filename,
|
||||
display_name: output.display_name,
|
||||
size: 0,
|
||||
created_at: taskItem.executionStartTimestamp
|
||||
? new Date(taskItem.executionStartTimestamp).toISOString()
|
||||
|
||||
@@ -68,7 +68,7 @@ export function useMediaAssetActions() {
|
||||
if (!targetAsset) return
|
||||
|
||||
try {
|
||||
const filename = targetAsset.name
|
||||
const filename = targetAsset.display_name || targetAsset.name
|
||||
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
|
||||
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
|
||||
|
||||
@@ -109,7 +109,7 @@ export function useMediaAssetActions() {
|
||||
|
||||
try {
|
||||
assets.forEach((asset) => {
|
||||
const filename = asset.name
|
||||
const filename = asset.display_name || asset.name
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,8 @@ export type CustomNodesI18n = z.infer<typeof zCustomNodesI18n>
|
||||
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<typeof zResultItem>
|
||||
const zOutputs = z
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user