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>