mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
3 Commits
feat/parti
...
glary/exr-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
787573516f | ||
|
|
67db42e715 | ||
|
|
492027db84 |
@@ -8,6 +8,7 @@ import {
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getNonPreviewableImageExtension,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
@@ -424,6 +425,28 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNonPreviewableImageExtension', () => {
|
||||
it('returns the extension for non-previewable image formats', () => {
|
||||
expect(getNonPreviewableImageExtension('render.exr')).toBe('exr')
|
||||
expect(getNonPreviewableImageExtension('RENDER.EXR')).toBe('exr')
|
||||
expect(getNonPreviewableImageExtension('path/to/file.exr')).toBe('exr')
|
||||
})
|
||||
|
||||
it('returns null for previewable image formats and other types', () => {
|
||||
expect(getNonPreviewableImageExtension('photo.png')).toBeNull()
|
||||
expect(getNonPreviewableImageExtension('photo.jpg')).toBeNull()
|
||||
expect(getNonPreviewableImageExtension('clip.webp')).toBeNull()
|
||||
expect(getNonPreviewableImageExtension('video.mp4')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty, missing, or extensionless inputs', () => {
|
||||
expect(getNonPreviewableImageExtension('')).toBeNull()
|
||||
expect(getNonPreviewableImageExtension(null)).toBeNull()
|
||||
expect(getNonPreviewableImageExtension(undefined)).toBeNull()
|
||||
expect(getNonPreviewableImageExtension('noextension')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCivitaiUrl', () => {
|
||||
it.for([
|
||||
{ url: 'https://civitai.com/models/123', expected: true },
|
||||
|
||||
@@ -589,6 +589,8 @@ const IMAGE_EXTENSIONS = [
|
||||
'tiff',
|
||||
'svg'
|
||||
] as const
|
||||
// Image-family extensions that browsers cannot render via `<img>`.
|
||||
const NON_PREVIEWABLE_IMAGE_EXTENSIONS = ['exr'] 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
|
||||
@@ -677,6 +679,24 @@ export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lowercased file extension for an image known to be unrenderable
|
||||
* by `<img>`, or null otherwise. Returned value is suitable as a short format
|
||||
* badge label (e.g. "exr").
|
||||
*/
|
||||
export function getNonPreviewableImageExtension(
|
||||
filename: string | null | undefined
|
||||
): string | null {
|
||||
if (!filename) return null
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return null
|
||||
return NON_PREVIEWABLE_IMAGE_EXTENSIONS.includes(
|
||||
ext as (typeof NON_PREVIEWABLE_IMAGE_EXTENSIONS)[number]
|
||||
)
|
||||
? ext
|
||||
: null
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds === 0) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"imageFailedToLoad": "Image failed to load",
|
||||
"imageDoesNotExist": "Image does not exist",
|
||||
"unknownFile": "Unknown file",
|
||||
"previewNotAvailable": "Preview not available",
|
||||
"reconnecting": "Reconnecting",
|
||||
"reconnected": "Reconnected",
|
||||
"disconnectedFromBackend": "Disconnected from backend. Check if the server is running.",
|
||||
|
||||
@@ -35,7 +35,8 @@ const i18n = createI18n({
|
||||
unknownFile: 'Unknown file',
|
||||
loading: 'Loading',
|
||||
viewGrid: 'Grid view',
|
||||
galleryThumbnail: 'Gallery thumbnail'
|
||||
galleryThumbnail: 'Gallery thumbnail',
|
||||
previewNotAvailable: 'Preview not available'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,4 +587,110 @@ describe('ImagePreview', () => {
|
||||
screen.getByRole('img')
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-previewable formats (EXR)', () => {
|
||||
const exrUrl = '/api/view?filename=render.exr&type=output'
|
||||
|
||||
it('renders a placeholder instead of <img> for an .exr output', () => {
|
||||
renderImagePreview({ imageUrls: [exrUrl] })
|
||||
|
||||
expect(screen.queryByTestId('main-image')).not.toBeInTheDocument()
|
||||
const placeholder = screen.getByTestId('image-preview-placeholder')
|
||||
expect(placeholder).toBeInTheDocument()
|
||||
expect(placeholder).toHaveTextContent('EXR')
|
||||
expect(placeholder).toHaveTextContent('Preview not available')
|
||||
expect(placeholder).toHaveTextContent('render.exr')
|
||||
})
|
||||
|
||||
it('keeps the download button available for placeholders', () => {
|
||||
renderImagePreview({ imageUrls: [exrUrl] })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Download image' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the mask/edit button for placeholders', () => {
|
||||
renderImagePreview({ imageUrls: [exrUrl] })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Edit or mask image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show the dimensions/loading footer for placeholders', () => {
|
||||
renderImagePreview({ imageUrls: [exrUrl] })
|
||||
|
||||
expect(screen.queryByText('Calculating dimensions')).toBeNull()
|
||||
expect(screen.queryByText(/Loading/)).toBeNull()
|
||||
expect(screen.queryByTestId('error-loading-image')).toBeNull()
|
||||
})
|
||||
|
||||
it('downloads the original file URL when the download button is clicked', async () => {
|
||||
renderImagePreview({ imageUrls: [exrUrl] })
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Download image' }))
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(exrUrl)
|
||||
})
|
||||
|
||||
it('renders a placeholder for each .exr thumbnail in grid view', () => {
|
||||
const { container } = renderImagePreview({
|
||||
imageUrls: [
|
||||
'/api/view?filename=a.exr&type=output',
|
||||
'/api/view?filename=b.exr&type=output'
|
||||
]
|
||||
})
|
||||
|
||||
const placeholders = screen.getAllByTestId('image-preview-placeholder')
|
||||
expect(placeholders).toHaveLength(2)
|
||||
expect(container.querySelectorAll('img')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('mixes placeholders and real images correctly', () => {
|
||||
const { container } = renderImagePreview({
|
||||
imageUrls: [
|
||||
'/api/view?filename=a.png&type=output',
|
||||
'/api/view?filename=b.exr&type=output'
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('image-preview-placeholder')).toHaveLength(1)
|
||||
expect(container.querySelectorAll('img')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cancels a pending delayed-loader when switching from a real image to a placeholder', async () => {
|
||||
vi.useFakeTimers()
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: vi.advanceTimersByTime
|
||||
})
|
||||
try {
|
||||
const { container } = renderImagePreview({
|
||||
imageUrls: [
|
||||
'/api/view?filename=a.png&type=output',
|
||||
'/api/view?filename=b.exr&type=output'
|
||||
]
|
||||
})
|
||||
await switchToGallery(user)
|
||||
await fireEvent.load(screen.getByTestId('main-image'))
|
||||
await nextTick()
|
||||
|
||||
const dots = screen.getAllByRole('button', { name: /View image/ })
|
||||
await user.click(dots[1])
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
container.querySelector('[aria-busy="true"]')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByTestId('image-preview-placeholder')
|
||||
).toBeInTheDocument()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,20 +12,28 @@
|
||||
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
|
||||
>
|
||||
<button
|
||||
v-for="(url, index) in imageUrls"
|
||||
v-for="(item, index) in imageItems"
|
||||
:key="index"
|
||||
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: imageUrls.length
|
||||
total: imageItems.length
|
||||
})
|
||||
"
|
||||
@pointerdown="trackPointerStart"
|
||||
@click="handleGridThumbnailClick($event, index)"
|
||||
>
|
||||
<ImagePreviewPlaceholder
|
||||
v-if="item.placeholderFormat"
|
||||
:format="item.placeholderFormat"
|
||||
:filename="item.filename"
|
||||
variant="thumbnail"
|
||||
class="pointer-events-none size-full"
|
||||
/>
|
||||
<img
|
||||
:src="url"
|
||||
v-else
|
||||
:src="item.url"
|
||||
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
|
||||
draggable="false"
|
||||
class="pointer-events-none size-full object-contain"
|
||||
@@ -44,9 +52,17 @@
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
:aria-busy="showLoader"
|
||||
>
|
||||
<!-- Placeholder for non-previewable formats (e.g. EXR) -->
|
||||
<ImagePreviewPlaceholder
|
||||
v-if="currentPlaceholderFormat"
|
||||
:format="currentPlaceholderFormat"
|
||||
:filename="getImageFilename(currentImageUrl)"
|
||||
variant="gallery"
|
||||
class="pointer-events-none absolute inset-0"
|
||||
/>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
v-else-if="imageError"
|
||||
role="alert"
|
||||
class="flex size-full flex-1 flex-col items-center justify-around self-center py-8 text-center text-base-foreground"
|
||||
>
|
||||
@@ -59,12 +75,15 @@
|
||||
</p>
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="showLoader && !imageError" class="size-full">
|
||||
<div
|
||||
v-if="!currentPlaceholderFormat && showLoader && !imageError"
|
||||
class="size-full"
|
||||
>
|
||||
<Skeleton class="size-full rounded-sm" />
|
||||
</div>
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-if="!imageError"
|
||||
v-if="!currentPlaceholderFormat && !imageError"
|
||||
data-testid="main-image"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
@@ -80,7 +99,7 @@
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
v-if="!hasMultipleImages && !imageError"
|
||||
v-if="!hasMultipleImages && !imageError && !currentPlaceholderFormat"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.editOrMaskImage')"
|
||||
:aria-label="$t('g.editOrMaskImage')"
|
||||
@@ -91,7 +110,7 @@
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
v-if="!imageError"
|
||||
v-if="!imageError || currentPlaceholderFormat"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.downloadImage')"
|
||||
:aria-label="$t('g.downloadImage')"
|
||||
@@ -115,7 +134,7 @@
|
||||
|
||||
<!-- Image Dimensions (gallery mode only) -->
|
||||
<div
|
||||
v-if="viewMode === 'gallery'"
|
||||
v-if="viewMode === 'gallery' && !currentPlaceholderFormat"
|
||||
class="pt-2 text-center text-xs text-base-foreground"
|
||||
>
|
||||
<span
|
||||
@@ -150,14 +169,14 @@
|
||||
|
||||
<!-- Navigation Dots -->
|
||||
<button
|
||||
v-for="(_, index) in imageUrls"
|
||||
v-for="(_, index) in imageItems"
|
||||
:key="index"
|
||||
:class="getNavigationDotClass(index)"
|
||||
:aria-current="index === currentIndex ? 'true' : undefined"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: imageUrls.length
|
||||
total: imageItems.length
|
||||
})
|
||||
"
|
||||
@click="setCurrentIndex(index)"
|
||||
@@ -175,7 +194,9 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import ImagePreviewPlaceholder from '@/renderer/extensions/vueNodes/components/ImagePreviewPlaceholder.vue'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { getNonPreviewableImageExtension } from '@/utils/formatUtil'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -218,7 +239,34 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function parseFilenameFromUrl(url: string): string | undefined {
|
||||
if (!url) return undefined
|
||||
try {
|
||||
return (
|
||||
new URL(url, window.location.origin).searchParams.get('filename') ??
|
||||
undefined
|
||||
)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const imageItems = computed(() =>
|
||||
imageUrls.map((url) => {
|
||||
const filename = parseFilenameFromUrl(url)
|
||||
return {
|
||||
url,
|
||||
filename,
|
||||
placeholderFormat:
|
||||
getNonPreviewableImageExtension(filename)?.toUpperCase()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
|
||||
const currentPlaceholderFormat = computed(
|
||||
() => imageItems.value[currentIndex.value]?.placeholderFormat
|
||||
)
|
||||
const hasMultipleImages = computed(() => imageUrls.length > 1)
|
||||
const imageAltText = computed(() =>
|
||||
t('g.viewImageOfTotal', {
|
||||
@@ -254,7 +302,12 @@ watch(
|
||||
|
||||
viewMode.value = defaultViewMode(newUrls)
|
||||
imageError.value = false
|
||||
if (newUrls.length > 0) startDelayedLoader()
|
||||
if (newUrls.length > 0 && !currentPlaceholderFormat.value) {
|
||||
startDelayedLoader()
|
||||
} else {
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -306,7 +359,12 @@ function setCurrentIndex(index: number) {
|
||||
const urlChanged = imageUrls[index] !== currentImageUrl.value
|
||||
currentIndex.value = index
|
||||
imageError.value = false
|
||||
if (urlChanged) startDelayedLoader()
|
||||
if (currentPlaceholderFormat.value) {
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
} else if (urlChanged) {
|
||||
startDelayedLoader()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,10 +437,6 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
|
||||
function getImageFilename(url: string): string {
|
||||
if (!url) return t('g.imageDoesNotExist')
|
||||
try {
|
||||
return new URL(url).searchParams.get('filename') || t('g.unknownFile')
|
||||
} catch {
|
||||
return t('g.imageDoesNotExist')
|
||||
}
|
||||
return parseFilenameFromUrl(url) || t('g.unknownFile')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-full flex-col items-center justify-center px-2 text-center text-base-foreground',
|
||||
variant === 'gallery' ? 'gap-2 py-8' : 'gap-1 py-3'
|
||||
)
|
||||
"
|
||||
role="img"
|
||||
:aria-label="ariaLabel"
|
||||
data-testid="image-preview-placeholder"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--file-image] text-base-foreground/70',
|
||||
variant === 'gallery' ? 'size-12' : 'size-6'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'rounded-sm bg-base-foreground/15 px-1.5 py-0.5 font-mono font-semibold tracking-wide uppercase',
|
||||
variant === 'gallery' ? 'text-xs' : 'text-2xs'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ format }}
|
||||
</span>
|
||||
<template v-if="variant === 'gallery'">
|
||||
<p class="text-xs text-base-foreground/70">
|
||||
{{ $t('g.previewNotAvailable') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="filename"
|
||||
class="line-clamp-2 max-w-full text-xs break-all text-base-foreground"
|
||||
>
|
||||
{{ filename }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
interface ImagePreviewPlaceholderProps {
|
||||
readonly format: string
|
||||
readonly filename?: string
|
||||
readonly variant?: 'gallery' | 'thumbnail'
|
||||
}
|
||||
|
||||
const {
|
||||
format,
|
||||
filename,
|
||||
variant = 'gallery'
|
||||
} = defineProps<ImagePreviewPlaceholderProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const ariaLabel = computed(
|
||||
() => t('g.previewNotAvailable') + (filename ? ` (${filename})` : '')
|
||||
)
|
||||
</script>
|
||||
@@ -309,6 +309,25 @@ describe('nodeOutputStore getPreviewParam', () => {
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return empty string if outputs.images contains EXR images', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs([{ filename: 'render.exr' }])
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return empty string if outputs.images mixes EXR and previewable formats', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs([
|
||||
{ filename: 'image.png' },
|
||||
{ filename: 'render.exr' }
|
||||
])
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return format param for standard image outputs', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
|
||||
@@ -14,7 +14,10 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { clone } from '@/scripts/utils'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { parseFilePath } from '@/utils/formatUtil'
|
||||
import {
|
||||
getNonPreviewableImageExtension,
|
||||
parseFilePath
|
||||
} from '@/utils/formatUtil'
|
||||
import {
|
||||
isAnimatedOutput,
|
||||
isVideoNode,
|
||||
@@ -97,6 +100,16 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
if (outputs.images.some((image) => image.filename?.endsWith('svg')))
|
||||
return false
|
||||
|
||||
// If any image is in a format the browser cannot render (e.g. EXR), the
|
||||
// backend's `/view?preview=...` path would try to decode it with PIL and
|
||||
// fail; skip the preview param so the original file is served instead.
|
||||
if (
|
||||
outputs.images.some((image) =>
|
||||
getNonPreviewableImageExtension(image.filename)
|
||||
)
|
||||
)
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user