Compare commits

...

1 Commits

Author SHA1 Message Date
Jin Yi
0447f2227d feat: replace ResultGallery with standalone MediaViewer for assets
- Add MediaViewer component using Reka UI Dialog with AssetItem directly
- Remove ResultItemImpl dependency from useMediaAssetGalleryStore
- Add getOutputAssets, getAssetsByJobIds to assetService
- Add prompt_id field to asset schema
2026-03-09 22:09:57 +09:00
8 changed files with 333 additions and 168 deletions

View File

@@ -170,9 +170,9 @@
</div>
</template>
</SidebarTabTemplate>
<ResultGallery
<MediaViewer
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
:items="previewableVisibleAssets"
/>
<MediaAssetContextMenu
v-if="contextMenuAsset"
@@ -221,12 +221,12 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaViewer from '@/platform/assets/components/MediaViewer.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
@@ -240,7 +240,6 @@ import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import {
formatDuration,
getMediaTypeFromFilename,
@@ -454,28 +453,6 @@ watch(galleryActiveIndex, (index) => {
}
})
const galleryItems = computed(() => {
return previewableVisibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: mediaType === 'image' ? 'images' : mediaType
})
Object.defineProperty(resultItem, 'url', {
get() {
return asset.preview_url || ''
},
configurable: true
})
return resultItem
})
})
const refreshAssets = async () => {
await currentAssets.value.fetchMediaList()
if (error.value) {

View File

@@ -1,17 +1,16 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetItem } from '../schemas/assetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
import MediaViewer from './MediaViewer.vue'
const meta: Meta<typeof MediaAssetCard> = {
title: 'Platform/Assets/MediaAssetCard',
component: MediaAssetCard,
decorators: [
() => ({
components: { ResultGallery },
components: { MediaViewer },
setup() {
const galleryStore = useMediaAssetGalleryStore()
return { galleryStore }
@@ -19,9 +18,9 @@ const meta: Meta<typeof MediaAssetCard> = {
template: `
<div>
<story />
<ResultGallery
<MediaViewer
v-model:active-index="galleryStore.activeIndex"
:all-gallery-items="galleryStore.items"
:items="galleryStore.items"
/>
</div>
`

View File

@@ -0,0 +1,135 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { AssetItem } from '../schemas/assetSchema'
import MediaViewer from './MediaViewer.vue'
const SAMPLE_MEDIA = {
image1: 'https://i.imgur.com/OB0y6MR.jpg',
image2: 'https://i.imgur.com/CzXTtJV.jpg',
image3: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
video:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
}
function makeAsset(
overrides: Partial<AssetItem> & { id: string; name: string }
): AssetItem {
return {
size: 0,
created_at: new Date().toISOString(),
tags: ['output'],
preview_url: '',
...overrides
}
}
const singleImage: AssetItem[] = [
makeAsset({
id: 'img-1',
name: 'landscape.jpg',
preview_url: SAMPLE_MEDIA.image1
})
]
const multipleImages: AssetItem[] = [
makeAsset({
id: 'img-1',
name: 'landscape.jpg',
preview_url: SAMPLE_MEDIA.image1
}),
makeAsset({
id: 'img-2',
name: 'portrait.jpg',
preview_url: SAMPLE_MEDIA.image2
}),
makeAsset({
id: 'img-3',
name: 'nature.jpg',
preview_url: SAMPLE_MEDIA.image3
})
]
const mixedMedia: AssetItem[] = [
makeAsset({
id: 'img-1',
name: 'photo.jpg',
preview_url: SAMPLE_MEDIA.image1
}),
makeAsset({
id: 'vid-1',
name: 'clip.mp4',
preview_url: SAMPLE_MEDIA.video
}),
makeAsset({
id: 'aud-1',
name: 'song.mp3',
preview_url: SAMPLE_MEDIA.audio
})
]
const meta: Meta<typeof MediaViewer> = {
title: 'Platform/Assets/MediaViewer',
component: MediaViewer,
parameters: { layout: 'fullscreen' },
argTypes: {
activeIndex: { control: { type: 'number', min: -1, max: 10 } }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const SingleImage: Story = {
args: {
items: singleImage,
activeIndex: 0
}
}
export const MultipleImages: Story = {
args: {
items: multipleImages,
activeIndex: 0
}
}
export const VideoPlayer: Story = {
args: {
items: [
makeAsset({
id: 'vid-1',
name: 'big-buck-bunny.mp4',
preview_url: SAMPLE_MEDIA.video
})
],
activeIndex: 0
}
}
export const AudioPlayer: Story = {
args: {
items: [
makeAsset({
id: 'aud-1',
name: 'soundtrack.mp3',
preview_url: SAMPLE_MEDIA.audio
})
],
activeIndex: 0
}
}
export const MixedMedia: Story = {
args: {
items: mixedMedia,
activeIndex: 0
}
}
export const Closed: Story = {
args: {
items: singleImage,
activeIndex: -1
}
}

View File

@@ -0,0 +1,122 @@
<template>
<DialogRoot :open="isOpen" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-black/80" />
<DialogContent
class="fixed inset-0 z-50 flex items-center justify-center outline-none"
:aria-describedby="undefined"
>
<VisuallyHidden as-child>
<DialogTitle>{{ currentItem?.name ?? '' }}</DialogTitle>
</VisuallyHidden>
<!-- Close button -->
<DialogClose
class="absolute top-4 right-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
:aria-label="$t('g.close')"
>
<i class="icon-[lucide--x] size-5" />
</DialogClose>
<!-- Previous button -->
<button
v-if="hasMultiple"
class="absolute left-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
@click="navigate(-1)"
>
<i class="icon-[lucide--chevron-left] size-6" />
</button>
<!-- Media content -->
<ComfyImage
v-if="currentMediaType === 'image'"
:key="currentItem?.id"
:src="currentItem?.preview_url ?? ''"
:contain="false"
:alt="currentItem?.name ?? ''"
class="max-h-screen max-w-full object-contain"
/>
<video
v-else-if="currentMediaType === 'video'"
:key="currentItem?.id"
:src="currentItem?.preview_url ?? ''"
controls
class="max-h-screen max-w-full"
/>
<audio
v-else-if="currentMediaType === 'audio'"
:key="currentItem?.id"
:src="currentItem?.preview_url ?? ''"
controls
/>
<!-- Next button -->
<button
v-if="hasMultiple"
class="absolute right-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
@click="navigate(1)"
>
<i class="icon-[lucide--chevron-right] size-6" />
</button>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
VisuallyHidden
} from 'reka-ui'
import { computed, onMounted, onUnmounted } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
const { items = [], activeIndex = -1 } = defineProps<{
items?: AssetItem[]
activeIndex?: number
}>()
const emit = defineEmits<{
'update:activeIndex': [value: number]
}>()
const isOpen = computed(() => activeIndex >= 0 && activeIndex < items.length)
const currentItem = computed(() =>
isOpen.value ? items[activeIndex] : undefined
)
const currentMediaType = computed(() =>
currentItem.value ? getMediaTypeFromFilename(currentItem.value.name) : ''
)
const hasMultiple = computed(() => items.length > 1)
function handleOpenChange(open: boolean) {
if (!open) {
emit('update:activeIndex', -1)
}
}
function navigate(direction: number) {
const newIndex = (activeIndex + direction + items.length) % items.length
emit('update:activeIndex', newIndex)
}
function handleKeyDown(event: KeyboardEvent) {
if (!isOpen.value) return
if (event.key === 'ArrowLeft') navigate(-1)
else if (event.key === 'ArrowRight') navigate(1)
}
onMounted(() => window.addEventListener('keydown', handleKeyDown))
onUnmounted(() => window.removeEventListener('keydown', handleKeyDown))
</script>

View File

@@ -1,161 +1,76 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { ResultItemImpl } from '@/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetItem } from '../schemas/assetSchema'
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
vi.mock('@/stores/queueStore', () => ({
ResultItemImpl: vi
.fn<typeof ResultItemImpl>()
.mockImplementation(function (data) {
Object.assign(this, {
...data,
url: ''
})
})
}))
describe('useMediaAssetGalleryStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('openSingle', () => {
it('should convert AssetMeta to ResultItemImpl format', () => {
it('should set items and activeIndex', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
const asset: AssetItem = {
id: 'test-1',
name: 'test-image.png',
kind: 'image',
src: 'https://example.com/image.png',
size: 1024,
tags: [],
preview_url: 'https://example.com/image.png',
tags: ['output'],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
store.openSingle(asset)
expect(ResultItemImpl).toHaveBeenCalledWith({
filename: 'test-image.png',
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: 'images'
})
expect(store.items).toHaveLength(1)
expect(store.items[0]).toBe(asset)
expect(store.activeIndex).toBe(0)
})
it('should set correct mediaType for video assets', () => {
it('should preserve asset properties', () => {
const store = useMediaAssetGalleryStore()
const mockVideoAsset: AssetMeta = {
const asset: AssetItem = {
id: 'test-2',
name: 'test-video.mp4',
kind: 'video',
src: 'https://example.com/video.mp4',
size: 2048,
tags: [],
preview_url: 'https://example.com/video.mp4',
tags: ['output'],
created_at: '2025-01-01'
}
store.openSingle(mockVideoAsset)
store.openSingle(asset)
expect(ResultItemImpl).toHaveBeenCalledWith(
expect.objectContaining({
filename: 'test-video.mp4',
mediaType: 'video'
})
)
expect(store.items[0].name).toBe('test-video.mp4')
expect(store.items[0].preview_url).toBe('https://example.com/video.mp4')
})
it('should set correct mediaType for audio assets', () => {
it('should replace previous items when called multiple times', () => {
const store = useMediaAssetGalleryStore()
const mockAudioAsset: AssetMeta = {
id: 'test-3',
name: 'test-audio.mp3',
kind: 'audio',
src: 'https://example.com/audio.mp3',
size: 512,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAudioAsset)
expect(ResultItemImpl).toHaveBeenCalledWith(
expect.objectContaining({
filename: 'test-audio.mp3',
mediaType: 'audio'
})
)
})
it('should override url getter with asset.src', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test-4',
name: 'test.png',
kind: 'image',
src: 'https://example.com/custom-url.png',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
const resultItem = store.items[0]
expect(resultItem.url).toBe('https://example.com/custom-url.png')
})
it('should handle assets without src gracefully', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test-5',
name: 'no-src.png',
kind: 'image',
src: '',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
const resultItem = store.items[0]
expect(resultItem.url).toBe('')
})
it('should update activeIndex and items when called multiple times', () => {
const store = useMediaAssetGalleryStore()
const asset1: AssetMeta = {
const asset1: AssetItem = {
id: '1',
name: 'first.png',
kind: 'image',
src: 'url1',
size: 100,
preview_url: 'url1',
tags: [],
created_at: '2025-01-01'
}
const asset2: AssetMeta = {
const asset2: AssetItem = {
id: '2',
name: 'second.png',
kind: 'image',
src: 'url2',
size: 200,
preview_url: 'url2',
tags: [],
created_at: '2025-01-01'
}
store.openSingle(asset1)
expect(store.items).toHaveLength(1)
expect(store.items[0].filename).toBe('first.png')
expect(store.items[0].name).toBe('first.png')
store.openSingle(asset2)
expect(store.items).toHaveLength(1)
expect(store.items[0].filename).toBe('second.png')
expect(store.items[0].name).toBe('second.png')
expect(store.activeIndex).toBe(0)
})
})
@@ -163,17 +78,16 @@ describe('useMediaAssetGalleryStore', () => {
describe('close', () => {
it('should reset activeIndex to -1', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
const asset: AssetItem = {
id: 'test',
name: 'test.png',
kind: 'image',
src: 'test-url',
size: 1024,
preview_url: 'test-url',
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
store.openSingle(asset)
expect(store.activeIndex).toBe(0)
store.close()

View File

@@ -1,39 +1,20 @@
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
import { ResultItemImpl } from '@/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetItem } from '../schemas/assetSchema'
export const useMediaAssetGalleryStore = defineStore(
'mediaAssetGallery',
() => {
const activeIndex = ref(-1)
const items = shallowRef<ResultItemImpl[]>([])
const items = shallowRef<AssetItem[]>([])
const close = () => {
activeIndex.value = -1
}
const openSingle = (asset: AssetMeta) => {
// Convert AssetMeta to ResultItemImpl format
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: asset.kind === 'image' ? 'images' : asset.kind
})
// Override the url getter to use asset.src
Object.defineProperty(resultItem, 'url', {
get() {
return asset.src || ''
},
configurable: true
})
items.value = [resultItem]
const openSingle = (asset: AssetItem) => {
items.value = [asset]
activeIndex.value = 0
}

View File

@@ -16,7 +16,8 @@ const zAsset = z.object({
is_immutable: z.boolean().optional(),
last_access_time: z.string().optional(),
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
prompt_id: z.string().nullish()
})
const zAssetResponse = z.object({

View File

@@ -31,6 +31,9 @@ export interface PaginationOptions {
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
includePublic?: boolean
jobIds?: string[]
sort?: string
order?: 'asc' | 'desc'
}
interface AssetExportOptions {
@@ -202,7 +205,10 @@ function createAssetService() {
includeTags,
limit = DEFAULT_LIMIT,
offset,
includePublic
includePublic,
jobIds,
sort,
order
} = options
const queryParams = new URLSearchParams({
include_tags: includeTags.join(','),
@@ -214,6 +220,15 @@ function createAssetService() {
if (includePublic !== undefined) {
queryParams.set('include_public', includePublic ? 'true' : 'false')
}
if (jobIds?.length) {
queryParams.set('job_ids', jobIds.join(','))
}
if (sort) {
queryParams.set('sort', sort)
}
if (order) {
queryParams.set('order', order)
}
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
const res = await api.fetchApi(url)
@@ -754,6 +769,25 @@ function createAssetService() {
return await res.json()
}
async function getOutputAssets(
options?: PaginationOptions & { sort?: string; order?: 'asc' | 'desc' }
): Promise<AssetResponse> {
return handleAssetRequest(
{ includeTags: ['output'], ...options },
'output assets'
)
}
async function getAssetsByJobIds(
jobIds: string[],
options?: PaginationOptions
): Promise<AssetResponse> {
return handleAssetRequest(
{ includeTags: ['output'], jobIds, ...options },
'job assets'
)
}
return {
getAssetModelFolders,
getAssetModels,
@@ -772,7 +806,9 @@ function createAssetService() {
uploadAssetFromBase64,
uploadAssetAsync,
createAssetExport,
getExportDownloadUrl
getExportDownloadUrl,
getOutputAssets,
getAssetsByJobIds
}
}