Simula_r
2025-10-02 13:58:47 -07:00
committed by GitHub
parent 3818ba5d17
commit 0d3d258995
13 changed files with 370 additions and 43 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -7,14 +7,20 @@
"comingSoon": "Coming Soon",
"download": "Download",
"downloadImage": "Download image",
"downloadVideo": "Download video",
"editOrMaskImage": "Edit or mask image",
"removeImage": "Remove image",
"removeVideo": "Remove video",
"viewImageOfTotal": "View image {index} of {total}",
"viewVideoOfTotal": "View video {index} of {total}",
"imagePreview": "Image preview - Use arrow keys to navigate between images",
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
"galleryImage": "Gallery image",
"galleryThumbnail": "Gallery thumbnail",
"errorLoadingImage": "Error loading image",
"errorLoadingVideo": "Error loading video",
"failedToDownloadImage": "Failed to download image",
"failedToDownloadVideo": "Failed to download video",
"calculatingDimensions": "Calculating dimensions",
"import": "Import",
"loadAllFolders": "Load All Folders",

View File

@@ -0,0 +1,257 @@
<template>
<div
v-if="imageUrls.length > 0"
class="video-preview relative group flex flex-col items-center"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeyDown"
>
<!-- Video Wrapper -->
<div
class="relative rounded-[5px] overflow-hidden w-full max-w-[352px] bg-[#262729]"
>
<!-- Error State -->
<div
v-if="videoError"
class="w-full h-[352px] flex flex-col items-center justify-center text-white text-center bg-gray-800/50"
>
<i-lucide:video-off class="w-12 h-12 mb-2 text-gray-400" />
<p class="text-sm text-gray-300">{{ $t('g.videoFailedToLoad') }}</p>
<p class="text-xs text-gray-400 mt-1">
{{ getVideoFilename(currentVideoUrl) }}
</p>
</div>
<!-- Loading State -->
<Skeleton
v-else-if="isLoading"
class="w-full h-[352px]"
border-radius="5px"
/>
<!-- Main Video -->
<video
v-else
:src="currentVideoUrl"
class="w-full h-[352px] object-contain block"
controls
loop
playsinline
@loadeddata="handleVideoLoad"
@error="handleVideoError"
/>
<!-- Floating Action Buttons (appear on hover) -->
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
<!-- Download Button -->
<button
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
:title="$t('g.downloadVideo')"
:aria-label="$t('g.downloadVideo')"
@click="handleDownload"
>
<i-lucide:download class="w-4 h-4" />
</button>
<!-- Close Button -->
<button
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
:title="$t('g.removeVideo')"
:aria-label="$t('g.removeVideo')"
@click="handleRemove"
>
<i-lucide:x class="w-4 h-4" />
</button>
</div>
<!-- Multiple Videos Navigation -->
<div
v-if="hasMultipleVideos"
class="absolute bottom-2 left-2 right-2 flex justify-center gap-1"
>
<button
v-for="(_, index) in imageUrls"
:key="index"
:class="getNavigationDotClass(index)"
:aria-label="
$t('g.viewVideoOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@click="setCurrentIndex(index)"
/>
</div>
</div>
<div class="relative">
<!-- Video Dimensions -->
<div class="text-white text-xs text-center mt-2">
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-gray-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './components/LODFallback.vue'
interface VideoPreviewProps {
/** Array of video URLs to display */
readonly imageUrls: readonly string[] // Named imageUrls for consistency with parent components
/** Optional node ID for context-aware actions */
readonly nodeId?: string
}
const props = defineProps<VideoPreviewProps>()
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
// Component state
const currentIndex = ref(0)
const isHovered = ref(false)
const actualDimensions = ref<string | null>(null)
const videoError = ref(false)
const isLoading = ref(false)
// Computed values
const currentVideoUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls) => {
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
}
// Reset loading and error states when URLs change
actualDimensions.value = null
videoError.value = false
isLoading.value = false
},
{ deep: true }
)
// Event handlers
const handleVideoLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
const video = event.target
isLoading.value = false
videoError.value = false
if (video.videoWidth && video.videoHeight) {
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
}
}
const handleVideoError = () => {
isLoading.value = false
videoError.value = true
actualDimensions.value = null
}
const handleDownload = () => {
try {
downloadFile(currentVideoUrl.value)
} catch (error) {
useToast().add({
severity: 'error',
summary: 'Error',
detail: t('g.failedToDownloadVideo'),
life: 3000,
group: 'video-preview'
})
}
}
const handleRemove = () => {
if (!props.nodeId) return
nodeOutputStore.removeNodeOutputs(props.nodeId)
}
const setCurrentIndex = (index: number) => {
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
isLoading.value = true
videoError.value = false
}
}
const handleMouseEnter = () => {
isHovered.value = true
}
const handleMouseLeave = () => {
isHovered.value = false
}
const getNavigationDotClass = (index: number) => {
return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',
index === currentIndex.value ? 'bg-white' : 'bg-white/50 hover:bg-white/80'
]
}
const handleKeyDown = (event: KeyboardEvent) => {
if (props.imageUrls.length <= 1) return
switch (event.key) {
case 'ArrowLeft':
event.preventDefault()
setCurrentIndex(
currentIndex.value > 0
? currentIndex.value - 1
: props.imageUrls.length - 1
)
break
case 'ArrowRight':
event.preventDefault()
setCurrentIndex(
currentIndex.value < props.imageUrls.length - 1
? currentIndex.value + 1
: 0
)
break
case 'Home':
event.preventDefault()
setCurrentIndex(0)
break
case 'End':
event.preventDefault()
setCurrentIndex(props.imageUrls.length - 1)
break
}
}
const getVideoFilename = (url: string): string => {
try {
return new URL(url).searchParams.get('filename') || 'Unknown file'
} catch {
return 'Invalid URL'
}
}
</script>

