Merge pull request #60 from space-nuko/gallery-and-send-images

Grid view for gallery
This commit is contained in:
space-nuko
2023-05-22 14:24:58 -05:00
18 changed files with 752 additions and 255 deletions

View File

@@ -22,14 +22,14 @@ export type ComfyNodeDefInput = [ComfyNodeDefInputType, ComfyNodeDefInputOptions
/**
* - Array: Combo widget. Usually the values are strings but they can also be other stuff like booleans.
* - "INT"/"FLOAT"/etc.: Non-combo type widgets. See ComfyWidgets type.
* - other string: Must be an input type, usually something lke "IMAGE" or "LATENT".
* - other string: Must be a backend input type, usually something lke "IMAGE" or "LATENT".
*/
export type ComfyNodeDefInputType = any[] | keyof typeof ComfyWidgets | string
export type ComfyNodeDefInputOptions = {
forceInput?: boolean;
// NOTE: For COMBO type inputs, the default value is always the first entry the list.
// NOTE: For COMBO type inputs, the default value is always the first entry in the list.
default?: any,
// INT/FLOAT options

View File

@@ -331,7 +331,7 @@ export default class ComfyAPI {
* @param {string} type The type of item to delete, queue or history
* @param {number} id The id of the item to delete
*/
async deleteItem(type: QueueItemType, id: number): Promise<Response> {
async deleteItem(type: QueueItemType, id: PromptID): Promise<Response> {
return this.postItem(type, { delete: [id] });
}

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { ListIcon as List, ImageIcon as Image, SettingsIcon as Settings } from "svelte-feather-icons";
import ComfyApp, { type A1111PromptAndInfo, type SerializedAppState } from "./ComfyApp";
import { Image, Gear } from "svelte-bootstrap-icons";
import ComfyApp from "./ComfyApp";
import uiState from "$lib/stores/uiState";
import configState from "$lib/stores/configState";
import workflowState from "$lib/stores/workflowState";
import { SvelteToast, toast } from '@zerodevx/svelte-toast'
import LightboxModal from "./LightboxModal.svelte";
@@ -34,6 +36,16 @@
document.getElementById("app-root").classList.remove("dark")
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!$configState.confirmWhenUnloadingUnsavedChanges)
return;
const unsavedChanges = $workflowState.openedWorkflows.some(w => w.isModified);
if (unsavedChanges) {
event.preventDefault();
event.returnValue = '';
}
}
</script>
<svelte:head>
@@ -42,13 +54,15 @@
{/if}
</svelte:head>
<svelte:window on:beforeunload={handleBeforeUnload} />
<div id="main" class:dark={uiTheme === "gradio-dark"}>
<div id="container">
<Sidebar selected="generate">
<SidebarItem id="generate" name="Generate" icon={Image}>
<ComfyWorkflowsView {app} {uiTheme} />
</SidebarItem>
<SidebarItem id="settings" name="Settings" icon={Settings}>
<SidebarItem id="settings" name="Settings" icon={Gear}>
</SidebarItem>
</Sidebar>
</div>

View File

@@ -1,6 +1,6 @@
import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID, type NodeTypeSpec, type NodeTypeOpts, type SlotIndex, type UUID } from "@litegraph-ts/core";
import type { LConnectionKind, INodeSlot } from "@litegraph-ts/core";
import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type ComfyNodeID, type PromptID } from "$lib/api"
import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type ComfyNodeID, type PromptID, type QueueItemType } from "$lib/api"
import { importA1111, parsePNGMetadata } from "$lib/pnginfo";
import EventEmitter from "events";
import type TypedEmitter from "typed-emitter";
@@ -33,7 +33,7 @@ import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode";
import { get, writable, type Writable } from "svelte/store";
import { tick } from "svelte";
import uiState from "$lib/stores/uiState";
import { basename, download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis } from "$lib/utils";
import { basename, capitalize, download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis } from "$lib/utils";
import notify from "$lib/notify";
import configState from "$lib/stores/configState";
import { blankGraph } from "$lib/defaultGraph";
@@ -312,7 +312,7 @@ export default class ComfyApp {
const workflows = state.workflows as SerializedAppState[];
await Promise.all(workflows.map(w => {
return this.openWorkflow(w, defs).catch(error => {
return this.openWorkflow(w, defs, false).catch(error => {
console.error("Failed restoring previous workflow", error)
notify(`Failed restoring previous workflow: ${error}`, { type: "error" })
})
@@ -509,6 +509,46 @@ export default class ComfyApp {
this.api.init();
}
async interrupt() {
if (get(queueState).isInterrupting)
return
queueState.update(s => { s.isInterrupting = true; return s; })
await this.api.interrupt()
.finally(() => {
queueState.update(s => { s.isInterrupting = true; return s })
});
}
async deleteQueueItem(type: QueueItemType, promptID: PromptID) {
if (get(queueState).isInterrupting)
return
queueState.update(s => { s.isInterrupting = true; return s; })
await this.api.deleteItem(type, promptID)
.then(() => {
queueState.queueItemDeleted(type, promptID);
})
.finally(() => {
queueState.update(s => { s.isInterrupting = false; return s; })
});
}
async clearQueue(type: QueueItemType) {
if (get(queueState).isInterrupting)
return
queueState.update(s => { s.isInterrupting = true; return s; })
await this.api.clearItems(type)
.then(() => {
queueState.queueCleared(type);
notify(`${capitalize(type)} cleared.`);
})
.finally(() => {
queueState.update(s => { s.isInterrupting = false; return s; })
});
}
private addKeyboardHandler() {
window.addEventListener("keydown", (e) => {
this.shiftDown = e.shiftKey;
@@ -557,9 +597,12 @@ export default class ComfyApp {
setColor(BuiltInSlotType.ACTION, "lightseagreen")
}
async openWorkflow(data: SerializedAppState, refreshCombos: boolean | Record<string, ComfyNodeDef> = true): Promise<ComfyWorkflow> {
async openWorkflow(data: SerializedAppState,
refreshCombos: boolean | Record<string, ComfyNodeDef> = true,
warnMissingNodeTypes: boolean = true
): Promise<ComfyWorkflow> {
if (data.version !== COMFYBOX_SERIAL_VERSION) {
const mes = `Invalid ComfyBox saved data format: ${data.version}`
const mes = `Invalid ComfyBox saved data format: ${data.version} `
notify(mes, { type: "error" })
return Promise.reject(mes);
}
@@ -580,7 +623,7 @@ export default class ComfyApp {
return Promise.reject(error)
}
if (workflow.missingNodeTypes.size > 0) {
if (workflow.missingNodeTypes.size > 0 && warnMissingNodeTypes) {
modalState.pushModal({
svelteComponent: MissingNodeTypesModal,
svelteProps: {
@@ -681,7 +724,7 @@ export default class ComfyApp {
}
catch (error) {
console.error("Failed to load default graph", error)
notify(`Failed to load default graph: ${error}`, { type: "error" })
notify(`Failed to load default graph: ${error} `, { type: "error" })
state = structuredClone(blankGraph)
}
await this.openWorkflow(state, defs)
@@ -737,7 +780,7 @@ export default class ComfyApp {
else {
const date = new Date();
const formattedDate = date.toISOString().replace(/:/g, '-').replace(/\.\d{3}/g, '').replace('T', '_').replace("Z", "");
filename = `workflow-${formattedDate}.json`
filename = `workflow - ${formattedDate}.json`
}
const indent = 2
@@ -782,7 +825,7 @@ export default class ComfyApp {
try {
while (this.queueItems.length) {
({ num, batchCount, workflow } = this.queueItems.pop());
console.debug(`Queue get! ${num} ${batchCount} ${tag}`);
console.debug(`Queue get! ${num} ${batchCount} ${tag} `);
const thumbnails = []
for (const node of workflow.graph.iterateNodesInOrderRecursive()) {
@@ -850,7 +893,7 @@ export default class ComfyApp {
if (error != null) {
const mes: string = error;
notify(`Error queuing prompt:\n${mes}`, { type: "error" })
notify(`Error queuing prompt: \n${mes} `, { type: "error" })
console.error(graphToGraphVis(workflow.graph))
console.error(promptToGraphVis(p))
console.error("Error queuing prompt", error, num, p)

View File

@@ -29,7 +29,7 @@
<label class="number-wrapper">
<BlockTitle>{name}</BlockTitle>
<div class="number">
<input type="number" bind:value {min} {max} {step} {disabled}>
<input type="number" bind:value {min} {max} {step} {disabled} />
</div>
</label>

View File

@@ -1,9 +1,23 @@
<script lang="ts" context="module">
export type QueueUIEntryStatus = QueueEntryStatus | "pending" | "running";
export type QueueUIEntry = {
entry: QueueEntry,
message: string,
submessage: string,
date?: string,
status: QueueUIEntryStatus,
images?: string[], // URLs
details?: string // shown in a tooltip on hover
}
</script>
<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 { List, ListUl, Grid } from "svelte-bootstrap-icons";
import { convertComfyOutputToComfyURL, convertFilenameToComfyURL, getNodeInfo, truncateString } from "$lib/utils"
import type { Writable } from "svelte/store";
import type { QueueItemType } from "$lib/api";
@@ -14,6 +28,8 @@
import Modal from "./Modal.svelte";
import DropZone from "./DropZone.svelte";
import workflowState from "$lib/stores/workflowState";
import ComfyQueueListDisplay from "./ComfyQueueListDisplay.svelte";
import ComfyQueueGridDisplay from "./ComfyQueueGridDisplay.svelte";
export let app: ComfyApp;
@@ -22,25 +38,18 @@
let queueCompleted: Writable<CompletedQueueEntry[]> | null = null;
let queueList: HTMLDivElement | null = null;
type QueueUIEntryStatus = QueueEntryStatus | "pending" | "running";
type QueueUIEntry = {
entry: QueueEntry,
message: string,
submessage: string,
date?: string,
status: QueueUIEntryStatus,
images?: string[], // URLs
details?: string // shown in a tooltip on hover
}
$: if ($queueState) {
queuePending = $queueState.queuePending
queueRunning = $queueState.queueRunning
queueCompleted = $queueState.queueCompleted
}
type DisplayModeType = "list" | "grid";
let mode: QueueItemType = "queue";
let displayMode: DisplayModeType = "list";
let imageSize: number = 40;
let gridColumns: number = 3;
let changed = true;
function switchMode(newMode: QueueItemType) {
@@ -53,6 +62,16 @@
}
}
function switchDisplayMode(newDisplayMode: DisplayModeType) {
// changed = displayMode !== newDisplayMode
displayMode = newDisplayMode
// if (changed) {
// _queuedEntries = []
// _runningEntries = []
// _entries = []
// }
}
let _queuedEntries: QueueUIEntry[] = []
let _runningEntries: QueueUIEntry[] = []
let _entries: QueueUIEntry[] = []
@@ -66,6 +85,39 @@
changed = false;
}
async function deleteEntry(entry: QueueUIEntry, event: MouseEvent) {
event.preventDefault();
event.stopImmediatePropagation()
// TODO support interrupting from multiple running items!
if (entry.status === "running") {
await app.interrupt();
}
else {
await app.deleteQueueItem(mode, entry.entry.promptID);
}
if (mode === "queue") {
_queuedEntries = []
_runningEntries = []
}
_entries = [];
changed = true;
}
async function clearQueue() {
await app.clearQueue(mode);
if (mode === "queue") {
_queuedEntries = []
_runningEntries = []
}
_entries = [];
changed = true;
}
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(',', '');
@@ -156,35 +208,15 @@
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 })
});
await app.interrupt();
}
let showModal = false;
let expandAll = false;
let selectedPrompt = null;
let selectedImages = [];
function showPrompt(entry: QueueUIEntry, e: MouseEvent) {
function showPrompt(entry: QueueUIEntry) {
selectedPrompt = entry.entry.prompt;
selectedImages = entry.images;
showModal = true;
@@ -219,48 +251,33 @@
</div>
</Modal>
<div class="queue">
<div class="queue-entries {mode}-mode" bind:this={queueList}>
<div class="queue {mode}-mode">
{#if mode === "history"}
<div class="display-mode-buttons">
<div class="mode-button image-display-button ternary"
on:click={() => switchDisplayMode("list")}
class:selected={displayMode === "list"}>
<List width="100%" height="100%" />
</div>
<div class="mode-button image-display-button ternary"
on:click={() => switchDisplayMode("grid")}
class:selected={displayMode === "grid"}>
<Grid width="100%" height="100%" />
</div>
</div>
{/if}
<div class="queue-entries" 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">
{truncateString(entry.message, 20)}
</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}
{#if mode === "history" && displayMode === "grid"}
<ComfyQueueGridDisplay entries={_entries} {showPrompt} {clearQueue} {mode} bind:gridColumns />
{:else}
<ComfyQueueListDisplay entries={_entries} {showPrompt} {clearQueue} {mode} {deleteEntry} bind:imageSize />
{/if}
{:else}
<div class="queue-empty">
<div class="queue-empty-container">
<div class="queue-empty-icon">
<List size="120rem" />
<ListUl width="100%" height="10rem" />
</div>
<div class="queue-empty-message">
(No entries)
@@ -272,12 +289,12 @@
<div class="mode-buttons">
<div class="mode-button secondary"
on:click={() => switchMode("queue")}
class:mode-selected={mode === "queue"}>
class:selected={mode === "queue"}>
Queue
</div>
<div class="mode-button secondary"
on:click={() => switchMode("history")}
class:mode-selected={mode === "history"}>
class:selected={mode === "history"}>
History
</div>
</div>
@@ -315,10 +332,12 @@
<style lang="scss">
$pending-height: 200px;
$display-mode-buttons-height: 2rem;
$bottom-bar-height: 70px;
$workflow-tabs-height: 2.5rem;
$mode-buttons-height: 30px;
$queue-height: calc(100vh - #{$pending-height} - #{$mode-buttons-height} - #{$bottom-bar-height} - #{$workflow-tabs-height} - 0.9rem);
$queue-height-history: calc(#{$queue-height} - #{$display-mode-buttons-height});
.prompt-modal-header {
padding-left: 0.2rem;
@@ -329,20 +348,22 @@
}
.queue {
color: var(--body-text-color);
&.queue-mode > .queue-entries {
height: $queue-height;
max-height: $queue-height;
}
&.history-mode > .queue-entries {
height: $queue-height-history;
max-height: $queue-height-history;
}
}
.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;
}
height: $queue-height;
> .queue-empty {
display: flex;
@@ -368,105 +389,21 @@
}
}
.queue-entry {
padding: 1.0rem;
.display-mode-buttons {
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;
top: 0px;
height: $display-mode-buttons-height;
margin-bottom: auto;
&:hover:not(:has(img:hover)) {
cursor: pointer;
background: var(--block-background-fill);
> .mode-button {
width: 100%;
color: var(--neutral-500);
&.running {
background: var(--comfy-accent-soft);
&.selected {
color: var(--body-text-color);
}
}
&.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: var(--block-background-fill);
border: 3px dashed var(--neutral-500);
}
&.pending, &.unknown {
}
}
.queue-entry-rest {
width: 100%;
position: relative;
&.all_cached, &.interrupted {
filter: brightness(80%);
color: var(--comfy-accent-soft);
}
}
$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 {
@@ -483,34 +420,8 @@
.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%)
}
@include square-button;
}
:global(.dark) .mode-button {
@@ -521,7 +432,7 @@
&:active {
filter: brightness(50%)
}
&.mode-selected {
&.selected {
filter: brightness(150%)
}
}

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import type { QueueItemType } from "$lib/api";
import { showLightbox } from "$lib/utils";
import type { QueueUIEntry } from "./ComfyQueue.svelte";
import queueState from "$lib/stores/queueState";
export let entries: QueueUIEntry[] = [];
export let showPrompt: (entry: QueueUIEntry) => void;
export let clearQueue: () => Promise<void>;
export let mode: QueueItemType = "queue";
export let gridColumns: number = 3;
let allEntries: [QueueUIEntry, string][] = []
let allImages: string[] = []
$: buildImageList(entries);
function buildImageList(entries: QueueUIEntry[]) {
allEntries = []
for (const entry of entries) {
for (const image of entry.images) {
allEntries.push([entry, image]);
}
}
allImages = allEntries.map(p => p[1]);
}
function handleClick(e: MouseEvent, entry: QueueUIEntry, index: number) {
if (e.ctrlKey) {
showPrompt(entry);
}
else {
showLightbox(allImages, index, e)
}
}
</script>
<div class="grid-wrapper {mode}-mode">
<div class="grid-controls">
<div>
<input type="range" bind:value={gridColumns} min={1} max={8} step={1} />
<div class="button-wrapper">
<button class="clear-queue-button secondary"
on:click={clearQueue}
disabled={$queueState.isInterrupting}>
🗑️
</button>
</div>
</div>
</div>
<div class="grid-entries">
<div class="grid" style:--cols={gridColumns}>
{#each allEntries as [entry, image], i}
<div class="grid-entry">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<img class="grid-entry-image"
on:click={(e) => handleClick(e, entry, i)}
src={image}
alt="thumbnail" />
</div>
{/each}
</div>
</div>
</div>
<style lang="scss">
$grid-controls-height: 3rem;
$grid-controls-margin: 0.25rem;
.grid-wrapper {
height: 100%;
}
.grid-controls {
height: $grid-controls-height;
position: relative;
display: flex;
flex-direction: column;
margin: $grid-controls-margin 1rem;
> div {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
input {
width: 100%;
margin: auto;
}
.button-wrapper {
padding: $grid-controls-margin;
.clear-queue-button {
@include square-button;
aspect-ratio: 1/1;
height: 100%;
padding: 0.25rem;
}
}
}
}
.grid-entries {
height: calc(100% - #{$grid-controls-height} - #{$grid-controls-margin} * 2);
padding: 0 var(--spacing-lg) var(--spacing-lg) var(--spacing-lg);
overflow-y: auto;
}
.grid {
--cols: 3;
width: 100%;
display: grid;
position: relative;
top: 0px;
grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
grid-auto-rows: 1fr;
gap: var(--spacing-lg);
}
.grid-entry {
display: flex;
justify-content: center;
align-items: center;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.grid-entry-image {
aspect-ratio: 1 / 1;
object-fit: cover;
&:hover {
cursor: pointer;
filter: brightness(120%) contrast(120%);
}
}
</style>

View File

@@ -0,0 +1,260 @@
<script lang="ts">
import type { QueueItemType } from "$lib/api";
import { showLightbox, truncateString } from "$lib/utils";
import type { QueueUIEntry } from "./ComfyQueue.svelte";
import queueState from "$lib/stores/queueState";
export let entries: QueueUIEntry[] = [];
export let showPrompt: (entry: QueueUIEntry) => void;
export let clearQueue: () => void;
export let deleteEntry: (entry: QueueUIEntry, event: MouseEvent) => void;
export let mode: QueueItemType = "queue";
export let imageSize: number = 40;
</script>
<div class="list-wrapper {mode}-mode">
{#if mode === "history"}
<div class="list-controls">
<div>
<input type="range" bind:value={imageSize} min={10} max={100} step={0.1} />
<div class="button-wrapper">
<button class="clear-queue-button secondary"
on:click={clearQueue}
disabled={$queueState.isInterrupting}>
🗑️
</button>
</div>
</div>
</div>
{/if}
<div class="list-entries {mode}-mode" style:--imageSize={imageSize}>
{#each entries as entry}
<div class="list-entry {entry.status}" on:click={(e) => showPrompt(entry, e)}>
<button class="list-entry-delete-button secondary"
on:click={(e) => deleteEntry(entry, e)}
disabled={$queueState.isInterrupting}>
<span></span>
</button>
{#if entry.images.length > 0}
<div class="list-entry-images"
style="--cols: {Math.ceil(Math.sqrt(Math.min(entry.images.length, 4)))}" >
{#each entry.images.slice(0, 4) as image, i}
<div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<img class="list-entry-image"
on:click={(e) => showLightbox(entry.images, i, e)}
src={image}
alt="thumbnail" />
</div>
{/each}
</div>
{/if}
<div class="list-entry-details">
<div class="list-entry-message">
{truncateString(entry.message, 20)}
</div>
<div class="list-entry-submessage">
{entry.submessage}
</div>
</div>
</div>
<div class="list-entry-rest {entry.status}">
{#if entry.date != null}
<span class="list-entry-queued-at">
{entry.date}
</span>
{/if}
</div>
{/each}
</div>
</div>
<style lang="scss">
$list-controls-height: 3rem;
$list-controls-margin: 0.25rem;
.list-wrapper {
height: 100%;
&.queue-mode .list-entries > :global(:first-child) {
// elements stick to bottom in queue mode only
// next element in queue is on the bottom
margin-top: auto !important;
}
}
.list-entries {
--imageSize: 40;
height: 100%;
&.history-mode {
height: calc(100% - #{$list-controls-height} - #{$list-controls-margin} * 2);
}
overflow-y: auto;
display: flex;
flex-flow: column nowrap;
}
.list-controls {
height: $list-controls-height;
position: relative;
display: flex;
flex-direction: column;
margin: $list-controls-margin 1rem;
> div {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
justify-content: center;
input {
width: 100%;
margin: auto;
}
.button-wrapper {
padding: $list-controls-margin;
.clear-queue-button {
@include square-button;
aspect-ratio: 1/1;
height: 100%;
padding: 0.25rem;
}
}
}
}
.list-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;
position: relative;
&:hover:not(:has(img:hover)):not(:has(button:hover)) {
cursor: pointer;
background: var(--block-background-fill);
&.running {
background: var(--comfy-accent-soft);
}
}
&.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: var(--block-background-fill);
border: 3px dashed var(--neutral-500);
}
&.pending, &.unknown {
}
}
.list-entry-delete-button {
@include square-button;
display: flex;
position: absolute;
width: 1.4rem;
height: 1.4rem;
text-align: center;
align-items: center;
font-size: 10pt;
justify-content: center;
text-align: center;
top:0;
right:0.5rem;
margin: 0.5rem;
z-index: 1000000000;
opacity: 70%;
background: var(--neutral-700);
color: var(--neutral-300);
&:hover {
opacity: 100%;
color: var(--neutral-100);
}
}
.list-entry-rest {
width: 100%;
position: relative;
&.all_cached, &.interrupted {
filter: brightness(80%);
color: var(--comfy-accent-soft);
}
}
$thumbnails-size: 12rem;
.list-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-grow: 1;
flex-shrink: 1;
flex-basis: calc(var(--imageSize) * 1%);
img {
aspect-ratio: 1 / 1;
object-fit: cover;
&:hover {
cursor: pointer;
filter: brightness(120%) contrast(120%);
}
}
}
.list-entry-details {
position: relative;
padding: 1rem;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
white-space: nowrap;
}
.list-entry-message {
font-size: 15px;
}
.list-entry-submessage {
font-size: 12px;
}
.list-entry-queued-at {
width: auto;
font-size: 12px;
position:absolute;
right: 0px;
bottom: 0px;
padding: 0.4rem 0.6rem;
color: var(--body-text-color);
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes';
import { PlusSquareIcon as PlusSquare } from 'svelte-feather-icons';
import { PlusSquareDotted } from 'svelte-bootstrap-icons';
import { Button } from "@gradio/button";
import { BlockTitle } from "@gradio/atoms";
import ComfyWorkflowView from "./ComfyWorkflowView.svelte";
@@ -253,7 +253,7 @@
</div>
<button class="workflow-add-new-button"
on:click={createNewWorkflow}>
<PlusSquare size="100%" strokeWidth={1.5} />
<PlusSquareDotted width="100%" height="100%" />
</button>
</div>
<div id="bottombar">

View File

@@ -62,7 +62,7 @@
{#if t.id === $selected_tab}
<button class="selected">
{#if t.icon !== null}
<svelte:component this={t.icon} size="100%" strokeWidth={1.5} />
<svelte:component this={t.icon} width="100%" height="100%" strokeWidth={1.5} />
{:else}
{t.name}
{/if}
@@ -75,7 +75,7 @@
}}
>
{#if t.icon !== null}
<svelte:component this={t.icon} size="100%" strokeWidth={1.5} />
<svelte:component this={t.icon} width="100%" height="100%" strokeWidth={1.5} />
{:else}
{t.name}
{/if}
@@ -115,7 +115,7 @@
> button {
width: 4rem;
height: 4rem;
padding: 0.5rem;
padding: 0.75rem;
color: var(--neutral-600);
border-right: 3px solid transparent;

View File

@@ -1,5 +1,5 @@
import { parseWhateverIntoImageMetadata, type ComfyBoxImageMetadata, type ComfyUploadImageType } from "$lib/utils";
import { BuiltInSlotType, LiteGraph, type IComboWidget, type ITextWidget, type PropertyLayout, type SlotLayout } from "@litegraph-ts/core";
import { BuiltInSlotType, LiteGraph, type IComboWidget, type ITextWidget, type PropertyLayout, type SlotLayout, type INumberWidget } from "@litegraph-ts/core";
import { get, writable, type Writable } from "svelte/store";
import GalleryWidget from "$lib/widgets/GalleryWidget.svelte";
@@ -42,15 +42,17 @@ export default class ComfyGalleryNode extends ComfyWidgetNode<ComfyBoxImageMetad
selectedFilename: string | null = null;
selectedIndexWidget: ITextWidget;
selectedIndexWidget: INumberWidget;
modeWidget: IComboWidget;
imageWidth: Writable<number> = writable(0);
imageHeight: Writable<number> = writable(0);
selectedImage: Writable<number | null> = writable(null);
constructor(name?: string) {
super(name, [])
this.selectedIndexWidget = this.addWidget("text", "Selected", String(this.properties.index), "index")
this.selectedIndexWidget = this.addWidget("number", "Selected", get(this.selectedImage))
this.selectedIndexWidget.disabled = true;
this.modeWidget = this.addWidget("combo", "Mode", this.properties.updateMode, null, { property: "updateMode", values: ["replace", "append"] })
}
@@ -63,11 +65,14 @@ export default class ComfyGalleryNode extends ComfyWidgetNode<ComfyBoxImageMetad
override onExecute() {
const value = get(this.value)
const index = get(this.selectedImage)
this.setOutputData(0, value)
this.setOutputData(1, this.properties.index)
this.setOutputData(1, index)
if (this.properties.index != null && value && value[this.properties.index] != null) {
const image = value[this.properties.index];
this.selectedIndexWidget.value = index;
if (index != null && value && value[index] != null) {
const image = value[index];
image.width = get(this.imageWidth)
image.height = get(this.imageHeight)
}
@@ -91,6 +96,8 @@ export default class ComfyGalleryNode extends ComfyWidgetNode<ComfyBoxImageMetad
if (this.properties.updateMode === "append") {
const currentValue = get(this.value)
if (meta.length > 0)
this.selectedImage.set(currentValue.length);
return currentValue.concat(meta)
}
else {
@@ -101,7 +108,6 @@ export default class ComfyGalleryNode extends ComfyWidgetNode<ComfyBoxImageMetad
override setValue(value: any, noChangedEvent: boolean = false) {
super.setValue(value, noChangedEvent)
this.setProperty("index", null)
}
}

View File

@@ -8,6 +8,9 @@ export type ConfigState = {
/** When saving, always prompt for a name to save the workflow as */
promptForWorkflowName: boolean,
/** When closing the tab, open the confirmation window if there's unsaved changes */
confirmWhenUnloadingUnsavedChanges: boolean,
}
type ConfigStateOps = {
@@ -17,7 +20,8 @@ export type WritableConfigStateStore = Writable<ConfigState> & ConfigStateOps;
const store: Writable<ConfigState> = writable(
{
alwaysStripUserState: false,
promptForWorkflowName: false
promptForWorkflowName: false,
confirmWhenUnloadingUnsavedChanges: true
})
const configStateStore: WritableConfigStateStore =

View File

@@ -1,4 +1,4 @@
import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, ComfyNodeID, PromptID } from "$lib/api";
import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, ComfyNodeID, PromptID, QueueItemType } from "$lib/api";
import type { Progress, SerializedPromptInputsAll, SerializedPromptOutputs, WorkflowInstID } from "$lib/components/ComfyApp";
import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify";
@@ -17,6 +17,8 @@ type QueueStateOps = {
progressUpdated: (progress: Progress) => void
getQueueEntry: (promptID: PromptID) => QueueEntry | null;
afterQueued: (workflowID: WorkflowInstID, promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void
queueItemDeleted: (type: QueueItemType, id: PromptID) => void;
queueCleared: (type: QueueItemType) => void;
onExecuted: (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => QueueEntry | null
}
@@ -181,6 +183,39 @@ function findEntryInPending(promptID: PromptID): [number, QueueEntry | null, Wri
return [-1, null, null]
}
function deleteEntry(promptID: PromptID): boolean {
const state = get(store);
let index = get(state.queuePending).findIndex(e => e.promptID === promptID)
let found = false;
if (index !== -1) {
state.queuePending.update(qp => {
qp.splice(index, 1)
return qp;
})
found = true;
}
index = get(state.queueRunning).findIndex(e => e.promptID === promptID)
if (index !== -1) {
state.queueRunning.update(qr => {
qr.splice(index, 1)
return qr;
})
found = true;
}
index = get(state.queueCompleted).findIndex(e => e.entry.promptID === promptID)
if (index !== -1) {
state.queueCompleted.update(qc => {
qc.splice(index, 1)
return qc;
})
found = true;
}
return found;
}
function moveToRunning(index: number, queue: Writable<QueueEntry[]>) {
const state = get(store)
@@ -294,7 +329,7 @@ function createNewQueueEntry(promptID: PromptID, number: number = -1, prompt: Se
return {
number,
queuedAt: new Date(), // Now
finishedAt: null,
finishedAt: undefined,
promptID,
prompt,
extraData,
@@ -363,6 +398,37 @@ function onExecuted(promptID: PromptID, nodeID: ComfyNodeID, outputs: ComfyExecu
return entry_;
}
function queueItemDeleted(type: QueueItemType, id: PromptID) {
console.debug("[queueState] queueItemDeleted", type, id)
store.update(s => {
if (!deleteEntry(id)) {
console.error("[queueState] Queue item to delete not found!", type, id);
}
s.isInterrupting = false;
return s;
})
}
function queueCleared(type: QueueItemType) {
console.debug("[queueState] queueCleared", type)
store.update(s => {
if (type === "queue") {
s.queuePending.set([]);
s.queueRunning.set([]);
s.queueRemaining = 0;
s.runningNodeID = null;
s.progress = null;
}
else {
s.queueCompleted.set([])
}
s.isInterrupting = false;
return s;
})
}
const queueStateStore: WritableQueueStateStore =
{
...store,
@@ -375,6 +441,8 @@ const queueStateStore: WritableQueueStateStore =
executionCached,
executionError,
afterQueued,
queueItemDeleted,
queueCleared,
getQueueEntry,
onExecuted
}

View File

@@ -6,6 +6,7 @@ import { get } from "svelte/store";
import type { ComfyNodeID } from "./api";
import { type SerializedPrompt } from "./components/ComfyApp";
import workflowState from "./stores/workflowState";
import { ImageViewer } from "./ImageViewer";
export function clamp(n: number, min: number, max: number): number {
return Math.min(Math.max(n, min), max)
@@ -23,6 +24,10 @@ export function countNewLines(str: string): number {
return str.split(/\r\n|\r|\n/).length
}
export function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function basename(filepath: string): string {
const filename = filepath.split('/').pop().split('\\').pop();
return filename.split('.').slice(0, -1).join('.');
@@ -501,3 +506,13 @@ export function comfyBoxImageToComfyFile(image: ComfyBoxImageMetadata): ComfyIma
export function comfyBoxImageToComfyURL(image: ComfyBoxImageMetadata): string {
return convertComfyOutputToComfyURL(image.comfyUIFile)
}
export function showLightbox(images: string[], index: number, e: Event) {
e.preventDefault()
if (!images)
return
ImageViewer.instance.showModal(images, index);
e.stopPropagation()
}

View File

@@ -7,7 +7,6 @@
import type { Styles } from "@gradio/utils";
import type { WidgetLayout } from "$lib/stores/layoutStates";
import { writable, type Writable } from "svelte/store";
import type { FileData as GradioFileData } from "@gradio/upload";
import type { SelectData as GradioSelectData } from "@gradio/utils";
import { clamp, comfyBoxImageToComfyURL, type ComfyBoxImageMetadata } from "$lib/utils";
import { f7 } from "framework7-svelte";
@@ -18,10 +17,9 @@
let node: ComfyGalleryNode | null = null;
let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null;
let propsChanged: Writable<number> | null = null;
let option: number | null = null;
let imageWidth: Writable<number> = writable(0);
let imageHeight: Writable<number> = writable(0);
let selected_image: number | null = null;
let selected_image: Writable<number | null> = writable(null);
$: widget && setNodeValue(widget);
@@ -32,6 +30,7 @@
propsChanged = node.propsChanged;
imageWidth = node.imageWidth
imageHeight = node.imageHeight
selected_image = node.selectedImage;
if ($nodeValue != null) {
if (node.properties.index < 0 || node.properties.index >= $nodeValue.length) {
@@ -81,7 +80,7 @@
thumbs: images.map(i => i.url),
type: 'popup',
});
mobileLightbox.open(selected_image)
mobileLightbox.open($selected_image)
}
function onClicked(e: CustomEvent<HTMLImageElement>) {
@@ -97,20 +96,6 @@
// Update index
node.setProperty("index", e.detail.index as number)
}
$: if ($propsChanged > -1 && widget && $nodeValue) {
if (widget.attrs.variant === "image") {
node.setProperty("index", selected_image)
}
else {
node.setProperty("index", $nodeValue.length > 0 ? 0 : null)
}
}
else {
node.setProperty("index", null)
}
$: node.setProperty("index", selected_image)
</script>
{#if widget && node && nodeValue && $nodeValue}
@@ -148,7 +133,7 @@
on:clicked={onClicked}
bind:imageWidth={$imageWidth}
bind:imageHeight={$imageHeight}
bind:selected_image
bind:selected_image={$selected_image}
/>
</div>
</Block>

View File

@@ -81,6 +81,44 @@ body {
--comfy-progress-bar-foreground: #B3D8A9
}
@mixin square-button {
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);
}
}
&.ternary {
background: var(--panel-background-fill);
&:hover {
background: var(--block-background-fill);
}
}
&:hover {
filter: brightness(85%);
}
&:active {
filter: brightness(50%)
}
&.selected {
filter: brightness(80%)
}
}
@mixin disable-input {
-webkit-text-fill-color: var(--comfy-disabled-textbox-text-color);
background-color: var(--comfy-disabled-textbox-background-fill);
@@ -108,6 +146,12 @@ hr {
color: var(--panel-border-color);
}
input {
color: var(--body-text-color);
background: var(--input-background-fill);
border: var(--input-border-width) solid var(--input-border-color)
}
input:not(input[type=radio]), textarea {
border-radius: 0 !important;
}