Enhancements to the queue image feed (#646)

* Enhancements to the queue image feed
 - Change flat list icon
 - Add cover/contain mode
 - Add right click -> go to node
 - Add go to node link on detail

* Add loading spinner

* resolve comments

---------

Co-authored-by: huchenlei <chenlei.hu@mail.utoronto.ca>
This commit is contained in:
pythongosssss
2024-08-27 02:57:23 +01:00
committed by GitHub
parent 84662ada9e
commit 9cdefca481
10 changed files with 207 additions and 67 deletions

View File

@@ -1,10 +1,25 @@
<!-- A image with placeholder fallback on error -->
<template>
<img
:src="src"
@error="handleImageError"
:class="[{ 'broken-image': imageBroken }, ...classArray]"
/>
<span
v-if="!imageBroken"
class="comfy-image-wrap"
:class="[{ contain: contain }]"
>
<img
v-if="contain"
:src="src"
@error="handleImageError"
:data-test="src"
class="comfy-image-blur"
:style="{ 'background-image': `url(${src})` }"
/>
<img
:src="src"
@error="handleImageError"
class="comfy-image-main"
:class="[...classArray]"
/>
</span>
<div v-if="imageBroken" class="broken-image-placeholder">
<i class="pi pi-image"></i>
<span>{{ $t('imageFailedToLoad') }}</span>
@@ -14,10 +29,16 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
src: string
class?: string | string[] | object
}>()
const props = withDefaults(
defineProps<{
src: string
class?: string | string[] | object
contain: boolean
}>(),
{
contain: false
}
)
const imageBroken = ref(false)
const handleImageError = (e: Event) => {
@@ -37,8 +58,37 @@ const classArray = computed(() => {
</script>
<style scoped>
.broken-image {
display: none;
.comfy-image-wrap {
display: contents;
}
.comfy-image-blur {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.comfy-image-main {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
z-index: 1;
}
.contain .comfy-image-wrap {
position: relative;
width: 100%;
height: 100%;
}
.contain .comfy-image-main {
object-fit: contain;
backdrop-filter: blur(10px);
position: absolute;
}
.broken-image-placeholder {

View File

@@ -1,6 +1,18 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.queue')">
<template #tool-buttons>
<Button
:icon="
imageFit === 'cover'
? 'pi pi-arrow-down-left-and-arrow-up-right-to-center'
: 'pi pi-arrow-up-right-and-arrow-down-left-from-center'
"
text
severity="secondary"
@click="toggleImageFit"
class="toggle-expanded-button"
v-tooltip="$t(`sideToolbar.queueTab.${imageFit}ImagePreview`)"
/>
<Button
v-if="isInFolderView"
icon="pi pi-arrow-left"
@@ -12,7 +24,7 @@
/>
<template v-else>
<Button
:icon="isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
:icon="isExpanded ? 'pi pi-images' : 'pi pi-image'"
text
severity="secondary"
@click="toggleExpanded"
@@ -47,6 +59,11 @@
</div>
<div ref="loadMoreTrigger" style="height: 1px" />
</div>
<div v-else-if="queueStore.isLoading">
<ProgressSpinner
style="width: 50px; left: 50%; transform: translateX(-50%)"
/>
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
@@ -74,16 +91,22 @@ import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import ProgressSpinner from 'primevue/progressspinner'
import TaskItem from './queue/TaskItem.vue'
import ResultGallery from './queue/ResultGallery.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { api } from '@/scripts/api'
import { ComfyNode } from '@/types/comfyWorkflow'
import { useSettingStore } from '@/stores/settingStore'
import { app } from '@/scripts/app'
const IMAGE_FIT = 'Comfy.Queue.ImageFit'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
const { t } = useI18n()
// Expanded view: show all outputs in a flat list.
@@ -95,6 +118,7 @@ const galleryActiveIndex = ref(-1)
// Folder view: only show outputs from a single selected task.
const folderTask = ref<TaskItemImpl | null>(null)
const isInFolderView = computed(() => folderTask.value !== null)
const imageFit = computed<string>(() => settingStore.get(IMAGE_FIT))
const ITEMS_PER_PAGE = 8
const SCROLL_THRESHOLD = 100 // pixels from bottom to trigger load
@@ -204,6 +228,7 @@ const onStatus = async () => {
const menu = ref(null)
const menuTargetTask = ref<TaskItemImpl | null>(null)
const menuTargetNode = ref<ComfyNode | null>(null)
const menuItems = computed<MenuItem[]>(() => [
{
label: t('delete'),
@@ -215,17 +240,26 @@ const menuItems = computed<MenuItem[]>(() => [
label: t('loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow()
},
{
label: t('goToNode'),
icon: 'pi pi-arrow-circle-right',
command: () => app.goToNode(menuTargetNode.value?.id),
visible: !!menuTargetNode.value
}
])
const handleContextMenu = ({
task,
event
event,
node
}: {
task: TaskItemImpl
event: Event
node?: ComfyNode
}) => {
menuTargetTask.value = task
menuTargetNode.value = node
menu.value?.show(event)
}
@@ -245,6 +279,10 @@ const exitFolderView = () => {
updateVisibleTasks()
}
const toggleImageFit = () => {
settingStore.set(IMAGE_FIT, imageFit.value === 'cover' ? 'contain' : 'cover')
}
onMounted(() => {
api.addEventListener('status', onStatus)
queueStore.update()

View File

@@ -13,7 +13,12 @@
:showThumbnails="false"
>
<template #item="{ item }">
<ComfyImage :key="item.url" :src="item.url" class="galleria-image" />
<ComfyImage
:key="item.url"
:src="item.url"
:contain="false"
class="galleria-image"
/>
</template>
</Galleria>
</template>

View File

@@ -3,6 +3,11 @@
<template
v-if="result.mediaType === 'images' || result.mediaType === 'gifs'"
>
<ComfyImage
:src="result.url"
class="task-output-image"
:contain="imageFit === 'contain'"
/>
<div class="image-preview-mask">
<Button
icon="pi pi-eye"
@@ -11,7 +16,6 @@
rounded
/>
</div>
<ComfyImage :src="result.url" class="task-output-image" />
</template>
<!-- TODO: handle more media types -->
<div v-else class="task-result-preview">
@@ -25,7 +29,8 @@
import { ResultItemImpl } from '@/stores/queueStore'
import ComfyImage from '@/components/common/ComfyImage.vue'
import Button from 'primevue/button'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
const props = defineProps<{
result: ResultItemImpl
@@ -36,6 +41,10 @@ const emit = defineEmits<{
}>()
const resultContainer = ref<HTMLElement | null>(null)
const settingStore = useSettingStore()
const imageFit = computed<string>(() =>
settingStore.get('Comfy.Queue.ImageFit')
)
onMounted(() => {
if (props.result.mediaType === 'images') {
@@ -58,13 +67,6 @@ onMounted(() => {
align-items: center;
}
:deep(.task-output-image) {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.image-preview-mask {
position: absolute;
left: 50%;
@@ -75,6 +77,7 @@ onMounted(() => {
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
}
.result-container:hover .image-preview-mask {

View File

@@ -28,7 +28,13 @@
<div class="task-item-details">
<div class="tag-wrapper status-tag-group">
<Tag v-if="isFlatTask && task.isHistory" class="node-name-tag">
{{ node?.type }} (#{{ node?.id }})
<Button
class="task-node-link"
:label="`${node?.type} (#${node?.id})`"
link
size="small"
@click="app.goToNode(node?.id)"
/>
</Tag>
<Tag :severity="taskTagSeverity(task.displayStatus)">
<span v-html="taskStatusText(task.displayStatus)"></span>
@@ -59,6 +65,7 @@ import Tag from 'primevue/tag'
import ResultItem from './ResultItem.vue'
import { TaskItemDisplayStatus, type TaskItemImpl } from '@/stores/queueStore'
import { ComfyNode } from '@/types/comfyWorkflow'
import { app } from '@/scripts/app'
const props = defineProps<{
task: TaskItemImpl
@@ -77,13 +84,16 @@ const node: ComfyNode | null = flatOutputs.length
: null
const emit = defineEmits<{
(e: 'contextmenu', value: { task: TaskItemImpl; event: MouseEvent }): void
(
e: 'contextmenu',
value: { task: TaskItemImpl; event: MouseEvent; node?: ComfyNode }
): void
(e: 'preview', value: TaskItemImpl): void
(e: 'task-output-length-clicked', value: TaskItemImpl): void
}>()
const handleContextMenu = (e: MouseEvent) => {
emit('contextmenu', { task: props.task, event: e })
emit('contextmenu', { task: props.task, event: e, node })
}
const handlePreview = () => {
@@ -164,6 +174,11 @@ const formatTime = (time?: number) => {
justify-content: space-between;
align-items: center;
width: 100%;
z-index: 1;
}
.task-node-link {
padding: 2px;
}
/* In dark mode, transparent background color for tags is not ideal for tags that

View File

@@ -27,6 +27,7 @@ const messages = {
experimental: 'BETA',
deprecated: 'DEPR',
loadWorkflow: 'Load Workflow',
goToNode: 'Go to Node',
settings: 'Settings',
searchSettings: 'Search Settings',
searchNodes: 'Search Nodes',
@@ -45,7 +46,9 @@ const messages = {
},
queueTab: {
showFlatList: 'Show Flat List',
backToAllTasks: 'Back to All Tasks'
backToAllTasks: 'Back to All Tasks',
containImagePreview: 'Fill Image Preview',
coverImagePreview: 'Fit Image Preview'
}
}
},

View File

@@ -17,6 +17,7 @@ import { applyTextReplacements, addStylesheet } from './utils'
import type { ComfyExtension } from '@/types/comfy'
import {
type ComfyWorkflowJSON,
type NodeId,
validateComfyWorkflow
} from '../types/comfyWorkflow'
import { ComfyNodeDef, StatusWsMessageStatus } from '@/types/apiTypes'
@@ -2188,7 +2189,7 @@ export class ComfyApp {
maximizable: true
})
}
this.logging.addEntry('Comfy.App', 'warn', {
MissingModels: missingModels
})
@@ -2272,15 +2273,17 @@ export class ComfyApp {
n.type = sanitizeNodeName(n.type)
}
}
if (graphData.models && useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')) {
if (
graphData.models &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
for (let m of graphData.models) {
const models_available = await this.getModelsInFolderCached(m.directory)
if (models_available === null) {
// @ts-expect-error
m.directory_invalid = true
missingModels.push(m)
}
else if (!models_available.includes(m.name)) {
} else if (!models_available.includes(m.name)) {
missingModels.push(m)
}
}
@@ -3008,6 +3011,13 @@ export class ComfyApp {
const [x, y, w, h] = app.canvas.ds.visible_area
return [x + w / dpi / 2, y + h / dpi / 2]
}
public goToNode(nodeId: NodeId) {
// @ts-expect-error TODO: Update litegraph's nodeId type to string | number
const graphNode = this.graph.getNodeById(nodeId)
if (!graphNode) return
this.canvas.centerOnNode(graphNode)
}
}
export const app = new ComfyApp()

View File

@@ -9,7 +9,7 @@ import type {
TaskOutput,
ResultItem
} from '@/types/apiTypes'
import type { NodeId } from '@/types/comfyWorkflow'
import type { ComfyNode, NodeId } from '@/types/comfyWorkflow'
import { plainToClass } from 'class-transformer'
import _ from 'lodash'
import { defineStore } from 'pinia'
@@ -253,6 +253,7 @@ interface State {
pendingTasks: TaskItemImpl[]
historyTasks: TaskItemImpl[]
maxHistoryItems: number
isLoading: boolean
}
export const useQueueStore = defineStore('queue', {
@@ -260,7 +261,8 @@ export const useQueueStore = defineStore('queue', {
runningTasks: [],
pendingTasks: [],
historyTasks: [],
maxHistoryItems: 64
maxHistoryItems: 64,
isLoading: false
}),
getters: {
tasks(state) {
@@ -280,43 +282,48 @@ export const useQueueStore = defineStore('queue', {
actions: {
// Fetch the queue data from the API
async update() {
const [queue, history] = await Promise.all([
api.getQueue(),
api.getHistory(this.maxHistoryItems)
])
this.isLoading = true
try {
const [queue, history] = await Promise.all([
api.getQueue(),
api.getHistory(this.maxHistoryItems)
])
const toClassAll = (tasks: TaskItem[]): TaskItemImpl[] =>
tasks
.map(
(task: TaskItem) =>
new TaskItemImpl(
task.taskType,
task.prompt,
task['status'],
task['outputs'] || {}
)
)
// Desc order to show the latest tasks first
.sort((a, b) => b.queueIndex - a.queueIndex)
const toClassAll = (tasks: TaskItem[]): TaskItemImpl[] =>
tasks
.map(
(task: TaskItem) =>
new TaskItemImpl(
task.taskType,
task.prompt,
task['status'],
task['outputs'] || {}
)
)
// Desc order to show the latest tasks first
.sort((a, b) => b.queueIndex - a.queueIndex)
this.runningTasks = toClassAll(queue.Running)
this.pendingTasks = toClassAll(queue.Pending)
this.runningTasks = toClassAll(queue.Running)
this.pendingTasks = toClassAll(queue.Pending)
// Process history items
const allIndex = new Set(
history.History.map((item: TaskItem) => item.prompt[0])
)
const newHistoryItems = toClassAll(
history.History.filter(
(item) => item.prompt[0] > this.lastHistoryQueueIndex
// Process history items
const allIndex = new Set(
history.History.map((item: TaskItem) => item.prompt[0])
)
)
const existingHistoryItems = this.historyTasks.filter(
(item: TaskItemImpl) => allIndex.has(item.queueIndex)
)
this.historyTasks = [...newHistoryItems, ...existingHistoryItems]
.slice(0, this.maxHistoryItems)
.sort((a, b) => b.queueIndex - a.queueIndex)
const newHistoryItems = toClassAll(
history.History.filter(
(item) => item.prompt[0] > this.lastHistoryQueueIndex
)
)
const existingHistoryItems = this.historyTasks.filter(
(item: TaskItemImpl) => allIndex.has(item.queueIndex)
)
this.historyTasks = [...newHistoryItems, ...existingHistoryItems]
.slice(0, this.maxHistoryItems)
.sort((a, b) => b.queueIndex - a.queueIndex)
} finally {
this.isLoading = false
}
},
async clear() {
await Promise.all(

View File

@@ -219,6 +219,14 @@ export const useSettingStore = defineStore('setting', {
type: 'hidden',
defaultValue: {}
})
// Hidden setting used by the queue for how to fit images
app.ui.settings.addSetting({
id: 'Comfy.Queue.ImageFit',
name: 'Queue image fit',
type: 'hidden',
defaultValue: 'cover'
})
},
set<K extends keyof Settings>(key: K, value: Settings[K]) {

View File

@@ -461,7 +461,8 @@ const zSettings = z.record(z.any()).and(
'Comfy.TextareaWidget.Spellcheck': z.boolean(),
'Comfy.UseNewMenu': z.any(),
'Comfy.Validation.Workflows': z.boolean(),
'Comfy.Workflow.SortNodeIdOnSave': z.boolean()
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover'])
})
.optional()
)