mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-26 17:30:07 +00:00
Compare commits
4 Commits
rizumu/fix
...
bl/fix-ply
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
759032ced2 | ||
|
|
f78b42a56a | ||
|
|
5699d188e2 | ||
|
|
a1b46d7d03 |
@@ -7,6 +7,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./mediaExtensions": "./src/mediaExtensions.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -3,11 +3,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFileExtension,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isPreviewableMediaType,
|
||||
isPreviewableMediaFilename,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -103,6 +104,11 @@ describe('formatUtil', () => {
|
||||
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('mesh.ply')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('point-cloud.spz')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('gaussian.splat')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('scene.ksplat')).toBe('3D')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,6 +170,21 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('returns a normalized lowercase extension when present', () => {
|
||||
expect(getFileExtension('mesh.PLY')).toBe('ply')
|
||||
expect(getFileExtension('/path/to/file.glb')).toBe('glb')
|
||||
expect(getFileExtension('C:\\path.with.dot\\file.OBJ')).toBe('obj')
|
||||
})
|
||||
|
||||
it('returns null when no extension is present', () => {
|
||||
expect(getFileExtension('README')).toBe(null)
|
||||
expect(getFileExtension('')).toBe(null)
|
||||
expect(getFileExtension(undefined)).toBe(null)
|
||||
expect(getFileExtension('C:\\path.with.dot\\README')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('highlightQuery', () => {
|
||||
it('should return text unchanged when query is empty', () => {
|
||||
expect(highlightQuery('Hello World', '')).toBe('Hello World')
|
||||
@@ -343,17 +364,27 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
expect(isPreviewableMediaType('video')).toBe(true)
|
||||
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||
describe('isPreviewableMediaFilename', () => {
|
||||
it('returns true for browser-previewable core media', () => {
|
||||
expect(isPreviewableMediaFilename('image.png')).toBe(true)
|
||||
expect(isPreviewableMediaFilename('clip.mp4')).toBe(true)
|
||||
expect(isPreviewableMediaFilename('sound.wav')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for text/other', () => {
|
||||
expect(isPreviewableMediaType('text')).toBe(false)
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
it('returns true for loadable 3D formats', () => {
|
||||
expect(isPreviewableMediaFilename('mesh.ply')).toBe(true)
|
||||
expect(isPreviewableMediaFilename('scene.glb')).toBe(true)
|
||||
expect(isPreviewableMediaFilename('print.stl')).toBe(true)
|
||||
expect(isPreviewableMediaFilename('points.ksplat')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for 3D media without a browser loader', () => {
|
||||
expect(isPreviewableMediaFilename('apple.usdz')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-previewable file types', () => {
|
||||
expect(isPreviewableMediaFilename('notes.txt')).toBe(false)
|
||||
expect(isPreviewableMediaFilename('archive.bin')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
import type { operations } from '@comfyorg/registry-types'
|
||||
|
||||
import {
|
||||
isThreeDLoadableExtension,
|
||||
isThreeDMediaExtension
|
||||
} from './mediaExtensions'
|
||||
|
||||
export function formatCamelCase(str: string): string {
|
||||
// Check if the string is camel case
|
||||
const isCamelCase = /^([A-Z][a-z]*)+$/.test(str)
|
||||
@@ -546,7 +551,6 @@ const IMAGE_EXTENSIONS = [
|
||||
] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
|
||||
const TEXT_EXTENSIONS = [
|
||||
'txt',
|
||||
'md',
|
||||
@@ -566,7 +570,6 @@ export type MediaType = (typeof MEDIA_TYPES)[number]
|
||||
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
|
||||
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
|
||||
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
|
||||
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
|
||||
type TextExtension = (typeof TEXT_EXTENSIONS)[number]
|
||||
|
||||
/**
|
||||
@@ -610,26 +613,44 @@ export function getMediaTypeFromFilename(
|
||||
filename: string | null | undefined
|
||||
): MediaType {
|
||||
if (!filename) return 'other'
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
const ext = getFileExtension(filename)
|
||||
if (!ext) return 'other'
|
||||
|
||||
// Type-safe array includes check using type assertion
|
||||
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
|
||||
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
|
||||
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
|
||||
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
|
||||
if (isThreeDMediaExtension(ext)) return '3D'
|
||||
if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text'
|
||||
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
return (
|
||||
mediaType === 'image' ||
|
||||
mediaType === 'video' ||
|
||||
mediaType === 'audio' ||
|
||||
mediaType === '3D'
|
||||
)
|
||||
export function getFileExtension(
|
||||
filename: string | null | undefined
|
||||
): string | null {
|
||||
if (!filename) return null
|
||||
const fullFilename = filename.split(/[/\\]/).pop() ?? filename
|
||||
const dotIndex = fullFilename.lastIndexOf('.')
|
||||
if (dotIndex <= 0) return null
|
||||
return fullFilename.slice(dotIndex + 1).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a file can be previewed directly in the browser UI.
|
||||
* For 3D assets this is stricter than media classification: only extensions
|
||||
* with an actual load3d loader implementation count as previewable.
|
||||
*/
|
||||
export function isPreviewableMediaFilename(
|
||||
filename: string | null | undefined
|
||||
): boolean {
|
||||
const ext = getFileExtension(filename)
|
||||
if (!ext) return false
|
||||
|
||||
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return true
|
||||
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return true
|
||||
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return true
|
||||
return isThreeDLoadableExtension(ext)
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
|
||||
50
packages/shared-frontend-utils/src/mediaExtensions.ts
Normal file
50
packages/shared-frontend-utils/src/mediaExtensions.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Extensions the in-app load3d viewer can load directly.
|
||||
*/
|
||||
export const THREE_D_LOADABLE_EXTENSIONS = [
|
||||
'gltf',
|
||||
'glb',
|
||||
'obj',
|
||||
'fbx',
|
||||
'stl',
|
||||
'spz',
|
||||
'splat',
|
||||
'ply',
|
||||
'ksplat'
|
||||
] as const
|
||||
|
||||
const THREE_D_NON_LOADABLE_MEDIA_EXTENSIONS = ['usdz'] as const
|
||||
|
||||
/**
|
||||
* Shared 3D file extension registries.
|
||||
*
|
||||
* Keep 3D media classification and load3d viewer support in one module so new
|
||||
* formats do not silently fall out of sync across the queue, asset sidebar,
|
||||
* and drag/drop flows.
|
||||
*/
|
||||
export const THREE_D_MEDIA_EXTENSIONS = [
|
||||
...THREE_D_LOADABLE_EXTENSIONS,
|
||||
...THREE_D_NON_LOADABLE_MEDIA_EXTENSIONS
|
||||
] as const
|
||||
|
||||
export type ThreeDMediaExtension = (typeof THREE_D_MEDIA_EXTENSIONS)[number]
|
||||
export type ThreeDLoadableExtension =
|
||||
(typeof THREE_D_LOADABLE_EXTENSIONS)[number]
|
||||
|
||||
export function isThreeDMediaExtension(
|
||||
extension: string | null | undefined
|
||||
): extension is ThreeDMediaExtension {
|
||||
return (
|
||||
typeof extension === 'string' &&
|
||||
THREE_D_MEDIA_EXTENSIONS.includes(extension as ThreeDMediaExtension)
|
||||
)
|
||||
}
|
||||
|
||||
export function isThreeDLoadableExtension(
|
||||
extension: string | null | undefined
|
||||
): extension is ThreeDLoadableExtension {
|
||||
return (
|
||||
typeof extension === 'string' &&
|
||||
THREE_D_LOADABLE_EXTENSIONS.includes(extension as ThreeDLoadableExtension)
|
||||
)
|
||||
}
|
||||
@@ -257,6 +257,10 @@ const focusAssetInSidebar = async (item: JobListItem) => {
|
||||
|
||||
const inspectJobAsset = wrapWithErrorHandlingAsync(
|
||||
async (item: JobListItem) => {
|
||||
if (!item.taskRef?.inspectableOutput) {
|
||||
return
|
||||
}
|
||||
|
||||
await openResultGallery(item)
|
||||
await focusAssetInSidebar(item)
|
||||
}
|
||||
|
||||
@@ -74,11 +74,13 @@ type TestPreviewOutput = {
|
||||
url: string
|
||||
isImage: boolean
|
||||
isVideo: boolean
|
||||
is3D: boolean
|
||||
}
|
||||
|
||||
type TestTaskRef = {
|
||||
workflowId?: string
|
||||
previewOutput?: TestPreviewOutput
|
||||
inspectableOutput?: TestPreviewOutput
|
||||
}
|
||||
|
||||
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
|
||||
@@ -97,14 +99,24 @@ const createPreviewOutput = (
|
||||
return {
|
||||
url,
|
||||
isImage: mediaType === 'images',
|
||||
isVideo: mediaType === 'video'
|
||||
isVideo: mediaType === 'video',
|
||||
is3D: mediaType === 'model'
|
||||
}
|
||||
}
|
||||
|
||||
const createTaskRef = (preview?: TestPreviewOutput): TestTaskRef => ({
|
||||
workflowId: 'workflow-1',
|
||||
...(preview && { previewOutput: preview })
|
||||
})
|
||||
const createTaskRef = (
|
||||
preview?: TestPreviewOutput,
|
||||
inspectable?: TestPreviewOutput | null
|
||||
): TestTaskRef => {
|
||||
const resolvedInspectable =
|
||||
inspectable === undefined ? preview : (inspectable ?? undefined)
|
||||
|
||||
return {
|
||||
workflowId: 'workflow-1',
|
||||
...(preview && { previewOutput: preview }),
|
||||
...(resolvedInspectable && { inspectableOutput: resolvedInspectable })
|
||||
}
|
||||
}
|
||||
|
||||
const buildJob = (
|
||||
overrides: Partial<TestJobListItem> = {}
|
||||
@@ -284,6 +296,36 @@ describe('JobAssetsList', () => {
|
||||
expect(onViewItem).toHaveBeenCalledWith(job)
|
||||
})
|
||||
|
||||
it('emits viewItem on icon click for completed PLY jobs without preview tile', async () => {
|
||||
const previewOutput = createPreviewOutput('job-1.ply', 'model')
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(previewOutput)
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const icon = container.querySelector('.assets-list-item-stub i')!
|
||||
await user.click(icon)
|
||||
|
||||
expect(onViewItem).toHaveBeenCalledWith(job)
|
||||
})
|
||||
|
||||
it('does not emit viewItem on icon click for completed USDZ jobs without inspectable output', async () => {
|
||||
const previewOutput = createPreviewOutput('job-1.usdz', 'model')
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(previewOutput, null)
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const icon = container.querySelector('.assets-list-item-stub i')!
|
||||
await user.click(icon)
|
||||
|
||||
expect(onViewItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not emit viewItem on double-click for non-completed jobs', async () => {
|
||||
const job = buildJob({
|
||||
state: 'running',
|
||||
@@ -298,21 +340,18 @@ describe('JobAssetsList', () => {
|
||||
expect(onViewItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits viewItem from the View button for completed jobs without preview output', async () => {
|
||||
it('does not show the View button for completed jobs without inspectable output', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef()
|
||||
taskRef: createTaskRef(undefined, null)
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
|
||||
await fireEvent.click(screen.getByText('menuLabels.View'))
|
||||
await nextTick()
|
||||
|
||||
expect(onViewItem).toHaveBeenCalledWith(job)
|
||||
expect(screen.queryByText('menuLabels.View')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows and hides the job details popover with hover delays', async () => {
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="row.job.state === 'completed'"
|
||||
v-else-if="hasInspectableCompletedAsset(row.job)"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(row.job)"
|
||||
@@ -284,6 +284,10 @@ function getPreviewOutput(job: JobListItem) {
|
||||
return job.taskRef?.previewOutput
|
||||
}
|
||||
|
||||
function getInspectableOutput(job: JobListItem) {
|
||||
return job.taskRef?.inspectableOutput
|
||||
}
|
||||
|
||||
function getJobPreviewUrl(job: JobListItem) {
|
||||
const preview = getPreviewOutput(job)
|
||||
if (preview?.isImage || preview?.isVideo) {
|
||||
@@ -296,12 +300,12 @@ function isVideoPreviewJob(job: JobListItem) {
|
||||
return job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
|
||||
}
|
||||
|
||||
function isPreviewableCompletedJob(job: JobListItem) {
|
||||
return job.state === 'completed' && !!getPreviewOutput(job)
|
||||
function hasInspectableCompletedAsset(job: JobListItem) {
|
||||
return job.state === 'completed' && !!getInspectableOutput(job)
|
||||
}
|
||||
|
||||
function emitViewItem(job: JobListItem) {
|
||||
if (isPreviewableCompletedJob(job)) {
|
||||
if (hasInspectableCompletedAsset(job)) {
|
||||
resetActiveDetails()
|
||||
emit('viewItem', job)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,6 @@
|
||||
ref="contextMenuRef"
|
||||
:asset="contextMenuAsset"
|
||||
:asset-type="contextMenuAssetType"
|
||||
:file-kind="contextMenuFileKind"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@@ -246,7 +245,6 @@ import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadat
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -254,7 +252,7 @@ import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import {
|
||||
formatDuration,
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
isPreviewableMediaFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -291,10 +289,6 @@ const contextMenuAssetType = computed(() =>
|
||||
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
|
||||
)
|
||||
|
||||
const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
@@ -421,9 +415,7 @@ const visibleAssets = computed(() => {
|
||||
})
|
||||
|
||||
const previewableVisibleAssets = computed(() =>
|
||||
visibleAssets.value.filter((asset) =>
|
||||
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
|
||||
)
|
||||
visibleAssets.value.filter((asset) => isPreviewableMediaFilename(asset.name))
|
||||
)
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
@@ -571,7 +563,7 @@ const handleDeleteSelected = async () => {
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
if (!isPreviewableMediaType(mediaType)) {
|
||||
if (!isPreviewableMediaFilename(asset.name)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -160,15 +160,15 @@ const {
|
||||
} = useResultGallery(() => filteredTasks.value)
|
||||
|
||||
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const previewOutput = item.taskRef?.previewOutput
|
||||
const inspectableOutput = item.taskRef?.inspectableOutput
|
||||
|
||||
if (previewOutput?.is3D) {
|
||||
if (inspectableOutput?.is3D) {
|
||||
dialogStore.showDialog({
|
||||
key: 'asset-3d-viewer',
|
||||
title: item.title,
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: previewOutput.url || ''
|
||||
modelUrl: inspectableOutput.url || ''
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
@@ -178,6 +178,10 @@ const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!inspectableOutput) {
|
||||
return
|
||||
}
|
||||
|
||||
await openResultGallery(item)
|
||||
})
|
||||
|
||||
|
||||
@@ -725,7 +725,7 @@ describe('useJobMenu', () => {
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
taskRef: { previewOutput: {}, inspectableOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -763,7 +763,7 @@ describe('useJobMenu', () => {
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
taskRef: { previewOutput: {}, inspectableOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -792,6 +792,35 @@ describe('useJobMenu', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('disables inspect when asset is surfaced but not inspectable', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu(vi.fn())
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
filename: 'mesh.usdz',
|
||||
subfolder: 'models',
|
||||
type: 'output',
|
||||
url: 'https://asset'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
|
||||
).toBe(true)
|
||||
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
|
||||
false
|
||||
)
|
||||
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('returns failed menu entries with error actions', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
|
||||
@@ -242,13 +242,14 @@ export function useJobMenu(
|
||||
const state = item?.state
|
||||
if (!state) return []
|
||||
const hasPreviewAsset = !!item?.taskRef?.previewOutput
|
||||
const hasInspectableAsset = !!item?.taskRef?.inspectableOutput
|
||||
if (state === 'completed') {
|
||||
return [
|
||||
{
|
||||
key: 'inspect-asset',
|
||||
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
disabled: !hasPreviewAsset || !onInspectAsset,
|
||||
disabled: !hasInspectableAsset || !onInspectAsset,
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
const item = currentMenuItem()
|
||||
|
||||
@@ -8,7 +8,13 @@ import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const createResultItem = (
|
||||
url: string,
|
||||
supportsPreview = true
|
||||
{
|
||||
supportsPreview = true,
|
||||
supportsInspection = supportsPreview
|
||||
}: {
|
||||
supportsPreview?: boolean
|
||||
supportsInspection?: boolean
|
||||
} = {}
|
||||
): ResultItemImpl => {
|
||||
const item = new ResultItemImpl({
|
||||
filename: url,
|
||||
@@ -20,6 +26,9 @@ const createResultItem = (
|
||||
// Override url getter for test matching
|
||||
Object.defineProperty(item, 'url', { get: () => url })
|
||||
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
|
||||
Object.defineProperty(item, 'supportsInspection', {
|
||||
get: () => supportsInspection
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -63,12 +72,15 @@ describe('useResultGallery', () => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('collects only previewable outputs and preserves their order', async () => {
|
||||
it('collects only inspectable outputs and preserves their order', async () => {
|
||||
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
|
||||
const nonPreviewable = createResultItem('skip-me', false)
|
||||
const nonInspectable = createResultItem('skip-me', {
|
||||
supportsPreview: true,
|
||||
supportsInspection: false
|
||||
})
|
||||
const tasks = [
|
||||
createTask(previewable[0]),
|
||||
createTask(nonPreviewable),
|
||||
createTask(nonInspectable),
|
||||
createTask(previewable[1]),
|
||||
createTask()
|
||||
]
|
||||
@@ -83,7 +95,7 @@ describe('useResultGallery', () => {
|
||||
expect(galleryActiveIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('does not change state when there are no previewable tasks', async () => {
|
||||
it('does not change state when there are no inspectable tasks', async () => {
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => []
|
||||
)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { findActiveIndex, getOutputsForTask } from '@/services/jobOutputCache'
|
||||
import {
|
||||
findActiveIndex,
|
||||
getInspectableOutputsForTask
|
||||
} from '@/services/jobOutputCache'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
@@ -17,7 +20,7 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
|
||||
|
||||
const targetTask = item.taskRef
|
||||
const targetOutputs = targetTask
|
||||
? await getOutputsForTask(targetTask)
|
||||
? await getInspectableOutputsForTask(targetTask)
|
||||
: null
|
||||
|
||||
// Request was superseded by a newer one
|
||||
@@ -27,7 +30,7 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
|
||||
const items = targetOutputs?.length
|
||||
? targetOutputs
|
||||
: tasks
|
||||
.map((t) => t.previewOutput)
|
||||
.map((t) => t.inspectableOutput)
|
||||
.filter((o): o is ResultItemImpl => !!o)
|
||||
|
||||
if (!items.length) return
|
||||
@@ -35,7 +38,7 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
|
||||
galleryItems.value = items
|
||||
galleryActiveIndex.value = findActiveIndex(
|
||||
items,
|
||||
item.taskRef?.previewOutput?.url
|
||||
item.taskRef?.inspectableOutput?.url
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
* This file can be imported without pulling in the entire THREE.js bundle
|
||||
*/
|
||||
|
||||
export const SUPPORTED_EXTENSIONS = new Set([
|
||||
'.gltf',
|
||||
'.glb',
|
||||
'.obj',
|
||||
'.fbx',
|
||||
'.stl',
|
||||
'.spz',
|
||||
'.splat',
|
||||
'.ply',
|
||||
'.ksplat'
|
||||
])
|
||||
import { THREE_D_LOADABLE_EXTENSIONS } from '@comfyorg/shared-frontend-utils/mediaExtensions'
|
||||
|
||||
export const SUPPORTED_EXTENSIONS = new Set(
|
||||
THREE_D_LOADABLE_EXTENSIONS.map((extension) => `.${extension}`)
|
||||
)
|
||||
|
||||
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')
|
||||
|
||||
@@ -148,7 +148,7 @@ import {
|
||||
formatSize,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
isPreviewableMediaFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -226,7 +226,7 @@ const previewKind = computed((): PreviewKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
})
|
||||
|
||||
const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
|
||||
const canInspect = computed(() => isPreviewableMediaFilename(asset?.name))
|
||||
|
||||
// Get filename without extension
|
||||
const fileName = computed(() => {
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
isPreviewableMediaType: () => true
|
||||
isPreviewableMediaFilename: () => true
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/loaderNodeUtil', () => ({
|
||||
|
||||
@@ -39,29 +39,22 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { isPreviewableMediaType } from '@/utils/formatUtil'
|
||||
import { isPreviewableMediaFilename } from '@/utils/formatUtil'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import type { AssetContext } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const {
|
||||
asset,
|
||||
assetType,
|
||||
fileKind,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
} = defineProps<{
|
||||
asset: AssetItem
|
||||
assetType: AssetContext['type']
|
||||
fileKind: MediaKind
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
const { asset, assetType, showDeleteButton, selectedAssets, isBulkMode } =
|
||||
defineProps<{
|
||||
asset: AssetItem
|
||||
assetType: AssetContext['type']
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
zoom: []
|
||||
@@ -196,7 +189,7 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
// Individual mode: Show all menu options
|
||||
|
||||
// Inspect
|
||||
if (isPreviewableMediaType(fileKind)) {
|
||||
if (isPreviewableMediaFilename(asset.name)) {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.inspect'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
findActiveIndex,
|
||||
getJobDetail,
|
||||
getJobWorkflow,
|
||||
getInspectableOutputsForTask,
|
||||
getOutputsForTask
|
||||
} from '@/services/jobOutputCache'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
@@ -29,7 +30,16 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
function createResultItem(url: string, supportsPreview = true): ResultItemImpl {
|
||||
function createResultItem(
|
||||
url: string,
|
||||
{
|
||||
supportsPreview = true,
|
||||
supportsInspection = supportsPreview
|
||||
}: {
|
||||
supportsPreview?: boolean
|
||||
supportsInspection?: boolean
|
||||
} = {}
|
||||
): ResultItemImpl {
|
||||
const item = new ResultItemImpl({
|
||||
filename: url,
|
||||
subfolder: '',
|
||||
@@ -39,6 +49,9 @@ function createResultItem(url: string, supportsPreview = true): ResultItemImpl {
|
||||
})
|
||||
Object.defineProperty(item, 'url', { get: () => url })
|
||||
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
|
||||
Object.defineProperty(item, 'supportsInspection', {
|
||||
get: () => supportsInspection
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -101,16 +114,30 @@ describe('jobOutputCache', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOutputsForTask', () => {
|
||||
it('returns previewable outputs directly when no lazy load needed', async () => {
|
||||
describe('getInspectableOutputsForTask', () => {
|
||||
it('returns inspectable outputs directly when no lazy load needed', async () => {
|
||||
const outputs = [createResultItem('p-1'), createResultItem('p-2')]
|
||||
const task = createTask(undefined, outputs, 1)
|
||||
|
||||
const result = await getOutputsForTask(task)
|
||||
const result = await getInspectableOutputsForTask(task)
|
||||
|
||||
expect(result).toEqual(outputs)
|
||||
})
|
||||
|
||||
it('filters out surfaced outputs that are not inspectable', async () => {
|
||||
const outputs = [
|
||||
createResultItem('mesh.usdz', {
|
||||
supportsPreview: true,
|
||||
supportsInspection: false
|
||||
})
|
||||
]
|
||||
const task = createTask(undefined, outputs, 1)
|
||||
|
||||
const result = await getInspectableOutputsForTask(task)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('lazy loads when outputsCount > 1', async () => {
|
||||
const previewOutput = createResultItem('preview')
|
||||
const fullOutputs = [
|
||||
@@ -123,7 +150,7 @@ describe('jobOutputCache', () => {
|
||||
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
|
||||
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
|
||||
|
||||
const result = await getOutputsForTask(task)
|
||||
const result = await getInspectableOutputsForTask(task)
|
||||
|
||||
expect(result).toEqual(fullOutputs)
|
||||
expect(task.loadFullOutputs).toHaveBeenCalled()
|
||||
@@ -138,11 +165,11 @@ describe('jobOutputCache', () => {
|
||||
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
|
||||
|
||||
// First call should load
|
||||
await getOutputsForTask(task)
|
||||
await getInspectableOutputsForTask(task)
|
||||
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call should use cache
|
||||
await getOutputsForTask(task)
|
||||
await getInspectableOutputsForTask(task)
|
||||
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -155,7 +182,7 @@ describe('jobOutputCache', () => {
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const result = await getOutputsForTask(task)
|
||||
const result = await getInspectableOutputsForTask(task)
|
||||
|
||||
expect(result).toEqual([previewOutput])
|
||||
})
|
||||
@@ -184,8 +211,8 @@ describe('jobOutputCache', () => {
|
||||
task2.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask2)
|
||||
|
||||
// Start task1, then immediately start task2
|
||||
const promise1 = getOutputsForTask(task1)
|
||||
const promise2 = getOutputsForTask(task2)
|
||||
const promise1 = getInspectableOutputsForTask(task1)
|
||||
const promise2 = getInspectableOutputsForTask(task2)
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2])
|
||||
|
||||
@@ -195,6 +222,17 @@ describe('jobOutputCache', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOutputsForTask', () => {
|
||||
it('aliases getInspectableOutputsForTask', async () => {
|
||||
const outputs = [createResultItem('p-1')]
|
||||
const task = createTask(undefined, outputs, 1)
|
||||
|
||||
const result = await getOutputsForTask(task)
|
||||
|
||||
expect(result).toEqual(outputs)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPreviewableOutputsFromJobDetail', () => {
|
||||
it('returns empty array when job detail or outputs are missing', async () => {
|
||||
const { getPreviewableOutputsFromJobDetail } =
|
||||
|
||||
@@ -40,10 +40,10 @@ export function findActiveIndex(
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets previewable outputs for a task, with lazy loading, caching, and request deduping.
|
||||
* Gets inspectable outputs for a task, with lazy loading, caching, and request deduping.
|
||||
* Returns null if a newer request superseded this one while loading.
|
||||
*/
|
||||
export async function getOutputsForTask(
|
||||
export async function getInspectableOutputsForTask(
|
||||
task: TaskItemImpl
|
||||
): Promise<ResultItemImpl[] | null> {
|
||||
const requestId = String(task.jobId)
|
||||
@@ -53,12 +53,12 @@ export async function getOutputsForTask(
|
||||
const needsLazyLoad = outputsCount > 1
|
||||
|
||||
if (!needsLazyLoad) {
|
||||
return [...task.previewableOutputs]
|
||||
return [...task.inspectableOutputs]
|
||||
}
|
||||
|
||||
const cached = taskCache.get(requestId)
|
||||
if (cached) {
|
||||
return [...cached.previewableOutputs]
|
||||
return [...cached.inspectableOutputs]
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -70,13 +70,22 @@ export async function getOutputsForTask(
|
||||
}
|
||||
|
||||
taskCache.set(requestId, loadedTask)
|
||||
return [...loadedTask.previewableOutputs]
|
||||
return [...loadedTask.inspectableOutputs]
|
||||
} catch (error) {
|
||||
console.warn('Failed to load full outputs, using preview:', error)
|
||||
return [...task.previewableOutputs]
|
||||
console.warn(
|
||||
'Failed to load full outputs, using inspectable outputs:',
|
||||
error
|
||||
)
|
||||
return [...task.inspectableOutputs]
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOutputsForTask(
|
||||
task: TaskItemImpl
|
||||
): Promise<ResultItemImpl[] | null> {
|
||||
return getInspectableOutputsForTask(task)
|
||||
}
|
||||
|
||||
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
|
||||
if (!outputs) return []
|
||||
return ResultItemImpl.filterPreviewable(parseTaskOutput(outputs))
|
||||
|
||||
@@ -207,6 +207,53 @@ describe('TaskItemImpl', () => {
|
||||
expect(task.previewOutput?.content).toBe('test')
|
||||
})
|
||||
|
||||
it('should treat PLY preview output as previewable 3D media', () => {
|
||||
const job: JobListItem = {
|
||||
...createHistoryJob(0, 'ply-job'),
|
||||
preview_output: {
|
||||
filename: 'mesh-output.ply',
|
||||
subfolder: 'mesh',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'model'
|
||||
}
|
||||
}
|
||||
|
||||
const task = new TaskItemImpl(job)
|
||||
const output = task.previewOutput
|
||||
|
||||
expect(task.previewableOutputs).toHaveLength(1)
|
||||
expect(task.inspectableOutputs).toHaveLength(1)
|
||||
expect(output?.filename).toBe('mesh-output.ply')
|
||||
expect(task.inspectableOutput?.filename).toBe('mesh-output.ply')
|
||||
expect(output?.is3D).toBe(true)
|
||||
expect(output?.supportsPreview).toBe(true)
|
||||
expect(output?.supportsInspection).toBe(true)
|
||||
})
|
||||
|
||||
it('should surface USDZ output without treating it as inspectable 3D media', () => {
|
||||
const job: JobListItem = {
|
||||
...createHistoryJob(0, 'usdz-job'),
|
||||
preview_output: {
|
||||
filename: 'mesh-output.usdz',
|
||||
subfolder: 'mesh',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType: 'model'
|
||||
}
|
||||
}
|
||||
|
||||
const task = new TaskItemImpl(job)
|
||||
|
||||
expect(task.previewableOutputs).toHaveLength(1)
|
||||
expect(task.previewOutput?.filename).toBe('mesh-output.usdz')
|
||||
expect(task.previewOutput?.is3D).toBe(true)
|
||||
expect(task.previewOutput?.supportsPreview).toBe(true)
|
||||
expect(task.previewOutput?.supportsInspection).toBe(false)
|
||||
expect(task.inspectableOutputs).toHaveLength(0)
|
||||
expect(task.inspectableOutput).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('error extraction getters', () => {
|
||||
it('errorMessage returns undefined when no execution_error', () => {
|
||||
const job = createHistoryJob(0, 'job-id')
|
||||
|
||||
@@ -22,7 +22,10 @@ import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import {
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaFilename
|
||||
} from '@/utils/formatUtil'
|
||||
|
||||
enum TaskItemDisplayStatus {
|
||||
Running = 'Running',
|
||||
@@ -222,10 +225,20 @@ export class ResultItemImpl {
|
||||
get is3D(): boolean {
|
||||
return getMediaTypeFromFilename(this.filename) === '3D'
|
||||
}
|
||||
|
||||
get isText(): boolean {
|
||||
return this.mediaType === 'text'
|
||||
}
|
||||
|
||||
get supportsInspection(): boolean {
|
||||
return (
|
||||
this.isImage ||
|
||||
this.isVideo ||
|
||||
this.isAudio ||
|
||||
(this.is3D && isPreviewableMediaFilename(this.filename))
|
||||
)
|
||||
}
|
||||
|
||||
get supportsPreview(): boolean {
|
||||
return (
|
||||
this.isImage || this.isVideo || this.isAudio || this.is3D || this.isText
|
||||
@@ -238,6 +251,12 @@ export class ResultItemImpl {
|
||||
return outputs.filter((o) => o.supportsPreview)
|
||||
}
|
||||
|
||||
static filterInspectable(
|
||||
outputs: readonly ResultItemImpl[]
|
||||
): ResultItemImpl[] {
|
||||
return outputs.filter((o) => o.supportsInspection)
|
||||
}
|
||||
|
||||
static findByUrl(items: readonly ResultItemImpl[], url?: string): number {
|
||||
if (!url) return 0
|
||||
const idx = items.findIndex((o) => o.url === url)
|
||||
@@ -278,11 +297,16 @@ export class TaskItemImpl {
|
||||
return parseTaskOutput(this.outputs)
|
||||
}
|
||||
|
||||
/** All outputs that support preview (images, videos, audio, 3D, text) */
|
||||
/** All surfaced outputs (images, videos, audio, 3D, text) */
|
||||
get previewableOutputs(): readonly ResultItemImpl[] {
|
||||
return ResultItemImpl.filterPreviewable(this.flatOutputs)
|
||||
}
|
||||
|
||||
/** Outputs the UI can inspect directly in the viewer/lightbox. */
|
||||
get inspectableOutputs(): readonly ResultItemImpl[] {
|
||||
return ResultItemImpl.filterInspectable(this.flatOutputs)
|
||||
}
|
||||
|
||||
get previewOutput(): ResultItemImpl | undefined {
|
||||
const previewable = this.previewableOutputs
|
||||
// Prefer the last saved media file (most recent result) over temp previews
|
||||
@@ -292,6 +316,14 @@ export class TaskItemImpl {
|
||||
)
|
||||
}
|
||||
|
||||
get inspectableOutput(): ResultItemImpl | undefined {
|
||||
const inspectable = this.inspectableOutputs
|
||||
return (
|
||||
inspectable.findLast((output) => output.type === 'output') ??
|
||||
inspectable.at(-1)
|
||||
)
|
||||
}
|
||||
|
||||
// Derive taskType from job status
|
||||
get taskType(): TaskType {
|
||||
switch (this.job.status) {
|
||||
|
||||
Reference in New Issue
Block a user