mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-01 05:49:54 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user