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-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);

View File

@@ -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]

View File

@@ -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(

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 { 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)

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 './contextMenuFilter'
import './dynamicPrompts'
@@ -21,3 +23,7 @@ import './uploadAudio'
import './uploadImage'
import './webcamCapture'
import './widgetInputs'
if (isCloud) {
import('./cloudBadge')
}

View File

@@ -197,7 +197,8 @@
"volume": "Volume",
"halfSpeed": "0.5x",
"1x": "1x",
"2x": "2x"
"2x": "2x",
"beta": "BETA"
},
"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 = {
render: () => ({
components: { AssetCard },

View File

@@ -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<{

View File

@@ -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',

View File

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

View File

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

View File

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

View File

@@ -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,

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

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[]
}
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

View File

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

View File

@@ -58,6 +58,7 @@
"src/types/**/*.d.ts",
"tailwind.config.ts",
"tests-ui/**/*",
"vite.config.mts",
"vitest.config.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({
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: {

View File

@@ -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(() => ({