Compare commits

..

2 Commits

Author SHA1 Message Date
Matt Miller
ee2a2b1b07 chore: add AGENTS.md code owner
Assign @christian-byrne as owner of **/AGENTS.md so agent instruction changes get a review gate. Other LLM-instruction paths stay intentionally ownerless.
2026-07-16 16:39:34 -07:00
claude[bot]
a154e6a311 Redirect comfy.org/login to cloud.comfy.org/login (#13727)
<!-- ccr-slack-attribution -->
_Requested by **nav** · [Slack
thread](https://comfy-organization.slack.com/archives/C0B61J7QCJW/p1784217313626029?thread_ts=1784217313.626029&cid=C0B61J7QCJW)_

## Summary

Redirect comfy.org/login to cloud.comfy.org/login.

## Changes

- **What**: Adds a `/login` entry to the `redirects` array in
`apps/website/vercel.json`, matching the existing `/blog` ->
`https://blog.comfy.org` cross-origin redirect pattern.

## Review Focus

- Before: `comfy.org/login` had no redirect (404/no route).
- After: `comfy.org/login` returns a 302 (`permanent: false`) to
`https://cloud.comfy.org/login`.
- How: Astro's native redirects don't support cross-origin destinations,
so this uses Vercel's `redirects` config in `apps/website/vercel.json`,
consistent with the existing `/blog` entry.

## Screenshots (if applicable)

N/A

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UZGZ27NenZf5dzdvNcTFev)_

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-16 22:20:02 +00:00
16 changed files with 185 additions and 518 deletions

View File

@@ -60,5 +60,7 @@
.claude/
.cursor/
.cursorrules
**/AGENTS.md
**/CLAUDE.md
# Agent instructions
**/AGENTS.md @christian-byrne

View File

@@ -54,6 +54,11 @@
"source": "/press",
"destination": "/about",
"permanent": true
},
{
"source": "/login",
"destination": "https://cloud.comfy.org/login",
"permanent": false
}
]
}

View File

