Merge pull request #56 from space-nuko/settings-tab

Vanilla workflow converter
This commit is contained in:
space-nuko
2023-05-21 20:57:21 -05:00
40 changed files with 1847 additions and 208 deletions

View File

@@ -12,20 +12,26 @@ ComfyBox is a frontend to Stable Diffusion that lets you create custom image gen
2. Start the ComfyUI backend with `python main.py --enable-cors-header`.
3. In the folder you extracted open the `run.bat`/`run.sh` script (requires Python 3 to be on your PATH). Alternatively you can serve the contents of the folder with any web server.
## Usage
A preconfigured workflow is included for the most common txt2img and img2img use cases, so all it takes to start generating is clicking `Load Default` to load the default workflow and then `Queue Prompt`.
You can import your existing workflows from ComfyUI into ComfyBox by clicking `Load` and choosing the `.json` or `.png` with embedded metadata, or dropping either file onto the graph viewer.
## NOTE
This project is *still under construction* and some features are missing, be aware of the tradeoffs if you're interested in using it.
This frontend isn't compatible with regular ComfyUI's workflow format since extra metadata is saved like panel layout, so you'll have to spend a bit of time recreating them. This project also isn't compatible with regular ComfyUI's frontend extension format, but useful extensions can be integrated into the base repo with some effort.
Also note that the saved workflow format is subject to change until it's been finalized after enough testing, so be prepared to lose some of your work from time to time.
## Features
- *No-Code UI Builder* - A novel system for creating your own Stable Diffusion user interfaces from the basic components.
- *Extension Support* - All custom ComfyUI nodes are supported out of the box.
- *Prompt Queue* - Queue up multiple prompts without waiting for them to finish first. Inspect currently queued and executed prompts.
- *Prompt History* - Browse through previously generated prompts and their output images/parameters.
- *Mobile-Friendly Version* - Includes a version of the UI optimized for mobile use, while still supporting the same customized workflows of the desktop version.
- **No-Code UI Builder** - A novel system for creating your own Stable Diffusion user interfaces from the basic components.
- **Manage Multiple Workflows** - You can open as many workflows as you like and switch between them using tabs within the app.
- **Use Your Existing Workflows** - Import workflows you've created in ComfyUI into ComfyBox and a new UI will be created for you.
- **Extension Support** - All custom ComfyUI nodes are supported out of the box.
- **Prompt Queue** - Queue up multiple prompts without waiting for them to finish first. Inspect currently queued and executed prompts.
- **Prompt History** - Browse through previously generated prompts and their output images/parameters.
- **Mobile-Friendly Version** - Includes a version of the UI optimized for mobile use, while still supporting the same customized workflows of the desktop version.
## Development

View File

@@ -10,7 +10,6 @@ import type { ComfyBackendNode } from "./nodes/ComfyBackendNode";
import type { ComfyComboNode, ComfyWidgetNode } from "./nodes/widgets";
import selectionState from "./stores/selectionState";
import type { WritableLayoutStateStore } from "./stores/layoutStates";
import type { WorkflowInstID } from "./components/ComfyApp";
import layoutStates from "./stores/layoutStates";
import type { ComfyWorkflow } from "./stores/workflowState";
import workflowState from "./stores/workflowState";

View File

@@ -1,4 +1,4 @@
import type { Progress, SerializedPrompt, SerializedPromptInputsForNode, SerializedPromptInputsAll, SerializedPromptOutputs } from "./components/ComfyApp";
import type { Progress, SerializedPrompt, SerializedPromptInputsForNode, SerializedPromptInputsAll, SerializedPromptOutputs, SerializedAppState } from "./components/ComfyApp";
import type TypedEmitter from "typed-emitter";
import EventEmitter from "events";
import type { ComfyImageLocation } from "$lib/utils";
@@ -57,10 +57,14 @@ export type ComfyAPIHistoryResponse = {
error?: string
}
export type SerializedComfyBoxPromptData = {
subgraphs: string[]
}
export type ComfyPromptPNGInfo = {
workflow: SerializedLGraph,
comfyBoxLayout: SerializedLayoutState,
comfyBoxSubgraphs: string[],
workflow?: SerializedLGraph, // ComfyUI format
comfyBoxWorkflow: SerializedAppState,
comfyBoxPrompt: SerializedComfyBoxPromptData,
}
export type ComfyBoxPromptExtraData = ComfyUIPromptExtraData & {

View File

@@ -7,11 +7,9 @@
import LightboxModal from "./LightboxModal.svelte";
import Sidebar from "./Sidebar.svelte";
import SidebarItem from "./SidebarItem.svelte";
// import Modal from "./Modal.svelte";
// import A1111PromptDisplay from "./A1111PromptDisplay.svelte";
import notify from "$lib/notify";
import ComfyWorkflowsView from "./ComfyWorkflowsView.svelte";
import notify from "$lib/notify";
import ComfyWorkflowsView from "./ComfyWorkflowsView.svelte";
import GlobalModal from "./GlobalModal.svelte";
export let app: ComfyApp = undefined;
let hasShownUIHelpToast: boolean = false;
@@ -36,11 +34,6 @@
document.getElementById("app-root").classList.remove("dark")
}
// let showModal: boolean = false;
//
// $: showModal = $a1111Prompt != null
//
// let selectedTab
</script>
<svelte:head>
@@ -49,20 +42,6 @@
{/if}
</svelte:head>
<!--
<Modal bind:showModal on:close={() => ($a1111Prompt = null)}>
<div slot="header" class="prompt-modal-header">
<h1 style="padding-bottom: 1rem;">A1111 Prompt Details</h1>
</div>
<A1111PromptDisplay prompt={$a1111Prompt} />
<div slot="buttons" let:closeDialog>
<Button variant="secondary" on:click={closeDialog}>
Close
</Button>
</div>
</Modal>
-->
<div id="main" class:dark={uiTheme === "gradio-dark"}>
<div id="container">
<Sidebar selected="generate">
@@ -74,6 +53,7 @@
</Sidebar>
</div>
<LightboxModal />
<GlobalModal/>
</div>
<SvelteToast options={toastOptions} />

View File

