mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Fix/vue nodes video (#5870)
## Summary Fix the video preview widget and associated dropdown to load and select videos. Fixes: - https://www.notion.so/comfy-org/Video-thumbnails-not-being-used-in-asset-explorer-dialog-27e6d73d365080ec8a3ee7c7ec413657?source=copy_link - https://www.notion.so/comfy-org/Image-Video-upload-dialog-doesnt-set-mime-type-27e6d73d365080c5bffdf08842855ba0?source=copy_link - https://www.notion.so/comfy-org/Video-Previews-are-not-displayed-2756d73d365080b2bfb9e0004e9d784d?source=copy_link - https://www.notion.so/comfy-org/Cannot-load-video-in-Load-Video-node-2756d73d365080009c21d3a67add96c4?source=copy_link ## Screenshots (if applicable) https://github.com/user-attachments/assets/b71dbecb-c9a7-4feb-83a3-c3e044a9c93c ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5870-Fix-vue-nodes-video-27e6d73d36508182b44bef8e90ef4018) by [Unito](https://www.unito.io) --------- Co-authored-by: JakeSchroeder <jake@axiom.co> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com> Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
This commit is contained in:
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 |
@@ -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",
|
||||
|
||||
257
src/renderer/extensions/vueNodes/VideoPreview.vue
Normal file
257
src/renderer/extensions/vueNodes/VideoPreview.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -83,6 +83,7 @@ const specDescriptor = computed<{
|
||||
const allowUpload =
|
||||
image_upload === true ||
|
||||
animated_image_upload === true ||
|
||||
video_upload === true ||
|
||||
audio_upload === true
|
||||
return {
|
||||
kind,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user