mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 06:47:33 +00:00
feat(frontend): update cloud branch 2025-10-16 (#6096)
## Summary Updates with cloud specific features merged into `main`. Notable changes include the `DISTRIBUTION=cloud` changes. Will also be changing cloud build workflow to build with that flag in https://github.com/Comfy-Org/cloud/pull/1043 ## Changes -bb61d9822feat: AssetCard tweaks (#6085) -05f73523ffix terminal style (#6056) -d5fa22168Add distribution detection pattern (#6028) -6c36aaa1dfeat: Improve MediaAssetCard video controls and add gallery view (#6065) -6944ef0a2fix Cloudbadge (#6063) -6764f8dabBadge for cloud environment (#6048) --------- Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Co-authored-by: Jin Yi <jin12cc@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
@@ -85,6 +85,10 @@
|
||||
--color-bypass: #6a246a;
|
||||
--color-error: #962a2a;
|
||||
|
||||
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
|
||||
--text-xxxs: 0.5625rem;
|
||||
--text-xxxs--line-height: calc(1 / 0.5625);
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
|
||||
|
||||
@@ -135,6 +135,7 @@ import type { CSSProperties, Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
@@ -265,7 +266,7 @@ const moreMenuItem = computed(() =>
|
||||
)
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'docs',
|
||||
type: 'item',
|
||||
@@ -305,8 +306,12 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
}
|
||||
]
|
||||
|
||||
// Extension manager - only in non-cloud distributions
|
||||
if (!isCloud) {
|
||||
items.push({
|
||||
key: 'manager',
|
||||
type: 'item',
|
||||
icon: PuzzleIcon,
|
||||
@@ -319,17 +324,20 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'more',
|
||||
type: 'item',
|
||||
icon: '',
|
||||
label: t('helpCenter.more'),
|
||||
visible: hasVisibleMoreItems.value,
|
||||
action: () => {}, // No action for more item
|
||||
items: moreItems.value
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'more',
|
||||
type: 'item',
|
||||
icon: '',
|
||||
label: t('helpCenter.more'),
|
||||
visible: hasVisibleMoreItems.value,
|
||||
action: () => {}, // No action for more item
|
||||
items: moreItems.value
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Utility Functions
|
||||
@@ -420,6 +428,9 @@ const formatReleaseDate = (dateString?: string): string => {
|
||||
}
|
||||
|
||||
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
|
||||
// Hide update buttons in cloud distribution
|
||||
if (isCloud) return false
|
||||
|
||||
return (
|
||||
releaseStore.shouldShowUpdateButton &&
|
||||
release === releaseStore.recentReleases[0]
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
<div>
|
||||
<div
|
||||
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
|
||||
class="z-1001 flex h-[38px] w-full content-end"
|
||||
class="z-1001 flex h-9.5 w-full content-end"
|
||||
style="background: var(--border-color)"
|
||||
>
|
||||
<WorkflowTabs />
|
||||
<TopbarBadges />
|
||||
</div>
|
||||
<div
|
||||
v-show="showTopMenu"
|
||||
ref="topMenuRef"
|
||||
class="comfyui-menu flex items-center"
|
||||
class="comfyui-menu flex items-center bg-gray-100"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<CommandMenubar />
|
||||
@@ -44,9 +45,10 @@ import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
|
||||
|
||||
import TopbarBadges from './TopbarBadges.vue'
|
||||
|
||||
const workspaceState = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
|
||||
const showTopMenu = computed(
|
||||
|
||||
20
src/components/topbar/TopbarBadge.vue
Normal file
20
src/components/topbar/TopbarBadge.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 bg-comfy-menu-secondary px-3">
|
||||
<div
|
||||
v-if="badge.label"
|
||||
class="rounded-full bg-white px-1.5 py-0.5 text-xxxs font-semibold text-black"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</div>
|
||||
<div class="font-inter text-sm font-extrabold text-slate-100">
|
||||
{{ badge.text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
|
||||
defineProps<{
|
||||
badge: TopbarBadge
|
||||
}>()
|
||||
</script>
|
||||
17
src/components/topbar/TopbarBadges.vue
Normal file
17
src/components/topbar/TopbarBadges.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<TopbarBadge
|
||||
v-for="badge in topbarBadgeStore.badges"
|
||||
:key="badge.text"
|
||||
:badge
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
|
||||
|
||||
import TopbarBadge from './TopbarBadge.vue'
|
||||
|
||||
const topbarBadgeStore = useTopbarBadgeStore()
|
||||
</script>
|
||||
@@ -5,12 +5,14 @@ import { debounce } from 'es-toolkit/compat'
|
||||
import type { Ref } from 'vue'
|
||||
import { markRaw, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
|
||||
export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||
const fitAddon = new FitAddon()
|
||||
const terminal = markRaw(
|
||||
new Terminal({
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
theme: isDesktop ? { background: '#171717' } : undefined
|
||||
})
|
||||
)
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
16
src/extensions/core/cloudBadge.ts
Normal file
16
src/extensions/core/cloudBadge.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CloudBadge',
|
||||
// Only show badge when running in cloud environment
|
||||
topbarBadges: isCloud
|
||||
? [
|
||||
{
|
||||
label: t('g.beta'),
|
||||
text: 'Comfy Cloud'
|
||||
}
|
||||
]
|
||||
: undefined
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './dynamicPrompts'
|
||||
@@ -21,3 +23,7 @@ import './uploadAudio'
|
||||
import './uploadImage'
|
||||
import './webcamCapture'
|
||||
import './widgetInputs'
|
||||
|
||||
if (isCloud) {
|
||||
import('./cloudBadge')
|
||||
}
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"volume": "Volume",
|
||||
"halfSpeed": "0.5x",
|
||||
"1x": "1x",
|
||||
"2x": "2x"
|
||||
"2x": "2x",
|
||||
"beta": "BETA"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
|
||||
@@ -84,6 +84,51 @@ export const NonInteractive: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const WithPreviewImage: Story = {
|
||||
args: {
|
||||
asset: createAssetData({
|
||||
preview_url: '/assets/images/comfy-logo-single.svg'
|
||||
}),
|
||||
interactive: true
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||
})
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'AssetCard with a preview image displayed.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FallbackGradient: Story = {
|
||||
args: {
|
||||
asset: createAssetData({
|
||||
preview_url: undefined
|
||||
}),
|
||||
interactive: true
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||
})
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'AssetCard showing fallback gradient when no preview image is available.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EdgeCases: Story = {
|
||||
render: () => ({
|
||||
components: { AssetCard },
|
||||
|
||||
@@ -4,38 +4,29 @@
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
v-bind="elementProps"
|
||||
:class="
|
||||
cn(
|
||||
// Base layout and container styles (always applied)
|
||||
'rounded-xl overflow-hidden transition-all duration-200',
|
||||
interactive && 'group',
|
||||
// Button-specific styles
|
||||
interactive && [
|
||||
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||
'bg-gray-100 dark-theme:bg-charcoal-800',
|
||||
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
|
||||
'border-none',
|
||||
'focus:outline-solid outline-blue-100 outline-4'
|
||||
],
|
||||
// Div-specific styles
|
||||
!interactive && 'bg-gray-100 dark-theme:bg-charcoal-800'
|
||||
)
|
||||
"
|
||||
:class="cardClasses"
|
||||
@click="interactive && $emit('select', asset)"
|
||||
@keydown.enter="interactive && $emit('select', asset)"
|
||||
>
|
||||
<div class="relative aspect-square w-full overflow-hidden">
|
||||
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
|
||||
<img
|
||||
v-if="shouldShowImage"
|
||||
:src="asset.preview_url"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600"
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-400 via-gray-800 to-charcoal-400"
|
||||
></div>
|
||||
<AssetBadgeGroup :badges="asset.badges" />
|
||||
</div>
|
||||
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
|
||||
<div>
|
||||
<h3
|
||||
:id="titleId"
|
||||
:class="
|
||||
cn(
|
||||
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
|
||||
'text-slate-800',
|
||||
'dark-theme:text-white'
|
||||
)
|
||||
@@ -44,6 +35,7 @@
|
||||
{{ asset.name }}
|
||||
</h3>
|
||||
<p
|
||||
:id="descId"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
||||
@@ -83,7 +75,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
@@ -94,13 +87,51 @@ const props = defineProps<{
|
||||
interactive?: boolean
|
||||
}>()
|
||||
|
||||
const titleId = useId()
|
||||
const descId = useId()
|
||||
|
||||
const { error } = useImage({
|
||||
src: props.asset.preview_url ?? '',
|
||||
alt: props.asset.name
|
||||
})
|
||||
|
||||
const shouldShowImage = computed(() => props.asset.preview_url && !error.value)
|
||||
|
||||
const cardClasses = computed(() => {
|
||||
const base = [
|
||||
'rounded-xl',
|
||||
'overflow-hidden',
|
||||
'transition-all',
|
||||
'duration-200'
|
||||
]
|
||||
|
||||
if (!props.interactive) {
|
||||
return cn(...base, 'bg-gray-100 dark-theme:bg-charcoal-800')
|
||||
}
|
||||
|
||||
return cn(
|
||||
...base,
|
||||
'group',
|
||||
'appearance-none bg-transparent p-0 m-0',
|
||||
'font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||
'bg-gray-100 dark-theme:bg-charcoal-800',
|
||||
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
|
||||
'border-none',
|
||||
'focus:outline-solid outline-blue-100 outline-4'
|
||||
)
|
||||
})
|
||||
|
||||
const elementProps = computed(() =>
|
||||
props.interactive
|
||||
? {
|
||||
type: 'button',
|
||||
'aria-label': `Select asset ${props.asset.name}`
|
||||
'aria-labelledby': titleId,
|
||||
'aria-describedby': descId
|
||||
}
|
||||
: {
|
||||
'aria-labelledby': titleId,
|
||||
'aria-describedby': descId
|
||||
}
|
||||
: {}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetCard from './MediaAssetCard.vue'
|
||||
|
||||
const meta: Meta<typeof MediaAssetCard> = {
|
||||
title: 'AssetLibrary/MediaAssetCard',
|
||||
title: 'Platform/Assets/MediaAssetCard',
|
||||
component: MediaAssetCard,
|
||||
decorators: [
|
||||
() => ({
|
||||
components: { ResultGallery },
|
||||
setup() {
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
return { galleryStore }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<story />
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryStore.activeIndex"
|
||||
:all-gallery-items="galleryStore.items"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
context: {
|
||||
control: 'select',
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
:is="getTopComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
@view="actions.viewAsset(asset!.id)"
|
||||
@view="handleZoomClick"
|
||||
@download="actions.downloadAsset(asset!.id)"
|
||||
@play="actions.playAsset(asset!.id)"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@@ -41,31 +41,48 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Actions overlay (top-left) - show on hover or when menu is open, but not when video is playing -->
|
||||
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
|
||||
<template v-if="showActionsOverlay" #top-left>
|
||||
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
|
||||
<MediaAssetActions
|
||||
@menu-state-changed="isMenuOpen = $event"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Zoom button (top-right) - show on hover, but not when video is playing -->
|
||||
<!-- Zoom button (top-right) - show on hover for all media types -->
|
||||
<template v-if="showZoomOverlay" #top-right>
|
||||
<IconButton size="sm" @click="actions.viewAsset(asset!.id)">
|
||||
<IconButton
|
||||
size="sm"
|
||||
@click.stop="handleZoomClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<!-- Duration/Format chips (bottom-left) - hide when video is playing -->
|
||||
<!-- Duration/Format chips (bottom-left) - show on hover even when playing -->
|
||||
<template v-if="showDurationChips" #bottom-left>
|
||||
<SquareChip variant="light" :label="formattedDuration" />
|
||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<SquareChip variant="light" :label="formattedDuration" />
|
||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Output count (bottom-right) - hide when video is playing -->
|
||||
<!-- Output count (bottom-right) - show on hover even when playing -->
|
||||
<template v-if="showOutputCount" #bottom-right>
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:label="context?.outputCount?.toString() ?? '0'"
|
||||
@click="actions.openMoreOutputs(asset?.id || '')"
|
||||
@click.stop="actions.openMoreOutputs(asset?.id || '')"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
@@ -116,6 +133,7 @@ import { formatDuration } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import type {
|
||||
AssetContext,
|
||||
AssetMeta,
|
||||
@@ -159,10 +177,12 @@ const cardContainerRef = ref<HTMLElement>()
|
||||
const isVideoPlaying = ref(false)
|
||||
const isMenuOpen = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
const isOverlayHovered = ref(false)
|
||||
|
||||
const isHovered = useElementHover(cardContainerRef)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
|
||||
provide(MediaAssetKey, {
|
||||
asset: toRef(() => asset),
|
||||
@@ -171,14 +191,14 @@ provide(MediaAssetKey, {
|
||||
showVideoControls
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
return cn(
|
||||
const containerClasses = computed(() =>
|
||||
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 ''
|
||||
@@ -201,33 +221,58 @@ const durationChipClasses = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const showHoverActions = computed(() => {
|
||||
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
|
||||
})
|
||||
const isCardOrOverlayHovered = computed(
|
||||
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
|
||||
)
|
||||
|
||||
const showZoomButton = computed(() => {
|
||||
return asset?.kind === 'image' || asset?.kind === '3D'
|
||||
})
|
||||
const showHoverActions = computed(
|
||||
() => !loading && !!asset && isCardOrOverlayHovered.value
|
||||
)
|
||||
|
||||
const showActionsOverlay = computed(() => {
|
||||
return showHoverActions.value && !isVideoPlaying.value
|
||||
})
|
||||
const showActionsOverlay = computed(
|
||||
() =>
|
||||
showHoverActions.value &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showZoomOverlay = computed(() => {
|
||||
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
|
||||
})
|
||||
const showZoomOverlay = computed(
|
||||
() =>
|
||||
showHoverActions.value &&
|
||||
asset?.kind !== '3D' &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showDurationChips = computed(() => {
|
||||
return !loading && asset?.duration && !isVideoPlaying.value
|
||||
})
|
||||
const showDurationChips = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
asset?.duration &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showOutputCount = computed(() => {
|
||||
return !loading && context?.outputCount && !isVideoPlaying.value
|
||||
})
|
||||
const showOutputCount = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
context?.outputCount &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (asset) {
|
||||
actions.selectAsset(asset)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOverlayMouseEnter = () => {
|
||||
isOverlayHovered.value = true
|
||||
}
|
||||
|
||||
const handleOverlayMouseLeave = () => {
|
||||
isOverlayHovered.value = false
|
||||
}
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
galleryStore.openSingle(asset)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<IconTextButton
|
||||
v-if="asset?.kind !== '3D'"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Inspect asset"
|
||||
@@ -93,6 +94,7 @@ import { computed, inject } from 'vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
||||
|
||||
@@ -102,14 +104,13 @@ const { close } = defineProps<{
|
||||
|
||||
const { asset, context } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
|
||||
const showWorkflowOptions = computed(() => {
|
||||
return context.value.type
|
||||
})
|
||||
const showWorkflowOptions = computed(() => context.value.type)
|
||||
|
||||
const handleInspect = () => {
|
||||
if (asset.value) {
|
||||
actions.viewAsset(asset.value.id)
|
||||
galleryStore.openSingle(asset.value)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden rounded bg-black"
|
||||
@mouseenter="showControls = true"
|
||||
@mouseleave="showControls = false"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:controls="showControls"
|
||||
:controls="shouldShowControls"
|
||||
preload="none"
|
||||
:poster="asset.preview_url"
|
||||
class="relative h-full w-full object-contain"
|
||||
@click.stop
|
||||
@play="onVideoPlay"
|
||||
@pause="onVideoPause"
|
||||
@ended="onVideoEnded"
|
||||
>
|
||||
<source :src="asset.src || ''" />
|
||||
</video>
|
||||
@@ -20,7 +21,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
@@ -36,22 +37,32 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
const showControls = ref(true)
|
||||
const isHovered = ref(false)
|
||||
const isPlaying = ref(false)
|
||||
|
||||
watch(showControls, (controlsVisible) => {
|
||||
// Always show controls when not playing, hide/show based on hover when playing
|
||||
const shouldShowControls = computed(() => !isPlaying.value || isHovered.value)
|
||||
|
||||
watch(shouldShowControls, (controlsVisible) => {
|
||||
emit('videoControlsChanged', controlsVisible)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
emit('videoControlsChanged', showControls.value)
|
||||
emit('videoControlsChanged', shouldShowControls.value)
|
||||
})
|
||||
|
||||
const onVideoPlay = () => {
|
||||
showControls.value = true
|
||||
isPlaying.value = true
|
||||
emit('videoPlayingStateChanged', true)
|
||||
}
|
||||
|
||||
const onVideoPause = () => {
|
||||
isPlaying.value = false
|
||||
emit('videoPlayingStateChanged', false)
|
||||
}
|
||||
|
||||
const onVideoEnded = () => {
|
||||
isPlaying.value = false
|
||||
emit('videoPlayingStateChanged', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,10 +6,6 @@ export function useMediaAssetActions() {
|
||||
console.log('Asset selected:', asset)
|
||||
}
|
||||
|
||||
const viewAsset = (assetId: string) => {
|
||||
console.log('Viewing asset:', assetId)
|
||||
}
|
||||
|
||||
const downloadAsset = (assetId: string) => {
|
||||
console.log('Downloading asset:', assetId)
|
||||
}
|
||||
@@ -48,7 +44,6 @@ export function useMediaAssetActions() {
|
||||
|
||||
return {
|
||||
selectAsset,
|
||||
viewAsset,
|
||||
downloadAsset,
|
||||
deleteAsset,
|
||||
playAsset,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
ResultItemImpl: vi.fn().mockImplementation((data) => ({
|
||||
...data,
|
||||
url: ''
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useMediaAssetGalleryStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('openSingle', () => {
|
||||
it('should convert AssetMeta to ResultItemImpl format', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-1',
|
||||
name: 'test-image.png',
|
||||
kind: 'image',
|
||||
src: 'https://example.com/image.png',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
|
||||
expect(ResultItemImpl).toHaveBeenCalledWith({
|
||||
filename: 'test-image.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '0',
|
||||
mediaType: 'images'
|
||||
})
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.activeIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('should set correct mediaType for video assets', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockVideoAsset: AssetMeta = {
|
||||
id: 'test-2',
|
||||
name: 'test-video.mp4',
|
||||
kind: 'video',
|
||||
src: 'https://example.com/video.mp4',
|
||||
size: 2048,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockVideoAsset)
|
||||
|
||||
expect(ResultItemImpl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: 'test-video.mp4',
|
||||
mediaType: 'video'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should set correct mediaType for audio assets', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAudioAsset: AssetMeta = {
|
||||
id: 'test-3',
|
||||
name: 'test-audio.mp3',
|
||||
kind: 'audio',
|
||||
src: 'https://example.com/audio.mp3',
|
||||
size: 512,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAudioAsset)
|
||||
|
||||
expect(ResultItemImpl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: 'test-audio.mp3',
|
||||
mediaType: 'audio'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should override url getter with asset.src', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-4',
|
||||
name: 'test.png',
|
||||
kind: 'image',
|
||||
src: 'https://example.com/custom-url.png',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
|
||||
const resultItem = store.items[0]
|
||||
expect(resultItem.url).toBe('https://example.com/custom-url.png')
|
||||
})
|
||||
|
||||
it('should handle assets without src gracefully', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-5',
|
||||
name: 'no-src.png',
|
||||
kind: 'image',
|
||||
src: '',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
|
||||
const resultItem = store.items[0]
|
||||
expect(resultItem.url).toBe('')
|
||||
})
|
||||
|
||||
it('should update activeIndex and items when called multiple times', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const asset1: AssetMeta = {
|
||||
id: '1',
|
||||
name: 'first.png',
|
||||
kind: 'image',
|
||||
src: 'url1',
|
||||
size: 100,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
const asset2: AssetMeta = {
|
||||
id: '2',
|
||||
name: 'second.png',
|
||||
kind: 'image',
|
||||
src: 'url2',
|
||||
size: 200,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(asset1)
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].filename).toBe('first.png')
|
||||
|
||||
store.openSingle(asset2)
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].filename).toBe('second.png')
|
||||
expect(store.activeIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('close', () => {
|
||||
it('should reset activeIndex to -1', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test',
|
||||
name: 'test.png',
|
||||
kind: 'image',
|
||||
src: 'test-url',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
expect(store.activeIndex).toBe(0)
|
||||
|
||||
store.close()
|
||||
expect(store.activeIndex).toBe(-1)
|
||||
})
|
||||
})
|
||||
})
|
||||
47
src/platform/assets/composables/useMediaAssetGalleryStore.ts
Normal file
47
src/platform/assets/composables/useMediaAssetGalleryStore.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
export const useMediaAssetGalleryStore = defineStore(
|
||||
'mediaAssetGallery',
|
||||
() => {
|
||||
const activeIndex = ref(-1)
|
||||
const items = shallowRef<ResultItemImpl[]>([])
|
||||
|
||||
const close = () => {
|
||||
activeIndex.value = -1
|
||||
}
|
||||
|
||||
const openSingle = (asset: AssetMeta) => {
|
||||
// Convert AssetMeta to ResultItemImpl format
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '0',
|
||||
mediaType: asset.kind === 'image' ? 'images' : asset.kind
|
||||
})
|
||||
|
||||
// Override the url getter to use asset.src
|
||||
Object.defineProperty(resultItem, 'url', {
|
||||
get() {
|
||||
return asset.src || ''
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
items.value = [resultItem]
|
||||
activeIndex.value = 0
|
||||
}
|
||||
|
||||
return {
|
||||
activeIndex,
|
||||
items,
|
||||
close,
|
||||
openSingle
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -12,7 +12,7 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
const DEFAULT_LIMIT = 300
|
||||
const DEFAULT_LIMIT = 500
|
||||
|
||||
export const MODELS_TAG = 'models'
|
||||
export const MISSING_TAG = 'missing'
|
||||
|
||||
20
src/platform/distribution/types.ts
Normal file
20
src/platform/distribution/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
/**
|
||||
* Distribution types and compile-time constants for managing
|
||||
* multi-distribution builds (Desktop, Localhost, Cloud)
|
||||
*/
|
||||
|
||||
type Distribution = 'desktop' | 'localhost' | 'cloud'
|
||||
|
||||
declare global {
|
||||
const __DISTRIBUTION__: Distribution
|
||||
}
|
||||
|
||||
/** Current distribution - replaced at compile time */
|
||||
const DISTRIBUTION: Distribution = __DISTRIBUTION__
|
||||
|
||||
/** Distribution type checks */
|
||||
export const isDesktop = DISTRIBUTION === 'desktop' || isElectron() // TODO: replace with build var
|
||||
export const isCloud = DISTRIBUTION === 'cloud'
|
||||
// export const isLocalhost = !isDesktop && !isCloud
|
||||
18
src/stores/topbarBadgeStore.ts
Normal file
18
src/stores/topbarBadgeStore.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
|
||||
import { useExtensionStore } from './extensionStore'
|
||||
|
||||
export const useTopbarBadgeStore = defineStore('topbarBadge', () => {
|
||||
const extensionStore = useExtensionStore()
|
||||
|
||||
const badges = computed<TopbarBadge[]>(() =>
|
||||
extensionStore.extensions.flatMap((e) => e.topbarBadges ?? [])
|
||||
)
|
||||
|
||||
return {
|
||||
badges
|
||||
}
|
||||
})
|
||||
@@ -33,6 +33,14 @@ type MenuCommandGroup = {
|
||||
commands: string[]
|
||||
}
|
||||
|
||||
export interface TopbarBadge {
|
||||
text: string
|
||||
/**
|
||||
* Optional badge label (e.g., "BETA", "ALPHA", "NEW")
|
||||
*/
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type MissingNodeType =
|
||||
| string
|
||||
// Primarily used by group nodes.
|
||||
@@ -74,6 +82,10 @@ export interface ComfyExtension {
|
||||
* Badges to add to the about page
|
||||
*/
|
||||
aboutPageBadges?: AboutPageBadge[]
|
||||
/**
|
||||
* Badges to add to the top bar
|
||||
*/
|
||||
topbarBadges?: TopbarBadge[]
|
||||
/**
|
||||
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
|
||||
* @param app The ComfyUI app instance
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('assetService', () => {
|
||||
const result = await assetService.getAssetModelFolders()
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models&limit=300'
|
||||
'/assets?include_tags=models&limit=500'
|
||||
)
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('assetService', () => {
|
||||
const result = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models,checkpoints&limit=300'
|
||||
'/assets?include_tags=models,checkpoints&limit=500'
|
||||
)
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
|
||||
@@ -236,7 +236,7 @@ describe('assetService', () => {
|
||||
|
||||
// Verify API call includes correct category
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models,checkpoints&limit=300'
|
||||
'/assets?include_tags=models,checkpoints&limit=500'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('assetService', () => {
|
||||
const result = await assetService.getAssetsByTag('models')
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models&limit=300'
|
||||
'/assets?include_tags=models&limit=500'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"src/types/**/*.d.ts",
|
||||
"tailwind.config.ts",
|
||||
"tests-ui/**/*",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
// "vitest.setup.ts",
|
||||
]
|
||||
|
||||
@@ -39,6 +39,11 @@ const addAuthHeaders = (proxy: any) => {
|
||||
})
|
||||
}
|
||||
|
||||
const DISTRIBUTION = (process.env.DISTRIBUTION || 'localhost') as
|
||||
| 'desktop'
|
||||
| 'localhost'
|
||||
| 'cloud'
|
||||
|
||||
export default defineConfig({
|
||||
base: '',
|
||||
server: {
|
||||
@@ -257,7 +262,8 @@ export default defineConfig({
|
||||
__SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || ''),
|
||||
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
|
||||
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
|
||||
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true'
|
||||
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
|
||||
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION)
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
@@ -14,6 +14,7 @@ globalThis.__ALGOLIA_APP_ID__ = ''
|
||||
globalThis.__ALGOLIA_API_KEY__ = ''
|
||||
// @ts-expect-error - Global variables are defined in global.d.ts
|
||||
globalThis.__USE_PROD_CONFIG__ = false
|
||||
globalThis.__DISTRIBUTION__ = 'localhost'
|
||||
|
||||
// Mock Worker for extendable-media-recorder
|
||||
globalThis.Worker = vi.fn().mockImplementation(() => ({
|
||||
|
||||
Reference in New Issue
Block a user