Compare commits

...

4 Commits

Author SHA1 Message Date
Benjamin Lu
759032ced2 fix: handle windows paths in media extension parsing 2026-04-07 15:35:35 -07:00
Benjamin Lu
f78b42a56a fix: separate inspectable outputs from surfaced outputs 2026-04-07 15:02:35 -07:00
Benjamin Lu
5699d188e2 Merge branch 'main' into bl/fix-ply-asset-previewability 2026-04-06 14:44:48 -07:00
Benjamin Lu
a1b46d7d03 fix: distinguish 3d media from loadable previews 2026-03-28 21:22:36 -07:00
21 changed files with 418 additions and 114 deletions

View File

@@ -7,6 +7,7 @@
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./mediaExtensions": "./src/mediaExtensions.ts",
"./networkUtil": "./src/networkUtil.ts"
},
"scripts": {

View File

@@ -3,11 +3,12 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFileExtension,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isPreviewableMediaType,
isPreviewableMediaFilename,
truncateFilename
} from './formatUtil'
@@ -103,6 +104,11 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('mesh.ply')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('point-cloud.spz')).toBe('3D')
expect(getMediaTypeFromFilename('gaussian.splat')).toBe('3D')
expect(getMediaTypeFromFilename('scene.ksplat')).toBe('3D')
})
})
@@ -164,6 +170,21 @@ describe('formatUtil', () => {
})
})
describe('getFileExtension', () => {
it('returns a normalized lowercase extension when present', () => {
expect(getFileExtension('mesh.PLY')).toBe('ply')
expect(getFileExtension('/path/to/file.glb')).toBe('glb')
expect(getFileExtension('C:\\path.with.dot\\file.OBJ')).toBe('obj')
})
it('returns null when no extension is present', () => {
expect(getFileExtension('README')).toBe(null)
expect(getFileExtension('')).toBe(null)
expect(getFileExtension(undefined)).toBe(null)
expect(getFileExtension('C:\\path.with.dot\\README')).toBe(null)
})
})
describe('highlightQuery', () => {
it('should return text unchanged when query is empty', () => {
expect(highlightQuery('Hello World', '')).toBe('Hello World')
@@ -343,17 +364,27 @@ describe('formatUtil', () => {
})
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
expect(isPreviewableMediaType('image')).toBe(true)
expect(isPreviewableMediaType('video')).toBe(true)
expect(isPreviewableMediaType('audio')).toBe(true)
expect(isPreviewableMediaType('3D')).toBe(true)
describe('isPreviewableMediaFilename', () => {
it('returns true for browser-previewable core media', () => {
expect(isPreviewableMediaFilename('image.png')).toBe(true)
expect(isPreviewableMediaFilename('clip.mp4')).toBe(true)
expect(isPreviewableMediaFilename('sound.wav')).toBe(true)
})
it('returns false for text/other', () => {
expect(isPreviewableMediaType('text')).toBe(false)
expect(isPreviewableMediaType('other')).toBe(false)
it('returns true for loadable 3D formats', () => {
expect(isPreviewableMediaFilename('mesh.ply')).toBe(true)
expect(isPreviewableMediaFilename('scene.glb')).toBe(true)
expect(isPreviewableMediaFilename('print.stl')).toBe(true)
expect(isPreviewableMediaFilename('points.ksplat')).toBe(true)
})
it('returns false for 3D media without a browser loader', () => {
expect(isPreviewableMediaFilename('apple.usdz')).toBe(false)
})
it('returns false for non-previewable file types', () => {
expect(isPreviewableMediaFilename('notes.txt')).toBe(false)
expect(isPreviewableMediaFilename('archive.bin')).toBe(false)
})
})
})

View File

@@ -1,6 +1,11 @@
import { default as DOMPurify } from 'dompurify'
import type { operations } from '@comfyorg/registry-types'
import {
isThreeDLoadableExtension,
isThreeDMediaExtension
} from './mediaExtensions'
export function formatCamelCase(str: string): string {
// Check if the string is camel case
const isCamelCase = /^([A-Z][a-z]*)+$/.test(str)
@@ -546,7 +551,6 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
const TEXT_EXTENSIONS = [
'txt',
'md',
@@ -566,7 +570,6 @@ export type MediaType = (typeof MEDIA_TYPES)[number]
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
type TextExtension = (typeof TEXT_EXTENSIONS)[number]
/**
@@ -610,26 +613,44 @@ export function getMediaTypeFromFilename(
filename: string | null | undefined
): MediaType {
if (!filename) return 'other'
const ext = filename.split('.').pop()?.toLowerCase()
const ext = getFileExtension(filename)
if (!ext) return 'other'
// Type-safe array includes check using type assertion
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
if (isThreeDMediaExtension(ext)) return '3D'
if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text'
return 'other'
}
export function isPreviewableMediaType(mediaType: MediaType): boolean {
return (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio' ||
mediaType === '3D'
)
export function getFileExtension(
filename: string | null | undefined
): string | null {
if (!filename) return null
const fullFilename = filename.split(/[/\\]/).pop() ?? filename
const dotIndex = fullFilename.lastIndexOf('.')
if (dotIndex <= 0) return null
return fullFilename.slice(dotIndex + 1).toLowerCase()
}
/**
* Determines whether a file can be previewed directly in the browser UI.
* For 3D assets this is stricter than media classification: only extensions
* with an actual load3d loader implementation count as previewable.
*/
export function isPreviewableMediaFilename(
filename: string | null | undefined
): boolean {
const ext = getFileExtension(filename)
if (!ext) return false
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return true
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return true
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return true
return isThreeDLoadableExtension(ext)
}
export function formatTime(seconds: number): string {

View File

@@ -0,0 +1,50 @@
/**
* Extensions the in-app load3d viewer can load directly.
*/
export const THREE_D_LOADABLE_EXTENSIONS = [
'gltf',
'glb',
'obj',
'fbx',
'stl',
'spz',
'splat',
'ply',
'ksplat'
] as const
const THREE_D_NON_LOADABLE_MEDIA_EXTENSIONS = ['usdz'] as const
/**
* Shared 3D file extension registries.
*
* Keep 3D media classification and load3d viewer support in one module so new
* formats do not silently fall out of sync across the queue, asset sidebar,
* and drag/drop flows.
*/
export const THREE_D_MEDIA_EXTENSIONS = [
...THREE_D_LOADABLE_EXTENSIONS,
...THREE_D_NON_LOADABLE_MEDIA_EXTENSIONS
] as const
export type ThreeDMediaExtension = (typeof THREE_D_MEDIA_EXTENSIONS)[number]
export type ThreeDLoadableExtension =
(typeof THREE_D_LOADABLE_EXTENSIONS)[number]
export function isThreeDMediaExtension(
extension: string | null | undefined
): extension is ThreeDMediaExtension {
return (
typeof extension === 'string' &&
THREE_D_MEDIA_EXTENSIONS.includes(extension as ThreeDMediaExtension)
)
}
export function isThreeDLoadableExtension(
extension: string | null | undefined
): extension is ThreeDLoadableExtension {
return (
typeof extension === 'string' &&
THREE_D_LOADABLE_EXTENSIONS.includes(extension as ThreeDLoadableExtension)
)
}

View File

@@ -257,6 +257,10 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
if (!item.taskRef?.inspectableOutput) {
return
}
await openResultGallery(item)
await focusAssetInSidebar(item)
}

View File

@@ -74,11 +74,13 @@ type TestPreviewOutput = {
url: string
isImage: boolean
isVideo: boolean
is3D: boolean
}
type TestTaskRef = {
workflowId?: string
previewOutput?: TestPreviewOutput
inspectableOutput?: TestPreviewOutput
}
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
@@ -97,14 +99,24 @@ const createPreviewOutput = (
return {
url,
isImage: mediaType === 'images',
isVideo: mediaType === 'video'
isVideo: mediaType === 'video',
is3D: mediaType === 'model'
}
}
const createTaskRef = (preview?: TestPreviewOutput): TestTaskRef => ({
workflowId: 'workflow-1',
...(preview && { previewOutput: preview })
})
const createTaskRef = (
preview?: TestPreviewOutput,
inspectable?: TestPreviewOutput | null
): TestTaskRef => {
const resolvedInspectable =
inspectable === undefined ? preview : (inspectable ?? undefined)
return {
workflowId: 'workflow-1',
...(preview && { previewOutput: preview }),
...(resolvedInspectable && { inspectableOutput: resolvedInspectable })
}
}
const buildJob = (
overrides: Partial<TestJobListItem> = {}
@@ -284,6 +296,36 @@ describe('JobAssetsList', () => {
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('emits viewItem on icon click for completed PLY jobs without preview tile', async () => {
const previewOutput = createPreviewOutput('job-1.ply', 'model')
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(previewOutput)
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const icon = container.querySelector('.assets-list-item-stub i')!
await user.click(icon)
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('does not emit viewItem on icon click for completed USDZ jobs without inspectable output', async () => {
const previewOutput = createPreviewOutput('job-1.usdz', 'model')
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(previewOutput, null)
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const icon = container.querySelector('.assets-list-item-stub i')!
await user.click(icon)
expect(onViewItem).not.toHaveBeenCalled()
})
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
@@ -298,21 +340,18 @@ describe('JobAssetsList', () => {
expect(onViewItem).not.toHaveBeenCalled()
})
it('emits viewItem from the View button for completed jobs without preview output', async () => {
it('does not show the View button for completed jobs without inspectable output', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef()
taskRef: createTaskRef(undefined, null)
})
const onViewItem = vi.fn()
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await fireEvent.click(screen.getByText('menuLabels.View'))
await nextTick()
expect(onViewItem).toHaveBeenCalledWith(job)
expect(screen.queryByText('menuLabels.View')).toBeNull()
})
it('shows and hides the job details popover with hover delays', async () => {

View File

@@ -67,7 +67,7 @@
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="row.job.state === 'completed'"
v-else-if="hasInspectableCompletedAsset(row.job)"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(row.job)"
@@ -284,6 +284,10 @@ function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}
function getInspectableOutput(job: JobListItem) {
return job.taskRef?.inspectableOutput
}
function getJobPreviewUrl(job: JobListItem) {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
@@ -296,12 +300,12 @@ function isVideoPreviewJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
}
function isPreviewableCompletedJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)
function hasInspectableCompletedAsset(job: JobListItem) {
return job.state === 'completed' && !!getInspectableOutput(job)
}
function emitViewItem(job: JobListItem) {
if (isPreviewableCompletedJob(job)) {
if (hasInspectableCompletedAsset(job)) {
resetActiveDetails()
emit('viewItem', job)
}

View File

@@ -189,7 +189,6 @@
ref="contextMenuRef"
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@@ -246,7 +245,6 @@ import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadat
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 { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
@@ -254,7 +252,7 @@ import { ResultItemImpl } from '@/stores/queueStore'
import {
formatDuration,
getMediaTypeFromFilename,
isPreviewableMediaType
isPreviewableMediaFilename
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -291,10 +289,6 @@ const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
@@ -421,9 +415,7 @@ const visibleAssets = computed(() => {
})
const previewableVisibleAssets = computed(() =>
visibleAssets.value.filter((asset) =>
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
)
visibleAssets.value.filter((asset) => isPreviewableMediaFilename(asset.name))
)
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
@@ -571,7 +563,7 @@ const handleDeleteSelected = async () => {
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
if (!isPreviewableMediaType(mediaType)) {
if (!isPreviewableMediaFilename(asset.name)) {
return
}

View File

@@ -160,15 +160,15 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const previewOutput = item.taskRef?.previewOutput
const inspectableOutput = item.taskRef?.inspectableOutput
if (previewOutput?.is3D) {
if (inspectableOutput?.is3D) {
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: item.title,
component: Load3dViewerContent,
props: {
modelUrl: previewOutput.url || ''
modelUrl: inspectableOutput.url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
@@ -178,6 +178,10 @@ const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
return
}
if (!inspectableOutput) {
return
}
await openResultGallery(item)
})

View File

@@ -725,7 +725,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
taskRef: { previewOutput: {}, inspectableOutput: {} }
})
)
@@ -763,7 +763,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
taskRef: { previewOutput: {}, inspectableOutput: {} }
})
)
@@ -792,6 +792,35 @@ describe('useJobMenu', () => {
)
})
it('disables inspect when asset is surfaced but not inspectable', async () => {
const { jobMenuEntries } = mountJobMenu(vi.fn())
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
filename: 'mesh.usdz',
subfolder: 'models',
type: 'output',
url: 'https://asset'
}
}
})
)
await nextTick()
expect(
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
).toBe(true)
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
false
)
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
true
)
})
it('returns failed menu entries with error actions', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(

View File

@@ -242,13 +242,14 @@ export function useJobMenu(
const state = item?.state
if (!state) return []
const hasPreviewAsset = !!item?.taskRef?.previewOutput
const hasInspectableAsset = !!item?.taskRef?.inspectableOutput
if (state === 'completed') {
return [
{
key: 'inspect-asset',
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
icon: 'icon-[lucide--zoom-in]',
disabled: !hasPreviewAsset || !onInspectAsset,
disabled: !hasInspectableAsset || !onInspectAsset,
onClick: onInspectAsset
? () => {
const item = currentMenuItem()

View File

@@ -8,7 +8,13 @@ import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
const createResultItem = (
url: string,
supportsPreview = true
{
supportsPreview = true,
supportsInspection = supportsPreview
}: {
supportsPreview?: boolean
supportsInspection?: boolean
} = {}
): ResultItemImpl => {
const item = new ResultItemImpl({
filename: url,
@@ -20,6 +26,9 @@ const createResultItem = (
// Override url getter for test matching
Object.defineProperty(item, 'url', { get: () => url })
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
Object.defineProperty(item, 'supportsInspection', {
get: () => supportsInspection
})
return item
}
@@ -63,12 +72,15 @@ describe('useResultGallery', () => {
setActivePinia(createPinia())
})
it('collects only previewable outputs and preserves their order', async () => {
it('collects only inspectable outputs and preserves their order', async () => {
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
const nonPreviewable = createResultItem('skip-me', false)
const nonInspectable = createResultItem('skip-me', {
supportsPreview: true,
supportsInspection: false
})
const tasks = [
createTask(previewable[0]),
createTask(nonPreviewable),
createTask(nonInspectable),
createTask(previewable[1]),
createTask()
]
@@ -83,7 +95,7 @@ describe('useResultGallery', () => {
expect(galleryActiveIndex.value).toBe(0)
})
it('does not change state when there are no previewable tasks', async () => {
it('does not change state when there are no inspectable tasks', async () => {
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => []
)

View File

@@ -1,7 +1,10 @@
import { ref, shallowRef } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import { findActiveIndex, getOutputsForTask } from '@/services/jobOutputCache'
import {
findActiveIndex,
getInspectableOutputsForTask
} from '@/services/jobOutputCache'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
/**
@@ -17,7 +20,7 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
const targetTask = item.taskRef
const targetOutputs = targetTask
? await getOutputsForTask(targetTask)
? await getInspectableOutputsForTask(targetTask)
: null
// Request was superseded by a newer one
@@ -27,7 +30,7 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
const items = targetOutputs?.length
? targetOutputs
: tasks
.map((t) => t.previewOutput)
.map((t) => t.inspectableOutput)
.filter((o): o is ResultItemImpl => !!o)
if (!items.length) return
@@ -35,7 +38,7 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
galleryItems.value = items
galleryActiveIndex.value = findActiveIndex(
items,
item.taskRef?.previewOutput?.url
item.taskRef?.inspectableOutput?.url
)
}

View File

@@ -3,16 +3,10 @@
* This file can be imported without pulling in the entire THREE.js bundle
*/
export const SUPPORTED_EXTENSIONS = new Set([
'.gltf',
'.glb',
'.obj',
'.fbx',
'.stl',
'.spz',
'.splat',
'.ply',
'.ksplat'
])
import { THREE_D_LOADABLE_EXTENSIONS } from '@comfyorg/shared-frontend-utils/mediaExtensions'
export const SUPPORTED_EXTENSIONS = new Set(
THREE_D_LOADABLE_EXTENSIONS.map((extension) => `.${extension}`)
)
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')

View File

@@ -148,7 +148,7 @@ import {
formatSize,
getFilenameDetails,
getMediaTypeFromFilename,
isPreviewableMediaType
isPreviewableMediaFilename
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -226,7 +226,7 @@ const previewKind = computed((): PreviewKind => {
return getMediaTypeFromFilename(asset?.name || '')
})
const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
const canInspect = computed(() => isPreviewableMediaFilename(asset?.name))
// Get filename without extension
const fileName = computed(() => {

View File

@@ -21,7 +21,7 @@ vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
}))
vi.mock('@/utils/formatUtil', () => ({
isPreviewableMediaType: () => true
isPreviewableMediaFilename: () => true
}))
vi.mock('@/utils/loaderNodeUtil', () => ({

View File

@@ -39,29 +39,22 @@ import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import { isCloud } from '@/platform/distribution/types'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { isPreviewableMediaType } from '@/utils/formatUtil'
import { isPreviewableMediaFilename } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
import type { AssetContext } from '../schemas/mediaAssetSchema'
const {
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset: AssetItem
assetType: AssetContext['type']
fileKind: MediaKind
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const { asset, assetType, showDeleteButton, selectedAssets, isBulkMode } =
defineProps<{
asset: AssetItem
assetType: AssetContext['type']
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const emit = defineEmits<{
zoom: []
@@ -196,7 +189,7 @@ const contextMenuItems = computed<MenuItem[]>(() => {
// Individual mode: Show all menu options
// Inspect
if (isPreviewableMediaType(fileKind)) {
if (isPreviewableMediaFilename(asset.name)) {
items.push({
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',

View File

@@ -11,6 +11,7 @@ import {
findActiveIndex,
getJobDetail,
getJobWorkflow,
getInspectableOutputsForTask,
getOutputsForTask
} from '@/services/jobOutputCache'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
@@ -29,7 +30,16 @@ vi.mock('@/scripts/api', () => ({
}
}))
function createResultItem(url: string, supportsPreview = true): ResultItemImpl {
function createResultItem(
url: string,
{
supportsPreview = true,
supportsInspection = supportsPreview
}: {
supportsPreview?: boolean
supportsInspection?: boolean
} = {}
): ResultItemImpl {
const item = new ResultItemImpl({
filename: url,
subfolder: '',
@@ -39,6 +49,9 @@ function createResultItem(url: string, supportsPreview = true): ResultItemImpl {
})
Object.defineProperty(item, 'url', { get: () => url })
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
Object.defineProperty(item, 'supportsInspection', {
get: () => supportsInspection
})
return item
}
@@ -101,16 +114,30 @@ describe('jobOutputCache', () => {
})
})
describe('getOutputsForTask', () => {
it('returns previewable outputs directly when no lazy load needed', async () => {
describe('getInspectableOutputsForTask', () => {
it('returns inspectable outputs directly when no lazy load needed', async () => {
const outputs = [createResultItem('p-1'), createResultItem('p-2')]
const task = createTask(undefined, outputs, 1)
const result = await getOutputsForTask(task)
const result = await getInspectableOutputsForTask(task)
expect(result).toEqual(outputs)
})
it('filters out surfaced outputs that are not inspectable', async () => {
const outputs = [
createResultItem('mesh.usdz', {
supportsPreview: true,
supportsInspection: false
})
]
const task = createTask(undefined, outputs, 1)
const result = await getInspectableOutputsForTask(task)
expect(result).toEqual([])
})
it('lazy loads when outputsCount > 1', async () => {
const previewOutput = createResultItem('preview')
const fullOutputs = [
@@ -123,7 +150,7 @@ describe('jobOutputCache', () => {
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
const result = await getOutputsForTask(task)
const result = await getInspectableOutputsForTask(task)
expect(result).toEqual(fullOutputs)
expect(task.loadFullOutputs).toHaveBeenCalled()
@@ -138,11 +165,11 @@ describe('jobOutputCache', () => {
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
// First call should load
await getOutputsForTask(task)
await getInspectableOutputsForTask(task)
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
// Second call should use cache
await getOutputsForTask(task)
await getInspectableOutputsForTask(task)
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
})
@@ -155,7 +182,7 @@ describe('jobOutputCache', () => {
.fn()
.mockRejectedValue(new Error('Network error'))
const result = await getOutputsForTask(task)
const result = await getInspectableOutputsForTask(task)
expect(result).toEqual([previewOutput])
})
@@ -184,8 +211,8 @@ describe('jobOutputCache', () => {
task2.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask2)
// Start task1, then immediately start task2
const promise1 = getOutputsForTask(task1)
const promise2 = getOutputsForTask(task2)
const promise1 = getInspectableOutputsForTask(task1)
const promise2 = getInspectableOutputsForTask(task2)
const [result1, result2] = await Promise.all([promise1, promise2])
@@ -195,6 +222,17 @@ describe('jobOutputCache', () => {
})
})
describe('getOutputsForTask', () => {
it('aliases getInspectableOutputsForTask', async () => {
const outputs = [createResultItem('p-1')]
const task = createTask(undefined, outputs, 1)
const result = await getOutputsForTask(task)
expect(result).toEqual(outputs)
})
})
describe('getPreviewableOutputsFromJobDetail', () => {
it('returns empty array when job detail or outputs are missing', async () => {
const { getPreviewableOutputsFromJobDetail } =

View File

@@ -40,10 +40,10 @@ export function findActiveIndex(
}
/**
* Gets previewable outputs for a task, with lazy loading, caching, and request deduping.
* Gets inspectable outputs for a task, with lazy loading, caching, and request deduping.
* Returns null if a newer request superseded this one while loading.
*/
export async function getOutputsForTask(
export async function getInspectableOutputsForTask(
task: TaskItemImpl
): Promise<ResultItemImpl[] | null> {
const requestId = String(task.jobId)
@@ -53,12 +53,12 @@ export async function getOutputsForTask(
const needsLazyLoad = outputsCount > 1
if (!needsLazyLoad) {
return [...task.previewableOutputs]
return [...task.inspectableOutputs]
}
const cached = taskCache.get(requestId)
if (cached) {
return [...cached.previewableOutputs]
return [...cached.inspectableOutputs]
}
try {
@@ -70,13 +70,22 @@ export async function getOutputsForTask(
}
taskCache.set(requestId, loadedTask)
return [...loadedTask.previewableOutputs]
return [...loadedTask.inspectableOutputs]
} catch (error) {
console.warn('Failed to load full outputs, using preview:', error)
return [...task.previewableOutputs]
console.warn(
'Failed to load full outputs, using inspectable outputs:',
error
)
return [...task.inspectableOutputs]
}
}
export async function getOutputsForTask(
task: TaskItemImpl
): Promise<ResultItemImpl[] | null> {
return getInspectableOutputsForTask(task)
}
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
if (!outputs) return []
return ResultItemImpl.filterPreviewable(parseTaskOutput(outputs))

View File

@@ -207,6 +207,53 @@ describe('TaskItemImpl', () => {
expect(task.previewOutput?.content).toBe('test')
})
it('should treat PLY preview output as previewable 3D media', () => {
const job: JobListItem = {
...createHistoryJob(0, 'ply-job'),
preview_output: {
filename: 'mesh-output.ply',
subfolder: 'mesh',
type: 'output',
nodeId: 'node-1',
mediaType: 'model'
}
}
const task = new TaskItemImpl(job)
const output = task.previewOutput
expect(task.previewableOutputs).toHaveLength(1)
expect(task.inspectableOutputs).toHaveLength(1)
expect(output?.filename).toBe('mesh-output.ply')
expect(task.inspectableOutput?.filename).toBe('mesh-output.ply')
expect(output?.is3D).toBe(true)
expect(output?.supportsPreview).toBe(true)
expect(output?.supportsInspection).toBe(true)
})
it('should surface USDZ output without treating it as inspectable 3D media', () => {
const job: JobListItem = {
...createHistoryJob(0, 'usdz-job'),
preview_output: {
filename: 'mesh-output.usdz',
subfolder: 'mesh',
type: 'output',
nodeId: 'node-1',
mediaType: 'model'
}
}
const task = new TaskItemImpl(job)
expect(task.previewableOutputs).toHaveLength(1)
expect(task.previewOutput?.filename).toBe('mesh-output.usdz')
expect(task.previewOutput?.is3D).toBe(true)
expect(task.previewOutput?.supportsPreview).toBe(true)
expect(task.previewOutput?.supportsInspection).toBe(false)
expect(task.inspectableOutputs).toHaveLength(0)
expect(task.inspectableOutput).toBeUndefined()
})
describe('error extraction getters', () => {
it('errorMessage returns undefined when no execution_error', () => {
const job = createHistoryJob(0, 'job-id')

View File

@@ -22,7 +22,10 @@ import { getJobDetail } from '@/services/jobOutputCache'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import {
getMediaTypeFromFilename,
isPreviewableMediaFilename
} from '@/utils/formatUtil'
enum TaskItemDisplayStatus {
Running = 'Running',
@@ -222,10 +225,20 @@ export class ResultItemImpl {
get is3D(): boolean {
return getMediaTypeFromFilename(this.filename) === '3D'
}
get isText(): boolean {
return this.mediaType === 'text'
}
get supportsInspection(): boolean {
return (
this.isImage ||
this.isVideo ||
this.isAudio ||
(this.is3D && isPreviewableMediaFilename(this.filename))
)
}
get supportsPreview(): boolean {
return (
this.isImage || this.isVideo || this.isAudio || this.is3D || this.isText
@@ -238,6 +251,12 @@ export class ResultItemImpl {
return outputs.filter((o) => o.supportsPreview)
}
static filterInspectable(
outputs: readonly ResultItemImpl[]
): ResultItemImpl[] {
return outputs.filter((o) => o.supportsInspection)
}
static findByUrl(items: readonly ResultItemImpl[], url?: string): number {
if (!url) return 0
const idx = items.findIndex((o) => o.url === url)
@@ -278,11 +297,16 @@ export class TaskItemImpl {
return parseTaskOutput(this.outputs)
}
/** All outputs that support preview (images, videos, audio, 3D, text) */
/** All surfaced outputs (images, videos, audio, 3D, text) */
get previewableOutputs(): readonly ResultItemImpl[] {
return ResultItemImpl.filterPreviewable(this.flatOutputs)
}
/** Outputs the UI can inspect directly in the viewer/lightbox. */
get inspectableOutputs(): readonly ResultItemImpl[] {
return ResultItemImpl.filterInspectable(this.flatOutputs)
}
get previewOutput(): ResultItemImpl | undefined {
const previewable = this.previewableOutputs
// Prefer the last saved media file (most recent result) over temp previews
@@ -292,6 +316,14 @@ export class TaskItemImpl {
)
}
get inspectableOutput(): ResultItemImpl | undefined {
const inspectable = this.inspectableOutputs
return (
inspectable.findLast((output) => output.type === 'output') ??
inspectable.at(-1)
)
}
// Derive taskType from job status
get taskType(): TaskType {
switch (this.job.status) {