Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/components/sidebar/tabs/AssetsSidebarListView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const AssetsListItemStub = defineComponent({
class="assets-list-item-stub"
:data-preview-url="previewUrl"
:data-is-video-preview="isVideoPreview"
:data-secondary-text="secondaryText"
data-testid="assets-list-item"
><button data-testid="preview-click-trigger" @click="$emit('preview-click')" /><slot /></div>`
})
Expand Down Expand Up @@ -171,4 +172,50 @@ describe('AssetsSidebarListView', () => {

expect(onPreviewAsset).toHaveBeenCalledWith(imageAsset)
})

describe('secondary text', () => {
function getSecondaryText(container: Element): string {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- reading rendered prop via stub attribute
const stub = container.querySelector('[data-testid="assets-list-item"]')
return stub?.getAttribute('data-secondary-text') ?? ''
}

it('renders just the extension when no size or duration is available', () => {
const asset = buildAsset('extension-only', 'photo.png')
const { container } = renderListView([buildOutputItem(asset)])
expect(getSecondaryText(container)).toBe('PNG')
})

it('prepends the extension to the formatted size when no duration is available', () => {
const asset = {
...buildAsset('with-size', 'note.txt'),
size: 2048
} satisfies AssetItem
const { container } = renderListView([buildOutputItem(asset)])
expect(getSecondaryText(container)).toBe('TXT 2 KB')
})

it('prepends the extension to the execution time when present in metadata', () => {
const asset = {
...buildAsset('with-exec-time', 'clip.mp4'),
user_metadata: {
jobId: 'job-1',
nodeId: '7',
subfolder: '',
executionTimeInSeconds: 1.234
}
} satisfies AssetItem
const { container } = renderListView([buildOutputItem(asset)])
expect(getSecondaryText(container)).toBe('MP4 1.23s')
})

it('prepends the extension to the duration when no execution time is present', () => {
const asset = {
...buildAsset('with-duration', 'song.mp3'),
user_metadata: { duration: 65000 }
} satisfies AssetItem
const { container } = renderListView([buildOutputItem(asset)])
expect(getSecondaryText(container)).toBe('MP3 1m 5s')
})
})
})
35 changes: 23 additions & 12 deletions src/components/sidebar/tabs/AssetsSidebarListView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import {
getAssetDisplayName,
getAssetExtensionLabel
} from '@/platform/assets/utils/assetMetadataUtils'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import {
Expand Down Expand Up @@ -140,22 +143,30 @@ function getAssetPreviewUrl(asset: AssetItem): string {
return ''
}

/**
* Builds the description line shown beneath an asset's name. Composed as
* `<EXT> <timing|size>` with a single space separator, preferring
* execution time → duration → file size for the trailing detail. Either
* half may be omitted (returns the other, or an empty string).
*/
function getAssetSecondaryText(asset: AssetItem): string {
const extensionLabel = getAssetExtensionLabel(asset)
const parts: string[] = []
if (extensionLabel) parts.push(extensionLabel)

const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.executionTimeInSeconds === 'number') {
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
}

const duration = asset.user_metadata?.duration
if (typeof duration === 'number') {
return formatDuration(duration)
parts.push(`${metadata.executionTimeInSeconds.toFixed(2)}s`)
} else {
const duration = asset.user_metadata?.duration
if (typeof duration === 'number') {
parts.push(formatDuration(duration))
} else if (typeof asset.size === 'number') {
parts.push(formatSize(asset.size))
}
}

if (typeof asset.size === 'number') {
return formatSize(asset.size)
}

return ''
return parts.join(' ')
}

function getStackCount(asset: AssetItem): number | undefined {
Expand Down
24 changes: 17 additions & 7 deletions src/platform/assets/components/MediaAssetCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import {
getAssetDisplayName,
getAssetExtensionLabel
} from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
Expand Down Expand Up @@ -279,17 +282,24 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})

// Get metadata info based on file kind
/**
* Composes the card's description line as `<EXT> <detail>`, where the
* trailing detail is the image's pixel dimensions (when loaded locally)
* or the file size for video/audio/3D assets. Either half may be
* omitted; the result is an empty string when nothing is available.
*/
const metaInfo = computed(() => {
if (!asset) return ''
const extensionLabel = getAssetExtensionLabel(asset)
const parts: string[] = []
if (extensionLabel) parts.push(extensionLabel)
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)
parts.push(`${imageDimensions.value.width}x${imageDimensions.value.height}`)
} else if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
parts.push(formatSize(asset.size))
}
return ''
return parts.join(' ')
})

const showActionsOverlay = computed(() => {
Expand Down
44 changes: 44 additions & 0 deletions src/platform/assets/utils/assetMetadataUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getAssetDescription,
getAssetDisplayFilename,
getAssetDisplayName,
getAssetExtensionLabel,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
Expand Down Expand Up @@ -383,4 +384,47 @@ describe('assetMetadataUtils', () => {
expect(getAssetCardTitle(asset)).toBe('pretty.png')
})
})

describe('getAssetExtensionLabel', () => {
it.for([
{ name: 'sunset.png', expected: 'PNG' },
{ name: 'clip.MP4', expected: 'MP4' },
{ name: 'model.safetensors', expected: 'SAFETENSORS' },
{ name: 'archive.app.json', expected: 'APP.JSON' }
])('uppercases the extension for $name', ({ name, expected }) => {
expect(getAssetExtensionLabel({ ...mockAsset, name })).toBe(expected)
})

it('falls back to the metadata filename when asset.name has no extension', () => {
const asset = {
...mockAsset,
name: 'blake3:abc',
user_metadata: { filename: 'sunset.png' }
}
expect(getAssetExtensionLabel(asset)).toBe('PNG')
})

it('falls back to display_name when asset.name is a content hash (Cloud case)', () => {
const asset = {
...mockAsset,
name: 'blake3:abcdef',
display_name: 'ComfyUI_00001_.png'
}
expect(getAssetExtensionLabel(asset)).toBe('PNG')
})

it('strips path prefixes before resolving the extension', () => {
const asset = {
...mockAsset,
name: 'subdir.v2/cover.jpg'
}
expect(getAssetExtensionLabel(asset)).toBe('JPG')
})

it('returns an empty string when no filename source has an extension', () => {
expect(
getAssetExtensionLabel({ ...mockAsset, name: 'no-extension' })
).toBe('')
})
})
})
16 changes: 15 additions & 1 deletion src/platform/assets/utils/assetMetadataUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCivitaiUrl } from '@/utils/formatUtil'
import { getPathDetails, isCivitaiUrl } from '@/utils/formatUtil'

/**
* Type-safe utilities for extracting metadata from assets.
Expand Down Expand Up @@ -198,3 +198,17 @@ export function getAssetCardTitle(asset: AssetItem): string {
if (curatedName && curatedName !== asset.name) return curatedName
return getAssetDisplayFilename(asset)
}

/**
* Returns the asset's file extension formatted for display in metadata lines,
* e.g. `PNG`, `MP4`, `GLB`. Resolves the filename via
* {@link getAssetDisplayFilename} (covering the Cloud hash case where the
* extension lives on `display_name`) and uses {@link getPathDetails} so
* path-like values still resolve to the basename's suffix.
*
* Returns an empty string when no extension can be determined.
*/
export function getAssetExtensionLabel(asset: AssetItem): string {
const suffix = getPathDetails(getAssetDisplayFilename(asset)).suffix
return suffix ? suffix.toUpperCase() : ''
}
Loading