Skip to content

File tree

13 files changed

+370
-43
lines changed

13 files changed

+370
-43
lines changed
53 Bytes
Loading
316 Bytes
Loading

src/locales/en/main.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@
77
"comingSoon": "Coming Soon",
88
"download": "Download",
99
"downloadImage": "Download image",
10+
"downloadVideo": "Download video",
1011
"editOrMaskImage": "Edit or mask image",
1112
"removeImage": "Remove image",
13+
"removeVideo": "Remove video",
1214
"viewImageOfTotal": "View image {index} of {total}",
15+
"viewVideoOfTotal": "View video {index} of {total}",
1316
"imagePreview": "Image preview - Use arrow keys to navigate between images",
17+
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
1418
"galleryImage": "Gallery image",
1519
"galleryThumbnail": "Gallery thumbnail",
1620
"errorLoadingImage": "Error loading image",
21+
"errorLoadingVideo": "Error loading video",
1722
"failedToDownloadImage": "Failed to download image",
23+
"failedToDownloadVideo": "Failed to download video",
1824
"calculatingDimensions": "Calculating dimensions",
1925
"import": "Import",
2026
"loadAllFolders": "Load All Folders",
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
<template>
2+
<div
3+
v-if="imageUrls.length > 0"
4+
class="video-preview relative group flex flex-col items-center"
5+
tabindex="0"
6+
role="region"
7+
:aria-label="$t('g.videoPreview')"
8+
@mouseenter="handleMouseEnter"
9+
@mouseleave="handleMouseLeave"
10+
@keydown="handleKeyDown"
11+
>
12+
<!-- Video Wrapper -->
13+
<div
14+
class="relative rounded-[5px] overflow-hidden w-full max-w-[352px] bg-[#262729]"
15+
>
16+
<!-- Error State -->
17+
<div
18+
v-if="videoError"
19+
class="w-full h-[352px] flex flex-col items-center justify-center text-white text-center bg-gray-800/50"
20+
>
21+
<i-lucide:video-off class="w-12 h-12 mb-2 text-gray-400" />
22+
<p class="text-sm text-gray-300">{{ $t('g.videoFailedToLoad') }}</p>
23+
<p class="text-xs text-gray-400 mt-1">
24+
{{ getVideoFilename(currentVideoUrl) }}
25+
</p>
26+
</div>
27+
28+
<!-- Loading State -->
29+
<Skeleton
30+
v-else-if="isLoading"
31+
class="w-full h-[352px]"
32+
border-radius="5px"
33+
/>
34+
35+
<!-- Main Video -->
36+
<video
37+
v-else
38+
:src="currentVideoUrl"
39+
class="w-full h-[352px] object-contain block"
40+
controls
41+
loop
42+
playsinline
43+
@loadeddata="handleVideoLoad"
44+
@error="handleVideoError"
45+
/>
46+
47+
<!-- Floating Action Buttons (appear on hover) -->
48+
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
49+
<!-- Download Button -->
50+
<button
51+
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"
52+
:title="$t('g.downloadVideo')"
53+
:aria-label="$t('g.downloadVideo')"
54+
@click="handleDownload"
55+
>
56+
<i-lucide:download class="w-4 h-4" />
57+
</button>
58+
59+
<!-- Close Button -->
60+
<button
61+
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"
62+
:title="$t('g.removeVideo')"
63+
:aria-label="$t('g.removeVideo')"
64+
@click="handleRemove"
65+
>
66+
<i-lucide:x class="w-4 h-4" />
67+
</button>
68+
</div>
69+
70+
<!-- Multiple Videos Navigation -->
71+
<div
72+
v-if="hasMultipleVideos"
73+
class="absolute bottom-2 left-2 right-2 flex justify-center gap-1"
74+
>
75+
<button
76+
v-for="(_, index) in imageUrls"
77+
:key="index"
78+
:class="getNavigationDotClass(index)"
79+
:aria-label="
80+
$t('g.viewVideoOfTotal', {
81+
index: index + 1,
82+
total: imageUrls.length
83+
})
84+
"
85+
@click="setCurrentIndex(index)"
86+
/>
87+
</div>
88+
</div>
89+
90+
<div class="relative">
91+
<!-- Video Dimensions -->
92+
<div class="text-white text-xs text-center mt-2">
93+
<span v-if="videoError" class="text-red-400">
94+
{{ $t('g.errorLoadingVideo') }}
95+
</span>
96+
<span v-else-if="isLoading" class="text-gray-400">
97+
{{ $t('g.loading') }}...
98+
</span>
99+
<span v-else>
100+
{{ actualDimensions || $t('g.calculatingDimensions') }}
101+
</span>
102+
</div>
103+
<LODFallback />
104+
</div>
105+
</div>
106+
</template>
107+
108+
<script setup lang="ts">
109+
import { useToast } from 'primevue'
110+
import Skeleton from 'primevue/skeleton'
111+
import { computed, ref, watch } from 'vue'
112+
import { useI18n } from 'vue-i18n'
113+
114+
import { downloadFile } from '@/base/common/downloadUtil'
115+
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
116+
117+
import LODFallback from './components/LODFallback.vue'
118+
119+
interface VideoPreviewProps {
120+
/** Array of video URLs to display */
121+
readonly imageUrls: readonly string[] // Named imageUrls for consistency with parent components
122+
/** Optional node ID for context-aware actions */
123+
readonly nodeId?: string
124+
}
125+
126+
const props = defineProps<VideoPreviewProps>()
127+
128+
const { t } = useI18n()
129+
const nodeOutputStore = useNodeOutputStore()
130+
131+
// Component state
132+
const currentIndex = ref(0)
133+
const isHovered = ref(false)
134+
const actualDimensions = ref<string | null>(null)
135+
const videoError = ref(false)
136+
const isLoading = ref(false)
137+
138+
// Computed values
139+
const currentVideoUrl = computed(() => props.imageUrls[currentIndex.value])
140+
const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
141+
142+
// Watch for URL changes and reset state
143+
watch(
144+
() => props.imageUrls,
145+
(newUrls) => {
146+
// Reset current index if it's out of bounds
147+
if (currentIndex.value >= newUrls.length) {
148+
currentIndex.value = 0
149+
}
150+
151+
// Reset loading and error states when URLs change
152+
actualDimensions.value = null
153+
videoError.value = false
154+
isLoading.value = false
155+
},
156+
{ deep: true }
157+
)
158+
159+
// Event handlers
160+
const handleVideoLoad = (event: Event) => {
161+
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
162+
const video = event.target
163+
isLoading.value = false
164+
videoError.value = false
165+
if (video.videoWidth && video.videoHeight) {
166+
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
167+
}
168+
}
169+
170+
const handleVideoError = () => {
171+
isLoading.value = false
172+
videoError.value = true
173+
actualDimensions.value = null
174+
}
175+
176+
const handleDownload = () => {
177+
try {
178+
downloadFile(currentVideoUrl.value)
179+
} catch (error) {
180+
useToast().add({
181+
severity: 'error',
182+
summary: 'Error',
183+
detail: t('g.failedToDownloadVideo'),
184+
life: 3000,
185+
group: 'video-preview'
186+
})
187+
}
188+
}
189+
190+
const handleRemove = () => {
191+
if (!props.nodeId) return
192+
nodeOutputStore.removeNodeOutputs(props.nodeId)
193+
}
194+
195+
const setCurrentIndex = (index: number) => {
196+
if (index >= 0 && index < props.imageUrls.length) {
197+
currentIndex.value = index
198+
actualDimensions.value = null
199+
isLoading.value = true
200+
videoError.value = false
201+
}
202+
}
203+
204+
const handleMouseEnter = () => {
205+
isHovered.value = true
206+
}
207+
208+
const handleMouseLeave = () => {
209+
isHovered.value = false
210+
}
211+
212+
const getNavigationDotClass = (index: number) => {
213+
return [
214+
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',
215+
index === currentIndex.value ? 'bg-white' : 'bg-white/50 hover:bg-white/80'
216+
]
217+
}
218+
219+
const handleKeyDown = (event: KeyboardEvent) => {
220+
if (props.imageUrls.length <= 1) return
221+
222+
switch (event.key) {
223+
case 'ArrowLeft':
224+
event.preventDefault()
225+
setCurrentIndex(
226+
currentIndex.value > 0
227+
? currentIndex.value - 1
228+
: props.imageUrls.length - 1
229+
)
230+
break
231+
case 'ArrowRight':
232+
event.preventDefault()
233+
setCurrentIndex(
234+
currentIndex.value < props.imageUrls.length - 1
235+
? currentIndex.value + 1
236+
: 0
237+
)
238+
break
239+
case 'Home':
240+
event.preventDefault()
241+
setCurrentIndex(0)
242+
break
243+
case 'End':
244+
event.preventDefault()
245+
setCurrentIndex(props.imageUrls.length - 1)
246+
break
247+
}
248+
}
249+
250+
const getVideoFilename = (url: string): string => {
251+
try {
252+
return new URL(url).searchParams.get('filename') || 'Unknown file'
253+
} catch {
254+
return 'Invalid URL'
255+
}
256+
}
257+
</script>

