Files
ComfyUI_frontend/src/platform/assets/components/MediaAssetCard.vue
Arjan Singh 0239a83da2 Update rh-test (as of 2025-10-11) (#6044)
## Summary

Tested these changes and confirmed that:
1. Feedback button shows.
2. You can run workflows and switch out models.
3. You can use the mask editor. (thank you @ric-yu for helping me
verify).

## Changes

A lot, please see commits.

Gets us up to date with `main` as of 10-11-2025.

---------

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Marwan Ahmed <155799754+marawan206@users.noreply.github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: Austin Mroz <austin@comfy.org>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
2025-10-14 15:59:26 -07:00

234 lines
7.1 KiB
Vue

<template>
<CardContainer
ref="cardContainerRef"
role="button"
:aria-label="
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
"
:tabindex="loading ? -1 : 0"
size="mini"
variant="ghost"
rounded="lg"
:class="containerClasses"
@click="handleCardClick"
@keydown.enter="handleCardClick"
@keydown.space.prevent="handleCardClick"
>
<template #top>
<CardTop
ratio="square"
:bottom-left-class="durationChipClasses"
:bottom-right-class="durationChipClasses"
>
<!-- Loading State -->
<template v-if="loading">
<div
class="h-full w-full animate-pulse rounded-lg bg-zinc-200 dark-theme:bg-zinc-700"
/>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset">
<component
:is="getTopComponent(asset.kind)"
:asset="asset"
:context="context"
@view="actions.viewAsset(asset!.id)"
@download="actions.downloadAsset(asset!.id)"
@play="actions.playAsset(asset!.id)"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
/>
</template>
<!-- Actions overlay (top-left) - show on hover or when menu is open, but not when video is playing -->
<template v-if="showActionsOverlay" #top-left>
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
</template>
<!-- Zoom button (top-right) - show on hover, but not when video is playing -->
<template v-if="showZoomOverlay" #top-right>
<IconButton size="sm" @click="actions.viewAsset(asset!.id)">
<i class="icon-[lucide--zoom-in] size-4" />
</IconButton>
</template>
<!-- Duration/Format chips (bottom-left) - hide when video is playing -->
<template v-if="showDurationChips" #bottom-left>
<SquareChip variant="light" :label="formattedDuration" />
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
</template>
<!-- Output count (bottom-right) - hide when video is playing -->
<template v-if="showOutputCount" #bottom-right>
<IconTextButton
type="secondary"
size="sm"
:label="context?.outputCount?.toString() ?? '0'"
@click="actions.openMoreOutputs(asset?.id || '')"
>
<template #icon>
<i class="icon-[lucide--layers] size-4" />
</template>
</IconTextButton>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<!-- Loading State -->
<template v-if="loading">
<div class="flex flex-col items-center justify-between gap-1">
<div
class="h-4 w-2/3 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
/>
<div
class="h-3 w-1/2 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
/>
</div>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset">
<component
:is="getBottomComponent(asset.kind)"
:asset="asset"
:context="context"
/>
</template>
</CardBottom>
</template>
</CardContainer>
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import { formatDuration } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type {
AssetContext,
AssetMeta,
MediaKind
} from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetActions from './MediaAssetActions.vue'
const mediaComponents = {
top: {
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
},
bottom: {
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
}
}
function getTopComponent(kind: MediaKind) {
return mediaComponents.top[kind] || mediaComponents.top.image
}
function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const { context, asset, loading, selected } = defineProps<{
context: AssetContext
asset?: AssetMeta
loading?: boolean
selected?: boolean
}>()
const cardContainerRef = ref<HTMLElement>()
const isVideoPlaying = ref(false)
const isMenuOpen = ref(false)
const showVideoControls = ref(false)
const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions()
provide(MediaAssetKey, {
asset: toRef(() => asset),
context: toRef(() => context),
isVideoPlaying,
showVideoControls
})
const containerClasses = computed(() => {
return cn(
'gap-1',
selected
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
)
})
const formattedDuration = computed(() => {
if (!asset?.duration) return ''
return formatDuration(asset.duration)
})
const fileFormat = computed(() => {
if (!asset?.name) return ''
const parts = asset.name.split('.')
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
})
const durationChipClasses = computed(() => {
if (asset?.kind === 'audio') {
return '-translate-y-11'
}
if (asset?.kind === 'video' && showVideoControls.value) {
return '-translate-y-16'
}
return ''
})
const showHoverActions = computed(() => {
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
})
const showZoomButton = computed(() => {
return asset?.kind === 'image' || asset?.kind === '3D'
})
const showActionsOverlay = computed(() => {
return showHoverActions.value && !isVideoPlaying.value
})
const showZoomOverlay = computed(() => {
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
})
const showDurationChips = computed(() => {
return !loading && asset?.duration && !isVideoPlaying.value
})
const showOutputCount = computed(() => {
return !loading && context?.outputCount && !isVideoPlaying.value
})
const handleCardClick = () => {
if (asset) {
actions.selectAsset(asset)
}
}
</script>