Files
ComfyBox/src/lib/components/ComfyQueue.svelte
2023-05-20 09:38:32 -05:00

551 lines
15 KiB
Svelte

<script lang="ts">
import queueState, { type CompletedQueueEntry, type QueueEntry, type QueueEntryStatus } from "$lib/stores/queueState";
import ProgressBar from "./ProgressBar.svelte";
import Spinner from "./Spinner.svelte";
import PromptDisplay from "./PromptDisplay.svelte";
import { ListIcon as List } from "svelte-feather-icons";
import { convertComfyOutputToComfyURL, convertFilenameToComfyURL, getNodeInfo } from "$lib/utils"
import type { Writable } from "svelte/store";
import type { QueueItemType } from "$lib/api";
import { ImageViewer } from "$lib/ImageViewer";
import { Button } from "@gradio/button";
import type ComfyApp from "./ComfyApp";
import { tick } from "svelte";
import Modal from "./Modal.svelte";
import DropZone from "./DropZone.svelte";
export let app: ComfyApp;
let queuePending: Writable<QueueEntry[]> | null = null;
let queueRunning: Writable<QueueEntry[]> | null = null;
let queueCompleted: Writable<CompletedQueueEntry[]> | null = null;
let queueList: HTMLDivElement | null = null;
type QueueUIEntry = {
entry: QueueEntry,
message: string,
submessage: string,
date?: string,
status: QueueEntryStatus | "pending" | "running",
images?: string[], // URLs
details?: string // shown in a tooltip on hover
}
$: if ($queueState) {
queuePending = $queueState.queuePending
queueRunning = $queueState.queueRunning
queueCompleted = $queueState.queueCompleted
}
let mode: QueueItemType = "queue";
function switchMode(newMode: QueueItemType) {
const changed = mode !== newMode
mode = newMode
if (changed)
_entries = []
}
let _entries: QueueUIEntry[] = []
$: if (mode === "queue" && $queuePending && $queuePending.length != _entries.length) {
updateFromQueue();
}
else if (mode === "history" && $queueCompleted && $queueCompleted.length != _entries.length) {
updateFromHistory();
}
function formatDate(date: Date): string {
const time = date.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true });
const day = date.toLocaleString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' }).replace(',', '');
return [time, day].join(", ")
}
function convertEntry(entry: QueueEntry): QueueUIEntry {
let date = entry.finishedAt || entry.queuedAt;
let dateStr = null;
if (date) {
dateStr = formatDate(date);
}
const subgraphs: string[] | null = entry.extraData?.extra_pnginfo?.comfyBoxSubgraphs;
let message = "Prompt";
if (subgraphs?.length > 0)
message = `Prompt: ${subgraphs.join(', ')}`
let submessage = `Nodes: ${Object.keys(entry.prompt).length}`
if (Object.keys(entry.outputs).length > 0) {
const imageCount = Object.values(entry.outputs).flatMap(o => o.images).length
submessage = `Images: ${imageCount}`
}
return {
entry,
message,
submessage,
dateStr,
status: "pending",
images: []
}
}
function convertPendingEntry(entry: QueueEntry): QueueUIEntry {
const result = convertEntry(entry);
const thumbnails = entry.extraData?.thumbnails
if (thumbnails) {
result.images = thumbnails.map(convertComfyOutputToComfyURL);
}
return result;
}
function convertCompletedEntry(entry: CompletedQueueEntry): QueueUIEntry {
const result = convertEntry(entry.entry);
result.status = entry.status;
const images = Object.values(entry.entry.outputs).flatMap(o => o.images)
.map(convertComfyOutputToComfyURL);
result.images = images
if (entry.message)
result.submessage = entry.message
else if (entry.status === "interrupted" || entry.status === "all_cached")
result.submessage = "Prompt was interrupted."
if (entry.error)
result.details = entry.error
return result;
}
async function updateFromQueue() {
_entries = $queuePending.map(convertPendingEntry).reverse(); // newest entries appear at the top
if (queueList) {
await tick(); // Wait for list size to be recalculated
queueList.scroll({ top: queueList.scrollHeight })
}
console.warn("[ComfyQueue] BUILDQUEUE", _entries, $queuePending)
}
async function updateFromHistory() {
_entries = $queueCompleted.map(convertCompletedEntry).reverse();
if (queueList) {
queueList.scrollTo(0, 0);
}
console.warn("[ComfyQueue] BUILDHISTORY", _entries, $queueCompleted)
}
function showLightbox(entry: QueueUIEntry, index: number, e: Event) {
e.preventDefault()
if (!entry.images)
return
ImageViewer.instance.showModal(entry.images, index);
e.stopPropagation()
}
async function interrupt() {
if ($queueState.isInterrupting)
return
const app = (window as any).app as ComfyApp;
if (!app || !app.api)
return;
await app.api.interrupt()
.then(() => {
queueState.update(s => { s.isInterrupting = true; return s })
});
}
let showModal = false;
let expandAll = false;
let selectedPrompt = null;
let selectedImages = [];
function showPrompt(entry: QueueUIEntry, e: MouseEvent) {
selectedPrompt = entry.entry.prompt;
selectedImages = entry.images;
showModal = true;
expandAll = false
}
$: if(!showModal)
selectedPrompt = null;
let queued = false
$: queued = Boolean($queueState.runningNodeID || $queueState.progress);
let inProgress = false;
$: inProgress = typeof $queueState.queueRemaining === "number" && $queueState.queueRemaining > 0;
</script>
<Modal bind:showModal>
<div slot="header" class="prompt-modal-header">
<h1 style="padding-bottom: 1rem;">Prompt Details</h1>
</div>
{#if selectedPrompt}
<PromptDisplay prompt={selectedPrompt} images={selectedImages} {expandAll} />
{/if}
<div slot="buttons" let:closeDialog>
<Button variant="secondary" on:click={closeDialog}>
Close
</Button>
<Button variant="secondary" on:click={() => (expandAll = !expandAll)}>
Expand All
</Button>
</div>
</Modal>
<div class="queue">
<DropZone {app} />
<div class="queue-entries {mode}-mode" bind:this={queueList}>
{#if _entries.length > 0}
{#each _entries as entry}
<div class="queue-entry {entry.status}" on:click={(e) => showPrompt(entry, e)}>
{#if entry.images.length > 0}
<div class="queue-entry-images"
style="--cols: {Math.ceil(Math.sqrt(Math.min(entry.images.length, 4)))}" >
{#each entry.images.slice(0, 4) as image, i}
<div>
<img class="queue-entry-image"
on:click={(e) => showLightbox(entry, i, e)}
src={image}
alt="thumbnail" />
</div>
{/each}
</div>
{:else}
<!-- <div class="queue-entry-image-placeholder" /> -->
{/if}
<div class="queue-entry-details">
<div class="queue-entry-message">
{entry.message}
</div>
<div class="queue-entry-submessage">
{entry.submessage}
</div>
</div>
</div>
<div class="queue-entry-rest {entry.status}">
{#if entry.date != null}
<span class="queue-entry-queued-at">
{entry.date}
</span>
{/if}
</div>
{/each}
{:else}
<div class="queue-empty">
<div class="queue-empty-container">
<div class="queue-empty-icon">
<List size="120rem" />
</div>
<div class="queue-empty-message">
(No entries)
</div>
</div>
</div>
{/if}
</div>
<div class="mode-buttons">
<div class="mode-button secondary"
on:click={() => switchMode("queue")}
class:mode-selected={mode === "queue"}>
Queue
</div>
<div class="mode-button secondary"
on:click={() => switchMode("history")}
class:mode-selected={mode === "history"}>
History
</div>
</div>
<div class="bottom">
<div class="queue-remaining" class:queued class:in-progress={inProgress}>
{#if inProgress}
<Spinner />
<div class="status">
Queued prompts: {$queueState.queueRemaining}
</div>
{:else}
<div>
Nothing queued.
</div>
{/if}
</div>
{#if queued}
<div class="node-name">
<span>Node: {getNodeInfo($queueState.runningNodeID)}</span>
</div>
<div>
<ProgressBar value={$queueState.progress?.value} max={$queueState.progress?.max} />
</div>
<div class="queue-action-buttons">
<Button variant="secondary"
disabled={$queueState.isInterrupting}
on:click={interrupt}
style={{ full_width: true }}>
Interrupt
</Button>
</div>
{/if}
</div>
</div>
<style lang="scss">
$pending-height: 200px;
$bottom-bar-height: 70px;
$mode-buttons-height: 30px;
$queue-height: calc(100vh - #{$pending-height} - #{$mode-buttons-height} - #{$bottom-bar-height});
.prompt-modal-header {
padding-left: 0.2rem;
h1 {
font-size: large;
}
}
.queue {
color: var(--body-text-color);
}
.queue-entries {
height: $queue-height;
max-height: $queue-height;
display: flex;
overflow-y: auto;
flex-flow: column nowrap;
&.queue-mode > :first-child {
// elements stick to bottom in queue mode only
// next element in queue is on the bottom
margin-top: auto !important;
}
> .queue-empty {
display: flex;
color: var(--comfy-accent-soft);
flex-direction: row;
margin: auto;
height: 100%;
> .queue-empty-container {
margin: auto;
display: flex;
flex-direction: column;
> .queue-empty-icon {
margin: auto;
}
> .queue-empty-message {
margin: auto;
font-size: 32px;
font-weight: bolder;
}
}
}
}
.queue-entry {
padding: 1.0rem;
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--block-border-color);
border-top: 1px solid var(--table-border-color);
background: var(--panel-background-fill);
max-height: 14rem;
&:hover:not(:has(img:hover)) {
cursor: pointer;
background: var(--block-background-fill);
}
&.success {
/* background: green; */
}
&.error {
background: red;
}
&.all_cached, &.interrupted {
filter: brightness(80%);
background: var(--comfy-disabled-textbox-background-fill);
color: var(--comfy-disable-textbox-text-color);
}
&.running {
/* background: lightblue; */
}
&.pending, &.unknown {
/* background: orange; */
}
}
.queue-entry-rest {
width: 100%;
position: relative;
&.all_cached, &.interrupted {
filter: brightness(80%);
color: var(--neutral-300);
}
}
$thumbnails-size: 12rem;
.queue-entry-images {
--cols: 1;
margin: auto;
width: calc($thumbnails-size * 2);
display: grid;
display: inline-grid;
grid-template-columns: repeat(var(--cols), 1fr);
grid-template-rows: repeat(var(--cols), 1fr);
column-gap: 1px;
row-gap: 1px;
vertical-align: top;
flex: 1 1 40%;
img {
aspect-ratio: 1 / 1;
object-fit: cover;
&:hover {
cursor: pointer;
filter: brightness(120%) contrast(120%);
}
}
}
.queue-entry-details {
position: relative;
padding: 1rem;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
white-space: nowrap;
}
.queue-entry-message {
font-size: 15px;
}
.queue-entry-submessage {
font-size: 12px;
}
.queue-entry-queued-at {
width: auto;
font-size: 12px;
position:absolute;
right: 0px;
bottom: 0px;
padding: 0.4rem 0.6rem;
color: var(--body-text-color);
}
.mode-buttons {
display: flex;
flex-direction: row;
justify-content: center;
height: 100%;
> .mode-button {
width: 100%;
}
}
.mode-button {
height: calc($mode-buttons-height);
padding: 0.2rem;
border: 1px solid var(--panel-border-color);
font-weight: bold;
text-align: center;
margin: auto;
&.primary {
background: var(--button-primary-background-fill);
&:hover {
background: var(--button-primary-background-fill-hover);
}
}
&.secondary {
background: var(--button-secondary-background-fill);
&:hover {
background: var(--button-secondary-background-fill-hover);
}
}
&:hover {
filter: brightness(85%);
}
&:active {
filter: brightness(50%)
}
&.mode-selected {
filter: brightness(80%)
}
}
:global(.dark) .mode-button {
filter: none;
&:hover {
filter: brightness(120%);
}
&:active {
filter: brightness(50%)
}
&.mode-selected {
filter: brightness(150%)
}
}
.bottom {
width: 100%;
height: calc($pending-height);
position: absolute;
.node-name {
background-color: var(--comfy-node-name-background);
color: var(--comfy-node-name-foreground);
padding: 0.2em;
margin: 5px;
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-remaining {
height: calc($pending-height - $bottom-bar-height - 50px);
width: 100%;
text-align: center;
position: relative;
display: flex;
justify-content: space-evenly;
align-items: center;
background: var(--panel-background-fill);
> .status {
}
&.queued {
height: calc($pending-height - $mode-buttons-height - $bottom-bar-height - 16px);
}
}
.queue-action-buttons {
margin: 5px;
height: 20px;
:global(button) {
border-radius: 0px !important;
}
}
}
</style>