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

- bb61d9822 feat: AssetCard tweaks (#6085)
- 05f73523f fix terminal style (#6056)
- d5fa22168 Add distribution detection pattern (#6028)
- 6c36aaa1d feat: Improve MediaAssetCard video controls and add gallery
view (#6065)
- 6944ef0a2 fix Cloudbadge (#6063)
- 6764f8dab Badge 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:
Arjan Singh
2025-10-16 17:37:27 -07:00
committed by GitHub
parent 31eb9ea640
commit e827138f6f
26 changed files with 609 additions and 97 deletions

View File

@@ -85,6 +85,10 @@
--color-bypass: #6a246a; --color-bypass: #6a246a;
--color-error: #962a2a; --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-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-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); --color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);

View File

@@ -135,6 +135,7 @@ import type { CSSProperties, Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue' import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import type { ReleaseNote } from '@/platform/updates/common/releaseService' import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore' import { useReleaseStore } from '@/platform/updates/common/releaseStore'
@@ -265,7 +266,7 @@ const moreMenuItem = computed(() =>
) )
const menuItems = computed<MenuItem[]>(() => { const menuItems = computed<MenuItem[]>(() => {
return [ const items: MenuItem[] = [
{ {
key: 'docs', key: 'docs',
type: 'item', type: 'item',
@@ -305,8 +306,12 @@ const menuItems = computed<MenuItem[]>(() => {
void commandStore.execute('Comfy.ContactSupport') void commandStore.execute('Comfy.ContactSupport')
emit('close') emit('close')
} }
}, }
{ ]
// Extension manager - only in non-cloud distributions
if (!isCloud) {
items.push({
key: 'manager', key: 'manager',
type: 'item', type: 'item',
icon: PuzzleIcon, icon: PuzzleIcon,
@@ -319,17 +324,20 @@ const menuItems = computed<MenuItem[]>(() => {
}) })
emit('close') emit('close')
} }
}, })
{ }
key: 'more',
type: 'item', items.push({
icon: '', key: 'more',
label: t('helpCenter.more'), type: 'item',
visible: hasVisibleMoreItems.value, icon: '',
action: () => {}, // No action for more item label: t('helpCenter.more'),
items: moreItems.value visible: hasVisibleMoreItems.value,
} action: () => {}, // No action for more item
] items: moreItems.value
})
return items
}) })
// Utility Functions // Utility Functions
@@ -420,6 +428,9 @@ const formatReleaseDate = (dateString?: string): string => {
} }
const shouldShowUpdateButton = (release: ReleaseNote): boolean => { const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
// Hide update buttons in cloud distribution
if (isCloud) return false
return ( return (
releaseStore.shouldShowUpdateButton && releaseStore.shouldShowUpdateButton &&
release === releaseStore.recentReleases[0] release === releaseStore.recentReleases[0]

View File

@@ -2,15 +2,16 @@
<div> <div>
<div <div
v-show="showTopMenu && workflowTabsPosition === 'Topbar'" 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)" style="background: var(--border-color)"
> >
<WorkflowTabs /> <WorkflowTabs />
<TopbarBadges />
</div> </div>
<div <div
v-show="showTopMenu" v-show="showTopMenu"
ref="topMenuRef" ref="topMenuRef"
class="comfyui-menu flex items-center" class="comfyui-menu flex items-center bg-gray-100"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }" :class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
> >
<CommandMenubar /> <CommandMenubar />
@@ -44,9 +45,10 @@ import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil' import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
import TopbarBadges from './TopbarBadges.vue'
const workspaceState = useWorkspaceStore() const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu')) const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled') const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
const showTopMenu = computed( const showTopMenu = computed(

View 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>

View 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>

View File

@@ -5,12 +5,14 @@ import { debounce } from 'es-toolkit/compat'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { markRaw, onMounted, onUnmounted } from 'vue' import { markRaw, onMounted, onUnmounted } from 'vue'
import { isDesktop } from '@/platform/distribution/types'
export function useTerminal(element: Ref<HTMLElement | undefined>) { export function useTerminal(element: Ref<HTMLElement | undefined>) {
const fitAddon = new FitAddon() const fitAddon = new FitAddon()
const terminal = markRaw( const terminal = markRaw(
new Terminal({ new Terminal({
convertEol: true, convertEol: true,
theme: { background: '#171717' } theme: isDesktop ? { background: '#171717' } : undefined
}) })
) )
terminal.loadAddon(fitAddon) terminal.loadAddon(fitAddon)

View 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
})

View File

@@ -1,3 +1,5 @@
import { isCloud } from '@/platform/distribution/types'
import './clipspace' import './clipspace'
import './contextMenuFilter' import './contextMenuFilter'
import './dynamicPrompts' import './dynamicPrompts'
@@ -21,3 +23,7 @@ import './uploadAudio'
import './uploadImage' import './uploadImage'
import './webcamCapture' import './webcamCapture'
import './widgetInputs' import './widgetInputs'
if (isCloud) {
import('./cloudBadge')
}

View File

@@ -197,7 +197,8 @@
"volume": "Volume", "volume": "Volume",
"halfSpeed": "0.5x", "halfSpeed": "0.5x",
"1x": "1x", "1x": "1x",
"2x": "2x" "2x": "2x",
"beta": "BETA"
}, },
"manager": { "manager": {
"title": "Custom Nodes Manager", "title": "Custom Nodes Manager",

View File

@@ -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 = { export const EdgeCases: Story = {
render: () => ({ render: () => ({
components: { AssetCard }, components: { AssetCard },

View File

@@ -4,38 +4,29 @@
data-component-id="AssetCard" data-component-id="AssetCard"
:data-asset-id="asset.id" :data-asset-id="asset.id"
v-bind="elementProps" v-bind="elementProps"
:class=" :class="cardClasses"
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'
)
"
@click="interactive && $emit('select', asset)" @click="interactive && $emit('select', asset)"
@keydown.enter="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 <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> ></div>
<AssetBadgeGroup :badges="asset.badges" /> <AssetBadgeGroup :badges="asset.badges" />
</div> </div>
<div :class="cn('p-4 h-32 flex flex-col justify-between')"> <div :class="cn('p-4 h-32 flex flex-col justify-between')">
<div> <div>
<h3 <h3
:id="titleId"
:class=" :class="
cn( 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', 'text-slate-800',
'dark-theme:text-white' 'dark-theme:text-white'
) )
@@ -44,6 +35,7 @@
{{ asset.name }} {{ asset.name }}
</h3> </h3>
<p <p
:id="descId"
:class=" :class="
cn( cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]', 'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
@@ -83,7 +75,8 @@
</template> </template>
<script setup lang="ts"> <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 AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
@@ -94,13 +87,51 @@ const props = defineProps<{
interactive?: boolean 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(() => const elementProps = computed(() =>
props.interactive props.interactive
? { ? {
type: 'button', type: 'button',
'aria-label': `Select asset ${props.asset.name}` 'aria-labelledby': titleId,
'aria-describedby': descId
}
: {
'aria-labelledby': titleId,
'aria-describedby': descId
} }
: {}
) )
defineEmits<{ defineEmits<{

View File

@@ -1,11 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' 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 type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaAssetCard from './MediaAssetCard.vue' import MediaAssetCard from './MediaAssetCard.vue'
const meta: Meta<typeof MediaAssetCard> = { const meta: Meta<typeof MediaAssetCard> = {
title: 'AssetLibrary/MediaAssetCard', title: 'Platform/Assets/MediaAssetCard',
component: 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: { argTypes: {
context: { context: {
control: 'select', control: 'select',

View File

@@ -33,7 +33,7 @@
:is="getTopComponent(asset.kind)" :is="getTopComponent(asset.kind)"
:asset="asset" :asset="asset"
:context="context" :context="context"
@view="actions.viewAsset(asset!.id)" @view="handleZoomClick"
@download="actions.downloadAsset(asset!.id)" @download="actions.downloadAsset(asset!.id)"
@play="actions.playAsset(asset!.id)" @play="actions.playAsset(asset!.id)"
@video-playing-state-changed="isVideoPlaying = $event" @video-playing-state-changed="isVideoPlaying = $event"
@@ -41,31 +41,48 @@
/> />
</template> </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> <template v-if="showActionsOverlay" #top-left>
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" /> <MediaAssetActions
@menu-state-changed="isMenuOpen = $event"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
/>
</template> </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> <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" /> <i class="icon-[lucide--zoom-in] size-4" />
</IconButton> </IconButton>
</template> </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> <template v-if="showDurationChips" #bottom-left>
<SquareChip variant="light" :label="formattedDuration" /> <div
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" /> 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> </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> <template v-if="showOutputCount" #bottom-right>
<IconTextButton <IconTextButton
type="secondary" type="secondary"
size="sm" size="sm"
:label="context?.outputCount?.toString() ?? '0'" :label="context?.outputCount?.toString() ?? '0'"
@click="actions.openMoreOutputs(asset?.id || '')" @click.stop="actions.openMoreOutputs(asset?.id || '')"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
> >
<template #icon> <template #icon>
<i class="icon-[lucide--layers] size-4" /> <i class="icon-[lucide--layers] size-4" />
@@ -116,6 +133,7 @@ import { formatDuration } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions' import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { import type {
AssetContext, AssetContext,
AssetMeta, AssetMeta,
@@ -159,10 +177,12 @@ const cardContainerRef = ref<HTMLElement>()
const isVideoPlaying = ref(false) const isVideoPlaying = ref(false)
const isMenuOpen = ref(false) const isMenuOpen = ref(false)
const showVideoControls = ref(false) const showVideoControls = ref(false)
const isOverlayHovered = ref(false)
const isHovered = useElementHover(cardContainerRef) const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions() const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
provide(MediaAssetKey, { provide(MediaAssetKey, {
asset: toRef(() => asset), asset: toRef(() => asset),
@@ -171,14 +191,14 @@ provide(MediaAssetKey, {
showVideoControls showVideoControls
}) })
const containerClasses = computed(() => { const containerClasses = computed(() =>
return cn( cn(
'gap-1', 'gap-1',
selected selected
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700' ? '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' : 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
) )
}) )
const formattedDuration = computed(() => { const formattedDuration = computed(() => {
if (!asset?.duration) return '' if (!asset?.duration) return ''
@@ -201,33 +221,58 @@ const durationChipClasses = computed(() => {
return '' return ''
}) })
const showHoverActions = computed(() => { const isCardOrOverlayHovered = computed(
return !loading && !!asset && (isHovered.value || isMenuOpen.value) () => isHovered.value || isOverlayHovered.value || isMenuOpen.value
}) )
const showZoomButton = computed(() => { const showHoverActions = computed(
return asset?.kind === 'image' || asset?.kind === '3D' () => !loading && !!asset && isCardOrOverlayHovered.value
}) )
const showActionsOverlay = computed(() => { const showActionsOverlay = computed(
return showHoverActions.value && !isVideoPlaying.value () =>
}) showHoverActions.value &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showZoomOverlay = computed(() => { const showZoomOverlay = computed(
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value () =>
}) showHoverActions.value &&
asset?.kind !== '3D' &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showDurationChips = computed(() => { const showDurationChips = computed(
return !loading && asset?.duration && !isVideoPlaying.value () =>
}) !loading &&
asset?.duration &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showOutputCount = computed(() => { const showOutputCount = computed(
return !loading && context?.outputCount && !isVideoPlaying.value () =>
}) !loading &&
context?.outputCount &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const handleCardClick = () => { const handleCardClick = () => {
if (asset) { if (asset) {
actions.selectAsset(asset) actions.selectAsset(asset)
} }
} }
const handleOverlayMouseEnter = () => {
isOverlayHovered.value = true
}
const handleOverlayMouseLeave = () => {
isOverlayHovered.value = false
}
const handleZoomClick = () => {
if (asset) {
galleryStore.openSingle(asset)
}
}
</script> </script>

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<IconTextButton <IconTextButton
v-if="asset?.kind !== '3D'"
type="transparent" type="transparent"
class="dark-theme:text-white" class="dark-theme:text-white"
label="Inspect asset" label="Inspect asset"
@@ -93,6 +94,7 @@ import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue' import IconTextButton from '@/components/button/IconTextButton.vue'
import { useMediaAssetActions } from '../composables/useMediaAssetActions' import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import { MediaAssetKey } from '../schemas/mediaAssetSchema' import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue' import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
@@ -102,14 +104,13 @@ const { close } = defineProps<{
const { asset, context } = inject(MediaAssetKey)! const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions() const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
const showWorkflowOptions = computed(() => { const showWorkflowOptions = computed(() => context.value.type)
return context.value.type
})
const handleInspect = () => { const handleInspect = () => {
if (asset.value) { if (asset.value) {
actions.viewAsset(asset.value.id) galleryStore.openSingle(asset.value)
} }
close() close()
} }

View File

@@ -1,18 +1,19 @@
<template> <template>
<div <div
class="relative h-full w-full overflow-hidden rounded bg-black" class="relative h-full w-full overflow-hidden rounded bg-black"
@mouseenter="showControls = true" @mouseenter="isHovered = true"
@mouseleave="showControls = false" @mouseleave="isHovered = false"
> >
<video <video
ref="videoRef" ref="videoRef"
:controls="showControls" :controls="shouldShowControls"
preload="none" preload="none"
:poster="asset.preview_url" :poster="asset.preview_url"
class="relative h-full w-full object-contain" class="relative h-full w-full object-contain"
@click.stop @click.stop
@play="onVideoPlay" @play="onVideoPlay"
@pause="onVideoPause" @pause="onVideoPause"
@ended="onVideoEnded"
> >
<source :src="asset.src || ''" /> <source :src="asset.src || ''" />
</video> </video>
@@ -20,7 +21,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema' import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
@@ -36,22 +37,32 @@ const emit = defineEmits<{
}>() }>()
const videoRef = ref<HTMLVideoElement>() 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) emit('videoControlsChanged', controlsVisible)
}) })
onMounted(() => { onMounted(() => {
emit('videoControlsChanged', showControls.value) emit('videoControlsChanged', shouldShowControls.value)
}) })
const onVideoPlay = () => { const onVideoPlay = () => {
showControls.value = true isPlaying.value = true
emit('videoPlayingStateChanged', true) emit('videoPlayingStateChanged', true)
} }
const onVideoPause = () => { const onVideoPause = () => {
isPlaying.value = false
emit('videoPlayingStateChanged', false)
}
const onVideoEnded = () => {
isPlaying.value = false
emit('videoPlayingStateChanged', false) emit('videoPlayingStateChanged', false)
} }
</script> </script>

View File

@@ -6,10 +6,6 @@ export function useMediaAssetActions() {
console.log('Asset selected:', asset) console.log('Asset selected:', asset)
} }
const viewAsset = (assetId: string) => {
console.log('Viewing asset:', assetId)
}
const downloadAsset = (assetId: string) => { const downloadAsset = (assetId: string) => {
console.log('Downloading asset:', assetId) console.log('Downloading asset:', assetId)
} }
@@ -48,7 +44,6 @@ export function useMediaAssetActions() {
return { return {
selectAsset, selectAsset,
viewAsset,
downloadAsset, downloadAsset,
deleteAsset, deleteAsset,
playAsset, playAsset,

View File

@@ -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)
})
})
})

View 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
}
}
)

View File

@@ -12,7 +12,7 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const ASSETS_ENDPOINT = '/assets' 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 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 MODELS_TAG = 'models'
export const MISSING_TAG = 'missing' export const MISSING_TAG = 'missing'

View 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

View 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
}
})

View File

@@ -33,6 +33,14 @@ type MenuCommandGroup = {
commands: string[] commands: string[]
} }
export interface TopbarBadge {
text: string
/**
* Optional badge label (e.g., "BETA", "ALPHA", "NEW")
*/
label?: string
}
export type MissingNodeType = export type MissingNodeType =
| string | string
// Primarily used by group nodes. // Primarily used by group nodes.
@@ -74,6 +82,10 @@ export interface ComfyExtension {
* Badges to add to the about page * Badges to add to the about page
*/ */
aboutPageBadges?: AboutPageBadge[] 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 * Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance * @param app The ComfyUI app instance

View File

@@ -117,7 +117,7 @@ describe('assetService', () => {
const result = await assetService.getAssetModelFolders() const result = await assetService.getAssetModelFolders()
expect(api.fetchApi).toHaveBeenCalledWith( expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models&limit=300' '/assets?include_tags=models&limit=500'
) )
expect(result).toHaveLength(2) expect(result).toHaveLength(2)
@@ -163,7 +163,7 @@ describe('assetService', () => {
const result = await assetService.getAssetModels('checkpoints') const result = await assetService.getAssetModels('checkpoints')
expect(api.fetchApi).toHaveBeenCalledWith( expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models,checkpoints&limit=300' '/assets?include_tags=models,checkpoints&limit=500'
) )
expect(result).toEqual([ expect(result).toEqual([
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 }) expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
@@ -236,7 +236,7 @@ describe('assetService', () => {
// Verify API call includes correct category // Verify API call includes correct category
expect(api.fetchApi).toHaveBeenCalledWith( 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') const result = await assetService.getAssetsByTag('models')
expect(api.fetchApi).toHaveBeenCalledWith( expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models&limit=300' '/assets?include_tags=models&limit=500'
) )
expect(result).toEqual(testAssets) expect(result).toEqual(testAssets)
}) })

View File

@@ -58,6 +58,7 @@
"src/types/**/*.d.ts", "src/types/**/*.d.ts",
"tailwind.config.ts", "tailwind.config.ts",
"tests-ui/**/*", "tests-ui/**/*",
"vite.config.mts",
"vitest.config.ts", "vitest.config.ts",
// "vitest.setup.ts", // "vitest.setup.ts",
] ]

View File

@@ -39,6 +39,11 @@ const addAuthHeaders = (proxy: any) => {
}) })
} }
const DISTRIBUTION = (process.env.DISTRIBUTION || 'localhost') as
| 'desktop'
| 'localhost'
| 'cloud'
export default defineConfig({ export default defineConfig({
base: '', base: '',
server: { server: {
@@ -257,7 +262,8 @@ export default defineConfig({
__SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || ''), __SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || ''),
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''), __ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''), __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: { resolve: {

View File

@@ -14,6 +14,7 @@ globalThis.__ALGOLIA_APP_ID__ = ''
globalThis.__ALGOLIA_API_KEY__ = '' globalThis.__ALGOLIA_API_KEY__ = ''
// @ts-expect-error - Global variables are defined in global.d.ts // @ts-expect-error - Global variables are defined in global.d.ts
globalThis.__USE_PROD_CONFIG__ = false globalThis.__USE_PROD_CONFIG__ = false
globalThis.__DISTRIBUTION__ = 'localhost'
// Mock Worker for extendable-media-recorder // Mock Worker for extendable-media-recorder
globalThis.Worker = vi.fn().mockImplementation(() => ({ globalThis.Worker = vi.fn().mockImplementation(() => ({