@@ -1,26 +1,11 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { render, screen } from '@testing-library/vue'
import { fireEvent, render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({ error: useImageMock.error })
}
})
import UserAvatar from './UserAvatar.vue'
const i18n = createI18n({
@@ -38,10 +23,6 @@ const i18n = createI18n({
})
describe('UserAvatar', () => {
beforeEach(() => {
if (useImageMock.error) useImageMock.error.value = null
})
function renderComponent(props: ComponentProps<typeof UserAvatar> = {}) {
return render(UserAvatar, {
global: {
@@ -86,10 +67,10 @@ describe('UserAvatar', () => {
photoUrl: 'https://example.com/broken-image.jpg'
})
expect(screen.getByRole('img')).toBeInTheDocument()
const img = screen.getByRole('img')
expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument()
useImageMock.error!.value = new Event('error')
await fireEvent.error(img)
await nextTick()
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()

View File

@@ -11,24 +11,22 @@
}"
shape="circle"
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
@error="handleImageError"
/>
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import Avatar from 'primevue/avatar'
import { computed } from 'vue'
import { computed, ref } from 'vue'
const { photoUrl, ariaLabel } = defineProps<{
photoUrl?: string | null
ariaLabel?: string
}>()
const { error: imageError } = useImage(
computed(() => ({
src: photoUrl ?? '',
alt: ariaLabel ?? ''
}))
)
const imageError = ref(false)
const handleImageError = () => {
imageError.value = true
}
const hasAvatar = computed(() => photoUrl && !imageError.value)
</script>

View File

@@ -21,6 +21,8 @@ function createDefaultCropState() {
isLockEnabled: ref(false),
cropBoxStyle: ref({}),
resizeHandles: ref([]),
handleImageLoad: () => {},
handleImageError: () => {},
handleDragStart: () => {},
handleDragMove: () => {},
handleDragEnd: () => {},

View File

@@ -29,6 +29,8 @@
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
@@ -179,6 +181,8 @@ const {
cropBoxStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,

View File

@@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { createApp, defineComponent, nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -18,35 +17,10 @@ import {
createMockSubgraphNode
} from '@/utils/__tests__/litegraphTestUtils'
import { useImageCrop } from './useImageCrop'
import { imageCropLoadingAfterUrlChange, useImageCrop } from './useImageCrop'
const resizeObserverCallbacks: Array<() => void> = []
const useImageMockState = vi.hoisted(() => {
return {
state: null as null | {
state: Ref<HTMLImageElement | undefined>
isReady: Ref<boolean>
error: Ref<unknown>
}
}
})
function getUseImageMock() {
if (!useImageMockState.state) {
useImageMockState.state = {
state: ref<HTMLImageElement | undefined>(undefined),
isReady: ref(false),
error: ref<unknown>(null)
}
}
return useImageMockState.state
}
function resetUseImageMock() {
useImageMockState.state = null
}
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
@@ -54,8 +28,7 @@ vi.mock('@vueuse/core', async () => {
useResizeObserver: (_target: unknown, cb: () => void) => {
resizeObserverCallbacks.push(cb)
return { stop: vi.fn() }
},
useImage: () => getUseImageMock()
}
}
})
@@ -205,33 +178,10 @@ function setupImageLayout(vm: CropVm, nw: number, nh: number) {
value: nh
})
}
triggerImageLoad(nw, nh)
;(vm.handleImageLoad as () => void)()
flushResizeObservers()
}
function triggerImageLoad(nw: number, nh: number) {
const mock = getUseImageMock()
const fakeImg = new Image()
Object.defineProperty(fakeImg, 'naturalWidth', {
configurable: true,
value: nw
})
Object.defineProperty(fakeImg, 'naturalHeight', {
configurable: true,
value: nh
})
mock.state.value = fakeImg
mock.error.value = null
mock.isReady.value = true
}
function triggerImageError() {
const mock = getUseImageMock()
mock.state.value = undefined
mock.isReady.value = false
mock.error.value = new Error('image failed to load')
}
const harnessCleanups: Array<() => void> = []
async function mountHarness(nodeId: NodeId = toNodeId(2)) {
@@ -253,6 +203,28 @@ async function flushTicks() {
await nextTick()
}
describe('imageCropLoadingAfterUrlChange', () => {
it('clears loading when url becomes null', () => {
expect(imageCropLoadingAfterUrlChange(null, 'https://a/b.png')).toBe(false)
})
it('keeps loading off when url stays null', () => {
expect(imageCropLoadingAfterUrlChange(null, null)).toBe(false)
})
it('starts loading when url changes to a new string', () => {
expect(imageCropLoadingAfterUrlChange('https://b', 'https://a')).toBe(true)
})
it('starts loading when first url is set', () => {
expect(imageCropLoadingAfterUrlChange('https://a', undefined)).toBe(true)
})
it('returns null when url is unchanged so caller can skip updating', () => {
expect(imageCropLoadingAfterUrlChange('https://a', 'https://a')).toBe(null)
})
})
describe('useImageCrop', () => {
let sourceNode: LGraphNode
let cropNode: LGraphNode
@@ -260,7 +232,6 @@ describe('useImageCrop', () => {
beforeEach(() => {
resizeObserverCallbacks.length = 0
resetUseImageMock()
vi.clearAllMocks()
outputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
@@ -412,8 +383,7 @@ describe('useImageCrop', () => {
configurable: true,
value: 0
})
triggerImageLoad(0, 0)
await flushTicks()
;(vm.handleImageLoad as () => void)()
vm.modelValue = { x: 0, y: 0, width: 100, height: 80 }
const style = vm.cropBoxStyle as Record<string, string>
expect(parseFloat(style.width)).toBeCloseTo(100, 1)
@@ -441,16 +411,14 @@ describe('useImageCrop', () => {
expect(vm.imageUrl).toBe('https://example.com/b.png')
expect(vm.isLoading).toBe(true)
triggerImageLoad(800, 600)
await flushTicks()
;(vm.handleImageLoad as () => void)()
expect(vm.isLoading).toBe(false)
})
it('clears imageUrl on image error', async () => {
const vm = await mountHarness()
expect(vm.imageUrl).toBeTruthy()
triggerImageError()
await flushTicks()
;(vm.handleImageError as () => void)()
expect(vm.imageUrl).toBeNull()
expect(vm.isLoading).toBe(false)
})
@@ -657,7 +625,6 @@ describe('WidgetImageCrop', () => {
beforeEach(() => {
resizeObserverCallbacks.length = 0
resetUseImageMock()
vi.clearAllMocks()
const outputStore: MockOutputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
@@ -746,7 +713,7 @@ describe('WidgetImageCrop', () => {
configurable: true,
value: 400
})
triggerImageLoad(400, 400)
img.dispatchEvent(new Event('load'))
await flushTicks()
expect(screen.getByTestId('crop-overlay')).toBeTruthy()
unmount()
@@ -790,7 +757,7 @@ describe('WidgetImageCrop', () => {
configurable: true,
value: 400
})
triggerImageLoad(400, 400)
img.dispatchEvent(new Event('load'))
await flushTicks()
await user.click(screen.getByRole('button', { name: 'Lock aspect ratio' }))

View File

@@ -1,4 +1,4 @@
import { useImage, useResizeObserver } from '@vueuse/core'
import { useResizeObserver } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
@@ -35,6 +35,19 @@ const CORNER_SIZE = 10
const MIN_CROP_SIZE = 16
const CROP_BOX_BORDER = 2
/**
* Next `isLoading` when `imageUrl` transitions. `null` means do not change
* `isLoading` (e.g. same URL).
*/
export function imageCropLoadingAfterUrlChange(
url: string | null,
previous: string | null | undefined
): boolean | null {
if (url == null) return false
if (url !== previous) return true
return null
}
export const ASPECT_RATIOS = {
'1:1': 1,
'3:4': 3 / 4,
@@ -192,37 +205,17 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
imageUrl.value = getInputImageUrl()
}
const {
state: imageState,
isReady: imageIsReady,
error: imageLoadError
} = useImage(computed(() => ({ src: imageUrl.value ?? '', alt: '' })))
watch(imageUrl, (url, previous) => {
if (url == null) {
isLoading.value = false
} else if (url !== previous) {
isLoading.value = true
const next = imageCropLoadingAfterUrlChange(url, previous)
if (next !== null) {
isLoading.value = next
}
})
watch([imageIsReady, imageState], ([ready, img]) => {
if (!ready || !img) return
isLoading.value = false
updateDisplayedDimensions(img)
})
watch(imageLoadError, (err) => {
if (err) {
isLoading.value = false
imageUrl.value = null
}
})
const updateDisplayedDimensions = (loadedImg?: HTMLImageElement | null) => {
const img = loadedImg ?? imageEl.value
if (!img || !containerEl.value) return
const updateDisplayedDimensions = () => {
if (!imageEl.value || !containerEl.value) return
const img = imageEl.value
const container = containerEl.value
naturalWidth.value = img.naturalWidth
@@ -378,6 +371,16 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
)
})
const handleImageLoad = () => {
isLoading.value = false
updateDisplayedDimensions()
}
const handleImageError = () => {
isLoading.value = false
imageUrl.value = null
}
const capturePointer = (e: PointerEvent) => {
if (e.target instanceof HTMLElement) e.target.setPointerCapture(e.pointerId)
}
@@ -615,6 +618,8 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
cropBoxStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,

View File

@@ -1,126 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
state: null as Ref<HTMLImageElement | undefined> | null,
isReady: null as Ref<boolean> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
useImageMock.isReady = ref(false)
return {
...(actual as Record<string, unknown>),
useImage: () => ({
state: useImageMock.state,
isReady: useImageMock.isReady
})
}
})
const executionStatusMock = vi.hoisted(() => ({
message: null as Ref<string | null> | null
}))
vi.mock('@/renderer/extensions/linearMode/useExecutionStatus', async () => {
const { ref } = await import('vue')
executionStatusMock.message = ref<string | null>(null)
return {
useExecutionStatus: () => ({
executionStatusMessage: executionStatusMock.message
})
}
})
import ImagePreview from './ImagePreview.vue'
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
function renderImagePreview(props: Record<string, unknown> = {}) {
return render(ImagePreview, {
props: { src: 'https://example.com/image.png', ...props },
global: {
plugins: [i18n],
stubs: {
ZoomPane: {
template: '<div data-testid="zoom-pane"><slot /></div>'
}
}
}
})
}
function setLoadedImage(width: number, height: number) {
const fakeImage = { naturalWidth: width, naturalHeight: height } as
| HTMLImageElement
| undefined
useImageMock.state!.value = fakeImage
useImageMock.isReady!.value = true
}
describe('ImagePreview (linearMode)', () => {
beforeEach(() => {
if (useImageMock.state) useImageMock.state.value = undefined
if (useImageMock.isReady) useImageMock.isReady.value = false
if (executionStatusMock.message) executionStatusMock.message.value = null
})
it('renders src inside ZoomPane in desktop mode', () => {
renderImagePreview()
expect(screen.getByTestId('zoom-pane')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/image.png'
)
})
it('renders bare img when mobile is true', () => {
renderImagePreview({ mobile: true })
expect(screen.queryByTestId('zoom-pane')).not.toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/image.png'
)
})
it('shows dimensions once the image is ready', async () => {
renderImagePreview()
setLoadedImage(800, 600)
await nextTick()
expect(screen.getByText('800 x 600')).toBeInTheDocument()
})
it('appends label when provided alongside dimensions', async () => {
renderImagePreview({ label: 'demo' })
setLoadedImage(64, 32)
await nextTick()
expect(screen.getByText(/64 x 32/)).toBeInTheDocument()
expect(screen.getByText(/demo/)).toBeInTheDocument()
})
it('does not show dimensions when showSize=false', async () => {
renderImagePreview({ showSize: false })
setLoadedImage(800, 600)
await nextTick()
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
})
it('does not show dimensions before the image is ready', () => {
renderImagePreview()
expect(screen.queryByText(/x/)).not.toBeInTheDocument()
})
it('shows execution status message instead of dimensions when present', async () => {
renderImagePreview()
setLoadedImage(800, 600)
executionStatusMock.message!.value = 'Generating…'
await nextTick()
expect(screen.getByText('Generating…')).toBeInTheDocument()
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import { ref, useTemplateRef } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
@@ -17,20 +16,15 @@ const { src, showSize = true } = defineProps<{
showSize?: boolean
}>()
const { state: imageState, isReady } = useImage(
computed(() => ({ src, alt: '' }))
)
const imageRef = useTemplateRef('imageRef')
const width = ref<number | null>(null)
const height = ref<number | null>(null)
const width = computed(() =>
showSize && isReady.value && imageState.value
? imageState.value.naturalWidth || null
: null
)
const height = computed(() =>
showSize && isReady.value && imageState.value
? imageState.value.naturalHeight || null
: null
)
function onImageLoad() {
if (!imageRef.value || !showSize) return
width.value = imageRef.value.naturalWidth
height.value = imageRef.value.naturalHeight
}
</script>
<template>
<ZoomPane
@@ -38,9 +32,21 @@ const height = computed(() =>
v-slot="slotProps"
:class="cn('w-full flex-1', $attrs.class as string)"
>
<img :src v-bind="slotProps" class="size-full object-contain" />
<img
ref="imageRef"
:src
v-bind="slotProps"
class="size-full object-contain"
@load="onImageLoad"
/>
</ZoomPane>
<img v-else class="grow object-contain contain-size" :src />
<img
v-else
ref="imageRef"
class="grow object-contain contain-size"
:src
@load="onImageLoad"
/>
<span
v-if="executionStatusMessage"
class="animate-pulse self-center text-muted md:z-10"

View File

@@ -4,24 +4,9 @@ import { createTestingPinia } from '@pinia/testing'
import { render, screen, fireEvent } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({ error: useImageMock.error })
}
})
import { downloadFile } from '@/base/common/downloadUtil'
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
@@ -100,7 +85,6 @@ describe('ImagePreview', () => {
}
beforeEach(() => {
if (useImageMock.error) useImageMock.error.value = null
vi.clearAllMocks()
})
@@ -179,7 +163,7 @@ describe('ImagePreview', () => {
screen.getByRole('button', { name: 'Download image' })
).toBeInTheDocument()
useImageMock.error!.value = new Error('failed to load')
await fireEvent.error(screen.getByTestId('main-image'))
await nextTick()
expect(

View File

@@ -99,6 +99,7 @@
draggable="false"
class="pointer-events-none absolute inset-0 block size-full object-contain"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover and focus) -->
@@ -194,7 +195,7 @@
</template>
<script setup lang="ts">
import { useElementSize, useImage, useTimeoutFn } from '@vueuse/core'
import { useElementSize, useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -242,6 +243,7 @@ const currentIndex = ref(0)
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
const galleryPanelEl = ref<HTMLDivElement>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const imageAspectRatio = ref(1)
@@ -269,20 +271,6 @@ const gridCols = computed(() => {
return Math.max(Math.round(Math.sqrt(imageUrls.length * bias)), 1)
})
// Use useImage for error detection only. Load handling stays on the rendered
// <img> @load handler so syncLegacyNodeImgs receives the actual DOM element.
const { error: imageError } = useImage(
computed(() => ({ src: currentImageUrl.value, alt: imageAltText.value }))
)
watch(imageError, (err) => {
if (err) {
stopDelayedLoader()
showLoader.value = false
actualDimensions.value = null
}
})
watch(
() => imageUrls,
(newUrls, oldUrls) => {
@@ -299,11 +287,11 @@ watch(
currentIndex.value = 0
}
// Reset loading and dimensions when URLs change. `imageError` is reset
// automatically by `useImage` when the source changes.
// Reset loading and error states when URLs change
actualDimensions.value = null
viewMode.value = defaultViewMode(newUrls)
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ immediate: true }
@@ -314,6 +302,7 @@ function handleImageLoad(event: Event) {
const img = event.target
stopDelayedLoader()
showLoader.value = false
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
@@ -331,6 +320,13 @@ function updateAspectRatio(event: Event, index: number) {
}
}
function handleImageError() {
stopDelayedLoader()
showLoader.value = false
imageError.value = true
actualDimensions.value = null
}
function handleEditMask() {
if (!nodeId) return
const node = resolveNode(nodeId)
@@ -355,6 +351,7 @@ function setCurrentIndex(index: number) {
if (index >= 0 && index < imageUrls.length) {
const urlChanged = imageUrls[index] !== currentImageUrl.value
currentIndex.value = index
imageError.value = false
if (urlChanged) startDelayedLoader()
}
}

View File

@@ -1,32 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
state: null as Ref<HTMLImageElement | undefined> | null,
isReady: null as Ref<boolean> | null,
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
useImageMock.isReady = ref(false)
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({
state: useImageMock.state,
isReady: useImageMock.isReady,
error: useImageMock.error
})
}
})
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
const i18n = createI18n({
@@ -44,19 +21,6 @@ const i18n = createI18n({
}
})
function makeFakeLoadedImage(width: number, height: number): HTMLImageElement {
const img = new Image()
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: width
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: height
})
return img
}
describe('LivePreview', () => {
const defaultProps = {
imageUrl: '/api/view?filename=test_sample.png&type=temp'
@@ -79,12 +43,6 @@ describe('LivePreview', () => {
})
}
beforeEach(() => {
useImageMock.state!.value = undefined
useImageMock.isReady!.value = false
useImageMock.error!.value = null
})
it('renders preview when imageUrl provided', () => {
renderLivePreview()
@@ -116,62 +74,54 @@ describe('LivePreview', () => {
it('handles image load event', async () => {
const { container } = renderLivePreview()
const img = screen.getByRole('img')
useImageMock.state!.value = makeFakeLoadedImage(512, 512)
useImageMock.isReady!.value = true
Object.defineProperty(img, 'naturalWidth', {
writable: false,
value: 512
})
Object.defineProperty(img, 'naturalHeight', {
writable: false,
value: 512
})
await fireEvent.load(img)
await nextTick()
expect(container.textContent).toContain('512 x 512')
})
it('keeps last good dimensions when imageUrl changes (no flicker)', async () => {
const { container, rerender } = renderLivePreview()
useImageMock.state!.value = makeFakeLoadedImage(800, 600)
useImageMock.isReady!.value = true
await nextTick()
expect(container.textContent).toContain('800 x 600')
// Simulate the source changing during live preview streaming. useImage
// would normally reset isReady to false until the next image is ready.
useImageMock.isReady!.value = false
await rerender({
imageUrl: '/api/view?filename=test_sample_2.png&type=temp'
})
await nextTick()
// Dimensions should still display, not flicker back to "Calculating".
expect(container.textContent).toContain('800 x 600')
expect(container.textContent).not.toContain('Calculating dimensions')
})
it('handles image error state', async () => {
renderLivePreview()
useImageMock.error!.value = new Event('error')
const img = screen.getByRole('img')
await fireEvent.error(img)
await nextTick()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
screen.getByText('Image failed to load')
})
it('resets error state when imageUrl changes', async () => {
it('resets state when imageUrl changes', async () => {
const { container, rerender } = renderLivePreview()
const img = screen.getByRole('img')
useImageMock.error!.value = new Event('error')
await fireEvent.error(img)
await nextTick()
expect(container.textContent).toContain('Error loading image')
// useImage resets error automatically when src changes.
useImageMock.error!.value = null
await rerender({ imageUrl: '/new-image.png' })
await nextTick()
expect(container.textContent).toContain('Calculating dimensions')
expect(container.textContent).not.toContain('Error loading image')
})
it('shows error state when image fails to load', async () => {
const { container } = renderLivePreview()
useImageMock.error!.value = new Event('error')
const img = screen.getByRole('img')
await fireEvent.error(img)
await nextTick()
expect(screen.queryByRole('img')).not.toBeInTheDocument()

View File

@@ -12,6 +12,8 @@
:src="imageUrl"
:alt="$t('g.liveSamplingPreview')"
class="pointer-events-none min-h-55 w-full flex-1 object-contain contain-size"
@load="handleImageLoad"
@error="handleImageError"
/>
<div class="text-node-component-header-text mt-1 text-center text-xs">
{{
@@ -24,9 +26,7 @@
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ref, watch } from 'vue'
interface LivePreviewProps {
imageUrl: string
@@ -34,36 +34,29 @@ interface LivePreviewProps {
const props = defineProps<LivePreviewProps>()
const { t } = useI18n()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const {
state: imageState,
isReady,
error
} = useImage(
computed(() => ({ src: props.imageUrl, alt: t('g.liveSamplingPreview') }))
)
// Cache last successfully loaded dimensions so the placeholder text does not
// flicker back to "Calculating dimensions" each time `imageUrl` changes during
// live preview streaming. Update only when a new image is ready, never on
// URL change alone.
const cachedWidth = ref<number | null>(null)
const cachedHeight = ref<number | null>(null)
watch([isReady, imageState], ([ready, img]) => {
if (!ready || !img) return
if (img.naturalWidth && img.naturalHeight) {
cachedWidth.value = img.naturalWidth
cachedHeight.value = img.naturalHeight
watch(
() => props.imageUrl,
() => {
// Reset error state when URL changes, but keep previous dimensions
// to avoid flickering "Calculating dimensions" text during live preview
imageError.value = false
}
})
const imageError = computed(() => !!error.value)
const actualDimensions = computed(() =>
cachedWidth.value && cachedHeight.value
? `${cachedWidth.value} x ${cachedHeight.value}`
: null
)
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
const handleImageError = () => {
imageError.value = true
actualDimensions.value = null
}
</script>

View File

@@ -1,95 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { components } from '@/types/comfyRegistryTypes'
const useImageMock = vi.hoisted(() => ({
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({ error: useImageMock.error })
}
})
import PackBanner from './PackBanner.vue'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
defaultBanner: 'Default banner'
}
}
}
})
function makePack(
overrides: Partial<components['schemas']['Node']> = {}
): components['schemas']['Node'] {
return {
id: 'pack-id',
name: 'TestPack',
...overrides
} as components['schemas']['Node']
}
function renderPackBanner(nodePack: components['schemas']['Node']) {
return render(PackBanner, {
props: { nodePack },
global: { plugins: [i18n] }
})
}
describe('PackBanner', () => {
beforeEach(() => {
if (useImageMock.error) useImageMock.error.value = null
})
it('renders the default banner when both banner_url and icon are missing', () => {
renderPackBanner(makePack())
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', DEFAULT_BANNER)
expect(img).toHaveAttribute('alt', 'Default banner')
})
it('renders the banner_url image when provided', () => {
renderPackBanner(makePack({ banner_url: 'https://example.com/banner.png' }))
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', 'https://example.com/banner.png')
expect(img).toHaveAttribute('alt', 'TestPack banner')
})
it('falls back to icon when banner_url is missing but icon is set', () => {
renderPackBanner(makePack({ icon: 'https://example.com/icon.svg' }))
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/icon.svg'
)
})
it('falls back to default banner when image fails to load', async () => {
renderPackBanner(makePack({ banner_url: 'https://example.com/broken.png' }))
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/broken.png'
)
useImageMock.error!.value = new Event('error')
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute('src', DEFAULT_BANNER)
})
})

View File

@@ -12,7 +12,7 @@
<div v-else class="relative size-full">
<!-- blur background -->
<div
v-if="imgSrc && !isImageError"
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
@@ -21,24 +21,21 @@
></div>
<!-- image -->
<img
v-if="isImageError"
:src="DEFAULT_BANNER"
:alt="bannerAlt"
class="relative z-10 size-full object-cover"
/>
<img
v-else
:src="imgSrc"
:alt="bannerAlt"
class="relative z-10 size-full object-contain"
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative z-10 size-full object-cover'
: 'relative z-10 size-full object-contain'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
@@ -48,11 +45,8 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon || '')
const bannerAlt = computed(() => `${nodePack.name} banner`)
const isImageError = ref(false)
const { error: isImageError } = useImage(
computed(() => ({ src: imgSrc.value, alt: bannerAlt.value }))
)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>