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: `
{{ headerTitle }}
` }) -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 @@