diff --git a/packages/shared-frontend-utils/package.json b/packages/shared-frontend-utils/package.json
index aa18c7a940..b7f44969d4 100644
--- a/packages/shared-frontend-utils/package.json
+++ b/packages/shared-frontend-utils/package.json
@@ -7,6 +7,7 @@
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
+ "./mediaExtensions": "./src/mediaExtensions.ts",
"./networkUtil": "./src/networkUtil.ts"
},
"scripts": {
diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts
index cf64b26ec8..86231c8a8a 100644
--- a/packages/shared-frontend-utils/src/formatUtil.test.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.test.ts
@@ -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 },
diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts
index 99b6bc79f5..e18afab0d9 100644
--- a/packages/shared-frontend-utils/src/formatUtil.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.ts
@@ -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 {
diff --git a/packages/shared-frontend-utils/src/mediaExtensions.ts b/packages/shared-frontend-utils/src/mediaExtensions.ts
new file mode 100644
index 0000000000..c958a4e066
--- /dev/null
+++ b/packages/shared-frontend-utils/src/mediaExtensions.ts
@@ -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)
+ )
+}
diff --git a/src/components/queue/QueueProgressOverlay.test.ts b/src/components/queue/QueueProgressOverlay.test.ts
index 067c8084d9..d866be7c08 100644
--- a/src/components/queue/QueueProgressOverlay.test.ts
+++ b/src/components/queue/QueueProgressOverlay.test.ts
@@ -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: `
`
})
-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')
+ }
+ })
+ })
})
diff --git a/src/components/queue/QueueProgressOverlay.vue b/src/components/queue/QueueProgressOverlay.vue
index 9ba1dea9f9..0f254fbb8c 100644
--- a/src/components/queue/QueueProgressOverlay.vue
+++ b/src/components/queue/QueueProgressOverlay.vue
@@ -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)
}
)
diff --git a/src/components/queue/job/JobAssetsList.test.ts b/src/components/queue/job/JobAssetsList.test.ts
index 6ddff89c02..f3a91eb86c 100644
--- a/src/components/queue/job/JobAssetsList.test.ts
+++ b/src/components/queue/job/JobAssetsList.test.ts
@@ -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 & {
@@ -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 })
diff --git a/src/components/queue/job/JobAssetsList.vue b/src/components/queue/job/JobAssetsList.vue
index 552b38084c..5bea8dd295 100644
--- a/src/components/queue/job/JobAssetsList.vue
+++ b/src/components/queue/job/JobAssetsList.vue
@@ -67,7 +67,7 @@