Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f042042
feat: Add Media Assets sidebar tab for file management
viva-jinyi Oct 17, 2025
c20ea26
refactor: Apply PR #6112 review feedback for Media Assets feature
viva-jinyi Oct 20, 2025
fffdae1
chore: unexpected export
viva-jinyi Oct 20, 2025
8765a10
feat: Improve media asset display with file format tags and filename …
viva-jinyi Oct 20, 2025
52b2129
feat: Add includePublic parameter to getAssetsByTag API
viva-jinyi Oct 22, 2025
aa3354a
fix: test code
viva-jinyi Oct 22, 2025
cddd8ea
refactor: useQueueStore
viva-jinyi Oct 22, 2025
ea7e910
refactor: Apply review feedback for media assets implementation
viva-jinyi Oct 23, 2025
02a1810
Extract AssetsSidebarTab template and improve UI structure (#6164)
viva-jinyi Oct 23, 2025
5c01e61
feat: Implement centralized AssetsStore for reactive assets updates
viva-jinyi Oct 24, 2025
38885b7
refactor: Apply formatUtil code review feedback and improve type safety
viva-jinyi Oct 24, 2025
fd953c6
[automated] Update test expectations
invalid-email-address Oct 24, 2025
4e2fc4a
feat: Auto-refresh assets on file upload
viva-jinyi Oct 24, 2025
9d28ec8
fix: Add AssetsStore update trigger to WidgetSelectDropdown uploads
viva-jinyi Oct 24, 2025
cb33c8f
refactor:
viva-jinyi Oct 27, 2025
9125459
fix: Prevent gallery index shift when new outputs are generated
viva-jinyi Oct 27, 2025
993f08f
refactor: delete unused export
viva-jinyi Oct 27, 2025
e0a0d9f
refactor: Simplify asset ID handling and remove UUID extraction, Acce…
viva-jinyi Oct 27, 2025
b778cde
feat: implement asset deletion functionality (#6203)
viva-jinyi Oct 27, 2025
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
Prev Previous commit
Next Next commit
refactor: Apply PR #6112 review feedback for Media Assets feature
- Move composables to platform/assets directory structure
- Extract interface-based abstraction (IAssetsProvider) for cloud/internal implementations
- Move constants to module scope to avoid re-initialization
- Extract helper functions (truncateFilename, assetMappers) for reusability
- Rename getMediaTypeFromFilename to return singular form (image/video/audio)
- Add deprecated plural version for backward compatibility
- Add comprehensive test coverage for new utility functions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
  • Loading branch information
viva-jinyi and claude committed Oct 29, 2025
commit c20ea269492bf4690cdba45b86ed0fc68715a889
98 changes: 70 additions & 28 deletions packages/shared-frontend-utils/src/formatUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,50 +475,92 @@ export function formatDuration(milliseconds: number): string {
return parts.join(' ')
}

// Module scope constants to avoid re-initialization on every call
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi']
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac']
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb']

/**
* Determines the media type from a filename's extension
* @param filename The filename to analyze
* @returns The media type: 'images', 'videos', 'audios', '3D' for gallery compatibility
* Truncates a filename while preserving the extension
* @param filename The filename to truncate
* @param maxLength Maximum length for the filename without extension
* @returns Truncated filename with extension preserved
*/
export function getMediaTypeFromFilename(filename: string): string {
if (!filename) return 'images'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'images'
export function truncateFilename(
filename: string,
maxLength: number = 20
): string {
if (!filename || filename.length <= maxLength) {
return filename
}

const lastDotIndex = filename.lastIndexOf('.')
const nameWithoutExt =
lastDotIndex > -1 ? filename.substring(0, lastDotIndex) : filename
const extension = lastDotIndex > -1 ? filename.substring(lastDotIndex) : ''

const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']
const videoExts = ['mp4', 'webm', 'mov', 'avi']
const audioExts = ['mp3', 'wav', 'ogg', 'flac']
const threeDExts = ['obj', 'fbx', 'gltf', 'glb']
// If the name without extension is short enough, return as is
if (nameWithoutExt.length <= maxLength) {
return filename
}

if (imageExts.includes(ext)) return 'images'
if (videoExts.includes(ext)) return 'videos'
if (audioExts.includes(ext)) return 'audios'
if (threeDExts.includes(ext)) return '3D'
// Calculate how to split the truncation
const halfLength = Math.floor((maxLength - 3) / 2) // -3 for '...'
const start = nameWithoutExt.substring(0, halfLength)
const end = nameWithoutExt.substring(nameWithoutExt.length - halfLength)

return 'images'
return `${start}...${end}${extension}`
}

/**
* Determines the media kind from a filename's extension
* Determines the media type from a filename's extension (singular form)
* @param filename The filename to analyze
* @returns The media kind: 'image', 'video', 'audio', or '3D'
* @returns The media type: 'image', 'video', 'audio', or '3D'
*/
export function getMediaKindFromFilename(
export function getMediaTypeFromFilename(
filename: string
): 'image' | 'video' | 'audio' | '3D' {
if (!filename) return 'image'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'

const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']
const videoExts = ['mp4', 'webm', 'mov', 'avi']
const audioExts = ['mp3', 'wav', 'ogg', 'flac']
const threeDExts = ['obj', 'fbx', 'gltf', 'glb']

if (imageExts.includes(ext)) return 'image'
if (videoExts.includes(ext)) return 'video'
if (audioExts.includes(ext)) return 'audio'
if (threeDExts.includes(ext)) return '3D'
if (IMAGE_EXTENSIONS.includes(ext)) return 'image'
if (VIDEO_EXTENSIONS.includes(ext)) return 'video'
if (AUDIO_EXTENSIONS.includes(ext)) return 'audio'
if (THREE_D_EXTENSIONS.includes(ext)) return '3D'

return 'image'
}

/**
* @deprecated Use getMediaTypeFromFilename instead - returns plural form for legacy compatibility
* @param filename The filename to analyze
* @returns The media type in plural form: 'images', 'videos', 'audios', '3D'
*/
export function getMediaTypeFromFilenamePlural(filename: string): string {
const type = getMediaTypeFromFilename(filename)
switch (type) {
case 'image':
return 'images'
case 'video':
return 'videos'
case 'audio':
return 'audios'
case '3D':
return '3D'
default:
return 'images'
}
}

/**
* @deprecated Use getMediaTypeFromFilename instead - kept for backward compatibility
* @param filename The filename to analyze
* @returns The media kind: 'image', 'video', 'audio', or '3D'
*/
export function getMediaKindFromFilename(
filename: string
): 'image' | 'video' | 'audio' | '3D' {
return getMediaTypeFromFilename(filename)
}
15 changes: 5 additions & 10 deletions src/components/sidebar/tabs/AssetsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,18 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useCloudMediaAssets } from '@/composables/useCloudMediaAssets'
import { useInternalMediaAssets } from '@/composables/useInternalMediaAssets'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { useMediaAssets } from '@/platform/assets/composables/useMediaAssets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { ResultItemImpl } from '@/stores/queueStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import { getMediaTypeFromFilenamePlural } from '@/utils/formatUtil'

const activeTab = ref<'input' | 'output'>('input')
const mediaAssets = ref<AssetItem[]>([])
const selectedAsset = ref<AssetItem | null>(null)

// Use appropriate implementation based on environment
const implementation = isCloud
? useCloudMediaAssets()
: useInternalMediaAssets()
const { loading, error, fetchMediaList } = implementation
// Use unified media assets implementation that handles cloud/internal automatically
const { loading, error, fetchMediaList } = useMediaAssets()

const galleryActiveIndex = ref(-1)
const galleryItems = computed(() => {
Expand All @@ -92,7 +87,7 @@ const galleryItems = computed(() => {
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: getMediaTypeFromFilename(asset.name)
mediaType: getMediaTypeFromFilenamePlural(asset.name)
})

// Override the url getter to use asset.preview_url
Expand Down
4 changes: 2 additions & 2 deletions src/platform/assets/components/MediaAssetCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import { formatDuration, getMediaKindFromFilename } from '@/utils/formatUtil'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'

import { useMediaAssetActions } from '../composables/useMediaAssetActions'
Expand Down Expand Up @@ -191,7 +191,7 @@ const assetType = computed(() => {

// Determine file type from extension
const fileKind = computed((): MediaKind => {
return getMediaKindFromFilename(asset?.name || '') as MediaKind
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
})

// Adapt AssetItem to legacy AssetMeta format for existing components
Expand Down
22 changes: 22 additions & 0 deletions src/platform/assets/composables/useMediaAssets/IAssetsProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Ref } from 'vue'

import type { AssetItem } from '@/platform/assets/schemas/assetSchema'

/**
* Interface for media assets providers
* Defines the common API for both cloud and internal file implementations
*/
export interface IAssetsProvider {
/** Loading state indicator */
loading: Ref<boolean>

/** Error state, null when no error */
error: Ref<string | null>

/**
* Fetch list of media assets from the specified directory
* @param directory - 'input' or 'output'
* @returns Promise resolving to array of AssetItem
*/
fetchMediaList: (directory: 'input' | 'output') => Promise<AssetItem[]>
}
81 changes: 81 additions & 0 deletions src/platform/assets/composables/useMediaAssets/assetMappers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { api } from '@/scripts/api'
import type { TaskItemImpl } from '@/stores/queueStore'
import { truncateFilename } from '@/utils/formatUtil'

/**
* Maps a TaskItemImpl output to an AssetItem format
* @param taskItem The task item containing execution data
* @param output The output from the task
* @param useDisplayName Whether to truncate the filename for display
* @returns AssetItem formatted object
*/
export function mapTaskOutputToAssetItem(
taskItem: TaskItemImpl,
output: any,
useDisplayName: boolean = false
): AssetItem {
const metadata: Record<string, any> = {
promptId: taskItem.promptId,
nodeId: output.nodeId,
subfolder: output.subfolder
}

// Add execution time if available
if (taskItem.executionTimeInSeconds) {
metadata.executionTimeInSeconds = taskItem.executionTimeInSeconds
}

// Add format if available
if (output.format) {
metadata.format = output.format
}

// Add workflow if available
if (taskItem.workflow) {
metadata.workflow = taskItem.workflow
}

// Store original filename if using display name
if (useDisplayName) {
metadata.originalFilename = output.filename
}

return {
id: `${taskItem.promptId}-${output.nodeId}-${output.filename}`,
name: useDisplayName
? truncateFilename(output.filename, 20)
: output.filename,
size: 0, // Size not available from history API
created_at: taskItem.executionStartTimestamp
? new Date(taskItem.executionStartTimestamp).toISOString()
: new Date().toISOString(),
tags: ['output'],
preview_url: output.url,
user_metadata: metadata
}
}

/**
* Maps input directory file to AssetItem format
* @param filename The filename
* @param index File index for unique ID
* @param directory The directory type
* @returns AssetItem formatted object
*/
export function mapInputFileToAssetItem(
filename: string,
index: number,
directory: 'input' | 'output' = 'input'
): AssetItem {
return {
id: `${directory}-${index}-${filename}`,
name: filename,
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
preview_url: api.apiURL(
`/view?filename=${encodeURIComponent(filename)}&type=${directory}`
)
}
}
17 changes: 17 additions & 0 deletions src/platform/assets/composables/useMediaAssets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { isCloud } from '@/platform/distribution/types'

import type { IAssetsProvider } from './IAssetsProvider'
import { useAssetsApi } from './useAssetsApi'
import { useInternalFilesApi } from './useInternalFilesApi'

/**
* Factory function that returns the appropriate media assets implementation
* based on the current distribution (cloud vs internal)
* @returns IAssetsProvider implementation
*/
export function useMediaAssets(): IAssetsProvider {
return isCloud ? useAssetsApi() : useInternalFilesApi()
}

// Re-export the interface for consumers
export type { IAssetsProvider } from './IAssetsProvider'
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import type { HistoryTaskItem } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { TaskItemImpl } from '@/stores/queueStore'

import { mapTaskOutputToAssetItem } from './assetMappers'

/**
* Composable for fetching media assets from cloud environment
* Includes execution time from history API
*/
export function useCloudMediaAssets() {
export function useAssetsApi() {
const loading = ref(false)
const error = ref<string | null>(null)

Expand Down Expand Up @@ -53,62 +55,16 @@ export function useCloudMediaAssets() {

// Only process completed tasks
if (taskItem.displayStatus === 'Completed' && taskItem.outputs) {
// Get execution time
const executionTimeInSeconds = taskItem.executionTimeInSeconds

// Process each output
taskItem.flatOutputs.forEach((output) => {
// Only include output type files (not temp previews)
if (output.type === 'output' && output.supportsPreview) {
// Truncate filename if longer than 15 characters
let displayName = output.filename
if (output.filename.length > 20) {
// Get file extension
const lastDotIndex = output.filename.lastIndexOf('.')
const nameWithoutExt =
lastDotIndex > -1
? output.filename.substring(0, lastDotIndex)
: output.filename
const extension =
lastDotIndex > -1
? output.filename.substring(lastDotIndex)
: ''

// If name without extension is still long, truncate it
if (nameWithoutExt.length > 10) {
displayName =
nameWithoutExt.substring(0, 10) +
'...' +
nameWithoutExt.substring(nameWithoutExt.length - 10) +
extension
}
}

assetItems.push({
id: `${taskItem.promptId}-${output.nodeId}-${output.filename}`,
name: displayName,
size: 0, // We don't have size info from history
created_at: taskItem.executionStartTimestamp
? new Date(taskItem.executionStartTimestamp).toISOString()
: new Date().toISOString(),
tags: ['output'],
preview_url: output.url,
user_metadata: {
originalFilename: output.filename, // Store original filename
promptId: taskItem.promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
...(executionTimeInSeconds && {
executionTimeInSeconds
}),
...(output.format && {
format: output.format
}),
...(taskItem.workflow && {
workflow: taskItem.workflow
})
}
})
const assetItem = mapTaskOutputToAssetItem(
taskItem,
output,
true // Use display name for cloud
)
assetItems.push(assetItem)
}
})
}
Expand Down
Loading