Compare commits

...

3 Commits

Author SHA1 Message Date
glary-bot
787573516f style: improve filename legibility in EXR placeholder
End-to-end testing with a real LoadImage -> SaveImageAdvanced workflow
revealed the filename row in the gallery placeholder was effectively
unreadable at typical viewing zoom. Bump from text-2xs / 60% opacity
to text-xs / full foreground to match the visual weight of the
'Preview not available' caption.
2026-05-15 04:35:32 +00:00
glary-bot
67db42e715 test: add mixed-format batch coverage for getPreviewParam
Verifies that when outputs contain a mix of previewable (.png) and
non-previewable (.exr) images, the preview-conversion param is still
suppressed, so the backend doesn't try to PIL-decode the EXR.
2026-05-15 04:27:30 +00:00
glary-bot
492027db84 feat: show placeholder for non-previewable image formats (.exr)
Replace the 'Image failed to load' error in node output previews with a
deliberate placeholder when the saved file is an image format the browser
cannot render natively (currently just .exr from SaveImageAdvanced). The
placeholder shows a file icon, an EXR badge, the filename, and a
'Preview not available' caption, while keeping the download button so
users can still retrieve the file.
2026-05-15 03:47:46 +00:00
8 changed files with 325 additions and 20 deletions

View File

@@ -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 },

View File

@@ -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)

View File

@@ -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.",

View File

@@ -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()
}
})
})
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()

View File

@@ -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
}