src/renderer/extensions/vueNodes/components/LGraphNode.vue

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
<NodeContent
102102
v-if="hasCustomContent"
103103
:node-data="nodeData"
104-
:image-urls="nodeImageUrls"
104+
:media="nodeMedia"
105105
/>
106106
<!-- Live preview image -->
107107
<div v-if="shouldShowPreviewImg" class="px-4">
@@ -267,10 +267,10 @@ onMounted(() => {
267267
// Track collapsed state
268268
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
269269
270-
// Check if node has custom content (like image outputs)
270+
// Check if node has custom content (like image/video outputs)
271271
const hasCustomContent = computed(() => {
272-
// Show custom content if node has image outputs
273-
return nodeImageUrls.value.length > 0
272+
// Show custom content if node has media outputs
273+
return !!nodeMedia.value && nodeMedia.value.urls.length > 0
274274
})
275275
276276
// Computed classes and conditions for better reusability
@@ -340,26 +340,29 @@ const nodeOutputs = useNodeOutputStore()
340340
const nodeOutputLocatorId = computed(() =>
341341
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
342342
)
343-
const nodeImageUrls = computed(() => {
344-
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
345-
const locatorId = getLocatorIdFromNodeData(nodeData)
346343
347-
// Use root graph for getNodeByLocatorId since it needs to traverse from root
344+
const lgraphNode = computed(() => {
345+
const locatorId = getLocatorIdFromNodeData(nodeData)
348346
const rootGraph = app.graph?.rootGraph || app.graph
349-
if (!rootGraph) {
350-
return []
351-
}
347+
if (!rootGraph) return null
348+
return getNodeByLocatorId(rootGraph, locatorId)
349+
})
352350
353-
const node = getNodeByLocatorId(rootGraph, locatorId)
351+
const nodeMedia = computed(() => {
352+
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
353+
const node = lgraphNode.value
354354
355+
// Note: Despite the field name "images", videos are also included.
356+
// The actual media type is determined by node.previewMediaType
357+
// TODO: fix the backend to return videos using the vidoes key instead of the images key
355358
if (node && newOutputs?.images?.length) {
356359
const urls = nodeOutputs.getNodeImageUrls(node)
357-
if (urls) {
358-
return urls
360+
if (urls && urls.length > 0) {
361+
const type = node.previewMediaType === 'video' ? 'video' : 'image'
362+
return { type, urls } as const
359363
}
360364
}
361-
// Clear URLs if no outputs or no images
362-
return []
365+
return undefined
363366
})
364367
365368
const nodeContainerRef = ref()

src/renderer/extensions/vueNodes/components/NodeContent.vue

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@
55
<div v-else class="lg-node-content">
66
<!-- Default slot for custom content -->
77
<slot>
8+
<VideoPreview
9+
v-if="hasMedia && media?.type === 'video'"
10+
:image-urls="media.urls"
11+
:node-id="nodeId"
12+
class="mt-2"
13+
/>
814
<ImagePreview
9-
v-if="hasImages"
10-
:image-urls="props.imageUrls || []"
15+
v-else-if="hasMedia && media?.type === 'image'"
16+
:image-urls="media.urls"
1117
:node-id="nodeId"
1218
class="mt-2"
1319
/>
@@ -20,24 +26,24 @@ import { computed, onErrorCaptured, ref } from 'vue'
2026
2127
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
2228
import { useErrorHandling } from '@/composables/useErrorHandling'
23-
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
2429
30+
import VideoPreview from '../VideoPreview.vue'
2531
import ImagePreview from './ImagePreview.vue'
2632
2733
interface NodeContentProps {
28-
node?: LGraphNode // For backwards compatibility
29-
nodeData?: VueNodeData // New clean data structure
30-
imageUrls?: string[]
34+
nodeData?: VueNodeData
35+
media?: {
36+
type: 'image' | 'video'
37+
urls: string[]
38+
}
3139
}
3240
3341
const props = defineProps<NodeContentProps>()
3442
35-
const hasImages = computed(() => props.imageUrls && props.imageUrls.length > 0)
43+
const hasMedia = computed(() => props.media && props.media.urls.length > 0)
3644
37-
// Get node ID from nodeData or node prop
38-
const nodeId = computed(() => {
39-
return props.nodeData?.id?.toString() || props.node?.id?.toString()
40-
})
45+
// Get node ID from nodeData
46+
const nodeId = computed(() => props.nodeData?.id?.toString())
4147
4248
// Error boundary implementation
4349
const renderError = ref<string | null>(null)

0 commit comments

Comments
 (0)