fix: separate media surfacing from inspection routing

This commit is contained in:
Benjamin Lu
2026-05-18 13:44:53 -07:00
parent 3e31de5bbb
commit 4da4f0dab5
25 changed files with 840 additions and 244 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

@@ -12,7 +12,6 @@ import {
highlightQuery,
isCivitaiModelUrl,
isCivitaiUrl,
isPreviewableMediaType,
joinFilePath,
truncateFilename
} from './formatUtil'
@@ -111,6 +110,11 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
expect(getMediaTypeFromFilename('mesh.stl')).toBe('3D')
expect(getMediaTypeFromFilename('pointcloud.spz')).toBe('3D')
expect(getMediaTypeFromFilename('scene.splat')).toBe('3D')
expect(getMediaTypeFromFilename('scene.ksplat')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
})
})
@@ -410,20 +414,6 @@ 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)
})
it('returns false for text/other', () => {
expect(isPreviewableMediaType('text')).toBe(false)
expect(isPreviewableMediaType('other')).toBe(false)
})
})
describe('isCivitaiUrl', () => {
it.for([
{ url: 'https://civitai.com/models/123', expected: true },

View File

@@ -1,5 +1,6 @@
import { default as DOMPurify } from 'dompurify'
import type { operations } from '@comfyorg/registry-types'
import { isThreeDMediaExtension } from '@comfyorg/shared-frontend-utils/mediaExtensions'
export function formatCamelCase(str: string): string {
// Check if the string is camel case
@@ -591,7 +592,6 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] 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',
@@ -611,7 +611,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]
/**
@@ -655,26 +654,22 @@ 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'
)
function getFileExtension(filename: string | null | undefined): string | null {
if (!filename) return null
return filename.split('.').pop()?.toLowerCase() ?? null
}
export function formatTime(seconds: number): string {

View File

@@ -0,0 +1,44 @@
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
// Classification is broader than viewer support: some valid 3D assets, such
// as USDZ, can be listed/downloaded but cannot be loaded by the in-app viewer.
export const THREE_D_MEDIA_EXTENSIONS = [
...THREE_D_LOADABLE_EXTENSIONS,
...THREE_D_NON_LOADABLE_MEDIA_EXTENSIONS
] as const
export type ThreeDLoadableExtension =
(typeof THREE_D_LOADABLE_EXTENSIONS)[number]
export type ThreeDMediaExtension = (typeof THREE_D_MEDIA_EXTENSIONS)[number]
export function isThreeDLoadableExtension(
extension: string | null | undefined
): boolean {
const normalized = extension?.toLowerCase()
return (
typeof normalized === 'string' &&
THREE_D_LOADABLE_EXTENSIONS.includes(normalized as ThreeDLoadableExtension)
)
}
export function isThreeDMediaExtension(
extension: string | null | undefined
): boolean {
const normalized = extension?.toLowerCase()
return (
typeof normalized === 'string' &&
THREE_D_MEDIA_EXTENSIONS.includes(normalized as ThreeDMediaExtension)
)
}

View File

@@ -6,8 +6,15 @@ import { defineComponent } from 'vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import { i18n } from '@/i18n'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
ResultItemImpl,
TaskItemImpl,
useQueueStore
} from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
vi.mock('@/platform/distribution/types', () => ({
@@ -20,28 +27,58 @@ const QueueOverlayExpandedStub = defineComponent({
headerTitle: {
type: String,
required: true
},
displayedJobGroups: {
type: Array,
required: true
}
},
template: `
<div>
<div data-testid="expanded-title">{{ headerTitle }}</div>
<button data-testid="show-assets-button" @click="$emit('show-assets')" />
<button
v-if="displayedJobGroups[0]?.items[0]"
data-testid="view-first-job"
@click="$emit('view-item', displayedJobGroups[0].items[0])"
/>
</div>
`
})
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl({
id,
status,
create_time: 0,
priority: 0
function createTask(
id: string,
status: JobStatus,
outputs: ResultItemImpl[] = []
): TaskItemImpl {
return new TaskItemImpl(
{
id,
status,
create_time: 0,
execution_start_time: 0,
execution_end_time: 1000,
priority: 0
},
{},
outputs
)
}
function createOutput(filename: string): ResultItemImpl {
return new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType: '3D'
})
}
function renderComponent(
runningTasks: TaskItemImpl[],
pendingTasks: TaskItemImpl[]
pendingTasks: TaskItemImpl[],
historyTasks: TaskItemImpl[] = []
) {
const pinia = createTestingPinia({
createSpy: vi.fn,
@@ -49,8 +86,11 @@ function renderComponent(
})
const queueStore = useQueueStore(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
const assetsStore = useAssetsStore(pinia)
const dialogStore = useDialogStore(pinia)
queueStore.runningTasks = runningTasks
queueStore.pendingTasks = pendingTasks
queueStore.historyTasks = historyTasks
const user = userEvent.setup()
@@ -71,7 +111,7 @@ function renderComponent(
}
})
return { sidebarTabStore, user }
return { assetsStore, dialogStore, sidebarTabStore, user }
}
describe('QueueProgressOverlay', () => {
@@ -116,4 +156,22 @@ describe('QueueProgressOverlay', () => {
await user.click(screen.getByTestId('show-assets-button'))
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
it('opens loadable 3D outputs in the 3D viewer dialog', async () => {
const task = createTask('completed-3d', 'completed', [
createOutput('model.ply')
])
const { assetsStore, dialogStore, user } = renderComponent([], [], [task])
vi.spyOn(assetsStore, 'updateHistory').mockResolvedValue()
assetsStore.historyAssets = [{ id: 'completed-3d' } as AssetItem]
await user.click(screen.getByTestId('view-first-job'))
expect(dialogStore.dialogStack[0]).toMatchObject({
key: 'asset-3d-viewer',
contentProps: {
modelUrl: expect.stringContaining('model.ply')
}
})
})
})

View File

@@ -64,15 +64,18 @@ import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHis
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useLoad3dViewerDialog } from '@/composables/useLoad3dViewerDialog'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { api } from '@/scripts/api'
import { getInspectionTargetsForTask } from '@/services/jobOutputCache'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { getPreferredInspectionTarget } from '@/utils/inspectionTarget'
type OverlayState = 'hidden' | 'active' | 'expanded'
@@ -92,6 +95,7 @@ const executionStore = useExecutionStore()
const sidebarTabStore = useSidebarTabStore()
const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { openLoad3dViewer } = useLoad3dViewerDialog()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
@@ -244,8 +248,7 @@ const openAssetsSidebar = () => {
const focusAssetInSidebar = async (item: JobListItem) => {
const task = item.taskRef
const jobId = task?.jobId
const preview = task?.previewOutput
if (!jobId || !preview) return
if (!jobId) return
const assetId = String(jobId)
openAssetsSidebar()
@@ -264,7 +267,24 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
trackFeatureUsed()
await openResultGallery(item)
const task = item.taskRef
if (!task) return
const targets = await getInspectionTargetsForTask(task)
if (targets === null) return
const target = getPreferredInspectionTarget(targets)
if (!target) return
if (target.kind === 'load3d') {
openLoad3dViewer({
title: item.title,
modelUrl: target.output.url
})
} else {
await openResultGallery(item)
}
await focusAssetInSidebar(item)
}
)

View File

@@ -151,15 +151,21 @@ vi.mock('vue-i18n', () => {
})
type TestPreviewOutput = {
filename: string
type: string
url: string
previewUrl: string
isImage: boolean
isVideo: boolean
isAudio: boolean
is3D: boolean
}
type TestTaskRef = {
workflowId?: string
previewOutput?: TestPreviewOutput
flatOutputs: TestPreviewOutput[]
outputsCount?: number
}
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
@@ -176,16 +182,42 @@ const createPreviewOutput = (
): TestPreviewOutput => {
const url = `/api/view/${filename}`
return {
filename,
type: 'output',
url,
previewUrl: mediaType === 'images' ? `${url}?res=512` : url,
isImage: mediaType === 'images',
isVideo: mediaType === 'video'
isVideo: mediaType === 'video',
isAudio: mediaType === 'audio',
is3D: [
'glb',
'gltf',
'obj',
'fbx',
'stl',
'spz',
'splat',
'ply',
'ksplat',
'usdz'
].some((extension) => filename.endsWith(`.${extension}`))
}
}
const createTaskRef = (preview?: TestPreviewOutput): TestTaskRef => ({
const createTaskRef = (
preview?: TestPreviewOutput,
inspectionOutput: TestPreviewOutput | null = preview ?? null
): TestTaskRef => ({
workflowId: 'workflow-1',
...(preview && { previewOutput: preview })
...(preview && { previewOutput: preview }),
flatOutputs:
inspectionOutput && inspectionOutput !== preview
? [preview, inspectionOutput].filter(
(output): output is TestPreviewOutput => !!output
)
: preview
? [preview]
: []
})
const buildJob = (
@@ -368,10 +400,28 @@ describe('JobAssetsList', () => {
expect(onViewItem).not.toHaveBeenCalled()
})
it('emits viewItem from the View button for completed jobs without preview output', async () => {
it('does not emit viewItem for completed jobs without an inspection target', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef()
taskRef: createTaskRef(createPreviewOutput('job-1.usdz', 'model'), null)
})
const onViewItem = vi.fn()
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
expect(screen.queryByText('menuLabels.View')).not.toBeInTheDocument()
await nextTick()
expect(onViewItem).not.toHaveBeenCalled()
})
it('emits viewItem from the View button for completed jobs with an inspection target', async () => {
const inspectionTarget = createPreviewOutput('job-1.ply', 'model')
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(undefined, inspectionTarget)
})
const onViewItem = vi.fn()
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })

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="canInspectCompletedAsset(row.job)"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(row.job)"
@@ -115,6 +115,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { cn } from '@comfyorg/tailwind-utils'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
import { canAttemptTaskInspection } from '@/utils/inspectionTarget'
import { buildVirtualJobRows } from './buildVirtualJobRows'
import type { VirtualJobRow } from './buildVirtualJobRows'
@@ -346,12 +347,16 @@ function isVideoPreviewJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
}
function isPreviewableCompletedJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)
function canInspectCompletedAsset(job: JobListItem) {
return (
job.state === 'completed' &&
!!job.taskRef &&
canAttemptTaskInspection(job.taskRef)
)
}
function emitViewItem(job: JobListItem) {
if (isPreviewableCompletedJob(job)) {
if (canInspectCompletedAsset(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"
@@ -214,15 +213,7 @@ import {
useTimeoutFn
} from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
@@ -242,27 +233,21 @@ 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 { useLoad3dViewerDialog } from '@/composables/useLoad3dViewerDialog'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
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 { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
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,
isPreviewableMediaType
} from '@/utils/formatUtil'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { getInspectionKindForFilename } from '@/utils/inspectionTarget'
import { cn } from '@comfyorg/tailwind-utils'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
const { t } = useI18n()
const { openLoad3dViewer } = useLoad3dViewerDialog()
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
@@ -291,10 +276,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
@@ -420,9 +401,9 @@ const visibleAssets = computed(() => {
return listViewSelectableAssets.value
})
const previewableVisibleAssets = computed(() =>
visibleAssets.value.filter((asset) =>
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
const lightboxVisibleAssets = computed(() =>
visibleAssets.value.filter(
(asset) => getInspectionKindForFilename(asset.name) === 'lightbox'
)
)
@@ -451,7 +432,7 @@ watch(visibleAssets, (newAssets) => {
// so selection stays consistent with what this view can act on.
reconcileSelection(newAssets)
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = previewableVisibleAssets.value.findIndex(
const newIndex = lightboxVisibleAssets.value.findIndex(
(asset) => asset.id === currentGalleryAssetId.value
)
galleryActiveIndex.value = newIndex
@@ -465,7 +446,7 @@ watch(galleryActiveIndex, (index) => {
})
const galleryItems = computed(() => {
return previewableVisibleAssets.value.map((asset) => {
return lightboxVisibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
@@ -570,32 +551,20 @@ const handleDeleteSelected = async () => {
}
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
if (!isPreviewableMediaType(mediaType)) {
return
}
const inspectionKind = getInspectionKindForFilename(asset.name)
if (mediaType === '3D') {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
if (inspectionKind === 'load3d') {
openLoad3dViewer({
title: getAssetDisplayName(asset),
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
modelUrl: asset.preview_url || getAssetUrl(asset)
})
return
}
if (inspectionKind !== 'lightbox') return
currentGalleryAssetId.value = asset.id
const index = previewableVisibleAssets.value.findIndex(
(a) => a.id === asset.id
)
const index = lightboxVisibleAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) {
galleryActiveIndex.value = index
}

View File

@@ -71,7 +71,7 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
@@ -86,24 +86,22 @@ import type { JobListItem, JobTab } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useLoad3dViewerDialog } from '@/composables/useLoad3dViewerDialog'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { getInspectionTargetsForTask } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
import { getPreferredInspectionTarget } from '@/utils/inspectionTarget'
const { t, n } = useI18n()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { openLoad3dViewer } = useLoad3dViewerDialog()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
@@ -175,20 +173,19 @@ const {
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const previewOutput = item.taskRef?.previewOutput
const task = item.taskRef
if (!task) return
if (previewOutput?.is3D) {
dialogStore.showDialog({
key: 'asset-3d-viewer',
const targets = await getInspectionTargetsForTask(task)
if (targets === null) return
const target = getPreferredInspectionTarget(targets)
if (!target) return
if (target.kind === 'load3d') {
openLoad3dViewer({
title: item.title,
component: Load3dViewerContent,
props: {
modelUrl: previewOutput.url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
modelUrl: target.output.url
})
return
}

View File

@@ -144,6 +144,11 @@ vi.mock('@/utils/formatUtil', () => ({
appendJsonExtMock(...args)
}))
const canAttemptTaskInspectionMock = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/utils/inspectionTarget', () => ({
canAttemptTaskInspection: canAttemptTaskInspectionMock
}))
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
@@ -210,6 +215,7 @@ describe('useJobMenu', () => {
}
// Default: no workflow available via lazy loading
getJobWorkflowMock.mockResolvedValue(undefined)
canAttemptTaskInspectionMock.mockReturnValue(false)
})
const setCurrentItem = (item: JobListItem | null) => {
@@ -739,6 +745,7 @@ describe('useJobMenu', () => {
it('provides completed menu structure with delete option', async () => {
const inspectSpy = vi.fn()
canAttemptTaskInspectionMock.mockReturnValue(true)
const { jobMenuEntries } = mountJobMenu(inspectSpy)
setCurrentItem(
createJobItem({
@@ -776,6 +783,29 @@ describe('useJobMenu', () => {
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
})
it('disables inspect when completed asset has no inspection target', async () => {
const inspectSpy = vi.fn()
const { jobMenuEntries } = mountJobMenu(inspectSpy)
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
expect(
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
).toBe(true)
expect(
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
).toBe(false)
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
false
)
})
it('omits inspect handler when callback missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(

View File

@@ -22,6 +22,7 @@ import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { appendJsonExt } from '@/utils/formatUtil'
import { canAttemptTaskInspection } from '@/utils/inspectionTarget'
export type MenuEntry =
| {
@@ -249,13 +250,16 @@ export function useJobMenu(
const state = item?.state
if (!state) return []
const hasPreviewAsset = !!item?.taskRef?.previewOutput
const canInspectAsset = item?.taskRef
? canAttemptTaskInspection(item.taskRef)
: false
if (state === 'completed') {
return [
{
key: 'inspect-asset',
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
icon: 'icon-[lucide--zoom-in]',
disabled: !hasPreviewAsset || !onInspectAsset,
disabled: !canInspectAsset || !onInspectAsset,
onClick: onInspectAsset
? () => {
const item = currentMenuItem()

View File

@@ -7,19 +7,18 @@ import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
const createResultItem = (
url: string,
supportsPreview = true
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
filename: url,
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType: supportsPreview ? 'images' : 'unknown'
mediaType
})
// Override url getter for test matching
Object.defineProperty(item, 'url', { get: () => url })
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
Object.defineProperty(item, 'url', { get: () => filename })
return item
}
@@ -63,13 +62,16 @@ describe('useResultGallery', () => {
setActivePinia(createPinia())
})
it('collects only previewable outputs and preserves their order', async () => {
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
const nonPreviewable = createResultItem('skip-me', false)
it('falls back to all lightbox outputs and preserves their order', async () => {
const lightboxOutputs = [
createResultItem('p-1.png'),
createResultItem('p-2.png')
]
const nonLightbox = createResultItem('skip-me.bin', 'unknown')
const tasks = [
createTask(previewable[0]),
createTask(nonPreviewable),
createTask(previewable[1]),
createTask(lightboxOutputs[0]),
createTask(nonLightbox),
createTask(lightboxOutputs[1]),
createTask()
]
@@ -77,13 +79,13 @@ describe('useResultGallery', () => {
() => tasks
)
await onViewItem(createJobViewItem('job-1', tasks[0]))
await onViewItem(createJobViewItem('job-1'))
expect(galleryItems.value).toEqual([previewable[0]])
expect(galleryItems.value).toEqual(lightboxOutputs)
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 lightbox tasks', async () => {
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => []
)
@@ -94,13 +96,13 @@ describe('useResultGallery', () => {
expect(galleryActiveIndex.value).toBe(-1)
})
it('activates the index that matches the viewed preview URL', async () => {
const previewable = [
createResultItem('p-1'),
createResultItem('p-2'),
createResultItem('p-3')
it('activates the inspected task output in the lightbox', async () => {
const lightboxOutputs = [
createResultItem('p-1.png'),
createResultItem('p-2.png'),
createResultItem('p-3.png')
]
const tasks = previewable.map((preview) => createTask(preview))
const tasks = lightboxOutputs.map((output) => createTask(output))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
@@ -108,13 +110,16 @@ describe('useResultGallery', () => {
await onViewItem(createJobViewItem('job-2', tasks[1]))
expect(galleryItems.value).toEqual([previewable[1]])
expect(galleryItems.value).toEqual([lightboxOutputs[1]])
expect(galleryActiveIndex.value).toBe(0)
})
it('defaults to the first entry when the clicked job lacks a preview', async () => {
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
const tasks = previewable.map((preview) => createTask(preview))
it('defaults to the first entry when the clicked job lacks a task ref', async () => {
const lightboxOutputs = [
createResultItem('p-1.png'),
createResultItem('p-2.png')
]
const tasks = lightboxOutputs.map((output) => createTask(output))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
@@ -122,33 +127,92 @@ describe('useResultGallery', () => {
await onViewItem(createJobViewItem('job-no-preview'))
expect(galleryItems.value).toEqual(previewable)
expect(galleryItems.value).toEqual(lightboxOutputs)
expect(galleryActiveIndex.value).toBe(0)
})
it('defaults to the first entry when no gallery item matches the preview URL', async () => {
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
const tasks = previewable.map((preview) => createTask(preview))
it('defaults to the first entry when no gallery item matches the lightbox URL', async () => {
const lightboxOutputs = [
createResultItem('p-1.png'),
createResultItem('p-2.png')
]
const tasks = lightboxOutputs.map((output) => createTask(output))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
const taskWithMismatchedPreview = createTask(createResultItem('missing'))
const taskWithMismatchedOutput = createTask(createResultItem('missing.png'))
await onViewItem(
createJobViewItem('job-mismatch', taskWithMismatchedPreview)
createJobViewItem('job-mismatch', taskWithMismatchedOutput)
)
expect(galleryItems.value).toEqual([createResultItem('missing')])
expect(galleryItems.value).toEqual([createResultItem('missing.png')])
expect(galleryActiveIndex.value).toBe(0)
})
it('does not open for surfaced outputs that are not inspection targets', async () => {
const nonLoadable3D = new ResultItemImpl({
filename: 'asset.usdz',
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType: '3D'
})
const task = createTask(nonLoadable3D)
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => [task]
)
await onViewItem(createJobViewItem('job-usdz', task))
expect(galleryItems.value).toEqual([])
expect(galleryActiveIndex.value).toBe(-1)
})
it('does not open lightbox for load3d targets', async () => {
const load3d = new ResultItemImpl({
filename: 'model.ply',
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType: '3D'
})
const task = createTask(load3d)
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => [task]
)
await onViewItem(createJobViewItem('job-ply', task))
expect(galleryItems.value).toEqual([])
expect(galleryActiveIndex.value).toBe(-1)
})
it('does not fall back to unrelated outputs when target task has no lightbox target', async () => {
const lightboxOutput = createResultItem('lightbox.png')
const nonLightbox = createResultItem('not-lightbox.bin', 'unknown')
const lightboxTask = createTask(lightboxOutput)
const nonLightboxTask = createTask(nonLightbox)
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => [lightboxTask, nonLightboxTask]
)
await onViewItem(createJobViewItem('job-not-lightbox', nonLightboxTask))
expect(galleryItems.value).toEqual([])
expect(galleryActiveIndex.value).toBe(-1)
})
it('loads full outputs when task has only preview outputs', async () => {
const previewOutput = createResultItem('preview-1')
const previewOutput = createResultItem('preview-1.png')
const fullOutputs = [
createResultItem('full-1'),
createResultItem('full-2'),
createResultItem('full-3')
createResultItem('full-1.png'),
createResultItem('full-2.png'),
createResultItem('full-3.png')
]
// Create a task with outputsCount > 1 to trigger lazy loading
@@ -166,6 +230,6 @@ describe('useResultGallery', () => {
await onViewItem(createJobViewItem('job-1', task))
expect(galleryItems.value).toEqual(fullOutputs)
expect(galleryActiveIndex.value).toBe(0)
expect(galleryActiveIndex.value).toBe(2)
})
})

View File

@@ -1,8 +1,16 @@
import { ref, shallowRef } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import { findActiveIndex, getOutputsForTask } from '@/services/jobOutputCache'
import {
findActiveIndex,
getInspectionTargetsForTask
} from '@/services/jobOutputCache'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import {
getInspectionTarget,
getLightboxOutputs,
getPreferredInspectionTarget
} from '@/utils/inspectionTarget'
/**
* Manages result gallery state and activation for queue items.
@@ -16,19 +24,35 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
if (!tasks.length) return
const targetTask = item.taskRef
const targetOutputs = targetTask
? await getOutputsForTask(targetTask)
const targets = targetTask
? await getInspectionTargetsForTask(targetTask)
: null
// Request was superseded by a newer one
if (targetOutputs === null && targetTask) return
if (targetTask) {
// Request was superseded by a newer one
if (targets === null) return
// Use target's outputs if available, otherwise fall back to all previews
const items = targetOutputs?.length
? targetOutputs
: tasks
.map((t) => t.previewOutput)
.filter((o): o is ResultItemImpl => !!o)
const targetOutputs = getLightboxOutputs(targets)
if (!targetOutputs.length) return
galleryItems.value = targetOutputs
const preferredTarget = getPreferredInspectionTarget(
targets.filter((target) => target.kind === 'lightbox')
)
galleryActiveIndex.value = findActiveIndex(
targetOutputs,
preferredTarget?.output.url
)
return
}
const items = tasks.flatMap((task) => {
const preview = task.previewOutput
if (!preview) return []
const target = getInspectionTarget(preview)
return target?.kind === 'lightbox' ? [target.output] : []
})
if (!items.length) return

View File

@@ -0,0 +1,34 @@
import { defineAsyncComponent } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
export function useLoad3dViewerDialog() {
const dialogStore = useDialogStore()
function openLoad3dViewer({
title,
modelUrl
}: {
title: string
modelUrl: string
}) {
dialogStore.showDialog({
key: 'asset-3d-viewer',
title,
component: Load3dViewerContent,
props: {
modelUrl
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
})
}
return { openLoad3dViewer }
}

View File

@@ -3,17 +3,11 @@
* 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

@@ -150,9 +150,9 @@ import {
formatDuration,
formatSize,
getFilenameDetails,
getMediaTypeFromFilename,
isPreviewableMediaType
getMediaTypeFromFilename
} from '@/utils/formatUtil'
import { getInspectionKindForFilename } from '@/utils/inspectionTarget'
import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
@@ -228,7 +228,7 @@ const previewKind = computed((): PreviewKind => {
return getMediaTypeFromFilename(asset?.name || '')
})
const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
const canInspect = computed(() => !!getInspectionKindForFilename(asset?.name))
// Get filename without extension
const fileName = computed(() => {

View File

@@ -21,8 +21,12 @@ vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
supportsWorkflowMetadata: () => true
}))
vi.mock('@/utils/formatUtil', () => ({
isPreviewableMediaType: () => true
const getInspectionKindForFilenameMock = vi.hoisted(() =>
vi.fn<() => 'lightbox' | 'load3d' | null>(() => 'lightbox')
)
vi.mock('@/utils/inspectionTarget', () => ({
getInspectionKindForFilename: getInspectionKindForFilenameMock
}))
vi.mock('@/utils/loaderNodeUtil', () => ({
@@ -118,7 +122,7 @@ function mountComponent() {
return { menuRef, asset, onHide }
},
template:
'<MediaAssetContextMenu ref="menuRef" :asset="asset" asset-type="output" file-kind="image" @hide="onHide" />'
'<MediaAssetContextMenu ref="menuRef" :asset="asset" asset-type="output" @hide="onHide" />'
}),
{
global: {
@@ -198,4 +202,18 @@ describe('MediaAssetContextMenu', () => {
unmount()
})
it('hides Inspect when the filename has no inspection target', async () => {
getInspectionKindForFilenameMock.mockReturnValueOnce(null)
const { container, unmount } = mountComponent()
await showMenu(container)
expect(
capturedMenu.model.some(
(item) => item.label === 'mediaAsset.actions.inspect'
)
).toBe(false)
unmount()
})
})

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 { getInspectionKindForFilename } from '@/utils/inspectionTarget'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { cn } from '@comfyorg/tailwind-utils'
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 (getInspectionKindForFilename(asset.name)) {
items.push({
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',

View File

@@ -9,9 +9,9 @@ import type {
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import {
findActiveIndex,
getInspectionTargetsForTask,
getJobDetail,
getJobWorkflow,
getOutputsForTask
getJobWorkflow
} from '@/services/jobOutputCache'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
@@ -29,16 +29,18 @@ vi.mock('@/scripts/api', () => ({
}
}))
function createResultItem(url: string, supportsPreview = true): ResultItemImpl {
function createResultItem(
filename: string,
mediaType: string = 'images'
): ResultItemImpl {
const item = new ResultItemImpl({
filename: url,
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType: supportsPreview ? 'images' : 'unknown'
mediaType
})
Object.defineProperty(item, 'url', { get: () => url })
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
Object.defineProperty(item, 'url', { get: () => filename })
return item
}
@@ -101,21 +103,46 @@ describe('jobOutputCache', () => {
})
})
describe('getOutputsForTask', () => {
it('returns previewable outputs directly when no lazy load needed', async () => {
const outputs = [createResultItem('p-1'), createResultItem('p-2')]
describe('getInspectionTargetsForTask', () => {
it('returns inspection targets directly when no lazy load needed', async () => {
const outputs = [createResultItem('p-1.png'), createResultItem('p-2.png')]
const task = createTask(undefined, outputs, 1)
const result = await getOutputsForTask(task)
const result = await getInspectionTargetsForTask(task)
expect(result).toEqual(outputs)
expect(result).toEqual([
{ kind: 'lightbox', output: outputs[0] },
{ kind: 'lightbox', output: outputs[1] }
])
})
it('routes loadable 3D outputs without treating all 3D as inspection targets', async () => {
const loadable3D = new ResultItemImpl({
filename: 'scan.ply',
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType: '3D'
})
const nonLoadable3D = new ResultItemImpl({
filename: 'asset.usdz',
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType: 'images'
})
const task = createTask(undefined, [loadable3D, nonLoadable3D], 1)
const result = await getInspectionTargetsForTask(task)
expect(result).toEqual([{ kind: 'load3d', output: loadable3D }])
})
it('lazy loads when outputsCount > 1', async () => {
const previewOutput = createResultItem('preview')
const previewOutput = createResultItem('preview.png')
const fullOutputs = [
createResultItem('full-1'),
createResultItem('full-2')
createResultItem('full-1.png'),
createResultItem('full-2.png')
]
const job = createMockJob(uniqueId('task'), 3)
@@ -123,31 +150,31 @@ describe('jobOutputCache', () => {
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
const result = await getOutputsForTask(task)
const result = await getInspectionTargetsForTask(task)
expect(result).toEqual(fullOutputs)
expect(result?.map((target) => target.output)).toEqual(fullOutputs)
expect(task.loadFullOutputs).toHaveBeenCalled()
})
it('caches loaded tasks', async () => {
const fullOutputs = [createResultItem('full-1')]
const fullOutputs = [createResultItem('full-1.png')]
const job = createMockJob(uniqueId('task'), 3)
const task = new TaskItemImpl(job, {}, [createResultItem('preview')])
const task = new TaskItemImpl(job, {}, [createResultItem('preview.png')])
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
// First call should load
await getOutputsForTask(task)
await getInspectionTargetsForTask(task)
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
// Second call should use cache
await getOutputsForTask(task)
await getInspectionTargetsForTask(task)
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
})
it('falls back to preview outputs on load error', async () => {
const previewOutput = createResultItem('preview')
it('falls back to current outputs on load error', async () => {
const previewOutput = createResultItem('preview.png')
const job = createMockJob(uniqueId('task'), 3)
const task = new TaskItemImpl(job, {}, [previewOutput])
@@ -155,23 +182,27 @@ describe('jobOutputCache', () => {
.fn()
.mockRejectedValue(new Error('Network error'))
const result = await getOutputsForTask(task)
const result = await getInspectionTargetsForTask(task)
expect(result).toEqual([previewOutput])
expect(result?.map((target) => target.output)).toEqual([previewOutput])
})
it('returns null when request is superseded', async () => {
const job1 = createMockJob(uniqueId('task'), 3)
const job2 = createMockJob(uniqueId('task'), 3)
const task1 = new TaskItemImpl(job1, {}, [createResultItem('preview-1')])
const task2 = new TaskItemImpl(job2, {}, [createResultItem('preview-2')])
const task1 = new TaskItemImpl(job1, {}, [
createResultItem('preview-1.png')
])
const task2 = new TaskItemImpl(job2, {}, [
createResultItem('preview-2.png')
])
const loadedTask1 = new TaskItemImpl(job1, {}, [
createResultItem('full-1')
createResultItem('full-1.png')
])
const loadedTask2 = new TaskItemImpl(job2, {}, [
createResultItem('full-2')
createResultItem('full-2.png')
])
// Task1 loads slowly, task2 loads quickly
@@ -184,14 +215,16 @@ describe('jobOutputCache', () => {
task2.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask2)
// Start task1, then immediately start task2
const promise1 = getOutputsForTask(task1)
const promise2 = getOutputsForTask(task2)
const promise1 = getInspectionTargetsForTask(task1)
const promise2 = getInspectionTargetsForTask(task2)
const [result1, result2] = await Promise.all([promise1, promise2])
// Task2 should succeed, task1 should return null (superseded)
expect(result1).toBeNull()
expect(result2).toEqual([createResultItem('full-2')])
expect(result2?.map((target) => target.output.filename)).toEqual([
'full-2.png'
])
})
})

View File

@@ -3,7 +3,7 @@
* @module services/jobOutputCache
*
* Centralizes job output and detail caching with LRU eviction.
* Provides helpers for working with previewable outputs and workflows.
* Provides helpers for working with output inspection targets and workflows.
*/
import QuickLRU from '@alloc/quick-lru'
@@ -16,6 +16,8 @@ import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import { parseTaskOutput } from '@/stores/resultItemParsing'
import { getInspectionTargets } from '@/utils/inspectionTarget'
import type { InspectionTarget } from '@/utils/inspectionTarget'
const MAX_TASK_CACHE_SIZE = 50
const MAX_JOB_DETAIL_CACHE_SIZE = 50
@@ -39,13 +41,9 @@ export function findActiveIndex(
return ResultItemImpl.findByUrl(items, url)
}
/**
* Gets previewable 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(
async function getTaskWithFullOutputs(
task: TaskItemImpl
): Promise<ResultItemImpl[] | null> {
): Promise<TaskItemImpl | null> {
const requestId = String(task.jobId)
latestTaskRequestId = requestId
@@ -53,12 +51,12 @@ export async function getOutputsForTask(
const needsLazyLoad = outputsCount > 1
if (!needsLazyLoad) {
return [...task.previewableOutputs]
return task
}
const cached = taskCache.get(requestId)
if (cached) {
return [...cached.previewableOutputs]
return cached
}
try {
@@ -70,13 +68,24 @@ export async function getOutputsForTask(
}
taskCache.set(requestId, loadedTask)
return [...loadedTask.previewableOutputs]
return loadedTask
} catch (error) {
console.warn('Failed to load full outputs, using preview:', error)
return [...task.previewableOutputs]
console.warn('Failed to load full outputs, using current outputs:', error)
return task
}
}
/**
* Gets inspection targets for a task, with lazy loading, caching, and request
* deduping. Returns null if a newer request superseded this one while loading.
*/
export async function getInspectionTargetsForTask(
task: TaskItemImpl
): Promise<InspectionTarget[] | null> {
const sourceTask = await getTaskWithFullOutputs(task)
return sourceTask ? getInspectionTargets(sourceTask.flatOutputs) : null
}
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
if (!outputs) return []
return ResultItemImpl.filterPreviewable(parseTaskOutput(outputs))

View File

@@ -190,6 +190,48 @@ describe('TaskItemImpl', () => {
})
})
describe('3D media classification', () => {
it('surfaces loadable 3D outputs for display', () => {
const taskItem = new TaskItemImpl(createHistoryJob(0, 'job-id'), {
'node-1': {
'3D': [
{
filename: 'scan.ply',
type: 'output',
subfolder: ''
}
]
}
})
const output = taskItem.flatOutputs[0]
expect(output.is3D).toBe(true)
expect(output.supportsPreview).toBe(true)
expect(taskItem.previewOutput).toBe(output)
})
it('surfaces non-loadable 3D outputs for display', () => {
const taskItem = new TaskItemImpl(createHistoryJob(0, 'job-id'), {
'node-1': {
images: [
{
filename: 'asset.usdz',
type: 'output',
subfolder: ''
}
]
}
})
const output = taskItem.flatOutputs[0]
expect(output.is3D).toBe(true)
expect(output.supportsPreview).toBe(true)
expect(taskItem.previewOutput).toBe(output)
})
})
it.skip('should parse text outputs', () => {
const job: JobListItem = {
...createHistoryJob(0, 'text-job'),

View File

@@ -278,7 +278,7 @@ 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)
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ResultItemType } from '@/schemas/apiSchema'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import {
canAttemptTaskInspection,
getInspectionKindForFilename,
getInspectionTarget,
getInspectionTargets,
getLightboxOutputs,
getPreferredInspectionTarget
} from '@/utils/inspectionTarget'
function createResultItem({
filename,
mediaType = 'images',
type = 'output',
format
}: {
filename: string
mediaType?: string
type?: ResultItemType
format?: string
}): ResultItemImpl {
return new ResultItemImpl({
filename,
subfolder: '',
type,
nodeId: 'node-1',
mediaType,
format
})
}
function createTask(
flatOutputs: ResultItemImpl[],
outputsCount = flatOutputs.length
): TaskItemImpl {
const job: JobListItem = {
id: 'job-1',
status: 'completed',
create_time: 0,
priority: 0,
preview_output: null,
outputs_count: outputsCount
}
return new TaskItemImpl(job, {}, flatOutputs)
}
describe('inspectionTarget', () => {
it('routes image, video, and audio outputs to the lightbox', () => {
const image = createResultItem({ filename: 'image.png' })
const video = createResultItem({
filename: 'clip.webm',
mediaType: 'video'
})
const audio = createResultItem({
filename: 'recording',
mediaType: 'audio',
format: 'audio/wav'
})
expect(getInspectionTarget(image)).toEqual({
kind: 'lightbox',
output: image
})
expect(getInspectionTarget(video)).toEqual({
kind: 'lightbox',
output: video
})
expect(getInspectionTarget(audio)).toEqual({
kind: 'lightbox',
output: audio
})
})
it('routes loadable 3D outputs to load3d', () => {
const output = createResultItem({
filename: 'scan.ply',
mediaType: '3D'
})
expect(getInspectionTarget(output)).toEqual({
kind: 'load3d',
output
})
})
it('rejects non-loadable 3D outputs even when backend mediaType is stale', () => {
const output = createResultItem({
filename: 'asset.usdz',
mediaType: 'images'
})
expect(output.is3D).toBe(true)
expect(getInspectionTarget(output)).toBeNull()
})
it('filters inspection targets and lightbox outputs independently', () => {
const image = createResultItem({ filename: 'image.png' })
const model = createResultItem({ filename: 'model.ply', mediaType: '3D' })
const text = createResultItem({ filename: 'note.txt', mediaType: 'text' })
const targets = getInspectionTargets([image, model, text])
expect(targets).toEqual([
{ kind: 'lightbox', output: image },
{ kind: 'load3d', output: model }
])
expect(getLightboxOutputs(targets)).toEqual([image])
})
it('prefers the last saved output target over temp targets', () => {
const temp = createResultItem({ filename: 'temp.png', type: 'temp' })
const firstOutput = createResultItem({ filename: 'first.png' })
const lastOutput = createResultItem({ filename: 'last.png' })
expect(
getPreferredInspectionTarget(
getInspectionTargets([temp, firstOutput, lastOutput])
)?.output
).toBe(lastOutput)
})
it('exposes filename-level inspection kind for asset callers', () => {
expect(getInspectionKindForFilename('image.png')).toBe('lightbox')
expect(getInspectionKindForFilename('clip.mp4')).toBe('lightbox')
expect(getInspectionKindForFilename('recording.wav')).toBe('lightbox')
expect(getInspectionKindForFilename('scan.ply')).toBe('load3d')
expect(getInspectionKindForFilename('asset.usdz')).toBeNull()
expect(getInspectionKindForFilename('metadata.json')).toBeNull()
})
it('allows multi-output tasks to attempt inspection before full outputs load', () => {
const task = createTask([], 2)
expect(canAttemptTaskInspection(task)).toBe(true)
})
})

View File

@@ -0,0 +1,82 @@
import { isThreeDLoadableExtension } from '@comfyorg/shared-frontend-utils/mediaExtensions'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
type InspectionKind = 'lightbox' | 'load3d'
export type InspectionTarget =
| { kind: 'lightbox'; output: ResultItemImpl }
| { kind: 'load3d'; output: ResultItemImpl }
export function getInspectionTarget(
output: ResultItemImpl
): InspectionTarget | null {
if (output.is3D) {
return getInspectionKindForFilename(output.filename) === 'load3d'
? { kind: 'load3d', output }
: null
}
if (output.isImage || output.isVideo || output.isAudio) {
return { kind: 'lightbox', output }
}
return null
}
export function getInspectionTargets(
outputs: readonly ResultItemImpl[]
): InspectionTarget[] {
return outputs.flatMap((output) => {
const target = getInspectionTarget(output)
return target ? [target] : []
})
}
export function getPreferredInspectionTarget(
targets: readonly InspectionTarget[]
): InspectionTarget | undefined {
return (
targets.findLast((target) => target.output.type === 'output') ??
targets.at(-1)
)
}
export function getLightboxOutputs(
targets: readonly InspectionTarget[]
): ResultItemImpl[] {
return targets.flatMap((target) =>
target.kind === 'lightbox' ? [target.output] : []
)
}
export function canAttemptTaskInspection(task: TaskItemImpl): boolean {
return (
getInspectionTargets(task.flatOutputs).length > 0 ||
(task.outputsCount ?? 0) > 1
)
}
export function getInspectionKindForFilename(
filename: string | null | undefined
): InspectionKind | null {
const mediaType = getMediaTypeFromFilename(filename)
switch (mediaType) {
case 'image':
case 'video':
case 'audio':
return 'lightbox'
case '3D':
return isThreeDLoadableExtension(getFileExtension(filename))
? 'load3d'
: null
default:
return null
}
}
function getFileExtension(filename: string | null | undefined): string | null {
if (!filename) return null
return filename.split('.').pop()?.toLowerCase() ?? null
}