make Vue nodes' outputs/previews responsively sized and work with node resizing (#5970)

## Summary

Added dedicated component for sampling previews and change all image
outputs (outputs, videos, previews) to be responsive and respond to node
resizing.



https://github.com/user-attachments/assets/7e683d32-4914-460c-ba08-4573c40aef24

## Changes

- **What**: Implemented `LivePreview` component for mid-execution
sampling visualization with responsive layout system
- **Dependencies**: Added resize handle composable and transform state
integration

## Review Focus

Node resize interaction conflicts with canvas dragging, and image
dimension calculation performance during rapid sampling updates.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5970-make-Vue-nodes-outputs-previews-responsively-sized-and-work-with-node-resizing-2866d73d365081508d53e6e286a9a3fe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-10-10 21:52:24 -07:00
committed by GitHub
parent e6534f17e6
commit a0c02dfca6
8 changed files with 228 additions and 33 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -31,6 +31,7 @@
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
"audioFailedToLoad": "Audio failed to load",
"liveSamplingPreview": "Live sampling preview",
"extensionName": "Extension Name",
"reloadToApplyChanges": "Reload to apply changes",
"insert": "Insert",

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="imageUrls.length > 0"
class="video-preview group relative flex flex-col items-center"
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
@@ -12,12 +12,12 @@
>
<!-- Video Wrapper -->
<div
class="relative w-full max-w-[352px] overflow-hidden rounded-[5px] bg-[#262729]"
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
>
<!-- Error State -->
<div
v-if="videoError"
class="flex h-[352px] w-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
class="flex size-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
>
<i class="mb-2 icon-[lucide--video-off] h-12 w-12 text-gray-400" />
<p class="text-sm text-gray-300">{{ $t('g.videoFailedToLoad') }}</p>
@@ -27,17 +27,13 @@
</div>
<!-- Loading State -->
<Skeleton
v-else-if="isLoading"
class="h-[352px] w-full"
border-radius="5px"
/>
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
<!-- Main Video -->
<video
v-else
:src="currentVideoUrl"
class="block h-[352px] w-full object-contain"
class="block size-full object-contain"
controls
loop
playsinline

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="imageUrls.length > 0"
class="image-preview group relative flex flex-col items-center"
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col"
data-capture-node="true"
tabindex="0"
role="region"
@@ -12,12 +12,12 @@
>
<!-- Image Wrapper -->
<div
class="relative w-full max-w-[352px] overflow-hidden rounded-[5px] bg-[#262729]"
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
>
<!-- Error State -->
<div
v-if="imageError"
class="flex h-[352px] w-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
class="flex size-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
>
<i class="mb-2 icon-[lucide--image-off] h-12 w-12 text-gray-400" />
<p class="text-sm text-gray-300">{{ $t('g.imageFailedToLoad') }}</p>
@@ -27,18 +27,14 @@
</div>
<!-- Loading State -->
<Skeleton
v-else-if="isLoading"
class="h-[352px] w-full"
border-radius="5px"
/>
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
<!-- Main Image -->
<img
v-else
:src="currentImageUrl"
:alt="imageAltText"
class="block h-[352px] w-full object-contain"
class="block size-full object-contain"
@load="handleImageLoad"
@error="handleImageError"
/>

View File