View File

@@ -101,7 +101,7 @@
<NodeContent
v-if="hasCustomContent"
:node-data="nodeData"
:image-urls="nodeImageUrls"
:media="nodeMedia"
/>
<!-- Live preview image -->
<div v-if="shouldShowPreviewImg" class="px-4">
@@ -267,10 +267,10 @@ onMounted(() => {
// Track collapsed state
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
// Check if node has custom content (like image outputs)
// Check if node has custom content (like image/video outputs)
const hasCustomContent = computed(() => {
// Show custom content if node has image outputs
return nodeImageUrls.value.length > 0
// Show custom content if node has media outputs
return !!nodeMedia.value && nodeMedia.value.urls.length > 0
})
// Computed classes and conditions for better reusability
@@ -340,26 +340,29 @@ const nodeOutputs = useNodeOutputStore()
const nodeOutputLocatorId = computed(() =>
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
)
const nodeImageUrls = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const lgraphNode = computed(() => {
const locatorId = getLocatorIdFromNodeData(nodeData)
// Use root graph for getNodeByLocatorId since it needs to traverse from root
const rootGraph = app.graph?.rootGraph || app.graph
if (!rootGraph) {
return []
}
if (!rootGraph) return null
return getNodeByLocatorId(rootGraph, locatorId)
})
const node = getNodeByLocatorId(rootGraph, locatorId)
const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const node = lgraphNode.value
// Note: Despite the field name "images", videos are also included.
// The actual media type is determined by node.previewMediaType
// TODO: fix the backend to return videos using the vidoes key instead of the images key
if (node && newOutputs?.images?.length) {
const urls = nodeOutputs.getNodeImageUrls(node)
if (urls) {
return urls
if (urls && urls.length > 0) {
const type = node.previewMediaType === 'video' ? 'video' : 'image'
return { type, urls } as const
}
}
// Clear URLs if no outputs or no images
return []
return undefined
})
const nodeContainerRef = ref()

View File

@@ -5,9 +5,15 @@
<div v-else class="lg-node-content">
<!-- Default slot for custom content -->
<slot>
<VideoPreview
v-if="hasMedia && media?.type === 'video'"
:image-urls="media.urls"
:node-id="nodeId"
class="mt-2"
/>
<ImagePreview
v-if="hasImages"
:image-urls="props.imageUrls || []"
v-else-if="hasMedia && media?.type === 'image'"
:image-urls="media.urls"
:node-id="nodeId"
class="mt-2"
/>
@@ -20,24 +26,24 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import VideoPreview from '../VideoPreview.vue'
import ImagePreview from './ImagePreview.vue'
interface NodeContentProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
imageUrls?: string[]
nodeData?: VueNodeData
media?: {
type: 'image' | 'video'
urls: string[]
}
}
const props = defineProps<NodeContentProps>()
const hasImages = computed(() => props.imageUrls && props.imageUrls.length > 0)
const hasMedia = computed(() => props.media && props.media.urls.length > 0)
// Get node ID from nodeData or node prop
const nodeId = computed(() => {
return props.nodeData?.id?.toString() || props.node?.id?.toString()
})
// Get node ID from nodeData
const nodeId = computed(() => props.nodeData?.id?.toString())
// Error boundary implementation
const renderError = ref<string | null>(null)

View File

