mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
6 Commits
sno-qa-986
...
media-asse
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0940b89db | ||
|
|
f1442817a6 | ||
|
|
5e318e1a36 | ||
|
|
733b2a259c | ||
|
|
f75cf28c87 | ||
|
|
a17b8a73f5 |
@@ -10,6 +10,11 @@ import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
}),
|
||||
createI18n: () => ({
|
||||
global: {
|
||||
t: (key: string) => key
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ import type { OutputStackListItem } from '@/platform/assets/composables/useOutpu
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import {
|
||||
@@ -135,7 +136,7 @@ function isVideoAsset(asset: AssetItem): boolean {
|
||||
function getAssetPreviewUrl(asset: AssetItem): string {
|
||||
const mediaType = getAssetMediaType(asset)
|
||||
if (mediaType === 'image' || mediaType === 'video') {
|
||||
return asset.preview_url || ''
|
||||
return asset.preview_url || getAssetUrl(asset)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -158,13 +159,13 @@ function getAssetSecondaryText(asset: AssetItem): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
// OSS: metadata?.outputCount (typed). Cloud: raw user_metadata fallback.
|
||||
function getStackCount(asset: AssetItem): number | undefined {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (typeof metadata?.outputCount === 'number') {
|
||||
return metadata.outputCount
|
||||
}
|
||||
const count = metadata?.outputCount ?? asset.user_metadata?.outputCount
|
||||
if (typeof count === 'number' && count > 1) return count
|
||||
|
||||
if (Array.isArray(metadata?.allOutputs)) {
|
||||
if (Array.isArray(metadata?.allOutputs) && metadata.allOutputs.length > 1) {
|
||||
return metadata.allOutputs.length
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<!-- Job Detail View Header -->
|
||||
<div v-if="isInFolderView" class="px-2 2xl:px-4">
|
||||
<Button variant="secondary" size="lg" @click="exitFolderView">
|
||||
<div v-if="isInFolderView" class="px-2 pt-2 2xl:px-4">
|
||||
<Button variant="secondary" @click="exitFolderView">
|
||||
<i class="icon-[lucide--arrow-left] size-4" />
|
||||
<span>{{ $t('sideToolbar.backToAssets') }}</span>
|
||||
</Button>
|
||||
@@ -232,12 +232,12 @@ import { useAssetSelection } from '@/platform/assets/composables/useAssetSelecti
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { resolveAssetOutputs } from '@/platform/assets/composables/resolveAssetOutputs'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
@@ -374,8 +374,8 @@ const {
|
||||
error: folderError,
|
||||
execute: loadFolderAssets
|
||||
} = useAsyncState(
|
||||
(metadata: OutputAssetMetadata, options: { createdAt?: string } = {}) =>
|
||||
resolveOutputAssetItems(metadata, options),
|
||||
(asset: AssetItem) =>
|
||||
resolveAssetOutputs(asset, { createdAt: asset.created_at }),
|
||||
[] as AssetItem[],
|
||||
{ immediate: false, resetOnExecute: true }
|
||||
)
|
||||
@@ -467,7 +467,7 @@ const galleryItems = computed(() => {
|
||||
|
||||
Object.defineProperty(resultItem, 'url', {
|
||||
get() {
|
||||
return asset.preview_url || ''
|
||||
return asset.preview_url || getAssetUrl(asset)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
@@ -593,12 +593,8 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
|
||||
const enterFolderView = async (asset: AssetItem) => {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!metadata) {
|
||||
console.warn('Invalid output asset metadata')
|
||||
return
|
||||
}
|
||||
|
||||
const { jobId, executionTimeInSeconds } = metadata
|
||||
const jobId = asset.prompt_id ?? metadata?.jobId
|
||||
const executionTimeInSeconds = metadata?.executionTimeInSeconds
|
||||
|
||||
if (!jobId) {
|
||||
console.warn('Missing required folder view data')
|
||||
@@ -607,9 +603,10 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
|
||||
folderJobId.value = jobId
|
||||
folderExecutionTime.value = executionTimeInSeconds
|
||||
expectedFolderCount.value = metadata.outputCount ?? 0
|
||||
const outputCount = asset.user_metadata?.outputCount
|
||||
expectedFolderCount.value = typeof outputCount === 'number' ? outputCount : 0
|
||||
|
||||
await loadFolderAssets(0, metadata, { createdAt: asset.created_at })
|
||||
await loadFolderAssets(0, asset)
|
||||
|
||||
if (folderError.value) {
|
||||
toast.add({
|
||||
|
||||
@@ -244,8 +244,8 @@ const adaptedAsset = computed(() => {
|
||||
src:
|
||||
fileKind.value === '3D'
|
||||
? getAssetUrl(asset)
|
||||
: asset.thumbnail_url || asset.preview_url || '',
|
||||
preview_url: asset.preview_url,
|
||||
: asset.thumbnail_url || asset.preview_url || getAssetUrl(asset),
|
||||
preview_url: asset.preview_url || getAssetUrl(asset),
|
||||
preview_id: asset.preview_id,
|
||||
size: asset.size,
|
||||
tags: asset.tags || [],
|
||||
@@ -309,12 +309,15 @@ const handleOutputCountClick = () => {
|
||||
emit('output-count-click')
|
||||
}
|
||||
function dragStart(e: DragEvent) {
|
||||
if (!asset?.preview_url) return
|
||||
if (!asset) return
|
||||
|
||||
const previewUrl = asset.preview_url || getAssetUrl(asset)
|
||||
if (!previewUrl) return
|
||||
|
||||
const { dataTransfer } = e
|
||||
if (!dataTransfer) return
|
||||
|
||||
const url = URL.parse(asset.preview_url, location.href)
|
||||
const url = URL.parse(previewUrl, location.href)
|
||||
if (!url) return
|
||||
|
||||
dataTransfer.items.add(url.toString(), 'text/uri-list')
|
||||
|
||||
24
src/platform/assets/composables/resolveAssetOutputs.ts
Normal file
24
src/platform/assets/composables/resolveAssetOutputs.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { ResolveOutputAssetItemsOptions } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
|
||||
/**
|
||||
* Resolve all output assets for a given asset.
|
||||
* Cloud: uses Assets API via fetchPromptAssets.
|
||||
* OSS: uses Jobs API via resolveOutputAssetItems.
|
||||
*/
|
||||
export async function resolveAssetOutputs(
|
||||
asset: AssetItem,
|
||||
options?: ResolveOutputAssetItemsOptions & { excludeParent?: boolean }
|
||||
): Promise<AssetItem[]> {
|
||||
if (asset.prompt_id) {
|
||||
const all = await assetService.fetchPromptAssets(asset.prompt_id)
|
||||
return options?.excludeParent ? all.filter((a) => a.id !== asset.id) : all
|
||||
}
|
||||
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!metadata) return []
|
||||
return resolveOutputAssetItems(metadata, options)
|
||||
}
|
||||
@@ -41,7 +41,9 @@ export function useMediaAssetActions() {
|
||||
|
||||
/**
|
||||
* Internal helper to perform the API deletion for a single asset
|
||||
* Handles both output assets (via history API) and input assets (via asset service)
|
||||
* Cloud output assets are deleted individually via asset service.
|
||||
* OSS output assets are deleted via history API (deletes entire job).
|
||||
* Input assets can only be deleted in cloud environment.
|
||||
* @throws Error if deletion fails or is not allowed
|
||||
*/
|
||||
const deleteAssetApi = async (
|
||||
@@ -49,14 +51,20 @@ export function useMediaAssetActions() {
|
||||
assetType: string
|
||||
): Promise<void> => {
|
||||
if (assetType === 'output') {
|
||||
const jobId =
|
||||
getOutputAssetMetadata(asset.user_metadata)?.jobId || asset.id
|
||||
if (!jobId) {
|
||||
throw new Error('Unable to extract job ID from asset')
|
||||
if (isCloud) {
|
||||
await assetService.deleteAsset(asset.id)
|
||||
if (asset.prompt_id) {
|
||||
assetService.invalidatePromptAssetsCache(asset.prompt_id)
|
||||
}
|
||||
} else {
|
||||
const jobId =
|
||||
getOutputAssetMetadata(asset.user_metadata)?.jobId || asset.id
|
||||
if (!jobId) {
|
||||
throw new Error('Unable to extract job ID from asset')
|
||||
}
|
||||
await api.deleteItem('history', jobId)
|
||||
}
|
||||
await api.deleteItem('history', jobId)
|
||||
} else {
|
||||
// Input assets can only be deleted in cloud environment
|
||||
if (!isCloud) {
|
||||
throw new Error(t('mediaAsset.deletingImportedFilesCloudOnly'))
|
||||
}
|
||||
@@ -100,7 +108,7 @@ export function useMediaAssetActions() {
|
||||
|
||||
const hasMultiOutputJobs = assets.some((a) => {
|
||||
const count = getOutputAssetMetadata(a.user_metadata)?.outputCount
|
||||
return typeof count === 'number' && count > 1
|
||||
return (typeof count === 'number' && count > 1) || !!a.prompt_id
|
||||
})
|
||||
|
||||
if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) {
|
||||
@@ -142,19 +150,23 @@ export function useMediaAssetActions() {
|
||||
for (const asset of assets) {
|
||||
if (getAssetType(asset) === 'output') {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const jobId = metadata?.jobId || asset.id
|
||||
const jobId = asset.prompt_id || metadata?.jobId
|
||||
if (!jobId) {
|
||||
assetIds.push(asset.id)
|
||||
continue
|
||||
}
|
||||
if (!jobIds.includes(jobId)) {
|
||||
jobIds.push(jobId)
|
||||
}
|
||||
// Only add name filters when outputCount is unknown.
|
||||
// When outputCount is set, the asset is a job-level selection
|
||||
// from the gallery and the user wants all outputs for that job.
|
||||
if (metadata?.jobId && asset.name && metadata.outputCount == null) {
|
||||
if (!jobAssetNameFilters[metadata.jobId]) {
|
||||
jobAssetNameFilters[metadata.jobId] = []
|
||||
if (asset.name && metadata?.outputCount == null) {
|
||||
if (!jobAssetNameFilters[jobId]) {
|
||||
jobAssetNameFilters[jobId] = []
|
||||
}
|
||||
if (!jobAssetNameFilters[metadata.jobId].includes(asset.name)) {
|
||||
jobAssetNameFilters[metadata.jobId].push(asset.name)
|
||||
if (!jobAssetNameFilters[jobId].includes(asset.name)) {
|
||||
jobAssetNameFilters[jobId].push(asset.name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -195,6 +207,7 @@ export function useMediaAssetActions() {
|
||||
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const jobId =
|
||||
targetAsset.prompt_id ||
|
||||
metadata?.jobId ||
|
||||
(getAssetType(targetAsset) === 'output' ? targetAsset.id : undefined)
|
||||
|
||||
|
||||
@@ -2,20 +2,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type * as OutputAssetUtil from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveOutputAssetItems: vi.fn()
|
||||
resolveAssetOutputs: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof OutputAssetUtil>()
|
||||
return {
|
||||
...actual,
|
||||
resolveOutputAssetItems: mocks.resolveOutputAssetItems
|
||||
}
|
||||
})
|
||||
vi.mock('@/platform/assets/composables/resolveAssetOutputs', () => ({
|
||||
resolveAssetOutputs: mocks.resolveAssetOutputs
|
||||
}))
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
@@ -66,19 +61,20 @@ describe('useOutputStacks', () => {
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([childA, childB])
|
||||
mocks.resolveAssetOutputs.mockResolvedValue([childA, childB])
|
||||
|
||||
const { assetItems, isStackExpanded, selectableAssets, toggleStack } =
|
||||
useOutputStacks({ assets: ref([parent]) })
|
||||
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job-1' }),
|
||||
{
|
||||
expect(mocks.resolveAssetOutputs).toHaveBeenCalledWith(
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
createdAt: parent.created_at,
|
||||
excludeOutputKey: 'node-1-outputs-parent.png'
|
||||
}
|
||||
excludeOutputKey: 'node-1-outputs-parent.png',
|
||||
excludeParent: true
|
||||
})
|
||||
)
|
||||
expect(isStackExpanded(parent)).toBe(true)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
|
||||
@@ -105,7 +101,7 @@ describe('useOutputStacks', () => {
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([child])
|
||||
mocks.resolveAssetOutputs.mockResolvedValue([child])
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
@@ -131,7 +127,7 @@ describe('useOutputStacks', () => {
|
||||
|
||||
await toggleStack(asset)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
expect(mocks.resolveAssetOutputs).not.toHaveBeenCalled()
|
||||
expect(isStackExpanded(asset)).toBe(false)
|
||||
expect(assetItems.value).toHaveLength(1)
|
||||
expect(assetItems.value[0].asset).toMatchObject(asset)
|
||||
@@ -140,7 +136,7 @@ describe('useOutputStacks', () => {
|
||||
it('does not expand when no children are resolved', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([])
|
||||
mocks.resolveAssetOutputs.mockResolvedValue([])
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
@@ -156,9 +152,7 @@ describe('useOutputStacks', () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockRejectedValue(
|
||||
new Error('resolve failed')
|
||||
)
|
||||
mocks.resolveAssetOutputs.mockRejectedValue(new Error('resolve failed'))
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
@@ -181,7 +175,7 @@ describe('useOutputStacks', () => {
|
||||
})
|
||||
const deferred = createDeferred<AssetItem[]>()
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockReturnValue(deferred.promise)
|
||||
mocks.resolveAssetOutputs.mockReturnValue(deferred.promise)
|
||||
|
||||
const { assetItems, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
@@ -190,7 +184,7 @@ describe('useOutputStacks', () => {
|
||||
const firstToggle = toggleStack(parent)
|
||||
const secondToggle = toggleStack(parent)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.resolveAssetOutputs).toHaveBeenCalledTimes(1)
|
||||
|
||||
deferred.resolve([child])
|
||||
|
||||
@@ -202,4 +196,77 @@ describe('useOutputStacks', () => {
|
||||
child.id
|
||||
])
|
||||
})
|
||||
|
||||
describe('cloud path (prompt_id)', () => {
|
||||
it('passes excludeParent for cloud assets with prompt_id', async () => {
|
||||
const parent = createAsset({
|
||||
id: 'parent',
|
||||
name: 'parent.png',
|
||||
prompt_id: 'prompt-1',
|
||||
user_metadata: undefined
|
||||
})
|
||||
const childA = createAsset({ id: 'child-a', name: 'a.png' })
|
||||
const childB = createAsset({ id: 'child-b', name: 'b.png' })
|
||||
|
||||
mocks.resolveAssetOutputs.mockResolvedValue([childA, childB])
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
})
|
||||
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(mocks.resolveAssetOutputs).toHaveBeenCalledWith(
|
||||
parent,
|
||||
expect.objectContaining({ excludeParent: true })
|
||||
)
|
||||
expect(isStackExpanded(parent)).toBe(true)
|
||||
expect(assetItems.value.map((i) => i.asset.id)).toEqual([
|
||||
'parent',
|
||||
'child-a',
|
||||
'child-b'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses prompt_id as stack key over metadata.jobId', async () => {
|
||||
const assetA = createAsset({
|
||||
id: 'a',
|
||||
prompt_id: 'shared-prompt',
|
||||
user_metadata: { jobId: 'job-1', nodeId: '1', subfolder: '' }
|
||||
})
|
||||
const assetB = createAsset({
|
||||
id: 'b',
|
||||
prompt_id: 'different-prompt',
|
||||
user_metadata: { jobId: 'job-1', nodeId: '1', subfolder: '' }
|
||||
})
|
||||
const child = createAsset({ id: 'child', name: 'child.png' })
|
||||
|
||||
mocks.resolveAssetOutputs.mockResolvedValue([child])
|
||||
|
||||
const { isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([assetA, assetB])
|
||||
})
|
||||
|
||||
await toggleStack(assetA)
|
||||
|
||||
expect(isStackExpanded(assetA)).toBe(true)
|
||||
expect(isStackExpanded(assetB)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores asset without prompt_id or metadata', async () => {
|
||||
const asset = createAsset({
|
||||
id: 'no-key',
|
||||
prompt_id: null,
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
const { toggleStack } = useOutputStacks({
|
||||
assets: ref([asset])
|
||||
})
|
||||
|
||||
await toggleStack(asset)
|
||||
|
||||
expect(mocks.resolveAssetOutputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,10 +3,8 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
getOutputKey,
|
||||
resolveOutputAssetItems
|
||||
} from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { getOutputKey } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { resolveAssetOutputs } from './resolveAssetOutputs'
|
||||
|
||||
export type OutputStackListItem = {
|
||||
key: string
|
||||
@@ -55,6 +53,7 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
)
|
||||
|
||||
function getStackJobId(asset: AssetItem): string | null {
|
||||
if (asset.prompt_id) return asset.prompt_id
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
return metadata?.jobId ?? null
|
||||
}
|
||||
@@ -107,21 +106,21 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
|
||||
async function resolveStackChildren(asset: AssetItem): Promise<AssetItem[]> {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!metadata) {
|
||||
return []
|
||||
}
|
||||
if (!metadata && !asset.prompt_id) return []
|
||||
|
||||
const excludeOutputKey =
|
||||
getOutputKey({
|
||||
nodeId: metadata.nodeId,
|
||||
subfolder: metadata.subfolder,
|
||||
filename: asset.name
|
||||
}) ?? undefined
|
||||
const excludeOutputKey = metadata
|
||||
? (getOutputKey({
|
||||
nodeId: metadata.nodeId,
|
||||
subfolder: metadata.subfolder,
|
||||
filename: asset.name
|
||||
}) ?? undefined)
|
||||
: undefined
|
||||
|
||||
try {
|
||||
return await resolveOutputAssetItems(metadata, {
|
||||
return await resolveAssetOutputs(asset, {
|
||||
createdAt: asset.created_at,
|
||||
excludeOutputKey
|
||||
excludeOutputKey,
|
||||
excludeParent: true
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve stack children:', error)
|
||||
|
||||
@@ -12,6 +12,7 @@ const zAsset = z.object({
|
||||
display_name: z.string().optional(),
|
||||
preview_url: z.string().optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
prompt_id: z.string().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
is_immutable: z.boolean().optional(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
|
||||
@@ -104,3 +105,77 @@ describe(assetService.shouldUseAssetBrowser, () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.getAssetsByJobIds, () => {
|
||||
const mockFetchApi = vi.mocked(api.fetchApi)
|
||||
|
||||
function mockFetchApiResponse(assets: Record<string, unknown>[]) {
|
||||
mockFetchApi.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ assets })
|
||||
} as unknown as Response)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns empty array for empty jobIds', async () => {
|
||||
const result = await assetService.getAssetsByJobIds([])
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('constructs URL with job_ids query param', async () => {
|
||||
mockFetchApiResponse([])
|
||||
|
||||
await assetService.getAssetsByJobIds(['job-1', 'job-2'])
|
||||
|
||||
const url = mockFetchApi.mock.calls[0][0] as string
|
||||
expect(url).toContain('job_ids=job-1%2Cjob-2')
|
||||
})
|
||||
|
||||
it('includes offset when greater than 0', async () => {
|
||||
mockFetchApiResponse([])
|
||||
|
||||
await assetService.getAssetsByJobIds(['job-1'], { offset: 10 })
|
||||
|
||||
const url = mockFetchApi.mock.calls[0][0] as string
|
||||
expect(url).toContain('offset=10')
|
||||
})
|
||||
|
||||
it('omits offset when 0', async () => {
|
||||
mockFetchApiResponse([])
|
||||
|
||||
await assetService.getAssetsByJobIds(['job-1'], { offset: 0 })
|
||||
|
||||
const url = mockFetchApi.mock.calls[0][0] as string
|
||||
expect(url).not.toContain('offset')
|
||||
})
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(assetService.getAssetsByJobIds(['job-1'])).rejects.toThrow(
|
||||
'Server returned 500'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns parsed assets from response', async () => {
|
||||
const assets = [
|
||||
{ id: 'a1', name: 'img.png', tags: ['output'] },
|
||||
{ id: 'a2', name: 'img2.png', tags: ['output'] }
|
||||
]
|
||||
mockFetchApiResponse(assets)
|
||||
|
||||
const result = await assetService.getAssetsByJobIds(['job-1'])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe('a1')
|
||||
expect(result[1].id).toBe('a2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
@@ -225,6 +226,59 @@ function createAssetService() {
|
||||
const data = await res.json()
|
||||
return validateAssetResponse(data)
|
||||
}
|
||||
/**
|
||||
* Gets output assets filtered by job IDs (prompt_id)
|
||||
*
|
||||
* @param jobIds - Array of job/prompt IDs to filter by
|
||||
* @param options - Pagination options
|
||||
* @returns Promise<AssetItem[]> - Assets matching the given job IDs
|
||||
*/
|
||||
async function getAssetsByJobIds(
|
||||
jobIds: string[],
|
||||
{ limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {}
|
||||
): Promise<AssetItem[]> {
|
||||
if (jobIds.length === 0) return []
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
job_ids: jobIds.join(','),
|
||||
limit: limit.toString()
|
||||
})
|
||||
if (offset > 0) {
|
||||
queryParams.set('offset', offset.toString())
|
||||
}
|
||||
|
||||
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
|
||||
const res = await api.fetchApi(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to load assets for job IDs: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
const data = await res.json()
|
||||
const response = validateAssetResponse(data)
|
||||
return response?.assets ?? []
|
||||
}
|
||||
|
||||
const promptAssetsCache = new QuickLRU<string, AssetItem[]>({ maxSize: 50 })
|
||||
|
||||
/**
|
||||
* Fetch output assets for a prompt_id with LRU caching.
|
||||
* Filters out temp assets — only returns assets tagged 'output'.
|
||||
*/
|
||||
async function fetchPromptAssets(promptId: string): Promise<AssetItem[]> {
|
||||
const cached = promptAssetsCache.get(promptId)
|
||||
if (cached) return cached
|
||||
|
||||
const allAssets = await getAssetsByJobIds([promptId])
|
||||
const outputAssets = allAssets.filter((a) => a.tags.includes('output'))
|
||||
promptAssetsCache.set(promptId, outputAssets)
|
||||
return outputAssets
|
||||
}
|
||||
|
||||
function invalidatePromptAssetsCache(promptId: string) {
|
||||
promptAssetsCache.delete(promptId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of model folder keys from the asset API
|
||||
*
|
||||
@@ -757,6 +811,9 @@ function createAssetService() {
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
getAssetsByJobIds,
|
||||
fetchPromptAssets,
|
||||
invalidatePromptAssetsCache,
|
||||
isAssetAPIEnabled,
|
||||
isAssetBrowserEligible,
|
||||
shouldUseAssetBrowser,
|
||||
|
||||
67
src/platform/assets/utils/assetUrlUtil.test.ts
Normal file
67
src/platform/assets/utils/assetUrlUtil.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import { getAssetUrl } from './assetUrlUtil'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `/api${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
function makeAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return { id: 'a', name: 'a.png', tags: [], ...overrides }
|
||||
}
|
||||
|
||||
describe(getAssetUrl, () => {
|
||||
it('uses hash-based URL for cloud assets with asset_hash', () => {
|
||||
const asset = makeAsset({ asset_hash: 'abc123' })
|
||||
|
||||
const url = getAssetUrl(asset)
|
||||
|
||||
expect(url).toBe('/api/view?filename=abc123')
|
||||
})
|
||||
|
||||
it('uses name+type+subfolder for OSS assets without asset_hash', () => {
|
||||
const asset = makeAsset({
|
||||
name: 'image.png',
|
||||
tags: ['output'],
|
||||
user_metadata: { subfolder: 'results' }
|
||||
})
|
||||
|
||||
const url = getAssetUrl(asset)
|
||||
|
||||
expect(url).toContain('filename=image.png')
|
||||
expect(url).toContain('type=output')
|
||||
expect(url).toContain('subfolder=results')
|
||||
})
|
||||
|
||||
it('omits subfolder when empty', () => {
|
||||
const asset = makeAsset({
|
||||
name: 'image.png',
|
||||
tags: ['output'],
|
||||
user_metadata: { subfolder: '' }
|
||||
})
|
||||
|
||||
const url = getAssetUrl(asset)
|
||||
|
||||
expect(url).not.toContain('subfolder')
|
||||
})
|
||||
|
||||
it('uses defaultType when asset has no tags', () => {
|
||||
const asset = makeAsset({ name: 'file.png', tags: [] })
|
||||
|
||||
const url = getAssetUrl(asset, 'input')
|
||||
|
||||
expect(url).toContain('type=input')
|
||||
})
|
||||
|
||||
it('falls back to output when no defaultType specified', () => {
|
||||
const asset = makeAsset({ name: 'file.png', tags: [] })
|
||||
|
||||
const url = getAssetUrl(asset)
|
||||
|
||||
expect(url).toContain('type=output')
|
||||
})
|
||||
})
|
||||
@@ -7,21 +7,19 @@ import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { getAssetType } from './assetTypeUtil'
|
||||
|
||||
/**
|
||||
* Get the download/view URL for an asset
|
||||
* Constructs the proper URL with filename encoding, type, and subfolder parameters
|
||||
*
|
||||
* @param asset The asset to get URL for
|
||||
* @param defaultType Default type if asset doesn't have tags (default: 'output')
|
||||
* @returns Full URL for viewing/downloading the asset
|
||||
*
|
||||
* @example
|
||||
* const url = getAssetUrl(asset)
|
||||
* downloadFile(url, asset.name)
|
||||
* Get the download/view URL for an asset.
|
||||
* Cloud assets with asset_hash use `/view?filename={asset_hash}`.
|
||||
* OSS assets use `/view?filename={name}&type={type}&subfolder={subfolder}`.
|
||||
*/
|
||||
export function getAssetUrl(
|
||||
asset: AssetItem,
|
||||
defaultType: 'input' | 'output' = 'output'
|
||||
): string {
|
||||
if (asset.asset_hash) {
|
||||
const params = new URLSearchParams({ filename: asset.asset_hash })
|
||||
return api.apiURL(`/view?${params}`)
|
||||
}
|
||||
|
||||
const assetType = getAssetType(asset, defaultType)
|
||||
const subfolder = asset.user_metadata?.subfolder
|
||||
const params = new URLSearchParams()
|
||||
|
||||
66
src/platform/assets/utils/groupAssetsByPromptId.test.ts
Normal file
66
src/platform/assets/utils/groupAssetsByPromptId.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import { groupAssetsByPromptId } from './groupAssetsByPromptId'
|
||||
|
||||
function makeAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return { id: 'a', name: 'a.png', tags: [], ...overrides }
|
||||
}
|
||||
|
||||
describe(groupAssetsByPromptId, () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(groupAssetsByPromptId([])).toEqual([])
|
||||
})
|
||||
|
||||
it('groups assets sharing the same prompt_id and sets outputCount', () => {
|
||||
const assets = [
|
||||
makeAsset({ id: '1', prompt_id: 'p1', created_at: '2024-01-01' }),
|
||||
makeAsset({ id: '2', prompt_id: 'p1', created_at: '2024-01-01' }),
|
||||
makeAsset({ id: '3', prompt_id: 'p1', created_at: '2024-01-01' })
|
||||
]
|
||||
|
||||
const result = groupAssetsByPromptId(assets)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('1')
|
||||
expect(result[0].user_metadata?.outputCount).toBe(3)
|
||||
})
|
||||
|
||||
it('treats assets with null prompt_id individually keyed by id', () => {
|
||||
const assets = [
|
||||
makeAsset({ id: 'a1', prompt_id: null, created_at: '2024-01-01' }),
|
||||
makeAsset({ id: 'a2', prompt_id: null, created_at: '2024-01-02' })
|
||||
]
|
||||
|
||||
const result = groupAssetsByPromptId(assets)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.every((a) => a.user_metadata?.outputCount === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('sets outputCount to 1 for single-asset groups', () => {
|
||||
const assets = [
|
||||
makeAsset({ id: '1', prompt_id: 'p1' }),
|
||||
makeAsset({ id: '2', prompt_id: 'p2' })
|
||||
]
|
||||
|
||||
const result = groupAssetsByPromptId(assets)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].user_metadata?.outputCount).toBe(1)
|
||||
expect(result[1].user_metadata?.outputCount).toBe(1)
|
||||
})
|
||||
|
||||
it('sorts results by created_at descending', () => {
|
||||
const assets = [
|
||||
makeAsset({ id: '1', prompt_id: 'p1', created_at: '2024-01-01' }),
|
||||
makeAsset({ id: '2', prompt_id: 'p2', created_at: '2024-03-01' }),
|
||||
makeAsset({ id: '3', prompt_id: 'p3', created_at: '2024-02-01' })
|
||||
]
|
||||
|
||||
const result = groupAssetsByPromptId(assets)
|
||||
|
||||
expect(result.map((a) => a.id)).toEqual(['2', '3', '1'])
|
||||
})
|
||||
})
|
||||
37
src/platform/assets/utils/groupAssetsByPromptId.ts
Normal file
37
src/platform/assets/utils/groupAssetsByPromptId.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Group flat output assets by prompt_id.
|
||||
* Returns one representative AssetItem per group with outputCount in user_metadata.
|
||||
*/
|
||||
export function groupAssetsByPromptId(assets: AssetItem[]): AssetItem[] {
|
||||
const groups = new Map<string, AssetItem[]>()
|
||||
|
||||
for (const asset of assets) {
|
||||
const key = asset.prompt_id ?? asset.id
|
||||
const group = groups.get(key)
|
||||
if (group) {
|
||||
group.push(asset)
|
||||
} else {
|
||||
groups.set(key, [asset])
|
||||
}
|
||||
}
|
||||
|
||||
const grouped: AssetItem[] = []
|
||||
for (const [, group] of groups) {
|
||||
const representative = group[0]
|
||||
grouped.push({
|
||||
...representative,
|
||||
user_metadata: {
|
||||
...representative.user_metadata,
|
||||
outputCount: group.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return grouped.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at ?? 0).getTime() -
|
||||
new Date(a.created_at ?? 0).getTime()
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ type OutputAssetMapOptions = {
|
||||
excludeOutputKey?: string
|
||||
}
|
||||
|
||||
type ResolveOutputAssetItemsOptions = {
|
||||
export type ResolveOutputAssetItemsOptions = {
|
||||
createdAt?: string
|
||||
excludeOutputKey?: string
|
||||
}
|
||||
@@ -88,6 +88,10 @@ function mapOutputsToAssetItems({
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all output assets for a given job via Jobs API (OSS/Desktop).
|
||||
* Pure data transformation — no platform branching or API state.
|
||||
*/
|
||||
export async function resolveOutputAssetItems(
|
||||
metadata: OutputAssetMetadata,
|
||||
{ createdAt, excludeOutputKey }: ResolveOutputAssetItemsOptions = {}
|
||||
@@ -101,7 +105,6 @@ export async function resolveOutputAssetItems(
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse so the most recent outputs appear first
|
||||
return mapOutputsToAssetItems({
|
||||
jobId: metadata.jobId,
|
||||
outputs: outputsToDisplay.toReversed(),
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
mapTaskOutputToAssetItem
|
||||
} from '@/platform/assets/composables/media/assetMappers'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { PaginationOptions } from '@/platform/assets/services/assetService'
|
||||
import { groupAssetsByPromptId } from '@/platform/assets/utils/groupAssetsByPromptId'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -114,6 +116,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
const allHistoryItems = ref<AssetItem[]>([])
|
||||
|
||||
const loadedIds = shallowReactive(new Set<string>())
|
||||
const loadedPromptIds = shallowReactive(new Set<string>())
|
||||
|
||||
const fetchInputFiles = isCloud
|
||||
? fetchInputFilesFromCloud
|
||||
@@ -133,11 +136,86 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* Fetch history assets with pagination support
|
||||
* @param loadMore - true for pagination (append), false for initial load (replace)
|
||||
* Fetch history assets from cloud Assets API with prompt_id grouping
|
||||
*/
|
||||
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
|
||||
// Reset state for initial load
|
||||
const fetchHistoryAssetsFromCloud = async (
|
||||
loadMore = false
|
||||
): Promise<AssetItem[]> => {
|
||||
if (!loadMore) {
|
||||
historyOffset.value = 0
|
||||
hasMoreHistory.value = true
|
||||
allHistoryItems.value = []
|
||||
loadedIds.clear()
|
||||
loadedPromptIds.clear()
|
||||
}
|
||||
|
||||
const rawAssets = await assetService.getAssetsByTag('output', false, {
|
||||
limit: BATCH_SIZE,
|
||||
offset: historyOffset.value
|
||||
})
|
||||
|
||||
const newAssets = groupAssetsByPromptId(rawAssets)
|
||||
|
||||
if (loadMore) {
|
||||
for (const asset of newAssets) {
|
||||
const mergeKey = asset.prompt_id || asset.id
|
||||
if (asset.prompt_id && loadedPromptIds.has(asset.prompt_id)) {
|
||||
const existing = allHistoryItems.value.find(
|
||||
(item) => item.prompt_id === asset.prompt_id
|
||||
)
|
||||
if (existing) {
|
||||
const existingCount =
|
||||
getOutputAssetMetadata(existing.user_metadata)?.outputCount ?? 1
|
||||
const newCount =
|
||||
getOutputAssetMetadata(asset.user_metadata)?.outputCount ?? 1
|
||||
existing.user_metadata = {
|
||||
...existing.user_metadata,
|
||||
outputCount: existingCount + newCount
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (loadedIds.has(mergeKey)) continue
|
||||
loadedIds.add(mergeKey)
|
||||
if (asset.prompt_id) loadedPromptIds.add(asset.prompt_id)
|
||||
|
||||
const assetTime = new Date(asset.created_at ?? 0).getTime()
|
||||
const insertIndex = allHistoryItems.value.findIndex(
|
||||
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
|
||||
)
|
||||
|
||||
if (insertIndex === -1) {
|
||||
allHistoryItems.value.push(asset)
|
||||
} else {
|
||||
allHistoryItems.value.splice(insertIndex, 0, asset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allHistoryItems.value = newAssets
|
||||
newAssets.forEach((asset) => {
|
||||
loadedIds.add(asset.prompt_id || asset.id)
|
||||
if (asset.prompt_id) loadedPromptIds.add(asset.prompt_id)
|
||||
})
|
||||
}
|
||||
|
||||
historyOffset.value += BATCH_SIZE
|
||||
hasMoreHistory.value = rawAssets.length === BATCH_SIZE
|
||||
|
||||
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
|
||||
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
||||
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
|
||||
removed.forEach((item) => loadedIds.delete(item.id))
|
||||
}
|
||||
|
||||
return allHistoryItems.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch history assets from Jobs API (OSS/Desktop)
|
||||
*/
|
||||
const fetchHistoryAssetsFromJobs = async (
|
||||
loadMore = false
|
||||
): Promise<AssetItem[]> => {
|
||||
if (!loadMore) {
|
||||
historyOffset.value = 0
|
||||
hasMoreHistory.value = true
|
||||
@@ -145,57 +223,49 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
loadedIds.clear()
|
||||
}
|
||||
|
||||
// Fetch from server with offset
|
||||
const history = await api.getHistory(BATCH_SIZE, {
|
||||
offset: historyOffset.value
|
||||
})
|
||||
|
||||
// Convert JobListItems to AssetItems
|
||||
const newAssets = mapHistoryToAssets(history)
|
||||
|
||||
if (loadMore) {
|
||||
// Filter out duplicates and insert in sorted order
|
||||
for (const asset of newAssets) {
|
||||
if (loadedIds.has(asset.id)) {
|
||||
continue // Skip duplicates
|
||||
}
|
||||
if (loadedIds.has(asset.id)) continue
|
||||
loadedIds.add(asset.id)
|
||||
|
||||
// Find insertion index to maintain sorted order (newest first)
|
||||
const assetTime = new Date(asset.created_at ?? 0).getTime()
|
||||
const insertIndex = allHistoryItems.value.findIndex(
|
||||
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
|
||||
)
|
||||
|
||||
if (insertIndex === -1) {
|
||||
// Asset is oldest, append to end
|
||||
allHistoryItems.value.push(asset)
|
||||
} else {
|
||||
// Insert at the correct position
|
||||
allHistoryItems.value.splice(insertIndex, 0, asset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Initial load: replace all
|
||||
allHistoryItems.value = newAssets
|
||||
newAssets.forEach((asset) => loadedIds.add(asset.id))
|
||||
}
|
||||
|
||||
// Update pagination state
|
||||
historyOffset.value += BATCH_SIZE
|
||||
hasMoreHistory.value = history.length === BATCH_SIZE
|
||||
|
||||
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
|
||||
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
||||
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
|
||||
|
||||
// Clean up Set
|
||||
removed.forEach((item) => loadedIds.delete(item.id))
|
||||
}
|
||||
|
||||
return allHistoryItems.value
|
||||
}
|
||||
|
||||
const fetchHistoryAssets = isCloud
|
||||
? fetchHistoryAssetsFromCloud
|
||||
: fetchHistoryAssetsFromJobs
|
||||
|
||||
const historyAssets = ref<AssetItem[]>([])
|
||||
const historyLoading = ref(false)
|
||||
const historyError = ref<unknown>(null)
|
||||
|
||||
Reference in New Issue
Block a user