mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 21:28:08 +00:00
Compare commits
2 Commits
media/refa
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5679af17b1 | ||
|
|
f3af3c90a7 |
@@ -46,6 +46,9 @@ const config: KnipConfig = {
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Pending integration - accessible color picker components
|
||||
'src/components/ui/color-picker/ColorPickerSaturationValue.vue',
|
||||
'src/components/ui/color-picker/ColorPickerSlider.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
],
|
||||
|
||||
@@ -34,7 +34,17 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
@@ -45,7 +55,6 @@
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
:has-any-error="hasAnyError"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
@@ -114,7 +123,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -136,7 +145,6 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -160,7 +168,6 @@ const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const actionBarButtonStore = useActionBarButtonStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
@@ -175,43 +182,6 @@ const isActionbarEnabled = computed(
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
/**
|
||||
* Whether the actionbar container has any visible docked buttons
|
||||
* (excluding ComfyActionbar, which uses position:fixed when floating
|
||||
* and does not contribute to the container's visual layout).
|
||||
*/
|
||||
const hasDockedButtons = computed(() => {
|
||||
if (actionBarButtonStore.buttons.length > 0) return true
|
||||
if (hasLegacyContent.value) return true
|
||||
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
|
||||
if (isDesktop && !isIntegratedTabBar.value) return true
|
||||
if (isCloud && flags.workflowSharingEnabled) return true
|
||||
if (!isRightSidePanelOpen.value) return true
|
||||
return false
|
||||
})
|
||||
const isActionbarContainerEmpty = computed(
|
||||
() => isActionbarFloating.value && !hasDockedButtons.value
|
||||
)
|
||||
const actionbarContainerClass = computed(() => {
|
||||
const base =
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
|
||||
|
||||
if (isActionbarContainerEmpty.value) {
|
||||
return cn(
|
||||
base,
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
|
||||
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
|
||||
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
}
|
||||
|
||||
const borderClass =
|
||||
!isActionbarFloating.value && hasAnyError.value
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
|
||||
return cn(base, 'px-2', borderClass)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
@@ -263,25 +233,6 @@ const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
const hasLegacyContent = ref(false)
|
||||
|
||||
function checkLegacyContent() {
|
||||
const el = legacyCommandsContainerRef.value
|
||||
if (!el) {
|
||||
hasLegacyContent.value = false
|
||||
return
|
||||
}
|
||||
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
|
||||
hasLegacyContent.value =
|
||||
el.querySelector(':scope > * > *:not(:empty)') !== null
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (legacyCommandsContainerRef.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
|
||||
@@ -119,14 +119,9 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const {
|
||||
topMenuContainer,
|
||||
queueOverlayExpanded = false,
|
||||
hasAnyError = false
|
||||
} = defineProps<{
|
||||
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
|
||||
topMenuContainer?: HTMLElement | null
|
||||
queueOverlayExpanded?: boolean
|
||||
hasAnyError?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -440,12 +435,7 @@ const panelClass = computed(() =>
|
||||
isDragging.value && 'pointer-events-none select-none',
|
||||
isDocked.value
|
||||
? 'static border-none bg-transparent p-0'
|
||||
: [
|
||||
'fixed shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
]
|
||||
: 'fixed shadow-interface'
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -170,9 +170,9 @@
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<MediaViewer
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:items="previewableVisibleAssets"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
<MediaAssetContextMenu
|
||||
v-if="contextMenuAsset"
|
||||
@@ -221,12 +221,12 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import MediaViewer from '@/platform/assets/components/MediaViewer.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
@@ -240,6 +240,7 @@ import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import {
|
||||
formatDuration,
|
||||
getMediaTypeFromFilename,
|
||||
@@ -453,6 +454,28 @@ watch(galleryActiveIndex, (index) => {
|
||||
}
|
||||
})
|
||||
|
||||
const galleryItems = computed(() => {
|
||||
return previewableVisibleAssets.value.map((asset) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '0',
|
||||
mediaType: mediaType === 'image' ? 'images' : mediaType
|
||||
})
|
||||
|
||||
Object.defineProperty(resultItem, 'url', {
|
||||
get() {
|
||||
return asset.preview_url || ''
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
return resultItem
|
||||
})
|
||||
})
|
||||
|
||||
const refreshAssets = async () => {
|
||||
await currentAssets.value.fetchMediaList()
|
||||
if (error.value) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { hue } = defineProps<{
|
||||
hue: number
|
||||
}>()
|
||||
|
||||
const saturation = defineModel<number>('saturation', { required: true })
|
||||
const value = defineModel<number>('value', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
|
||||
|
||||
const handleStyle = computed(() => ({
|
||||
left: `${saturation.value}%`,
|
||||
top: `${100 - value.value}%`
|
||||
}))
|
||||
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, v))
|
||||
}
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||
saturation.value = Math.round(x * 100)
|
||||
value.value = Math.round((1 - y) * 100)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
saturation.value = clamp(saturation.value - step, 0, 100)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
saturation.value = clamp(saturation.value + step, 0, 100)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
value.value = clamp(value.value + step, 0, 100)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
value.value = clamp(value.value - step, 0, 100)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
:aria-label="t('colorPicker.saturationValue')"
|
||||
:aria-valuetext="`${t('colorPicker.saturation')}: ${saturation}%, ${t('colorPicker.brightness')}: ${value}%`"
|
||||
class="relative aspect-square w-full cursor-crosshair rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-highlight"
|
||||
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="handleStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
115
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
115
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
|
||||
const {
|
||||
type,
|
||||
hue = 0,
|
||||
saturation = 100,
|
||||
brightness = 100
|
||||
} = defineProps<{
|
||||
type: 'hue' | 'alpha'
|
||||
hue?: number
|
||||
saturation?: number
|
||||
brightness?: number
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const max = computed(() => (type === 'hue' ? 360 : 100))
|
||||
|
||||
const fraction = computed(() => modelValue.value / max.value)
|
||||
|
||||
const trackBackground = computed(() => {
|
||||
if (type === 'hue') {
|
||||
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
|
||||
}
|
||||
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
|
||||
const hex = rgbToHex(rgb)
|
||||
return `linear-gradient(to right, transparent, ${hex})`
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
if (type === 'alpha') {
|
||||
return {
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '8px 8px',
|
||||
touchAction: 'none'
|
||||
}
|
||||
}
|
||||
return {
|
||||
background: trackBackground.value,
|
||||
touchAction: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
type === 'hue' ? t('colorPicker.hue') : t('colorPicker.alpha')
|
||||
)
|
||||
|
||||
function clamp(v: number, min: number, maxVal: number) {
|
||||
return Math.max(min, Math.min(maxVal, v))
|
||||
}
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
modelValue.value = Math.round(x * max.value)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
modelValue.value = clamp(modelValue.value - step, 0, max.value)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
modelValue.value = clamp(modelValue.value + step, 0, max.value)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-valuenow="modelValue"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="max"
|
||||
class="relative flex h-4 cursor-pointer items-center rounded-full p-px outline-none focus-visible:ring-2 focus-visible:ring-highlight"
|
||||
:style="containerStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div
|
||||
v-if="type === 'alpha'"
|
||||
class="absolute inset-0 rounded-full"
|
||||
:style="{ background: trackBackground }"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="{ left: `${fraction * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -483,6 +483,13 @@
|
||||
"black": "Black",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"colorPicker": {
|
||||
"saturationValue": "Saturation and brightness",
|
||||
"saturation": "Saturation",
|
||||
"brightness": "Brightness",
|
||||
"hue": "Hue",
|
||||
"alpha": "Alpha"
|
||||
},
|
||||
"contextMenu": {
|
||||
"Inputs": "Inputs",
|
||||
"Outputs": "Outputs",
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import MediaAssetCard from './MediaAssetCard.vue'
|
||||
import MediaViewer from './MediaViewer.vue'
|
||||
|
||||
const meta: Meta<typeof MediaAssetCard> = {
|
||||
title: 'Platform/Assets/MediaAssetCard',
|
||||
component: MediaAssetCard,
|
||||
decorators: [
|
||||
() => ({
|
||||
components: { MediaViewer },
|
||||
components: { ResultGallery },
|
||||
setup() {
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
return { galleryStore }
|
||||
@@ -18,9 +19,9 @@ const meta: Meta<typeof MediaAssetCard> = {
|
||||
template: `
|
||||
<div>
|
||||
<story />
|
||||
<MediaViewer
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryStore.activeIndex"
|
||||
:items="galleryStore.items"
|
||||
:all-gallery-items="galleryStore.items"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import MediaViewer from './MediaViewer.vue'
|
||||
|
||||
const SAMPLE_MEDIA = {
|
||||
image1: 'https://i.imgur.com/OB0y6MR.jpg',
|
||||
image2: 'https://i.imgur.com/CzXTtJV.jpg',
|
||||
image3: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
|
||||
video:
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
|
||||
}
|
||||
|
||||
function makeAsset(
|
||||
overrides: Partial<AssetItem> & { id: string; name: string }
|
||||
): AssetItem {
|
||||
return {
|
||||
size: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: ['output'],
|
||||
preview_url: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const singleImage: AssetItem[] = [
|
||||
makeAsset({
|
||||
id: 'img-1',
|
||||
name: 'landscape.jpg',
|
||||
preview_url: SAMPLE_MEDIA.image1
|
||||
})
|
||||
]
|
||||
|
||||
const multipleImages: AssetItem[] = [
|
||||
makeAsset({
|
||||
id: 'img-1',
|
||||
name: 'landscape.jpg',
|
||||
preview_url: SAMPLE_MEDIA.image1
|
||||
}),
|
||||
makeAsset({
|
||||
id: 'img-2',
|
||||
name: 'portrait.jpg',
|
||||
preview_url: SAMPLE_MEDIA.image2
|
||||
}),
|
||||
makeAsset({
|
||||
id: 'img-3',
|
||||
name: 'nature.jpg',
|
||||
preview_url: SAMPLE_MEDIA.image3
|
||||
})
|
||||
]
|
||||
|
||||
const mixedMedia: AssetItem[] = [
|
||||
makeAsset({
|
||||
id: 'img-1',
|
||||
name: 'photo.jpg',
|
||||
preview_url: SAMPLE_MEDIA.image1
|
||||
}),
|
||||
makeAsset({
|
||||
id: 'vid-1',
|
||||
name: 'clip.mp4',
|
||||
preview_url: SAMPLE_MEDIA.video
|
||||
}),
|
||||
makeAsset({
|
||||
id: 'aud-1',
|
||||
name: 'song.mp3',
|
||||
preview_url: SAMPLE_MEDIA.audio
|
||||
})
|
||||
]
|
||||
|
||||
const meta: Meta<typeof MediaViewer> = {
|
||||
title: 'Platform/Assets/MediaViewer',
|
||||
component: MediaViewer,
|
||||
parameters: { layout: 'fullscreen' },
|
||||
argTypes: {
|
||||
activeIndex: { control: { type: 'number', min: -1, max: 10 } }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const SingleImage: Story = {
|
||||
args: {
|
||||
items: singleImage,
|
||||
activeIndex: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleImages: Story = {
|
||||
args: {
|
||||
items: multipleImages,
|
||||
activeIndex: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const VideoPlayer: Story = {
|
||||
args: {
|
||||
items: [
|
||||
makeAsset({
|
||||
id: 'vid-1',
|
||||
name: 'big-buck-bunny.mp4',
|
||||
preview_url: SAMPLE_MEDIA.video
|
||||
})
|
||||
],
|
||||
activeIndex: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const AudioPlayer: Story = {
|
||||
args: {
|
||||
items: [
|
||||
makeAsset({
|
||||
id: 'aud-1',
|
||||
name: 'soundtrack.mp3',
|
||||
preview_url: SAMPLE_MEDIA.audio
|
||||
})
|
||||
],
|
||||
activeIndex: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const MixedMedia: Story = {
|
||||
args: {
|
||||
items: mixedMedia,
|
||||
activeIndex: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const Closed: Story = {
|
||||
args: {
|
||||
items: singleImage,
|
||||
activeIndex: -1
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<template>
|
||||
<DialogRoot :open="isOpen" @update:open="handleOpenChange">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/80" />
|
||||
<DialogContent
|
||||
class="fixed inset-0 z-50 flex items-center justify-center outline-none"
|
||||
:aria-describedby="undefined"
|
||||
>
|
||||
<VisuallyHidden as-child>
|
||||
<DialogTitle>{{ currentItem?.name ?? '' }}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
<!-- Close button -->
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
|
||||
:aria-label="$t('g.close')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-5" />
|
||||
</DialogClose>
|
||||
|
||||
<!-- Previous button -->
|
||||
<button
|
||||
v-if="hasMultiple"
|
||||
class="absolute left-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
|
||||
@click="navigate(-1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left] size-6" />
|
||||
</button>
|
||||
|
||||
<!-- Media content -->
|
||||
<ComfyImage
|
||||
v-if="currentMediaType === 'image'"
|
||||
:key="currentItem?.id"
|
||||
:src="currentItem?.preview_url ?? ''"
|
||||
:contain="false"
|
||||
:alt="currentItem?.name ?? ''"
|
||||
class="max-h-screen max-w-full object-contain"
|
||||
/>
|
||||
<video
|
||||
v-else-if="currentMediaType === 'video'"
|
||||
:key="currentItem?.id"
|
||||
:src="currentItem?.preview_url ?? ''"
|
||||
controls
|
||||
class="max-h-screen max-w-full"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="currentMediaType === 'audio'"
|
||||
:key="currentItem?.id"
|
||||
:src="currentItem?.preview_url ?? ''"
|
||||
controls
|
||||
/>
|
||||
|
||||
<!-- Next button -->
|
||||
<button
|
||||
v-if="hasMultiple"
|
||||
class="absolute right-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
|
||||
@click="navigate(1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right] size-6" />
|
||||
</button>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
VisuallyHidden
|
||||
} from 'reka-ui'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import ComfyImage from '@/components/common/ComfyImage.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
const { items = [], activeIndex = -1 } = defineProps<{
|
||||
items?: AssetItem[]
|
||||
activeIndex?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeIndex': [value: number]
|
||||
}>()
|
||||
|
||||
const isOpen = computed(() => activeIndex >= 0 && activeIndex < items.length)
|
||||
|
||||
const currentItem = computed(() =>
|
||||
isOpen.value ? items[activeIndex] : undefined
|
||||
)
|
||||
|
||||
const currentMediaType = computed(() =>
|
||||
currentItem.value ? getMediaTypeFromFilename(currentItem.value.name) : ''
|
||||
)
|
||||
|
||||
const hasMultiple = computed(() => items.length > 1)
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
emit('update:activeIndex', -1)
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(direction: number) {
|
||||
const newIndex = (activeIndex + direction + items.length) % items.length
|
||||
emit('update:activeIndex', newIndex)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isOpen.value) return
|
||||
if (event.key === 'ArrowLeft') navigate(-1)
|
||||
else if (event.key === 'ArrowRight') navigate(1)
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', handleKeyDown))
|
||||
onUnmounted(() => window.removeEventListener('keydown', handleKeyDown))
|
||||
</script>
|
||||
@@ -1,76 +1,161 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
ResultItemImpl: vi
|
||||
.fn<typeof ResultItemImpl>()
|
||||
.mockImplementation(function (data) {
|
||||
Object.assign(this, {
|
||||
...data,
|
||||
url: ''
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useMediaAssetGalleryStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('openSingle', () => {
|
||||
it('should set items and activeIndex', () => {
|
||||
it('should convert AssetMeta to ResultItemImpl format', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const asset: AssetItem = {
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-1',
|
||||
name: 'test-image.png',
|
||||
kind: 'image',
|
||||
src: 'https://example.com/image.png',
|
||||
size: 1024,
|
||||
preview_url: 'https://example.com/image.png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(asset)
|
||||
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0]).toBe(asset)
|
||||
expect(store.activeIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('should preserve asset properties', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const asset: AssetItem = {
|
||||
id: 'test-2',
|
||||
name: 'test-video.mp4',
|
||||
size: 2048,
|
||||
preview_url: 'https://example.com/video.mp4',
|
||||
tags: ['output'],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(asset)
|
||||
|
||||
expect(store.items[0].name).toBe('test-video.mp4')
|
||||
expect(store.items[0].preview_url).toBe('https://example.com/video.mp4')
|
||||
})
|
||||
|
||||
it('should replace previous items when called multiple times', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const asset1: AssetItem = {
|
||||
id: '1',
|
||||
name: 'first.png',
|
||||
size: 100,
|
||||
preview_url: 'url1',
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
const asset2: AssetItem = {
|
||||
|
||||
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,
|
||||
preview_url: 'url2',
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(asset1)
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].name).toBe('first.png')
|
||||
expect(store.items[0].filename).toBe('first.png')
|
||||
|
||||
store.openSingle(asset2)
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].name).toBe('second.png')
|
||||
expect(store.items[0].filename).toBe('second.png')
|
||||
expect(store.activeIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -78,16 +163,17 @@ describe('useMediaAssetGalleryStore', () => {
|
||||
describe('close', () => {
|
||||
it('should reset activeIndex to -1', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const asset: AssetItem = {
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test',
|
||||
name: 'test.png',
|
||||
kind: 'image',
|
||||
src: 'test-url',
|
||||
size: 1024,
|
||||
preview_url: 'test-url',
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(asset)
|
||||
store.openSingle(mockAsset)
|
||||
expect(store.activeIndex).toBe(0)
|
||||
|
||||
store.close()
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
export const useMediaAssetGalleryStore = defineStore(
|
||||
'mediaAssetGallery',
|
||||
() => {
|
||||
const activeIndex = ref(-1)
|
||||
const items = shallowRef<AssetItem[]>([])
|
||||
const items = shallowRef<ResultItemImpl[]>([])
|
||||
|
||||
const close = () => {
|
||||
activeIndex.value = -1
|
||||
}
|
||||
|
||||
const openSingle = (asset: AssetItem) => {
|
||||
items.value = [asset]
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ const zAsset = z.object({
|
||||
is_immutable: z.boolean().optional(),
|
||||
last_access_time: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||
prompt_id: z.string().nullish()
|
||||
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
|
||||
})
|
||||
|
||||
const zAssetResponse = z.object({
|
||||
|
||||
@@ -31,9 +31,6 @@ export interface PaginationOptions {
|
||||
interface AssetRequestOptions extends PaginationOptions {
|
||||
includeTags: string[]
|
||||
includePublic?: boolean
|
||||
jobIds?: string[]
|
||||
sort?: string
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
interface AssetExportOptions {
|
||||
@@ -205,10 +202,7 @@ function createAssetService() {
|
||||
includeTags,
|
||||
limit = DEFAULT_LIMIT,
|
||||
offset,
|
||||
includePublic,
|
||||
jobIds,
|
||||
sort,
|
||||
order
|
||||
includePublic
|
||||
} = options
|
||||
const queryParams = new URLSearchParams({
|
||||
include_tags: includeTags.join(','),
|
||||
@@ -220,15 +214,6 @@ function createAssetService() {
|
||||
if (includePublic !== undefined) {
|
||||
queryParams.set('include_public', includePublic ? 'true' : 'false')
|
||||
}
|
||||
if (jobIds?.length) {
|
||||
queryParams.set('job_ids', jobIds.join(','))
|
||||
}
|
||||
if (sort) {
|
||||
queryParams.set('sort', sort)
|
||||
}
|
||||
if (order) {
|
||||
queryParams.set('order', order)
|
||||
}
|
||||
|
||||
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
|
||||
const res = await api.fetchApi(url)
|
||||
@@ -769,25 +754,6 @@ function createAssetService() {
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
async function getOutputAssets(
|
||||
options?: PaginationOptions & { sort?: string; order?: 'asc' | 'desc' }
|
||||
): Promise<AssetResponse> {
|
||||
return handleAssetRequest(
|
||||
{ includeTags: ['output'], ...options },
|
||||
'output assets'
|
||||
)
|
||||
}
|
||||
|
||||
async function getAssetsByJobIds(
|
||||
jobIds: string[],
|
||||
options?: PaginationOptions
|
||||
): Promise<AssetResponse> {
|
||||
return handleAssetRequest(
|
||||
{ includeTags: ['output'], jobIds, ...options },
|
||||
'job assets'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
@@ -806,9 +772,7 @@ function createAssetService() {
|
||||
uploadAssetFromBase64,
|
||||
uploadAssetAsync,
|
||||
createAssetExport,
|
||||
getExportDownloadUrl,
|
||||
getOutputAssets,
|
||||
getAssetsByJobIds
|
||||
getExportDownloadUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user