mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
fix: separate media surfacing from inspection routing
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./mediaExtensions": "./src/mediaExtensions.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
44
packages/shared-frontend-utils/src/mediaExtensions.ts
Normal file
44
packages/shared-frontend-utils/src/mediaExtensions.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
34
src/composables/useLoad3dViewerDialog.ts
Normal file
34
src/composables/useLoad3dViewerDialog.ts
Normal 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 }
|
||||
}
|
||||
@@ -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(',')
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
140
src/utils/inspectionTarget.test.ts
Normal file
140
src/utils/inspectionTarget.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
82
src/utils/inspectionTarget.ts
Normal file
82
src/utils/inspectionTarget.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user