@@ -83,6 +83,7 @@ const specDescriptor = computed<{
const allowUpload =
image_upload === true ||
animated_image_upload === true ||
video_upload === true ||
audio_upload === true
return {
kind,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
@@ -16,10 +16,11 @@ import {
} from '@/utils/widgetPropFilter'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type {
DropdownItem,
FilterOption,
SelectedKey
import {
AssetKindKey,
type DropdownItem,
type FilterOption,
type SelectedKey
} from './form/dropdown/types'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -31,6 +32,11 @@ const props = defineProps<{
uploadFolder?: ResultItemType
}>()
provide(
AssetKindKey,
computed(() => props.assetKind)
)
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
@@ -69,7 +75,7 @@ const inputItems = computed<DropdownItem[]>(() => {
return values.map((value: string, index: number) => ({
id: `input-${index}`,
imageSrc: getMediaUrl(value, 'input'),
mediaSrc: getMediaUrl(value, 'input'),
name: value,
metadata: ''
}))
@@ -99,7 +105,7 @@ const outputItems = computed<DropdownItem[]>(() => {
return Array.from(outputs).map((output, index) => ({
id: `output-${index}`,
imageSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output,
metadata: ''
}))
@@ -145,6 +151,21 @@ const mediaPlaceholder = computed(() => {
const uploadable = computed(() => props.allowUpload === true)
const acceptTypes = computed(() => {
// Be permissive with accept types because backend uses libraries
// that can handle a wide range of formats
switch (props.assetKind) {
case 'image':
return 'image/*'
case 'video':
return 'video/*'
case 'audio':
return 'audio/*'
default:
return undefined // model or unknown
}
})
watch(
localValue,
(currentValue) => {
@@ -269,6 +290,7 @@ function getMediaUrl(
:placeholder="mediaPlaceholder"
:multiple="false"
:uploadable="uploadable"
:accept="acceptTypes"
:filter-options="filterOptions"
v-bind="combinedProps"
class="w-full"

View File

@@ -29,6 +29,7 @@ interface Props {
uploadable?: boolean
disabled?: boolean
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
isSelected?: (
@@ -195,6 +196,7 @@ function handleSelection(item: DropdownItem, index: number) {
:selected="selected"
:uploadable="uploadable"
:disabled="disabled"
:accept="accept"
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>

View File

@@ -15,6 +15,7 @@ interface Props {
maxSelectable: number
uploadable: boolean
disabled: boolean
accept?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -92,6 +93,7 @@ const theButtonStyle = computed(() => [
class="opacity-0 absolute inset-0 -z-1"
:multiple="maxSelectable > 1"
:disabled="disabled"
:accept="accept"
@change="emit('file-change', $event)"
/>
</label>

View File

@@ -84,7 +84,7 @@ const searchQuery = defineModel<string>('searchQuery')
:key="item.id"
:index="index"
:selected="isSelected(item, index)"
:image-src="item.imageSrc"
:media-src="item.mediaSrc"
:name="item.name"
:metadata="item.metadata"
:layout="layoutMode"

View File

@@ -1,14 +1,14 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, inject, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import type { LayoutMode } from './types'
import { AssetKindKey, type LayoutMode } from './types'
interface Props {
index: number
selected: boolean
imageSrc: string
mediaSrc: string
name: string
metadata?: string
layout?: LayoutMode
@@ -18,23 +18,36 @@ const props = defineProps<Props>()
const emit = defineEmits<{
click: [index: number]
imageLoad: [event: Event]
mediaLoad: [event: Event]
}>()
const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
function handleClick() {
emit('click', props.index)
}
function handleImageLoad(event: Event) {
emit('imageLoad', event)
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
function handleVideoLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
const video = event.target
if (video.videoWidth && video.videoHeight) {
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
}
}
</script>
<template>
@@ -81,9 +94,17 @@ function handleImageLoad(event: Event) {
>
<i-lucide:check class="size-3 text-white -translate-y-[0.5px]" />
</div>
<video
v-if="mediaSrc && isVideo"
:src="mediaSrc"
class="size-full object-cover"
preload="metadata"
muted
@loadeddata="handleVideoLoad"
/>
<img
v-if="imageSrc"
:src="imageSrc"
v-else-if="mediaSrc"
:src="mediaSrc"
class="size-full object-cover"
@load="handleImageLoad"
/>

View File

@@ -1,9 +1,13 @@
import type { ComputedRef, InjectionKey } from 'vue'
import type { AssetKind } from '@/types/widgetTypes'
export type OptionId = string | number | symbol
export type SelectedKey = OptionId
export interface DropdownItem {
id: SelectedKey
imageSrc: string
mediaSrc: string // URL for image, video, or other media
name: string
metadata: string
}
@@ -19,3 +23,6 @@ export interface FilterOption {
}
export type LayoutMode = 'list' | 'grid' | 'list-small'
export const AssetKindKey: InjectionKey<ComputedRef<AssetKind | undefined>> =
Symbol('assetKind')