@@ -1,9 +1,14 @@
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 { getPngMetadata, importA1111 } from "$lib/pnginfo";
import { importA1111, parsePNGMetadata } from "$lib/pnginfo";
import EventEmitter from "events";
import type TypedEmitter from "typed-emitter";
import A1111PromptModal from "./modal/A1111PromptModal.svelte";
import MissingNodeTypesModal from "./modal/MissingNodeTypesModal.svelte";
import WorkflowLoadErrorModal from "./modal/WorkflowLoadErrorModal.svelte";
import ConfirmConvertWithMissingNodeTypesModal from "./modal/ConfirmConvertWithMissingNodeTypesModal.svelte";
// Import nodes
import "@litegraph-ts/nodes-basic"
@@ -21,14 +26,14 @@ import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import queueState from "$lib/stores/queueState";
import { type SvelteComponentDev } from "svelte/internal";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import type { LayoutState, SerializedLayoutState, WritableLayoutStateStore } from "$lib/stores/layoutStates";
import { defaultWorkflowAttributes, type LayoutState, type SerializedLayoutState, type WritableLayoutStateStore } from "$lib/stores/layoutStates";
import { toast } from '@zerodevx/svelte-toast'
import ComfyGraph from "$lib/ComfyGraph";
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 { download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis } from "$lib/utils";
import { basename, 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";
@@ -44,6 +49,8 @@ import selectionState from "$lib/stores/selectionState";
import layoutStates from "$lib/stores/layoutStates";
import { ComfyWorkflow, type WorkflowAttributes, type WorkflowInstID } from "$lib/stores/workflowState";
import workflowState from "$lib/stores/workflowState";
import convertVanillaWorkflow, { type ComfyVanillaWorkflow } from "$lib/convertVanillaWorkflow";
import modalState from "$lib/stores/modalState";
export const COMFYBOX_SERIAL_VERSION = 1;
@@ -72,8 +79,10 @@ export type A1111PromptAndInfo = {
* Represents a single workflow that can be loaded into the program from JSON.
*/
export type SerializedAppState = {
/** Program identifier, should always be "ComfyBox" */
createdBy: "ComfyBox",
/** For easy structural typing use */
comfyBoxWorkflow: true,
/** Program identifier, should be something like "ComfyBox" or "ComfyUI" */
createdBy: string,
/** Serial version, should be incremented on breaking changes */
version: number,
/** Commit hash if found */
@@ -138,6 +147,28 @@ type CanvasState = {
canvas: ComfyGraphCanvas,
}
export type WorkflowLoadError = {
message: string,
error: Error
}
export type VanillaWorkflowConvertResult = {
comfyBoxWorkflow: SerializedAppState,
missingNodeTypes: Set<string>
}
function isComfyBoxWorkflow(data: any): data is SerializedAppState {
return data != null && (typeof data === "object") && data.comfyBoxWorkflow;
}
function isVanillaWorkflow(data: any): data is SerializedLGraph {
return data != null && (typeof data === "object") && data.last_node_id != null;
}
type BackendNodeDef = {
nodeDef: ComfyNodeDef
}
export default class ComfyApp {
api: ComfyAPI;
@@ -150,7 +181,6 @@ export default class ComfyApp {
ctrlDown: boolean = false;
selectedGroupMoving: boolean = false;
alreadySetup: Writable<boolean> = writable(false);
a1111Prompt: Writable<A1111PromptAndInfo | null> = writable(null);
private queueItems: PromptQueueItem[] = [];
private processingQueue: boolean = false;
@@ -228,15 +258,16 @@ export default class ComfyApp {
this.lCanvas.draw(true, true);
}
serialize(workflow: ComfyWorkflow): SerializedAppState {
serialize(workflow: ComfyWorkflow, canvas?: SerializedGraphCanvasState): SerializedAppState {
const layoutState = layoutStates.getLayout(workflow.id);
if (layoutState == null)
throw new Error("Workflow has no layout!")
const { graph, layout, attrs } = workflow.serialize(layoutState);
const canvas = this.lCanvas.serialize();
canvas ||= this.lCanvas.serialize();
return {
comfyBoxWorkflow: true,
createdBy: "ComfyBox",
version: COMFYBOX_SERIAL_VERSION,
commitHash: __GIT_COMMIT_HASH__,
@@ -280,9 +311,12 @@ export default class ComfyApp {
return false;
const workflows = state.workflows as SerializedAppState[];
for (const workflow of workflows) {
await this.openWorkflow(workflow, defs)
}
await Promise.all(workflows.map(w => {
return this.openWorkflow(w, defs).catch(error => {
console.error("Failed restoring previous workflow", error)
notify(`Failed restoring previous workflow: ${error}`, { type: "error" })
})
}));
if (typeof state.activeWorkflowIndex === "number") {
workflowState.setActiveWorkflow(this.lCanvas, state.activeWorkflowIndex);
@@ -295,7 +329,11 @@ export default class ComfyApp {
static node_type_overrides: Record<string, typeof ComfyBackendNode> = {}
static widget_type_overrides: Record<string, typeof SvelteComponentDev> = {}
static knownBackendNodes: Record<string, BackendNodeDef> = {}
private async registerNodes(defs: Record<ComfyNodeID, ComfyNodeDef>) {
ComfyApp.knownBackendNodes = {}
// Register a node for each definition
for (const [nodeId, nodeDef] of Object.entries(defs)) {
const typeOverride = ComfyApp.node_type_overrides[nodeId]
@@ -318,6 +356,9 @@ export default class ComfyApp {
LiteGraph.registerNodeType(node);
node.category = nodeDef.category;
ComfyApp.knownBackendNodes[nodeId] = {
nodeDef
}
ComfyApp.registerDefaultSlotHandlers(nodeId, nodeDef)
}
@@ -399,7 +440,7 @@ export default class ComfyApp {
} catch (error) { }
}
if (workflow && workflow.createdBy === "ComfyBox") {
if (workflow && typeof workflow.createdBy === "string") {
this.openWorkflow(workflow);
}
else {
@@ -518,11 +559,35 @@ export default class ComfyApp {
async openWorkflow(data: SerializedAppState, refreshCombos: boolean | Record<string, ComfyNodeDef> = true): Promise<ComfyWorkflow> {
if (data.version !== COMFYBOX_SERIAL_VERSION) {
throw `Invalid ComfyBox saved data format: ${data.version}`
const mes = `Invalid ComfyBox saved data format: ${data.version}`
notify(mes, { type: "error" })
return Promise.reject(mes);
}
this.clean();
const workflow = workflowState.openWorkflow(this.lCanvas, data);
let workflow: ComfyWorkflow;
try {
workflow = workflowState.openWorkflow(this.lCanvas, data);
}
catch (error) {
modalState.pushModal({
svelteComponent: WorkflowLoadErrorModal,
svelteProps: {
error
}
})
return Promise.reject(error)
}
if (workflow.missingNodeTypes.size > 0) {
modalState.pushModal({
svelteComponent: MissingNodeTypesModal,
svelteProps: {
missingNodeTypes: workflow.missingNodeTypes
}
})
}
// Restore canvas offset/zoom
this.lCanvas.deserialize(data.canvas)
@@ -537,6 +602,56 @@ export default class ComfyApp {
return workflow;
}
async openVanillaWorkflow(data: SerializedLGraph, filename: string) {
const title = basename(filename)
const attrs: WorkflowAttributes = {
...defaultWorkflowAttributes,
title
}
const canvas: SerializedGraphCanvasState = {
offset: [0, 0],
scale: 1
}
const [comfyBoxWorkflow, layoutState] = convertVanillaWorkflow(data, attrs);
const addWorkflow = () => {
notify("Converted ComfyUI workflow to ComfyBox format.", { type: "info" })
workflowState.addWorkflow(this.lCanvas, comfyBoxWorkflow)
this.lCanvas.deserialize(canvas);
}
if (comfyBoxWorkflow.missingNodeTypes.size > 0) {
modalState.pushModal({
svelteComponent: ConfirmConvertWithMissingNodeTypesModal,
svelteProps: {
missingNodeTypes: comfyBoxWorkflow.missingNodeTypes
},
closeOnClick: false,
showCloseButton: false,
buttons: [
{
name: "Cancel",
variant: "secondary",
onClick: () => {
layoutStates.remove(comfyBoxWorkflow.id)
}
},
{
name: "Convert",
variant: "primary",
onClick: addWorkflow
},
]
})
}
else {
addWorkflow()
}
}
setActiveWorkflow(id: WorkflowInstID) {
const index = get(workflowState).openedWorkflows.findIndex(w => w.id === id)
if (index === -1)
@@ -695,7 +810,7 @@ export default class ComfyApp {
}
const p = this.graphToPrompt(workflow, tag);
const l = workflow.layout.serialize();
const wf = this.serialize(workflow)
console.debug(graphToGraphVis(workflow.graph))
console.debug(promptToGraphVis(p))
@@ -704,9 +819,10 @@ export default class ComfyApp {
const extraData: ComfyBoxPromptExtraData = {
extra_pnginfo: {
workflow: p.workflow,
comfyBoxLayout: l,
comfyBoxSubgraphs: [tag],
comfyBoxWorkflow: wf,
comfyBoxPrompt: {
subgraphs: [tag]
}
},
thumbnails
}
@@ -761,10 +877,14 @@ export default class ComfyApp {
*/
async handleFile(file: File) {
if (file.type === "image/png") {
const pngInfo = await getPngMetadata(file);
const buffer = await file.arrayBuffer();
const pngInfo = await parsePNGMetadata(buffer);
if (pngInfo) {
if (pngInfo.comfyBoxConfig) {
await this.openWorkflow(JSON.parse(pngInfo.comfyBoxConfig));
if (pngInfo.comfyBoxWorkflow) {
await this.openWorkflow(JSON.parse(pngInfo.comfyBoxWorkflow));
} else if (pngInfo.workflow) {
const workflow = JSON.parse(pngInfo.workflow);
await this.openVanillaWorkflow(workflow, file.name);
} else if (pngInfo.parameters) {
const parsed = parseA1111(pngInfo.parameters)
if ("error" in parsed) {
@@ -772,11 +892,18 @@ export default class ComfyApp {
return;
}
const converted = convertA1111ToStdPrompt(parsed)
this.a1111Prompt.set({
const a1111Info: A1111PromptAndInfo = {
infotext: pngInfo.parameters,
parsedInfotext: parsed,
stdPrompt: converted,
imageFile: file
}
modalState.pushModal({
title: "A1111 Prompt Details",
svelteComponent: A1111PromptModal,
svelteProps: {
prompt: a1111Info
},
})
}
else {
@@ -787,7 +914,13 @@ export default class ComfyApp {
} else if (file.type === "application/json" || file.name.endsWith(".json")) {
const reader = new FileReader();
reader.onload = async () => {
await this.openWorkflow(JSON.parse(reader.result as string));
const result = JSON.parse(reader.result as string)
if (isComfyBoxWorkflow(result)) {
await this.openWorkflow(result);
}
else if (isVanillaWorkflow(result)) {
await this.openVanillaWorkflow(result, file.name);
}
};
reader.readAsText(file);
}
@@ -930,6 +1063,5 @@ export default class ComfyApp {
* Clean current state
*/
clean() {
this.a1111Prompt.set(null);
}
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { Button } from "@gradio/button";
import type ComfyApp from "./ComfyApp";
import DropZone from "./DropZone.svelte";
export let app: ComfyApp;
export let transitioning: boolean = false;
@@ -13,6 +14,7 @@
<div class="wrapper litegraph">
<div class="canvas-wrapper pane-wrapper">
<canvas id="graph-canvas" />
<DropZone {app} />
</div>
<div class="bar">
{#if !transitioning}

View File

@@ -4,7 +4,7 @@
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 { convertComfyOutputToComfyURL, convertFilenameToComfyURL, getNodeInfo, truncateString } from "$lib/utils"
import type { Writable } from "svelte/store";
import type { QueueItemType } from "$lib/api";
import { ImageViewer } from "$lib/ImageViewer";
@@ -22,12 +22,14 @@
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: QueueEntryStatus | "pending" | "running",
status: QueueUIEntryStatus,
images?: string[], // URLs
details?: string // shown in a tooltip on hover
}
@@ -39,21 +41,29 @@
}
let mode: QueueItemType = "queue";
let changed = true;
function switchMode(newMode: QueueItemType) {
const changed = mode !== newMode
changed = mode !== newMode
mode = newMode
if (changed)
if (changed) {
_queuedEntries = []
_runningEntries = []
_entries = []
}
}
let _queuedEntries: QueueUIEntry[] = []
let _runningEntries: QueueUIEntry[] = []
let _entries: QueueUIEntry[] = []
$: if (mode === "queue" && $queuePending && $queuePending.length != _entries.length) {
$: if (mode === "queue" && (changed || ($queuePending && $queuePending.length != _queuedEntries.length))) {
updateFromQueue();
changed = false;
}
else if (mode === "history" && $queueCompleted && $queueCompleted.length != _entries.length) {
else if (mode === "history" && (changed || ($queueCompleted && $queueCompleted.length != _entries.length))) {
updateFromHistory();
changed = false;
}
function formatDate(date: Date): string {
@@ -62,20 +72,20 @@
return [time, day].join(", ")
}
function convertEntry(entry: QueueEntry): QueueUIEntry {
function convertEntry(entry: QueueEntry, status: QueueUIEntryStatus): QueueUIEntry {
let date = entry.finishedAt || entry.queuedAt;
let dateStr = null;
if (date) {
dateStr = formatDate(date);
}
const subgraphs: string[] | null = entry.extraData?.extra_pnginfo?.comfyBoxSubgraphs;
const subgraphs: string[] | null = entry.extraData?.extra_pnginfo?.comfyBoxPrompt?.subgraphs;
let message = "Prompt";
if (entry.workflowID != null) {
const workflow = workflowState.getWorkflow(entry.workflowID);
if (workflow != null && workflow.attrs.title) {
message = `Workflow: ${workflow.attrs.title}`
message = `${workflow.attrs.title}`
}
if (subgraphs?.length > 0)
message += ` (${subgraphs.join(', ')})`
@@ -93,13 +103,13 @@
message,
submessage,
date: dateStr,
status: "pending",
status,
images: []
}
}
function convertPendingEntry(entry: QueueEntry): QueueUIEntry {
const result = convertEntry(entry);
function convertPendingEntry(entry: QueueEntry, status: QueueUIEntryStatus): QueueUIEntry {
const result = convertEntry(entry, status);
const thumbnails = entry.extraData?.thumbnails
if (thumbnails) {
@@ -110,8 +120,7 @@
}
function convertCompletedEntry(entry: CompletedQueueEntry): QueueUIEntry {
const result = convertEntry(entry.entry);
result.status = entry.status;
const result = convertEntry(entry.entry, entry.status);
const images = Object.values(entry.entry.outputs).flatMap(o => o.images)
.map(convertComfyOutputToComfyURL);
@@ -128,12 +137,15 @@
}
async function updateFromQueue() {
_entries = $queuePending.map(convertPendingEntry).reverse(); // newest entries appear at the top
// newest entries appear at the top
_queuedEntries = $queuePending.map((e) => convertPendingEntry(e, "pending")).reverse();
_runningEntries = $queueRunning.map((e) => convertPendingEntry(e, "running")).reverse();
_entries = [..._queuedEntries, ..._runningEntries]
if (queueList) {
await tick(); // Wait for list size to be recalculated
queueList.scroll({ top: queueList.scrollHeight })
}
console.warn("[ComfyQueue] BUILDQUEUE", _entries, $queuePending)
console.warn("[ComfyQueue] BUILDQUEUE", _entries, $queuePending, $queueRunning)
}
async function updateFromHistory() {
@@ -208,7 +220,6 @@
</Modal>
<div class="queue">
<DropZone {app} />
<div class="queue-entries {mode}-mode" bind:this={queueList}>
{#if _entries.length > 0}
{#each _entries as entry}
@@ -230,7 +241,7 @@
{/if}
<div class="queue-entry-details">
<div class="queue-entry-message">
{entry.message}
{truncateString(entry.message, 20)}
</div>
<div class="queue-entry-submessage">
{entry.submessage}
@@ -305,8 +316,9 @@
<style lang="scss">
$pending-height: 200px;
$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});
$queue-height: calc(100vh - #{$pending-height} - #{$mode-buttons-height} - #{$bottom-bar-height} - #{$workflow-tabs-height} - 0.9rem);
.prompt-modal-header {
padding-left: 0.2rem;
@@ -368,6 +380,10 @@
&:hover:not(:has(img:hover)) {
cursor: pointer;
background: var(--block-background-fill);
&.running {
background: var(--comfy-accent-soft);
}
}
&.success {
@@ -382,10 +398,10 @@
color: var(--comfy-disable-textbox-text-color);
}
&.running {
/* background: lightblue; */
background: var(--block-background-fill);
border: 3px dashed var(--neutral-500);
}
&.pending, &.unknown {
/* background: orange; */
}
}

View File

@@ -18,6 +18,7 @@
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import { fade } from 'svelte/transition';
import { cubicIn } from 'svelte/easing';
import { truncateString } from '$lib/utils';
export let app: ComfyApp;
export let uiTheme: string = "gradio-dark" // TODO config
@@ -50,7 +51,7 @@
}
async function doRefreshCombos() {
await app.refreshComboInNodes(undefined, true)
await app.refreshComboInNodes(undefined, undefined, true)
}
function refreshView(event?: Event) {
@@ -136,12 +137,12 @@
if (!fileInput)
return;
fileInput.value = null;
fileInput.click();
}
function loadWorkflow(): void {
app.handleFile(fileInput.files[0]);
fileInput.files = null;
}
function doSaveLocal(): void {
@@ -203,7 +204,7 @@
{#if $workflowState.activeWorkflow != null}
<ComfyWorkflowView {app} workflow={$workflowState.activeWorkflow} />
{:else}
<span>No workflow loaded</span>
<span style:color="var(--body-text-color)">No workflow loaded</span>
{/if}
</Pane>
<Pane bind:size={graphSize}>
@@ -235,7 +236,7 @@
class:selected={item.id === $workflowState.activeWorkflowID}
on:click={() => app.setActiveWorkflow(item.id)}>
<span class="workflow-tab-title">
{workflow.attrs.title}
{truncateString(workflow.attrs.title, 32)}
{#if workflow.isModified}
*
{/if}
@@ -313,7 +314,7 @@
</div>
</div>
</div>
<input bind:this={fileInput} id="comfy-file-input" type="file" accept=".json" on:change={loadWorkflow} />
<input bind:this={fileInput} id="comfy-file-input" type="file" accept="application/json,image/png" on:change={loadWorkflow} />
{#if appSetupPromise}
{#await appSetupPromise}
@@ -326,7 +327,7 @@
Error loading app
</div>
<div>{error}</div>
{#if error.stack}
{#if error != null && error.stack}
{@const lines = error.stack.split("\n")}
{#each lines as line}
<div style:font-size="16px">{line}</div>

View File

@@ -1,15 +1,12 @@
<script lang="ts">
import { writable, type Writable } from "svelte/store";
import modalState from "$lib/stores/modalState";
import type ComfyApp from "./ComfyApp";
export let app: ComfyApp;
let a1111Prompt: Writable<any | null> = writable(null);
let dropZone: HTMLDivElement | null = null;
let disabled = false;
$: a1111Prompt = app.a1111Prompt;
$: disabled = a1111Prompt && $a1111Prompt;
$: disabled = $modalState.activeModals.length > 0;
$: if (disabled) {
hideDropZone();

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import modalState, { type ModalButton, type ModalData } from "$lib/stores/modalState";
import { Button } from "@gradio/button";
import Modal from "./Modal.svelte";
function onClose(modal: ModalData | null) {
if (modal == null)
return;
if (modal.onClose)
modal.onClose()
modalState.closeModal(modal.id)
}
function onButtonClicked(button: ModalButton, closeDialog: Function) {
button.onClick();
if (button.closeOnClick !== false) {
closeDialog()
}
}
</script>
{#each $modalState.activeModals as modal(modal.id)}
<Modal showModal={true} closeOnClick={modal.closeOnClick} on:close={() => onClose(modal)}>
<div slot="header" class="modal-header">
{#if modal != null && modal.title != null}
<h1 style="padding-bottom: 1rem;">{modal.title}</h1>
{/if}
</div>
<svelte:fragment>
{#if modal != null && modal.svelteComponent != null}
<svelte:component this={modal.svelteComponent} {...modal.svelteProps}/>
{/if}
</svelte:fragment>
<div slot="buttons" class="buttons" let:closeDialog>
{#if modal != null && modal.buttons?.length > 0}
{#each modal.buttons as button}
<Button variant={button.variant} on:click={() => onButtonClicked(button, closeDialog)}>
{button.name}
</Button>
{/each}
{/if}
{#if modal.showCloseButton}
<Button variant="secondary" on:click={closeDialog}>
Close
</Button>
{/if}
</div>
</Modal>
{/each}
<style lang="scss">
.buttons {
gap: var(--spacing-sm);
}
</style>

View File

@@ -50,7 +50,7 @@
</div>
</dialog>
<style>
<style lang="scss">
dialog {
max-width: 75vw;
border-radius: 0.2em;
@@ -93,7 +93,10 @@
.button-row {
display: flex;
flex-direction: row;
gap: var(--spacing-sm);
padding-top: 0.5em;
}
.button-row, .buttons {
gap: var(--spacing-sm);
}
</style>

View File

@@ -8,6 +8,7 @@
import Gallery from "$lib/components/gradio/gallery/Gallery.svelte";
import { ImageViewer } from "$lib/ImageViewer";
import type { Styles } from "@gradio/utils";
import { countNewLines } from "$lib/utils";
const splitLength = 50;
@@ -18,7 +19,7 @@
let galleryStyle: Styles = {
grid_cols: [2],
object_fit: "cover",
object_fit: "contain",
height: "var(--size-96)"
}
@@ -29,10 +30,6 @@
&& typeof input[1] === "number"
}
function countNewLines(str: string): number {
return str.split(/\r\n|\r|\n/).length
}
function isMultiline(input: any): boolean {
return typeof input === "string" && (input.length > splitLength || countNewLines(input) > 1);
}
@@ -165,6 +162,11 @@
> :global(.block) {
height: 100%;
:global(> .preview) {
height: 100%;
max-height: none !important;
}
}
}

View File

@@ -87,7 +87,7 @@
class:is-executing={$queueState.runningNodeID && $queueState.runningNodeID == widget.node.id}
class:hidden={hidden}
>
<svelte:component this={widget.node.svelteComponentType} {layoutState} {widget} {isMobile} />
<svelte:component this={widget.node.svelteComponentType} {widget} {isMobile} />
</div>
{#if hidden && edit}
<div class="handle handle-hidden" class:hidden={!edit} />

View File

@@ -1,11 +1,10 @@
<script lang="ts">
import ComfyBoxStdPrompt from "$lib/ComfyBoxStdPrompt";
import type { A1111ParsedInfotext } from "$lib/parseA1111";
import type { A1111ParsedInfotext } from "$lib/parseA1111";
import { Block, BlockTitle } from "@gradio/atoms";
import { TextBox } from "@gradio/form";
import { JsonView } from '@zerodevx/svelte-json-view'
import type { A1111PromptAndInfo } from "./ComfyApp";
import { StaticImage } from "./gradio/image";
import type { A1111PromptAndInfo } from "$lib/components/ComfyApp";
import { StaticImage } from "$lib/components/gradio/image";
export let prompt: A1111PromptAndInfo | null = null;

View File

@@ -0,0 +1,26 @@
<script lang="ts">
export let missingNodeTypes: Set<string> = new Set();
</script>
<div>
When loading the graph, the following node types were not found:
<ul>
{#each Array.from(missingNodeTypes) as type}
<li>{type}</li>
{/each}
</ul>
Do you want to convert the workflow into ComfyBox format anyway? (You may lose some workflow state)
</div>
<style lang="scss">
div {
padding: 1rem;
}
ul {
padding-left: 2rem;
> li {
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
export let missingNodeTypes: Set<string> = new Set();
</script>
<div>
When loading the graph, the following node types were not found:
<ul>
{#each Array.from(missingNodeTypes) as type}
<li>{type}</li>
{/each}
</ul>
Nodes that have failed to load will show as red on the graph.
</div>
<style lang="scss">
div {
padding: 1rem;
}
ul {
padding-left: 2rem;
> li {
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
export let error: Error;
</script>
<div>
Loading aborted due to error reloading workflow data:
<div class="error-block">
<pre class="error">
{error.toString()}
</pre>
<pre class="stack">
{error?.stack || "(No stacktrace available)"}
</pre>
</div>
</div>
<style lang="scss">
div {
padding: 1rem;
> .error-block {
margin-top: 1rem;
}
}
pre {
font-size: 1.2rem;
padding: 1rem 0;
background-color: rgba(255, 0, 0, 0.2);
&.stack {
font-size: 1.0rem;
color: #ccc;
max-height: 50vh;
overflow: auto;
background-color: rgb(0, 0, 0, 0.2);
}
}
</style>

View File

@@ -0,0 +1,522 @@
import { LGraph, type INodeInputSlot, type SerializedLGraph, type LinkID, type UUID, type NodeID, LiteGraph, BuiltInSlotType, type SerializedLGraphNode, type Vector2, BuiltInSlotShape, type INodeOutputSlot, type SlotType } from "@litegraph-ts/core";
import type { SerializedAppState } from "./components/ComfyApp";
import layoutStates, { defaultWorkflowAttributes, type ContainerLayout, type DragItemID, type SerializedDragEntry, type SerializedLayoutState, type WritableLayoutStateStore } from "./stores/layoutStates";
import { ComfyWorkflow, type WorkflowAttributes } from "./stores/workflowState";
import type { SerializedGraphCanvasState } from "./ComfyGraphCanvas";
import ComfyApp from "./components/ComfyApp";
import { iterateNodeDefInputs, type ComfyNodeDefInputType, type ComfyNodeDefInputOptions } from "./ComfyNodeDef";
import type { ComfyNodeDefInput } from "./ComfyNodeDef";
import type IComfyInputSlot from "./IComfyInputSlot";
import ComfyWidgets from "./widgets"
import type { SerializedComfyWidgetNode } from "./nodes/widgets/ComfyWidgetNode";
import { v4 as uuidv4 } from "uuid"
import type ComfyWidgetNode from "./nodes/widgets/ComfyWidgetNode";
import { ComfyGalleryNode } from "./nodes/widgets";
import { countNewLines } from "./utils";
/*
* The workflow type used by base ComfyUI
*/
export type ComfyVanillaWorkflow = SerializedLGraph;
/*
* The settings for a widget converted to an input slot via the widgetInputs.js
* frontend extension.
*/
type ComfyUIConvertedWidget = {
name: string,
config: ComfyNodeDefInput
}
/*
* Input slot for widgets converted to inputs
*/
interface IComfyUINodeInputSlot extends INodeInputSlot {
widget?: ComfyUIConvertedWidget
}
/*
* Output slot for PrimitiveNode
*/
interface IComfyUINodeOutputSlot extends INodeOutputSlot {
widget?: ComfyUIConvertedWidget
}
/*
* ComfyUI frontend nodes that should be converted directly to another type.
*/
const vanillaToComfyBoxNodeMapping: Record<string, string> = {
"Reroute": "utils/reroute"
}
/*
* Version of LGraphNode.getConnectionPos but for serialized nodes.
* TODO handle other node types! (horizontal, hardcoded slot pos, collapsed...)
*/
function getConnectionPos(node: SerializedLGraphNode, is_input: boolean, slotNumber: number, out: Vector2 = [0, 0]): Vector2 {
var offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5;
if (is_input) {
out[0] = node.pos[0] + offset;
} else {
out[0] = node.pos[0] + node.size[0] + 1 - offset;
}
out[1] =
node.pos[1] +
(slotNumber + 0.7) * LiteGraph.NODE_SLOT_HEIGHT +
((node.constructor as any).slot_start_y || 0);
return out;
}
function createSerializedWidgetNode(vanillaWorkflow: ComfyVanillaWorkflow, node: SerializedLGraphNode, slotIndex: number, isInput: boolean, widgetNodeType: string, value: any): [ComfyWidgetNode, SerializedComfyWidgetNode] {
const comfyWidgetNode = LiteGraph.createNode<ComfyWidgetNode>(widgetNodeType);
comfyWidgetNode.flags.collapsed = true;
const size: Vector2 = [0, 0];
// Compute collapsed size, since computeSize() ignores the collapsed flag
// LiteGraph only computes it if the node is rendered
const fontSize = LiteGraph.NODE_TEXT_SIZE;
size[0] = Math.min(
comfyWidgetNode.size[0],
comfyWidgetNode.title.length * fontSize +
LiteGraph.NODE_TITLE_HEIGHT * 2
);
const serWidgetNode = comfyWidgetNode.serialize() as SerializedComfyWidgetNode;
serWidgetNode.comfyValue = value;
serWidgetNode.shownOutputProperties = {};
getConnectionPos(node, isInput, slotIndex, serWidgetNode.pos);
if (isInput)
serWidgetNode.pos[0] -= size[0] - 20;
else
serWidgetNode.pos[0] += 20;
serWidgetNode.pos[1] += LiteGraph.NODE_TITLE_HEIGHT / 2;
if (widgetNodeType === "ui/text" && typeof value === "string" && value.indexOf("\n") != -1) {
const lineCount = countNewLines(value);
serWidgetNode.properties.multiline = true;
serWidgetNode.properties.lines = lineCount + 2
serWidgetNode.properties.maxLines = lineCount + 2
}
vanillaWorkflow.nodes.push(serWidgetNode)
return [comfyWidgetNode, serWidgetNode];
}
function connectSerializedNodes(vanillaWorkflow: ComfyVanillaWorkflow, originNode: SerializedLGraphNode, originSlot: number, targetNode: SerializedLGraphNode, targetSlot: number) {
const connInput = targetNode.inputs[targetSlot]
const connOutput = originNode.outputs[originSlot]
const newLinkID = uuidv4();
connInput.link = newLinkID
connOutput.links ||= []
connOutput.links.push(newLinkID);
vanillaWorkflow.links ||= []
vanillaWorkflow.links.push([newLinkID, originNode.id, originSlot, targetNode.id, targetSlot, connInput.type])
}
/*
* Converts all the IDs in the serialized graph into UUID format
*/
function rewriteIDsInGraph(vanillaWorkflow: ComfyVanillaWorkflow) {
const nodeIDs: Record<NodeID, UUID> = {};
const linkIDs: Record<LinkID, UUID> = {};
const getNodeID = (id: NodeID): UUID => {
if (typeof id === "string")
return id
nodeIDs[id] ||= uuidv4();
return nodeIDs[id];
}
const getLinkID = (id: LinkID): UUID => {
if (typeof id === "string")
return id
linkIDs[id] ||= uuidv4();
return linkIDs[id];
}
for (const node of vanillaWorkflow.nodes) {
node.id = getNodeID(node.id);
if (node.inputs != null) {
for (const input of node.inputs) {
if (input.link != null) {
input.link = getLinkID(input.link)
}
}
}
if (node.outputs != null) {
for (const output of node.outputs) {
if (output.links != null)
output.links = output.links.map(getLinkID);
}
}
}
for (const link of vanillaWorkflow.links) {
link[0] = getLinkID(link[0])
link[1] = getNodeID(link[1])
link[3] = getNodeID(link[3])
}
// Recurse!
for (const node of vanillaWorkflow.nodes) {
if (node.type === "graph/subgraph") {
rewriteIDsInGraph((node as any).subgraph as SerializedLGraph)
}
}
}
/*
* Returns [nodeType, inputType, addedWidgetCount] for a config type, like "FLOAT" -> ["ui/number", "number", 1]
* For "INT:seed" it's ["ui/number", "number", 2] since that type adds a randomizer combo widget,
* so there will be 2 total widgets
*/
function getWidgetTypesFromConfig(inputName: string, inputType: ComfyNodeDefInputType): [string, SlotType, number] | null {
let widgetNodeType = null;
let widgetInputType = null;
let addedWidgetCount = 1;
if (Array.isArray(inputType)) {
// Combo options of string[]
widgetNodeType = "ui/combo";
widgetInputType = "string"
addedWidgetCount = 1;
}
else if (`${inputType}:${inputName}` in ComfyWidgets) {
// Widget type override for input of type with given name ("seed", "noise_seed")
const widgetFactory = ComfyWidgets[`${inputType}:${inputName}`]
widgetNodeType = widgetFactory.nodeType;
widgetInputType = widgetFactory.inputType
addedWidgetCount = widgetFactory.addedWidgetCount
}
else if (inputType in ComfyWidgets) {
// Widget type
const widgetFactory = ComfyWidgets[inputType]
widgetNodeType = widgetFactory.nodeType;
widgetInputType = widgetFactory.inputType
addedWidgetCount = widgetFactory.addedWidgetCount
}
else {
// Backend type, we can safely ignore this
return null;
}
return [widgetNodeType, widgetInputType, addedWidgetCount]
}
function configureWidgetNodeProperties(serWidgetNode: SerializedComfyWidgetNode, inputOpts?: ComfyNodeDefInputOptions) {
inputOpts ||= {}
switch (serWidgetNode.type) {
case `ui/number`:
serWidgetNode.properties.min = inputOpts.min || 0;
serWidgetNode.properties.max = inputOpts.max || 100;
serWidgetNode.properties.step = inputOpts.step || 1;
break;
case "ui/text":
serWidgetNode.properties.multiline = inputOpts.multiline || false;
break;
}
}
/*
* Attempts to convert a primitive node
* The primitive node should be pruned from the graph afterwards
*/
function convertPrimitiveNode(vanillaWorkflow: ComfyVanillaWorkflow, node: SerializedLGraphNode, layoutState: WritableLayoutStateStore, group: ContainerLayout): boolean {
// Get the value output
// On primitive nodes it's the one in the first slot
const mainOutput = (node.outputs || [])[0] as IComfyUINodeOutputSlot;
if (!mainOutput || !mainOutput.links) {
console.error("PrimitiveNode output had no output with links!", node)
return false;
}
const widget = mainOutput.widget;
if (widget === null) {
console.error("PrimitiveNode output had no widget config!", node)
return false;
}
const [widgetType, widgetOpts] = widget.config
if (!node.widgets_values) {
console.error("PrimitiveNode had no serialized widget values!", node)
return false;
}
let pair = getWidgetTypesFromConfig(widget.name, widgetType);
if (pair == null) {
// This should never happen! Primitive nodes only deal with frontend types!
console.error("PrimitiveNode had a backend type configured!", node)
return false;
}
let [widgetNodeType, widgetInputType, addedWidgetCount] = pair
// PrimitiveNode will have a widget in the first slot with the actual value.
// The rest are configuration values for e.g. seed action on prompt queue.
const value = node.widgets_values[0];
const [comfyWidgetNode, serWidgetNode] = createSerializedWidgetNode(
vanillaWorkflow,
node,
0, // first output on the PrimitiveNode
false, // this is an output slot index
widgetNodeType,
value);
// Set the UI node's min/max/step from the node def
configureWidgetNodeProperties(serWidgetNode, widgetOpts)
let foundTitle = null;
const widgetLayout = layoutState.addWidget(group, comfyWidgetNode)
widgetLayout.attrs.title = mainOutput.name;
// Follow the existing links on the original node and do some cleanup
const newLinkOutputSlot = serWidgetNode.outputs.findIndex(o => o.name === comfyWidgetNode.outputSlotName)
if (newLinkOutputSlot !== -1) {
const newLinkOutput = serWidgetNode.outputs[newLinkOutputSlot];
for (const linkID of mainOutput.links) {
const link = vanillaWorkflow.links.find(l => l[0] === linkID)
if (link) {
// Rewrite links to point to the new widget node
link[1] = serWidgetNode.id; // origin node ID
link[2] = newLinkOutputSlot; // origin node slot
newLinkOutput.links ||= []
newLinkOutput.links.push(linkID)
// Look up the node the link was connected to.
const targetNode = vanillaWorkflow.nodes.find(n => n.id === link[3]) // target node ID
const foundInput = targetNode != null ? targetNode.inputs[link[4]] : null // target node slot
// Make sure that the input type for the connected inputs is correct.
// ComfyUI seems to set them to the input def type instead of the litegraph type.
// For example a "number" input gets changed to type "INT" or "FLOAT"
// Also ensure the input is marked for serialization, else there
// will be random prompt validation errors on the backend
link[5] = widgetInputType // link data type
if (foundInput != null) {
foundInput.type = widgetInputType;
(foundInput as IComfyInputSlot).serialize = true; // IMPORTANT!!!
}
// Change the title of the widget to the name of the first input connected to
if (foundTitle == null && foundInput != null && foundInput.name) {
foundTitle = foundInput.name;
widgetLayout.attrs.title = foundTitle;
}
}
}
// Remove links on the old node so they won't be double-removed when
// it's pruned (removeSerializedNode will remove any links still
// connected to other inputs, but we want to keep the ones we rewrote)
mainOutput.links = []
}
else {
console.error("Could not find output slot for new widget node!", comfyWidgetNode, serWidgetNode)
}
return true;
}
function removeSerializedNode(vanillaWorkflow: SerializedLGraph, node: SerializedLGraphNode) {
if (node.outputs) {
for (const output of node.outputs) {
if (output.links) {
vanillaWorkflow.links = vanillaWorkflow.links.filter(l => output.links.indexOf(l[0]) === -1);
output.links = []
}
}
}
if (node.inputs) {
for (const input of node.inputs) {
if (input.link) {
vanillaWorkflow.links = vanillaWorkflow.links.filter(l => input.link !== l[0]);
input.link = null;
}
}
}
vanillaWorkflow.nodes = vanillaWorkflow.nodes.filter(n => n.id !== node.id);
}
/*
* Converts a workflow saved with vanilla ComfyUI into a ComfyBox workflow,
* adding UI nodes for each widget.
*/
export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWorkflow, attrs: WorkflowAttributes): [ComfyWorkflow, WritableLayoutStateStore] {
const [comfyBoxWorkflow, layoutState] = ComfyWorkflow.create();
const { root, left, right } = layoutState.initDefaultLayout();
// TODO will need to convert IDs to UUIDs
const idToUUID: Record<NodeID | LinkID, UUID> = {}
rewriteIDsInGraph(vanillaWorkflow);
for (const [id, node] of Object.entries(vanillaWorkflow.nodes)) {
const newType = vanillaToComfyBoxNodeMapping[node.type];
if (newType != null) {
node.type = newType;
}
// renamed field
const bgcolor = (node as any).bgcolor
if (bgcolor != null)
node.bgColor ||= bgcolor
node.color ||= LiteGraph.NODE_DEFAULT_COLOR;
node.bgColor ||= LiteGraph.NODE_DEFAULT_BGCOLOR;
// ComfyUI uses widgets on the node itself to change values. These are
// all made into input/output slots in ComfyBox. So we must convert
// serialized widgets into ComfyWidgetNodes, add new inputs/outputs,
// then attach the new nodes to the slots
// Primitive nodes are special since they can interface with converted
// widget inputs
if (node.type === "PrimitiveNode") {
convertPrimitiveNode(vanillaWorkflow, node, layoutState, left)
removeSerializedNode(vanillaWorkflow, node);
continue
}
const def = ComfyApp.knownBackendNodes[node.type];
if (def == null) {
console.error("[convertVanillaWorkflow] Unknown backend node", node.type)
continue;
}
// Lazily create group in case there are no inputs
let group: ContainerLayout | null = null;
// TODO needs to be generalized!
let isOutputNode = ["PreviewImage", "SaveImage"].indexOf(node.type) !== -1
for (const [inputName, [inputType, inputOpts]] of iterateNodeDefInputs(def.nodeDef)) {
// Detect if this input was a widget converted to an input
const convertedWidget = node.inputs?.find((i: IComfyUINodeInputSlot) => {
return i.widget?.name === inputName;
})
let pair = getWidgetTypesFromConfig(inputName, inputType);
if (pair == null) {
// Input type is backend-only, we can skip adding a UI node here
continue
}
let [widgetNodeType, widgetInputType, widgetCount] = pair
if (convertedWidget != null) {
// This input is an extra input slot on the node that should be
// accounted for.
const values = node.widgets_values.splice(0, widgetCount);
const value = values[0]
// TODO
}
else {
// This input is a widget, it should be converted to an input
// connected to a ComfyWidgetNode.
const newInput: IComfyInputSlot = {
name: inputName,
link: null,
type: widgetInputType,
config: inputOpts,
defaultWidgetNode: null,
widgetNodeType,
serialize: true, // IMPORTANT!
properties: {}
}
node.inputs ||= []
node.inputs.push(newInput);
const connInputIndex = node.inputs.length - 1;
// Now get the widget value.
//
// Assumes the value is the first in the widget list for the
// case of e.g. the seed randomizer
// That input type adds a number widget and a combo widget so
// the widgets_values will have entries like
//
// [ 8, "randomize", ... ]
//
// Only care about 8 and want to skip "randomize", that's the purpose of `widgetCount`
const values = node.widgets_values.splice(0, widgetCount);
const value = values[0]
const [comfyWidgetNode, serWidgetNode] = createSerializedWidgetNode(
vanillaWorkflow,
node,
connInputIndex,
true,
widgetNodeType,
value);
configureWidgetNodeProperties(serWidgetNode, inputOpts)
if (group == null)
group = layoutState.addContainer(isOutputNode ? right : left, { title: node.title || node.type })
const widget = layoutState.addWidget(group, comfyWidgetNode)
widget.attrs.title = inputName;
const connOutputIndex = serWidgetNode.outputs?.findIndex(o => o.name === comfyWidgetNode.outputSlotName)
if (connOutputIndex != null) {
connectSerializedNodes(vanillaWorkflow, serWidgetNode, connOutputIndex, node, connInputIndex)
}
else {
console.error("[convertVanillaWorkflow] No output to connect converted widget into!", comfyWidgetNode.outputSlotName, node)
}
}
}
// Add OUTPUT event slot to output nodes
// TODO needs to be generalized!
if (isOutputNode) {
const newOutput: INodeOutputSlot = {
name: "OUTPUT",
type: BuiltInSlotType.EVENT,
color_off: "rebeccapurple",
color_on: "rebeccapurple",
shape: BuiltInSlotShape.BOX_SHAPE,
links: [],
properties: {},
}
node.outputs ||= []
node.outputs.push(newOutput)
const connOutputIndex = node.outputs.length - 1;
// Let's create a gallery for this output node and hook it up
const [comfyGalleryNode, serGalleryNode] = createSerializedWidgetNode(
vanillaWorkflow,
node,
connOutputIndex,
false,
"ui/gallery",
[]);
if (group == null)
group = layoutState.addContainer(isOutputNode ? right : left, { title: node.title || node.type })
const widget = layoutState.addWidget(group, comfyGalleryNode)
widget.attrs.title = "Output"
const connInputIndex = serGalleryNode.inputs?.findIndex(o => o.name === comfyGalleryNode.storeActionName)
if (connInputIndex != null) {
connectSerializedNodes(vanillaWorkflow, node, connOutputIndex, serGalleryNode, connInputIndex)
}
else {
console.error("[convertVanillaWorkflow] No input to connect gallery widget into!", comfyGalleryNode.storeActionName, node)
}
}
}
const layout = layoutState.serialize();
comfyBoxWorkflow.deserialize(layoutState, { graph: vanillaWorkflow, attrs, layout })
return [comfyBoxWorkflow, layoutState]
}

View File

@@ -2,7 +2,7 @@ import LGraphCanvas from "@litegraph-ts/core/src/LGraphCanvas";
import ComfyGraphNode from "./ComfyGraphNode";
import ComfyWidgets from "$lib/widgets"
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import { BuiltInSlotShape, BuiltInSlotType, type SerializedLGraphNode } from "@litegraph-ts/core";
import { BuiltInSlotShape, BuiltInSlotType, LiteGraph, type SerializedLGraphNode } from "@litegraph-ts/core";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import type { ComfyInputConfig } from "$lib/IComfyInputSlot";
import { iterateNodeDefOutputs, type ComfyNodeDef, iterateNodeDefInputs } from "$lib/ComfyNodeDef";
@@ -13,20 +13,24 @@ import type { SerializedPromptOutput } from "$lib/utils";
*/
export class ComfyBackendNode extends ComfyGraphNode {
comfyClass: string;
comfyNodeDef: ComfyNodeDef;
displayName: string | null;
constructor(title: string, comfyClass: string, nodeData: any) {
constructor(title: string, comfyClass: string, nodeDef: ComfyNodeDef) {
super(title)
this.type = comfyClass; // XXX: workaround dependency in LGraphNode.addInput()
this.displayName = nodeData.display_name;
this.displayName = nodeDef.display_name;
this.comfyNodeDef = nodeDef;
this.comfyClass = comfyClass;
this.isBackendNode = true;
const color = LGraphCanvas.node_colors["yellow"];
this.color = color.color
this.bgColor = color.bgColor
if (this.color == null)
this.color = color.color
if (this.bgColor == null)
this.bgColor = color.bgColor
this.setup(nodeData)
this.setup(nodeDef)
// ComfyUI has no obvious way to identify if a node will return outputs back to the frontend based on its properties.
// It just returns a hash like { "ui": { "images": results } } internally.
@@ -55,13 +59,13 @@ export class ComfyBackendNode extends ComfyGraphNode {
} else {
if (Array.isArray(type)) {
// Enums
Object.assign(config, ComfyWidgets.COMBO(this, inputName, inputData) || {});
Object.assign(config, ComfyWidgets.COMBO.callback(this, inputName, inputData) || {});
} else if (`${type}:${inputName}` in ComfyWidgets) {
// Support custom ComfyWidgets by Type:Name
Object.assign(config, ComfyWidgets[`${type}:${inputName}`](this, inputName, inputData) || {});
Object.assign(config, ComfyWidgets[`${type}:${inputName}`].callback(this, inputName, inputData) || {});
} else if (type in ComfyWidgets) {
// Standard type ComfyWidgets
Object.assign(config, ComfyWidgets[type](this, inputName, inputData) || {});
Object.assign(config, ComfyWidgets[type].callback(this, inputName, inputData) || {});
} else {
// Node connection inputs (backend)
this.addInput(inputName, type);

View File

@@ -312,11 +312,16 @@ export default class ComfyGraphNode extends LGraphNode {
(o as any).saveUserState = this.saveUserState
if (!this.saveUserState && (!get(uiState).isSavingToLocalStorage || get(configState).alwaysStripUserState)) {
this.stripUserState(o)
console.warn("[ComfyGraphNode] stripUserState", this, o)
console.debug("[ComfyGraphNode] stripUserState", this, o)
}
}
override onConfigure(o: SerializedLGraphNode) {
if (this.inputs.length != (o.inputs || []).length || this.outputs.length != (o.outputs || []).length) {
console.error("Expected node slot size mismatch when deserializing!", o.type, "ours", this.inputs, this.outputs, "theirs", o.inputs, o.outputs)
return;
}
// Save the litegraph type of the default ComfyWidgetNode for each input.
for (let index = 0; index < this.inputs.length; index++) {
const input = this.inputs[index]

View File

@@ -5,13 +5,17 @@ import ComfyWidgetNode, { type ComfyWidgetProperties } from "./ComfyWidgetNode";
export interface ComfyTextProperties extends ComfyWidgetProperties {
multiline: boolean;
lines: number;
maxLines: number;
}
export default class ComfyTextNode extends ComfyWidgetNode<string> {
override properties: ComfyTextProperties = {
tags: [],
defaultValue: "",
multiline: false
multiline: false,
lines: 5,
maxLines: 5,
}
static slotLayout: SlotLayout = {

View File

@@ -15,6 +15,11 @@ export type AutoConfigOptions = {
setWidgetTitle?: boolean
}
export type SerializedComfyWidgetNode = {
comfyValue?: any
shownOutputProperties?: ComfyWidgetNode["shownOutputProperties"]
} & SerializedLGraphNode
/*
* NOTE: If you want to add a new widget but it has the same input/output type
* as another one of the existing widgets, best to create a new "variant" of
@@ -35,6 +40,11 @@ export interface ComfyWidgetProperties extends ComfyGraphNodeProperties {
defaultValue: any
}
export type ShownOutputProperty = {
type: string,
outputName: string
}
/*
* A node that is tied to a UI widget in the frontend. When the frontend's
* widget is changed, the value of the first output in the node is updated
@@ -72,7 +82,7 @@ export default abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
// shownInputProperties: string[] = []
/** Names of properties to add as outputs */
private shownOutputProperties: Record<string, { type: string, outputName: string }> = {}
private shownOutputProperties: Record<string, ShownOutputProperty> = {}
outputProperties: { name: string, type: string }[] = []
override isBackendNode = false;
@@ -328,7 +338,7 @@ export default abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
clampOneConfig(input: IComfyInputSlot) { }
override onSerialize(o: SerializedLGraphNode) {
override onSerialize(o: SerializedComfyWidgetNode) {
(o as any).comfyValue = get(this.value);
(o as any).shownOutputProperties = this.shownOutputProperties
super.onSerialize(o);

View File

@@ -1,65 +1,158 @@
import { LiteGraph, LGraph, LGraphNode } from "@litegraph-ts/core"
import type ComfyAPI from "$lib/api"
class PNGMetadataPromise extends Promise<Record<string, string>> {
public cancelMethod: () => void;
constructor(executor: (resolve: (value?: Record<string, string>) => void, reject: (reason?: any) => void) => void) {
super(executor);
class Lazy<T> {
thunk: () => T;
cache: T | null;
constructor(thunk: () => T) {
this.thunk = thunk;
this.cache = null;
}
//cancel the operation
public cancel() {
if (this.cancelMethod) {
this.cancelMethod();
get(): T {
if (this.cache === null) {
this.cache = this.thunk();
}
return this.cache;
}
}
export function getPngMetadata(file: File): PNGMetadataPromise {
return new PNGMetadataPromise((r, _) => {
const reader = new FileReader();
reader.onload = (event: Event) => {
// Get the PNG data as a Uint8Array
const pngData = new Uint8Array((event.target as any).result);
const dataView = new DataView(pngData.buffer);
interface PNGChunk {
type: string,
data: Uint8Array,
}
// Check that the PNG signature is present
if (dataView.getUint32(0) !== 0x89504e47) {
console.error("Not a valid PNG file");
r();
return;
}
enum ParseErrorKind {
PNGMalformedHeader,
PNGMalformedTextChunk,
JPEGNoEXIF,
JPEGNoUserComment,
JPEGMalformedUserComment,
UnexpectedEOF,
UnsupportedFileType,
Other,
}
// Start searching for chunks after the PNG signature
let offset = 8;
let txt_chunks = {};
// Loop through the chunks in the PNG file
while (offset < pngData.length) {
// Get the length of the chunk
const length = dataView.getUint32(offset);
// Get the chunk type
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8));
if (type === "tEXt") {
// Get the keyword
let keyword_end = offset + 8;
while (pngData[keyword_end] !== 0) {
keyword_end++;
}
const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end));
// Get the text
const text = String.fromCharCode(...pngData.slice(keyword_end + 1, offset + 8 + length));
txt_chunks[keyword] = text;
interface ParseError {
kind: ParseErrorKind,
error: any,
}
/*
* This function was taken from the hdg userscript
*/
function* pngChunks(bytes: Uint8Array): Generator<PNGChunk, ParseError | null> {
const HEADER: number[] = [137, 80, 78, 71, 13, 10, 26, 10];
const LENGTH_LEN = 4;
const TYPE_LEN = 4;
const CRC_LEN = 4;
let view = new DataView(bytes.buffer);
let decoder = new TextDecoder("utf-8", { fatal: true });
let pos = 0;
for (let i = 0; i < HEADER.length; i++) {
if (bytes[i] != HEADER[i]) {
return {
kind: ParseErrorKind.PNGMalformedHeader,
error: `wrong PNG header: ${bytes.slice(0, HEADER.length)}`,
};
}
}
pos += HEADER.length;
while (pos < bytes.byteLength) {
try {
let len = view.getUint32(pos, false);
let type = decoder.decode(bytes.subarray(pos + LENGTH_LEN, pos + LENGTH_LEN + TYPE_LEN));
if (type.length < 4) {
return {
kind: ParseErrorKind.UnexpectedEOF,
error: "PNG parse error: unexpected EOF when parsing chunk type",
}
}
let start = pos + LENGTH_LEN + TYPE_LEN;
offset += 12 + length;
yield {
type,
data: bytes.subarray(start, start + len),
}
r(txt_chunks);
};
pos = start + len + CRC_LEN;
} catch (err) {
return {
kind: ParseErrorKind.Other,
error: err,
};
}
}
reader.readAsArrayBuffer(file);
});
return null;
}
type PNGTextChunk = {
keyword: string,
text: string
}
function parsePNGTextChunk(data: Uint8Array): PNGTextChunk | ParseError {
let decoder = new TextDecoder("utf-8", { fatal: true });
let sep = data.findIndex(v => v === 0);
if (sep < 0) {
return {
kind: ParseErrorKind.PNGMalformedTextChunk,
error: "PNG parse error: no null separator in tEXt chunk",
}
}
try {
let keyword = decoder.decode(data.subarray(0, sep));
let text = decoder.decode(data.subarray(sep + 1, data.byteLength));
return { keyword, text }
} catch (err) {
return {
kind: ParseErrorKind.Other,
error: err,
};
}
}
export async function parsePNGMetadata(buf: ArrayBuffer): Promise<Record<string, string>> {
let bytes = new Uint8Array(buf);
const metadata = {}
let next: IteratorResult<PNGChunk, ParseError>;
let chunks = pngChunks(bytes);
do {
next = chunks.next();
if (!next.done) {
let chunk = next.value;
if ("kind" in chunk) {
console.warn("ignored a malformed PNG text chunk");
console.error(chunk.error);
continue;
}
if (chunk.type !== "tEXt") {
continue;
}
let result = parsePNGTextChunk(chunk.data);
if ("kind" in result) {
console.warn("ignored a malformed PNG text chunk");
console.error(result.error);
continue;
}
let textChunk = result;
metadata[textChunk.keyword] = textChunk.text
}
} while (next.value != null && !next.done)
return metadata;
}
type NodeIndex = { node: LGraphNode, index: number }

View File

@@ -428,6 +428,38 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: "gallery",
refreshPanelOnChange: true
},
// Text
{
name: "multiline",
type: "boolean",
location: "nodeProps",
editable: true,
validNodeTypes: ["ui/text"],
defaultValue: false
},
{
name: "lines",
type: "number",
location: "nodeProps",
editable: true,
validNodeTypes: ["ui/text"],
defaultValue: 5,
min: 1,
max: 100,
step: 1
},
{
name: "maxLines",
type: "number",
location: "nodeProps",
editable: true,
validNodeTypes: ["ui/text"],
defaultValue: 5,
min: 1,
max: 100,
step: 1
},
]
},
{
@@ -657,13 +689,19 @@ export interface WidgetLayout extends IDragItem {
node: ComfyWidgetNode
}
export type DefaultLayout = {
root: ContainerLayout,
left: ContainerLayout,
right: ContainerLayout,
}
export type DragItemID = UUID;
type LayoutStateOps = {
workflow: ComfyWorkflow,
workflow: ComfyWorkflow | null,
addContainer: (parent: ContainerLayout | null, attrs: Partial<Attributes>, index?: number) => ContainerLayout,
addWidget: (parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial<Attributes>, index?: number) => WidgetLayout,
addContainer: (parent: ContainerLayout | null, attrs?: Partial<Attributes>, index?: number) => ContainerLayout,
addWidget: (parent: ContainerLayout, node: ComfyWidgetNode, attrs?: Partial<Attributes>, index?: number) => WidgetLayout,
findDefaultContainerForInsertion: () => ContainerLayout | null,
updateChildren: (parent: IDragItem, children: IDragItem[]) => IDragItem[],
nodeAdded: (node: LGraphNode, options: LGraphAddNodeOptions) => void,
@@ -675,7 +713,7 @@ type LayoutStateOps = {
findLayoutForNode: (nodeId: ComfyNodeID) => IDragItem | null,
serialize: () => SerializedLayoutState,
deserialize: (data: SerializedLayoutState, graph: LGraph) => void,
initDefaultLayout: () => void,
initDefaultLayout: () => DefaultLayout,
onStartConfigure: () => void
notifyWorkflowModified: () => void
}
@@ -700,11 +738,7 @@ export type SerializedDragItem = {
export type WritableLayoutStateStore = Writable<LayoutState> & LayoutStateOps;
function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
if (get(layoutStates).all[workflow.id] != null) {
throw new Error(`Layout state already created! ${id}`)
}
function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateStore {
const store: Writable<LayoutState> = writable({
root: null,
allItems: {},
@@ -1040,7 +1074,7 @@ function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
return found.dragItem as WidgetLayout
}
function initDefaultLayout() {
function initDefaultLayout(): DefaultLayout {
store.set({
root: null,
allItems: {},
@@ -1060,6 +1094,8 @@ function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
})
console.debug("[layoutState] initDefault")
return { root, left, right }
}
function serialize(): SerializedLayoutState {
@@ -1160,7 +1196,7 @@ function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
function notifyWorkflowModified() {
if (!get(store).isConfiguring)
workflow.notifyModified();
workflow?.notifyModified();
}
const layoutStateStore: WritableLayoutStateStore =
@@ -1185,6 +1221,16 @@ function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
notifyWorkflowModified
}
return layoutStateStore
}
function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
if (get(layoutStates).all[workflow.id] != null) {
throw new Error(`Layout state already created! ${id}`)
}
const layoutStateStore = createRaw(workflow);
layoutStates.update(s => {
s.all[workflow.id] = layoutStateStore;
return s;
@@ -1233,6 +1279,7 @@ export type LayoutStateStores = {
export type LayoutStateStoresOps = {
create: (workflow: ComfyWorkflow) => WritableLayoutStateStore,
createRaw: (workflow?: ComfyWorkflow | null) => WritableLayoutStateStore,
remove: (workflowID: WorkflowInstID) => void,
getLayout: (workflowID: WorkflowInstID) => WritableLayoutStateStore | null,
getLayoutByGraph: (graph: LGraph) => WritableLayoutStateStore | null,
@@ -1249,6 +1296,7 @@ const store = writable({
const layoutStates: WritableLayoutStateStores = {
...store,
create,
createRaw,
remove,
getLayout,
getLayoutByGraph,

View File

@@ -0,0 +1,71 @@
import type { SvelteComponentDev } from "svelte/internal";
import { writable, type Writable } from "svelte/store";
import { v4 as uuidv4 } from "uuid";
export type ModalButton = {
name: string,
variant: "primary" | "secondary",
onClick: () => void,
closeOnClick?: boolean
}
export interface ModalData {
id: string,
title?: string,
onClose?: () => void,
svelteComponent?: typeof SvelteComponentDev,
svelteProps: Record<string, any>,
buttons: ModalButton[],
showCloseButton: boolean,
closeOnClick: boolean
}
export interface ModalState {
activeModals: ModalData[]
}
export interface ModalStateOps {
pushModal: (data: Partial<ModalData>) => void,
closeModal: (id: string) => void,
closeAllModals: () => void,
}
export type WritableModalStateStore = Writable<ModalState> & ModalStateOps;
const store: Writable<ModalState> = writable(
{
activeModals: []
})
function pushModal(data: Partial<ModalData>) {
const modal: ModalData = {
showCloseButton: true,
closeOnClick: true,
buttons: [],
svelteProps: {},
...data,
id: uuidv4(),
}
store.update(s => {
s.activeModals.push(modal);
return s;
})
}
function closeModal(id: string) {
store.update(s => {
s.activeModals = s.activeModals.filter(m => m.id !== id)
return s;
})
}
function closeAllModals() {
store.set({ activeModals: [] })
}
const modalStateStore: WritableModalStateStore =
{
...store,
pushModal,
closeModal,
closeAllModals
}
export default modalStateStore;

View File

@@ -181,6 +181,19 @@ function findEntryInPending(promptID: PromptID): [number, QueueEntry | null, Wri
return [-1, null, null]
}
function moveToRunning(index: number, queue: Writable<QueueEntry[]>) {
const state = get(store)
const entry = get(queue)[index];
console.debug("[queueState] Move to running", entry.promptID, index)
// entry.startedAt = new Date() // Now
queue.update(qp => { qp.splice(index, 1); return qp });
state.queueRunning.update(qr => { qr.push(entry); return qr })
state.isInterrupting = false;
store.set(state)
}
function moveToCompleted(index: number, queue: Writable<QueueEntry[]>, status: QueueEntryStatus, message?: string, error?: string) {
const state = get(store)
@@ -298,9 +311,12 @@ function executionStart(promptID: PromptID) {
const [index, entry, queue] = findEntryInPending(promptID);
if (entry == null) {
const entry = createNewQueueEntry(promptID);
s.queuePending.update(qp => { qp.push(entry); return qp })
s.queueRunning.update(qr => { qr.push(entry); return qr })
console.debug("[queueState] ADD PROMPT", promptID)
}
else {
moveToRunning(index, queue)
}
s.isInterrupting = false;
return s
})

View File

@@ -1,5 +1,5 @@
import type { SerializedGraphCanvasState } from '$lib/ComfyGraphCanvas';
import { clamp, LGraphNode, type LGraphCanvas, type NodeID, type SerializedLGraph, type UUID, LGraph } from '@litegraph-ts/core';
import { clamp, LGraphNode, type LGraphCanvas, type NodeID, type SerializedLGraph, type UUID, LGraph, LiteGraph } from '@litegraph-ts/core';
import { get, writable } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store';
import { defaultWorkflowAttributes, type SerializedLayoutState, type WritableLayoutStateStore } from './layoutStates';
@@ -76,6 +76,11 @@ export class ComfyWorkflow {
*/
isModified: boolean = false;
/*
* Missing node types encountered when deserializing the graph
*/
missingNodeTypes: Set<string> = new Set();
get layout(): WritableLayoutStateStore | null {
return layoutStates.getLayout(this.id)
}
@@ -134,6 +139,7 @@ export class ComfyWorkflow {
return;
}
canvas.canvas.closeAllSubgraphs();
this.graph.detachCanvas(canvas.canvas);
this.graph.eventBus.removeListener("afterExecute", canvas.canvasHandler)
@@ -162,6 +168,13 @@ export class ComfyWorkflow {
}
}
/*
* Creates a workflow and layout.
*
* NOTE: The layout will be attached to the global store, but the workflow
* will not. If you change your mind later be sure to call
* layoutStates.remove(workflow.id)!
*/
static create(title: string = "New Workflow"): [ComfyWorkflow, WritableLayoutStateStore] {
const workflow = new ComfyWorkflow(title);
const layoutState = layoutStates.create(workflow);
@@ -169,6 +182,18 @@ export class ComfyWorkflow {
}
deserialize(layoutState: WritableLayoutStateStore, data: SerializedWorkflowState) {
this.missingNodeTypes.clear();
for (let n of data.graph.nodes) {
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
this.missingNodeTypes.add(n.type);
}
}
// Ensure loadGraphData does not trigger any state changes in layoutState
// (isConfiguring is set to true here)
// lGraph.configure will add new nodes, triggering onNodeAdded, but we
@@ -215,6 +240,7 @@ type WorkflowStateOps = {
getActiveWorkflow: () => ComfyWorkflow | null
createNewWorkflow: (canvas: ComfyGraphCanvas, title?: string, setActive?: boolean) => ComfyWorkflow,
openWorkflow: (canvas: ComfyGraphCanvas, data: SerializedAppState) => ComfyWorkflow,
addWorkflow: (canvas: ComfyGraphCanvas, data: ComfyWorkflow) => void,
closeWorkflow: (canvas: ComfyGraphCanvas, index: number) => void,
closeAllWorkflows: (canvas: ComfyGraphCanvas) => void,
setActiveWorkflow: (canvas: ComfyGraphCanvas, index: number) => ComfyWorkflow | null
@@ -278,6 +304,12 @@ function openWorkflow(canvas: ComfyGraphCanvas, data: SerializedAppState): Comfy
const [workflow, layoutState] = ComfyWorkflow.create("Workflow")
workflow.deserialize(layoutState, { graph: data.workflow, layout: data.layout, attrs: data.attrs })
addWorkflow(canvas, workflow);
return workflow;
}
function addWorkflow(canvas: ComfyGraphCanvas, workflow: ComfyWorkflow) {
const state = get(store);
state.openedWorkflows.push(workflow);
state.openedWorkflowsByID[workflow.id] = workflow;
@@ -299,7 +331,6 @@ function closeWorkflow(canvas: ComfyGraphCanvas, index: number) {
layoutStates.remove(workflow.id)
state.openedWorkflows.splice(index, 1)
delete state.openedWorkflowsByID[workflow.id]
let newIndex = clamp(index, 0, state.openedWorkflows.length - 1)
@@ -354,6 +385,7 @@ const workflowStateStore: WritableWorkflowStateStore =
getActiveWorkflow,
createNewWorkflow,
openWorkflow,
addWorkflow,
closeWorkflow,
closeAllWorkflows,
setActiveWorkflow,

View File

@@ -19,6 +19,25 @@ export function range(size: number, startAt: number = 0): ReadonlyArray<number>
return [...Array(size).keys()].map(i => i + startAt);
}
export function countNewLines(str: string): number {
return str.split(/\r\n|\r|\n/).length
}
export function basename(filepath: string): string {
const filename = filepath.split('/').pop().split('\\').pop();
return filename.split('.').slice(0, -1).join('.');
}
export function truncateString(str: string, num: number): string {
if (num <= 0)
return "…";
if (str.length <= num) {
return str;
}
return str.slice(0, num) + "…";
}
export function* enumerate<T>(iterable: Iterable<T>): Iterable<[number, T]> {
let index = 0;
for (const value of iterable) {

View File

@@ -4,7 +4,18 @@ import type { ComfyInputConfig } from "./IComfyInputSlot";
import { ComfyComboNode, ComfyNumberNode, ComfyTextNode } from "./nodes/widgets";
import type { ComfyNodeDefInput } from "./ComfyNodeDef";
type WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput) => IComfyInputSlot;
type WidgetFactoryCallback = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput) => IComfyInputSlot;
type WidgetFactory = {
/* Creates the input */
callback: WidgetFactoryCallback,
/* Input type as used by litegraph */
inputType: string,
/* Node type to instantiate */
nodeType: string,
/* Number of widgets this factory instantiates. */
addedWidgetCount: number
}
function getNumberDefaults(inputData: ComfyNodeDefInput, defaultStep: number): ComfyInputConfig {
let defaultValue = inputData[1].default;
@@ -34,41 +45,76 @@ function addComfyInput(node: LGraphNode, inputName: string, extraInfo: Partial<I
return input;
}
const FLOAT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 0.5);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode })
const FLOAT: WidgetFactory = {
callback: (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 0.5);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode })
},
inputType: "number",
nodeType: "ui/number",
addedWidgetCount: 1
}
const INT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 1);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode })
};
const STRING: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const defaultValue = inputData[1].default || "";
const multiline = !!inputData[1].multiline;
return addComfyInput(node, inputName, { type: "string", config: { defaultValue, multiline }, defaultWidgetNode: ComfyTextNode })
};
const COMBO: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const type = inputData[0] as string[];
let defaultValue = type[0];
if (inputData[1] && inputData[1].default) {
defaultValue = inputData[1].default;
}
return addComfyInput(node, inputName, { type: "string", config: { values: type, defaultValue }, defaultWidgetNode: ComfyComboNode })
const INT: WidgetFactory = {
callback: (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 1);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode })
},
nodeType: "ui/number",
inputType: "number",
addedWidgetCount: 1
}
const IMAGEUPLOAD: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
return addComfyInput(node, inputName, { type: "number", config: {} })
const STRING: WidgetFactory = {
callback: (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const defaultValue = inputData[1].default || "";
const multiline = !!inputData[1].multiline;
return addComfyInput(node, inputName, { type: "string", config: { defaultValue, multiline }, defaultWidgetNode: ComfyTextNode })
},
inputType: "number",
nodeType: "ui/text",
addedWidgetCount: 1
}
const COMBO: WidgetFactory = {
callback: (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const type = inputData[0] as string[];
let defaultValue = type[0];
if (inputData[1] && inputData[1].default) {
defaultValue = inputData[1].default;
}
return addComfyInput(node, inputName, { type: "string", config: { values: type, defaultValue }, defaultWidgetNode: ComfyComboNode })
},
inputType: "number",
nodeType: "ui/combo",
addedWidgetCount: 1
}
const IMAGEUPLOAD: WidgetFactory = {
callback: (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
return addComfyInput(node, inputName, { type: "number", config: {} })
},
inputType: "COMFY_IMAGES",
nodeType: "ui/image_upload",
addedWidgetCount: 1
}
const INT_seed: WidgetFactory = {
...INT,
// Adds a "randomize" combo box
// When converting from vanilla it should be skipped in the widgets_values
// array, so indicate this here
// litegraph really ought to key these by name instead of array indices...
addedWidgetCount: 2
}
export type WidgetRepository = Record<string, WidgetFactory>
const ComfyWidgets: WidgetRepository = {
"INT:seed": INT,
"INT:noise_seed": INT,
"INT:seed": INT_seed,
"INT:noise_seed": INT_seed,
FLOAT,
INT,
STRING,

View File

@@ -35,8 +35,8 @@
bind:value={$nodeValue}
label={widget.attrs.title}
disabled={isDisabled(widget)}
lines={node.properties.multiline ? 5 : 1}
max_lines={node.properties.multiline ? 5 : 1}
lines={node.properties.multiline ? node.properties.lines : 1}
max_lines={node.properties.multiline ? node.properties.maxLines : 1}
show_label={widget.attrs.title !== ""}
on:change
on:submit

View File

@@ -41,12 +41,12 @@
return;
navigator.vibrate(20)
fileInput.value = null;
fileInput.click();
}
function loadWorkflow(): void {
app.handleFile(fileInput.files[0]);
fileInput.files = null;
}
function doSaveLocal(): void {

View File

@@ -2,9 +2,9 @@
import { Page, Navbar, Link, BlockTitle, Block, List, ListItem, Toolbar } from "framework7-svelte"
import WidgetContainer from "$lib/components/WidgetContainer.svelte";
import type ComfyApp from "$lib/components/ComfyApp";
import type { ComfyWorkflow } from "$lib/components/ComfyApp";
import { writable, type Writable } from "svelte/store";
import type { WritableLayoutStateStore } from "$lib/stores/layoutStates";
import workflowState, { type ComfyWorkflow } from "$lib/stores/workflowState";
export let subworkflowID: number = -1;
export let app: ComfyApp
@@ -13,6 +13,7 @@
let workflow: ComfyWorkflow | null = null
let layoutState: WritableLayoutStateStore | null = null;
$: workflow = $workflowState.activeWorkflow;
$: layoutState = workflow ? workflow.layout : null;
</script>

View File

@@ -31,6 +31,7 @@ body {
--comfy-splitpanes-background-fill: var(--secondary-100);
--comfy-splitpanes-background-fill-hover: var(--secondary-300);
--comfy-splitpanes-background-fill-active: var(--secondary-400);
--comfy-dropdown-list-background: white;
--comfy-dropdown-item-color-hover: white;
--comfy-dropdown-item-background-hover: var(--neutral-400);
--comfy-dropdown-item-color-active: var(--neutral-100);
@@ -107,7 +108,7 @@ hr {
color: var(--panel-border-color);
}
input, textarea {
input:not(input[type=radio]), textarea {
border-radius: 0 !important;
}

View File

@@ -0,0 +1,138 @@
import { expect } from 'vitest';
import UnitTest from "./UnitTest";
import { readFile } from "fs/promises"
import { get } from "svelte/store";
import convertVanillaWorkflow, { type ComfyVanillaWorkflow } from '$lib/convertVanillaWorkflow';
import type { WorkflowAttributes } from '$lib/stores/workflowState';
import layoutStates, { defaultWorkflowAttributes, type IDragItem, type WidgetLayout } from '$lib/stores/layoutStates';
import ComfyApp from '$lib/components/ComfyApp';
import { LiteGraph } from '@litegraph-ts/core';
import type { ComfyNodeDef } from '$lib/ComfyNodeDef';
import type IComfyInputSlot from '$lib/IComfyInputSlot';
const objectInfo: Record<string, ComfyNodeDef> = await import("./data/objectInfo.json")
const json1: ComfyVanillaWorkflow = await import("./data/convertedWidget.json")
const json2: ComfyVanillaWorkflow = await import("./data/convertedSeedWidget.json")
const json3: ComfyVanillaWorkflow = await import("./data/convertedWidgetAndPrimitiveNode.json")
export default class convertVanillaWorkflowTests extends UnitTest {
test__convertsWidget() {
const workflow = LiteGraph.cloneObject(json1)
const attrs: WorkflowAttributes = { ...defaultWorkflowAttributes }
ComfyApp.knownBackendNodes["KSampler"] = {
nodeDef: objectInfo["KSampler"]
}
const converted = convertVanillaWorkflow(workflow, attrs)
expect(converted).toBeInstanceOf(Array)
const [convWorkflow, convLayout] = converted;
const layout = get(convLayout)
expect(Object.keys(layout.allItems)).toHaveLength(10)
const widgets = Object.values(layout.allItems).filter(di => di.dragItem.type === "widget").map(di => di.dragItem) as WidgetLayout[];
expect(widgets).toHaveLength(6);
const widgetsValues = widgets.map(w => { return [w.node.type, w.node.getValue(), w.attrs.title] })
expect(widgetsValues).toEqual([
["ui/number", 0, 'seed'],
["ui/number", 20, 'steps'],
["ui/number", 8.5, 'cfg'],
["ui/combo", 'euler', 'sampler_name'],
["ui/combo", 'normal', 'scheduler'],
["ui/number", 1, 'denoise']
]);
const widget = widgets.find(w => w.attrs.title === "cfg") as WidgetLayout | null;
expect(widget).toBeDefined();
expect(widget.node).toBeDefined();
expect(widget.node.type).toEqual("ui/number")
expect(widget.node.getValue()).toEqual(8.5)
expect(convWorkflow.graph.getNodeById(widget.node.id)).toEqual(widget.node)
}
test__convertsSeedWidget() {
const workflow = LiteGraph.cloneObject(json2)
const attrs: WorkflowAttributes = { ...defaultWorkflowAttributes }
ComfyApp.knownBackendNodes["KSampler"] = {
nodeDef: objectInfo["KSampler"]
}
const converted = convertVanillaWorkflow(workflow, attrs)
expect(converted).toBeInstanceOf(Array)
const [convWorkflow, convLayout] = converted;
const layout = get(convLayout)
expect(Object.keys(layout.allItems)).toHaveLength(10)
const widgets = Object.values(layout.allItems).filter(di => di.dragItem.type === "widget").map(di => di.dragItem) as WidgetLayout[];
expect(widgets).toHaveLength(6);
const widgetsValues = widgets.map(w => { return [w.node.type, w.node.getValue(), w.attrs.title] })
expect(widgetsValues).toEqual([
["ui/number", 20, 'steps'],
["ui/number", 8, 'cfg'],
["ui/combo", 'euler', 'sampler_name'],
["ui/combo", 'normal', 'scheduler'],
["ui/number", 1, 'denoise'],
["ui/number", 1461, 'seed']
]);
const widget = widgets.find(w => w.attrs.title === "seed") as WidgetLayout | null;
expect(widget).toBeDefined();
expect(widget.node).toBeDefined();
expect(widget.node.type).toEqual("ui/number")
expect(widget.node.getValue()).toEqual(1461)
expect(convWorkflow.graph.getNodeById(widget.node.id)).toEqual(widget.node)
const links = widget.node.getOutputLinks(0)
expect(links).toHaveLength(1);
const kSampler = convWorkflow.graph.findNodesByType("KSampler")[0];
expect(links[0].origin_id).toEqual(widget.node.id);
expect(links[0].target_id).toEqual(kSampler.id);
expect(widget.node.outputs[0].type).toEqual("number");
const targetNode = widget.node.getOutputNodes(0)[0]
expect(targetNode.inputs[links[0].target_slot].type).toEqual("number")
expect((targetNode.inputs[links[0].target_slot] as IComfyInputSlot).serialize).toEqual(true)
expect(links[0].type).toEqual("number");
}
test__convertsPrimitiveNodeAndConvertedInput() {
const workflow = LiteGraph.cloneObject(json3)
const attrs: WorkflowAttributes = { ...defaultWorkflowAttributes }
ComfyApp.knownBackendNodes["KSampler"] = {
nodeDef: objectInfo["KSampler"]
}
const converted = convertVanillaWorkflow(workflow, attrs)
expect(converted).toBeInstanceOf(Array)
const [convWorkflow, convLayout] = converted;
const layout = get(convLayout)
expect(Object.keys(layout.allItems)).toHaveLength(10)
const widgets = Object.values(layout.allItems).filter(di => di.dragItem.type === "widget").map(di => di.dragItem);
expect(widgets).toHaveLength(6);
const widget = widgets.find(w => w.attrs.title === "cfg") as WidgetLayout | null;
expect(widget).toBeDefined();
expect(widget.node).toBeDefined();
expect(widget.node.type).toEqual("ui/number")
expect(convWorkflow.graph.getNodeById(widget.node.id)).toEqual(widget.node)
}
}

View File

@@ -0,0 +1,134 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [
1514,
348
],
"size": [
315,
262
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "seed",
"type": "INT",
"link": 1,
"widget": {
"name": "seed",
"config": [
"INT",
{
"default": 0,
"min": 0,
"max": 18446744073709552000
}
]
},
"slot_index": 4
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
1461,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 2,
"type": "PrimitiveNode",
"pos": [
1160,
420
],
"size": {
"0": 210,
"1": 82
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [
1
],
"widget": {
"name": "seed",
"config": [
"INT",
{
"default": 0,
"min": 0,
"max": 18446744073709552000
}
]
}
}
],
"properties": {},
"widgets_values": [
1461,
"randomize"
]
}
],
"links": [
[
1,
2,
0,
1,
4,
"INT"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1,68 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [
707,
502
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8.5,
"euler",
"normal",
1
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1,135 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [
843,
567
],
"size": [
315,
262
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "cfg",
"type": "FLOAT",
"link": 1,
"widget": {
"name": "cfg",
"config": [
"FLOAT",
{
"default": 8,
"min": 0,
"max": 100
}
]
},
"slot_index": 4
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 2,
"type": "PrimitiveNode",
"pos": [
506,
637
],
"size": {
"0": 210,
"1": 82
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [
1
],
"slot_index": 0,
"widget": {
"name": "cfg",
"config": [
"FLOAT",
{
"default": 8,
"min": 0,
"max": 100
}
]
}
}
],
"properties": {},
"widgets_values": [
8,
"fixed"
]
}
],
"links": [
[
1,
2,
0,
1,
4,
"FLOAT"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1 @@
{"KSampler":{"input":{"required":{"model":["MODEL"],"seed":["INT",{"default":0,"min":0,"max":18446744073709552000}],"steps":["INT",{"default":20,"min":1,"max":10000}],"cfg":["FLOAT",{"default":8,"min":0,"max":100}],"sampler_name":[["euler","euler_ancestral","heun","dpm_2","dpm_2_ancestral","lms","dpm_fast","dpm_adaptive","dpmpp_2s_ancestral","dpmpp_sde","dpmpp_2m","ddim","uni_pc","uni_pc_bh2"]],"scheduler":[["normal","karras","simple","ddim_uniform"]],"positive":["CONDITIONING"],"negative":["CONDITIONING"],"latent_image":["LATENT"],"denoise":["FLOAT",{"default":1,"min":0,"max":1,"step":0.01}]}},"output":["LATENT"],"output_is_list":[false],"output_name":["LATENT"],"name":"KSampler","display_name":"KSampler","description":"","category":"sampling"}}

View File

@@ -2,3 +2,4 @@ export { default as ComfyPromptSerializerTests } from "./ComfyPromptSerializerTe
export { default as ComfyGraphTests } from "./ComfyGraphTests"
export { default as parseA1111Tests } from "./parseA1111Tests"
export { default as convertA1111ToStdPromptTests } from "./convertA1111ToStdPromptTests"
export { default as convertVanillaWorkflowTest } from "./convertVanillaWorkflowTests"

View File

@@ -5,6 +5,7 @@
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"esModuleInterop": true,
"allowJs": true,
"checkJs": true,
"strict": false,