Queue media preview (#449)

* output url

* Basic image previews

* Split out task item component

* Move task actions to context menu

* simplify

* Move spinner

* Lift context menu to tab scope

* Better tag

* Fix placeholder style

* nit

* Correctly handle cancelled

* nit

* Split out result item as separate component

* nit

* Fix center crop

* nit

* Simplify task item

* Flat list

* Show prompt id

* Make image draggable

* Disable preview for dragging

* Fix key

* Correctly handle task in expanded view

* Add preview
This commit is contained in:
Chenlei Hu
2024-08-15 23:26:38 -04:00
committed by GitHub
parent 9e3dffd7fd
commit e6d29656fa
8 changed files with 397 additions and 125 deletions

View File

@@ -1,125 +1,82 @@
<template>
<DataTable
v-if="tasks.length > 0"
:value="tasks"
dataKey="promptId"
class="queue-table"
>
<Column header="STATUS">
<template #body="{ data }">
<Tag :severity="taskTagSeverity(data.displayStatus)">
{{ data.displayStatus.toUpperCase() }}
</Tag>
</template>
</Column>
<Column header="TIME" :pt="{ root: { class: 'queue-time-cell' } }">
<template #body="{ data }">
<div v-if="data.isHistory" class="queue-time-cell-content">
{{ formatTime(data.executionTimeInSeconds) }}
</div>
<div v-else-if="data.isRunning" class="queue-time-cell-content">
<i class="pi pi-spin pi-spinner"></i>
</div>
<div v-else class="queue-time-cell-content">...</div>
</template>
</Column>
<Column
:pt="{
headerCell: {
class: 'queue-tool-header-cell'
},
bodyCell: {
class: 'queue-tool-body-cell'
}
}"
>
<template #header>
<Toast />
<ConfirmPopup />
<Button
icon="pi pi-trash"
text
severity="primary"
@click="confirmRemoveAll($event)"
<SideBarTabTemplate :title="$t('sideToolbar.queue')">
<template #tool-buttons>
<Button
:icon="isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
text
severity="secondary"
@click="isExpanded = !isExpanded"
class="toggle-expanded-button"
v-tooltip="$t('sideToolbar.queueTab.showFlatList')"
/>
<Button
icon="pi pi-trash"
text
severity="primary"
@click="confirmRemoveAll($event)"
class="clear-all-button"
/>
</template>
<template #body>
<div v-if="tasks.length > 0" class="queue-grid">
<TaskItem
v-for="task in tasks"
:key="task.key"
:task="task"
:isFlatTask="isExpanded"
@contextmenu="handleContextMenu"
/>
</template>
<template #body="{ data }">
<Button
icon="pi pi-file-export"
text
severity="primary"
@click="data.loadWorkflow()"
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="$t('noTasksFound')"
:message="$t('noTasksFoundMessage')"
/>
<Button
icon="pi pi-times"
text
severity="secondary"
@click="removeTask(data)"
/>
</template>
</Column>
</DataTable>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="$t('noTasksFound')"
:message="$t('noTasksFoundMessage')"
/>
</div>
</div>
</template>
</SideBarTabTemplate>
<Toast />
<ConfirmPopup />
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import Toast from 'primevue/toast'
import ContextMenu from 'primevue/contextmenu'
import TaskItem from './queue/TaskItem.vue'
import SideBarTabTemplate from './SidebarTabTemplate.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import {
TaskItemDisplayStatus,
TaskItemImpl,
useQueueStore
} from '@/stores/queueStore'
import { computed, onMounted, onUnmounted } from 'vue'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { type MenuItem } from 'primevue/menuitem'
import { api } from '@/scripts/api'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
const { t } = useI18n()
const tasks = computed(() =>
isExpanded.value ? queueStore.flatTasks : queueStore.tasks
)
const tasks = computed(() => queueStore.tasks)
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'secondary'
case TaskItemDisplayStatus.Running:
return 'info'
case TaskItemDisplayStatus.Completed:
return 'success'
case TaskItemDisplayStatus.Failed:
return 'danger'
case TaskItemDisplayStatus.Cancelled:
return 'warning'
}
}
const formatTime = (time?: number) => {
if (time === undefined) {
return ''
}
return `${time.toFixed(2)}s`
}
const removeTask = (task: TaskItemImpl) => {
if (task.isRunning) {
api.interrupt()
}
queueStore.delete(task)
}
const removeAllTasks = async () => {
await queueStore.clear()
}
const confirmRemoveAll = (event) => {
confirm.require({
target: event.currentTarget,
@@ -147,30 +104,45 @@ const confirmRemoveAll = (event) => {
}
const onStatus = () => queueStore.update()
const menu = ref(null)
const menuTargetTask = ref<TaskItemImpl | null>(null)
const menuItems = computed<MenuItem[]>(() => {
return [
{
label: t('delete'),
icon: 'pi pi-trash',
command: () => removeTask(menuTargetTask.value!)
},
{
label: t('loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow()
}
]
})
const handleContextMenu = ({ task, event }) => {
menuTargetTask.value = task
menu.value.show(event)
}
onMounted(() => {
api.addEventListener('status', onStatus)
queueStore.update()
})
onUnmounted(() => {
api.removeEventListener('status', onStatus)
})
const isExpanded = ref(false)
</script>
<style>
.queue-tool-header-cell {
display: flex;
justify-content: flex-end;
}
.queue-tool-body-cell {
display: table-cell;
text-align: right !important;
}
</style>
<style scoped>
.queue-time-cell-content {
width: fit-content;
.queue-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
padding: 0.5rem;
gap: 0.5rem;
}
</style>

View File

@@ -42,6 +42,7 @@ const props = defineProps({
border-top: none;
border-radius: 0;
padding: 0.25rem 1rem;
min-height: 2.5rem;
}
.comfy-vue-side-bar-header-span {

View File

@@ -0,0 +1,81 @@
<template>
<div class="result-container" ref="resultContainer">
<Image
v-if="result.mediaType === 'images'"
:src="result.url"
alt="Task Output"
width="100%"
height="100%"
preview
:pt="{ previewMask: { class: 'image-preview-mask' } }"
/>
<!-- TODO: handle more media types -->
<div v-else class="task-result-preview">
<i class="pi pi-file"></i>
<span>{{ result.mediaType }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ResultItemImpl } from '@/stores/queueStore'
import Image from 'primevue/image'
import { onMounted, ref } from 'vue'
const props = defineProps<{
result: ResultItemImpl
}>()
const resultContainer = ref<HTMLElement | null>(null)
onMounted(() => {
if (props.result.mediaType === 'images') {
resultContainer.value.querySelectorAll('img').forEach((img) => {
img.draggable = true
})
}
})
</script>
<style scoped>
.result-container {
aspect-ratio: 1 / 1;
overflow: hidden;
}
:deep(img) {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
}
.p-image-preview {
position: static;
display: contents;
}
:deep(.image-preview-mask) {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
padding: 10px;
cursor: pointer;
background: rgba(0, 0, 0, 0.5);
color: var(--p-image-preview-mask-color);
transition:
opacity var(--p-image-transition-duration),
background var(--p-image-transition-duration);
border-radius: 50%;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<!-- @contextmenu="handleContextMenu" -->
<div class="task-item">
<div class="task-result-preview">
<div v-if="task.displayStatus === TaskItemDisplayStatus.Completed">
<ResultItem v-if="flatOutputs.length" :result="flatOutputs[0]" />
</div>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Running"
class="pi pi-spin pi-spinner"
></i>
<span v-else-if="task.displayStatus === TaskItemDisplayStatus.Pending"
>...</span
>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Cancelled"
class="pi pi-exclamation-triangle"
></i>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Failed"
class="pi pi-exclamation-circle"
></i>
</div>
<div class="task-item-details">
<div class="tag-wrapper">
<Tag :severity="taskTagSeverity(task.displayStatus)">
<span v-html="taskStatusText(task.displayStatus)"></span>
<span v-if="task.isHistory" class="task-time">
{{ formatTime(task.executionTimeInSeconds) }}
</span>
<span v-if="isFlatTask" class="task-prompt-id">
{{ task.promptId.split('-')[0] }}
</span>
</Tag>
</div>
<div class="tag-wrapper">
<Tag v-if="task.isHistory && flatOutputs.length > 1">
<span>{{ flatOutputs.length }}</span>
</Tag>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import ResultItem from './ResultItem.vue'
import { TaskItemDisplayStatus, type TaskItemImpl } from '@/stores/queueStore'
const props = defineProps<{
task: TaskItemImpl
isFlatTask: boolean
}>()
const flatOutputs = props.task.flatOutputs
const emit = defineEmits<{
(e: 'contextmenu', { task: TaskItemImpl, event: MouseEvent }): void
}>()
const handleContextMenu = (e: MouseEvent) => {
emit('contextmenu', { task: props.task, event: e })
}
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'secondary'
case TaskItemDisplayStatus.Running:
return 'info'
case TaskItemDisplayStatus.Completed:
return 'success'
case TaskItemDisplayStatus.Failed:
return 'danger'
case TaskItemDisplayStatus.Cancelled:
return 'warn'
}
}
const taskStatusText = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'Pending'
case TaskItemDisplayStatus.Running:
return 'Running'
case TaskItemDisplayStatus.Completed:
return '<i class="pi pi-check" style="font-weight: bold"></i>'
case TaskItemDisplayStatus.Failed:
return 'Failed'
case TaskItemDisplayStatus.Cancelled:
return 'Cancelled'
}
}
const formatTime = (time?: number) => {
if (time === undefined) {
return ''
}
return `${time.toFixed(2)}s`
}
</script>
<style scoped>
.task-result-preview {
aspect-ratio: 1 / 1;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.task-result-preview i,
.task-result-preview span {
font-size: 2rem;
}
.task-item {
display: flex;
flex-direction: column;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.task-item-details {
position: absolute;
bottom: 0;
padding: 0.6rem;
display: flex;
justify-content: space-between;
width: 100%;
}
/* In dark mode, transparent background color for tags is not ideal for tags that
are floating on top of images. */
.tag-wrapper {
background-color: var(--p-primary-contrast-color);
border-radius: 6px;
display: inline-flex;
}
</style>

View File

@@ -2,6 +2,8 @@ import { createI18n } from 'vue-i18n'
const messages = {
en: {
delete: 'Delete',
loadWorkflow: 'Load Workflow',
settings: 'Settings',
searchSettings: 'Search Settings',
noResultsFound: 'No Results Found',
@@ -15,10 +17,15 @@ const messages = {
nodeLibrary: 'Node Library',
nodeLibraryTab: {
sortOrder: 'Sort Order'
},
queueTab: {
showFlatList: 'Show Flat List'
}
}
},
zh: {
delete: '删除',
loadWorkflow: '加载工作流',
settings: '设置',
searchSettings: '搜索设置',
noResultsFound: '未找到结果',
@@ -32,6 +39,9 @@ const messages = {
nodeLibrary: '节点库',
nodeLibraryTab: {
sortOrder: '排序顺序'
},
queueTab: {
showFlatList: '平铺结果'
}
}
}

View File

@@ -1,15 +1,17 @@
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import {
validateTaskItem,
import type {
TaskItem,
TaskType,
TaskPrompt,
TaskStatus,
StatusWsMessageStatus,
TaskOutput,
StatusWsMessageStatus
ResultItem
} from '@/types/apiTypes'
import { plainToClass } from 'class-transformer'
import { validateTaskItem } from '@/types/apiTypes'
import type { NodeId } from '@/types/comfyWorkflow'
import { instanceToPlain, plainToClass } from 'class-transformer'
import _ from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
@@ -25,11 +27,43 @@ export enum TaskItemDisplayStatus {
Cancelled = 'Cancelled'
}
export class ResultItemImpl {
filename: string
subfolder?: string
type: string
nodeId: NodeId
// 'audio' | 'images' | ...
mediaType: string
get url(): string {
return api.apiURL(`/view?filename=${encodeURIComponent(this.filename)}&type=${this.type}&
subfolder=${encodeURIComponent(this.subfolder || '')}&t=${+new Date()}`)
}
}
export class TaskItemImpl {
taskType: TaskType
prompt: TaskPrompt
status?: TaskStatus
outputs?: TaskOutput
outputs: TaskOutput
get flatOutputs(): ResultItemImpl[] {
if (!this.outputs) {
return []
}
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as ResultItem[]).flatMap((item: ResultItem) =>
plainToClass(ResultItemImpl, {
...item,
nodeId,
mediaType
})
)
)
)
}
get apiTaskType(): APITaskType {
switch (this.taskType) {
@@ -41,6 +75,10 @@ export class TaskItemImpl {
}
}
get key() {
return this.promptId + this.displayStatus
}
get queueIndex() {
return this.prompt[0]
}
@@ -174,6 +212,31 @@ export const useQueueStore = defineStore('queue', {
...state.historyTasks
]
},
flatTasks(): TaskItemImpl[] {
return this.tasks.flatMap((task: TaskItemImpl) => {
if (task.displayStatus !== TaskItemDisplayStatus.Completed) {
return [task]
}
return task.flatOutputs.map((output: ResultItemImpl, i: number) =>
plainToClass(TaskItemImpl, {
...instanceToPlain(task),
prompt: [
task.queueIndex,
`${task.promptId}-${i}`,
task.promptInputs,
task.extraData,
task.outputsToExecute
],
outputs: {
[output.nodeId]: {
[output.mediaType]: [instanceToPlain(output)]
}
}
})
)
})
},
lastHistoryQueueIndex(state) {
return state.historyTasks.length ? state.historyTasks[0].queueIndex : -1
}

View File

@@ -6,11 +6,18 @@ import { colorPalettesSchema } from './colorPalette'
const zNodeType = z.string()
const zQueueIndex = z.number()
const zPromptId = z.string()
const zImageResult = z.object({
const zResultItem = z.object({
filename: z.string(),
subfolder: z.string().optional(),
type: z.string()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional()
})
.passthrough()
// WS messages
const zStatusWsMessageStatus = z.object({
@@ -37,11 +44,7 @@ const zExecutingWsMessage = z.object({
})
const zExecutedWsMessage = zExecutingWsMessage.extend({
outputs: z
.object({
images: z.array(zImageResult)
})
.passthrough()
outputs: zOutputs
})
const zExecutionWsMessageBase = z.object({
@@ -144,9 +147,6 @@ const zStatus = z.object({
messages: z.array(zStatusMessage)
})
// TODO: this is a placeholder
const zOutput = z.any()
const zTaskPrompt = z.tuple([
zQueueIndex,
zPromptId,
@@ -170,7 +170,7 @@ const zPendingTaskItem = z.object({
prompt: zTaskPrompt
})
const zTaskOutput = z.record(zNodeId, zOutput)
const zTaskOutput = z.record(zNodeId, zOutputs)
const zHistoryTaskItem = z.object({
taskType: z.literal('History'),

View File

@@ -5,6 +5,7 @@ import { fromZodError } from 'zod-validation-error'
// innerNode.id = `${this.node.id}:${i}`
// Remove it after GroupNode is redesigned.
export const zNodeId = z.union([z.number().int(), z.string()])
export type NodeId = z.infer<typeof zNodeId>
export const zSlotIndex = z.union([
z.number().int(),
z