@@ -9,7 +9,7 @@
:class="
cn(
'bg-node-component-surface',
'lg-node absolute rounded-2xl touch-none',
'lg-node absolute rounded-2xl touch-none flex flex-col',
'border-1 border-solid border-node-component-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
@@ -88,7 +88,7 @@
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
class="flex flex-col gap-4 pb-4"
class="flex min-h-0 flex-1 flex-col gap-4 pb-4"
:data-testid="`node-body-${nodeData.id}`"
>
<!-- Slots only rendered at full detail -->
@@ -98,18 +98,12 @@
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="hasCustomContent"
:node-data="nodeData"
:media="nodeMedia"
/>
<!-- Live preview image -->
<div v-if="shouldShowPreviewImg" class="px-4">
<img
:src="latestPreviewUrl"
alt="preview"
class="max-h-64 w-full object-contain"
/>
<div v-if="hasCustomContent" class="min-h-0 flex-1">
<NodeContent :node-data="nodeData" :media="nodeMedia" />
</div>
<!-- Live mid-execution preview images -->
<div v-if="shouldShowPreviewImg" class="min-h-0 flex-1 px-4">
<LivePreview :image-url="latestPreviewUrl || null" />
</div>
</div>
</template>
@@ -155,6 +149,7 @@ import { cn } from '@/utils/tailwindUtil'
import { useNodeResize } from '../composables/useNodeResize'
import { calculateIntrinsicSize } from '../utils/calculateIntrinsicSize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'

View File

@@ -0,0 +1,73 @@
<template>
<div v-if="imageUrl" class="flex h-full min-h-16 w-full min-w-16 flex-col">
<!-- Image Container -->
<div
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
>
<!-- Error State -->
<div
v-if="imageError"
class="flex h-full w-full flex-col items-center justify-center text-center text-pure-white"
>
<i-lucide:image-off class="mb-1 size-8 text-gray-500" />
<p class="text-xs text-gray-400">{{ $t('g.imageFailedToLoad') }}</p>
</div>
<!-- Main Image -->
<img
v-else
:src="imageUrl"
:alt="$t('g.liveSamplingPreview')"
class="pointer-events-none h-full w-full object-contain object-center"
@load="handleImageLoad"
@error="handleImageError"
/>
</div>
<!-- Image Dimensions -->
<div class="text-node-component-header-text mt-1 text-center text-xs">
{{
imageError
? $t('g.errorLoadingImage')
: actualDimensions || $t('g.calculatingDimensions')
}}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface LivePreviewProps {
/** Image URL to display */
imageUrl: string | null
}
const props = defineProps<LivePreviewProps>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
watch(
() => props.imageUrl,
() => {
// Reset states when URL changes
actualDimensions.value = null
imageError.value = false
}
)
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

@@ -2,7 +2,7 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ $t('Node Content Error') }}
</div>
<div v-else class="lg-node-content">
<div v-else class="lg-node-content flex h-full flex-col">
<!-- Default slot for custom content -->
<slot>
<VideoPreview

View File

@@ -0,0 +1,134 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
liveSamplingPreview: 'Live sampling preview',
imageFailedToLoad: 'Image failed to load',
errorLoadingImage: 'Error loading image',
calculatingDimensions: 'Calculating dimensions'
}
}
}
})
describe('LivePreview', () => {
const defaultProps = {
imageUrl: '/api/view?filename=test_sample.png&type=temp'
}
const mountLivePreview = (props = {}) => {
return mount(LivePreview, {
props: { ...defaultProps, ...props },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
'i-lucide:image-off': true
}
}
})
}
it('renders preview when imageUrl provided', () => {
const wrapper = mountLivePreview()
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('img').attributes('src')).toBe(defaultProps.imageUrl)
})
it('does not render when no imageUrl provided', () => {
const wrapper = mountLivePreview({ imageUrl: null })
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
it('displays calculating dimensions text initially', () => {
const wrapper = mountLivePreview()
expect(wrapper.text()).toContain('Calculating dimensions')
})
it('has proper accessibility attributes', () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Live sampling preview')
})
it('handles image load event', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Mock the naturalWidth and naturalHeight properties on the img element
Object.defineProperty(img.element, 'naturalWidth', {
writable: false,
value: 512
})
Object.defineProperty(img.element, 'naturalHeight', {
writable: false,
value: 512
})
// Trigger the load event
await img.trigger('load')
expect(wrapper.text()).toContain('512 x 512')
})
it('handles image error state', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Trigger the error event
await img.trigger('error')
// Check that the image is hidden and error content is shown
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toContain('Image failed to load')
})
it('resets state when imageUrl changes', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Set error state via event
await img.trigger('error')
expect(wrapper.text()).toContain('Error loading image')
// Change imageUrl prop
await wrapper.setProps({ imageUrl: '/new-image.png' })
await nextTick()
// State should be reset - dimensions text should show calculating
expect(wrapper.text()).toContain('Calculating dimensions')
expect(wrapper.text()).not.toContain('Error loading image')
})
it('shows error state when image fails to load', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Trigger error event
await img.trigger('error')
// Should show error state instead of image
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toContain('Image failed to load')
expect(wrapper.text()).toContain('Error loading image')
})
})