Start vanilla workflow conversion, better PNG parser based on catbox

userscript code
This commit is contained in:
space-nuko
2023-05-21 11:10:10 -05:00
parent affe973375
commit c3ab3aa69a
9 changed files with 267 additions and 74 deletions

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 type TypedEmitter from "typed-emitter";
import EventEmitter from "events"; import EventEmitter from "events";
import type { ComfyImageLocation } from "$lib/utils"; import type { ComfyImageLocation } from "$lib/utils";
@@ -57,10 +57,14 @@ export type ComfyAPIHistoryResponse = {
error?: string error?: string
} }
export type SerializedComfyBoxPromptData = {
subgraphs: string[]
}
export type ComfyPromptPNGInfo = { export type ComfyPromptPNGInfo = {
workflow: SerializedLGraph, workflow?: SerializedLGraph, // ComfyUI format
comfyBoxLayout: SerializedLayoutState, comfyBoxWorkflow: SerializedAppState,
comfyBoxSubgraphs: string[], comfyBoxPrompt: SerializedComfyBoxPromptData,
} }
export type ComfyBoxPromptExtraData = ComfyUIPromptExtraData & { export type ComfyBoxPromptExtraData = ComfyUIPromptExtraData & {

View File

@@ -1,7 +1,7 @@
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 { 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 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 } from "$lib/api"
import { getPngMetadata, importA1111 } from "$lib/pnginfo"; import { getPngMetadata, importA1111, parsePNGMetadata } from "$lib/pnginfo";
import EventEmitter from "events"; import EventEmitter from "events";
import type TypedEmitter from "typed-emitter"; import type TypedEmitter from "typed-emitter";
@@ -44,6 +44,7 @@ import selectionState from "$lib/stores/selectionState";
import layoutStates from "$lib/stores/layoutStates"; import layoutStates from "$lib/stores/layoutStates";
import { ComfyWorkflow, type WorkflowAttributes, type WorkflowInstID } from "$lib/stores/workflowState"; import { ComfyWorkflow, type WorkflowAttributes, type WorkflowInstID } from "$lib/stores/workflowState";
import workflowState from "$lib/stores/workflowState"; import workflowState from "$lib/stores/workflowState";
import convertVanillaWorkflow from "$lib/convertVanillaWorkflow";
export const COMFYBOX_SERIAL_VERSION = 1; export const COMFYBOX_SERIAL_VERSION = 1;
@@ -72,8 +73,10 @@ export type A1111PromptAndInfo = {
* Represents a single workflow that can be loaded into the program from JSON. * Represents a single workflow that can be loaded into the program from JSON.
*/ */
export type SerializedAppState = { export type SerializedAppState = {
/** Program identifier, should always be "ComfyBox" */ /** For easy structural typing use */
createdBy: "ComfyBox", comfyBoxWorkflow: true,
/** Program identifier, should be something like "ComfyBox" or "ComfyUI" */
createdBy: string,
/** Serial version, should be incremented on breaking changes */ /** Serial version, should be incremented on breaking changes */
version: number, version: number,
/** Commit hash if found */ /** Commit hash if found */
@@ -138,6 +141,14 @@ type CanvasState = {
canvas: ComfyGraphCanvas, canvas: ComfyGraphCanvas,
} }
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;
}
export default class ComfyApp { export default class ComfyApp {
api: ComfyAPI; api: ComfyAPI;
@@ -237,6 +248,7 @@ export default class ComfyApp {
const canvas = this.lCanvas.serialize(); const canvas = this.lCanvas.serialize();
return { return {
comfyBoxWorkflow: true,
createdBy: "ComfyBox", createdBy: "ComfyBox",
version: COMFYBOX_SERIAL_VERSION, version: COMFYBOX_SERIAL_VERSION,
commitHash: __GIT_COMMIT_HASH__, commitHash: __GIT_COMMIT_HASH__,
@@ -399,7 +411,7 @@ export default class ComfyApp {
} catch (error) { } } catch (error) { }
} }
if (workflow && workflow.createdBy === "ComfyBox") { if (workflow && typeof workflow.createdBy === "string") {
this.openWorkflow(workflow); this.openWorkflow(workflow);
} }
else { else {
@@ -537,6 +549,13 @@ export default class ComfyApp {
return workflow; return workflow;
} }
async openVanillaWorkflow(data: SerializedLGraph) {
const converted = convertVanillaWorkflow(data)
console.info("WORKFLWO", converted)
notify("Converted ComfyUI workflow to ComfyBox format.", { type: "info" })
// await this.openWorkflow(JSON.parse(pngInfo.workflow));
}
setActiveWorkflow(id: WorkflowInstID) { setActiveWorkflow(id: WorkflowInstID) {
const index = get(workflowState).openedWorkflows.findIndex(w => w.id === id) const index = get(workflowState).openedWorkflows.findIndex(w => w.id === id)
if (index === -1) if (index === -1)
@@ -695,7 +714,7 @@ export default class ComfyApp {
} }
const p = this.graphToPrompt(workflow, tag); const p = this.graphToPrompt(workflow, tag);
const l = workflow.layout.serialize(); const wf = this.serialize(workflow)
console.debug(graphToGraphVis(workflow.graph)) console.debug(graphToGraphVis(workflow.graph))
console.debug(promptToGraphVis(p)) console.debug(promptToGraphVis(p))
@@ -704,9 +723,10 @@ export default class ComfyApp {
const extraData: ComfyBoxPromptExtraData = { const extraData: ComfyBoxPromptExtraData = {
extra_pnginfo: { extra_pnginfo: {
workflow: p.workflow, comfyBoxWorkflow: wf,
comfyBoxLayout: l, comfyBoxPrompt: {
comfyBoxSubgraphs: [tag], subgraphs: [tag]
}
}, },
thumbnails thumbnails
} }
@@ -761,10 +781,14 @@ export default class ComfyApp {
*/ */
async handleFile(file: File) { async handleFile(file: File) {
if (file.type === "image/png") { if (file.type === "image/png") {
const pngInfo = await getPngMetadata(file); const buffer = await file.arrayBuffer();
const pngInfo = await parsePNGMetadata(buffer);
if (pngInfo) { if (pngInfo) {
if (pngInfo.comfyBoxConfig) { if (pngInfo.comfyBoxWorkflow) {
await this.openWorkflow(JSON.parse(pngInfo.comfyBoxConfig)); await this.openWorkflow(JSON.parse(pngInfo.comfyBoxWorkflow));
} else if (pngInfo.workflow) {
const workflow = JSON.parse(pngInfo.workflow);
await this.openVanillaWorkflow(workflow);
} else if (pngInfo.parameters) { } else if (pngInfo.parameters) {
const parsed = parseA1111(pngInfo.parameters) const parsed = parseA1111(pngInfo.parameters)
if ("error" in parsed) { if ("error" in parsed) {
@@ -787,7 +811,13 @@ export default class ComfyApp {
} else if (file.type === "application/json" || file.name.endsWith(".json")) { } else if (file.type === "application/json" || file.name.endsWith(".json")) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async () => { 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);
}
}; };
reader.readAsText(file); reader.readAsText(file);
} }

View File

@@ -69,7 +69,7 @@
dateStr = formatDate(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"; let message = "Prompt";
if (entry.workflowID != null) { if (entry.workflowID != null) {
@@ -208,7 +208,7 @@
</Modal> </Modal>
<div class="queue"> <div class="queue">
<DropZone {app} /> <!-- <DropZone {app} /> -->
<div class="queue-entries {mode}-mode" bind:this={queueList}> <div class="queue-entries {mode}-mode" bind:this={queueList}>
{#if _entries.length > 0} {#if _entries.length > 0}
{#each _entries as entry} {#each _entries as entry}
@@ -305,8 +305,9 @@
<style lang="scss"> <style lang="scss">
$pending-height: 200px; $pending-height: 200px;
$bottom-bar-height: 70px; $bottom-bar-height: 70px;
$workflow-tabs-height: 2.5rem;
$mode-buttons-height: 30px; $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 { .prompt-modal-header {
padding-left: 0.2rem; padding-left: 0.2rem;

View File

@@ -203,7 +203,7 @@
{#if $workflowState.activeWorkflow != null} {#if $workflowState.activeWorkflow != null}
<ComfyWorkflowView {app} workflow={$workflowState.activeWorkflow} /> <ComfyWorkflowView {app} workflow={$workflowState.activeWorkflow} />
{:else} {:else}
<span>No workflow loaded</span> <span style:color="var(--body-text-color)">No workflow loaded</span>
{/if} {/if}
</Pane> </Pane>
<Pane bind:size={graphSize}> <Pane bind:size={graphSize}>
@@ -313,7 +313,7 @@
</div> </div>
</div> </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} {#if appSetupPromise}
{#await appSetupPromise} {#await appSetupPromise}

View File

@@ -18,7 +18,7 @@
let galleryStyle: Styles = { let galleryStyle: Styles = {
grid_cols: [2], grid_cols: [2],
object_fit: "cover", object_fit: "contain",
height: "var(--size-96)" height: "var(--size-96)"
} }
@@ -165,6 +165,11 @@
> :global(.block) { > :global(.block) {
height: 100%; height: 100%;
:global(> .preview) {
height: 100%;
max-height: none !important;
}
} }
} }

View File

@@ -0,0 +1,51 @@
import { LGraph, type SerializedLGraph } from "@litegraph-ts/core";
import type { SerializedAppState } from "./components/ComfyApp";
import layoutStates, { defaultWorkflowAttributes, type DragItemID, type SerializedDragEntry, type SerializedLayoutState } from "./stores/layoutStates";
import type { WorkflowAttributes } from "./stores/workflowState";
import type { SerializedGraphCanvasState } from "./ComfyGraphCanvas";
/*
* The workflow type used by base ComfyUI
*/
export type ComfyVanillaWorkflow = SerializedLGraph;
function addLayoutToVanillaWorkflow(workflow: ComfyVanillaWorkflow): SerializedLayoutState {
// easier to create a real layout first and then serialize it, then have to
// deal with manually constructing the serialized state from the ground up
const layoutState = layoutStates.createRaw();
const graph = new LGraph();
graph.configure(workflow)
for (const node of graph.iterateNodesInOrder()) {
console.warn("NODE", node)
}
return layoutState.serialize()
}
export default function convertVanillaWorkflow(workflow: ComfyVanillaWorkflow): SerializedAppState {
const attrs: WorkflowAttributes = {
...defaultWorkflowAttributes,
title: "ComfyUI Workflow"
}
const canvas: SerializedGraphCanvasState = {
offset: [0, 0],
scale: 1
}
const layout = addLayoutToVanillaWorkflow(workflow);
const appState: SerializedAppState = {
comfyBoxWorkflow: true,
createdBy: "ComfyUI", // ???
version: 1,
workflow,
attrs,
canvas,
layout,
commitHash: null
}
return appState
}

View File

@@ -1,65 +1,158 @@
import { LiteGraph, LGraph, LGraphNode } from "@litegraph-ts/core" import { LiteGraph, LGraph, LGraphNode } from "@litegraph-ts/core"
import type ComfyAPI from "$lib/api" import type ComfyAPI from "$lib/api"
class PNGMetadataPromise extends Promise<Record<string, string>> { class Lazy<T> {
public cancelMethod: () => void; thunk: () => T;
constructor(executor: (resolve: (value?: Record<string, string>) => void, reject: (reason?: any) => void) => void) { cache: T | null;
super(executor);
constructor(thunk: () => T) {
this.thunk = thunk;
this.cache = null;
} }
//cancel the operation get(): T {
public cancel() { if (this.cache === null) {
if (this.cancelMethod) { this.cache = this.thunk();
this.cancelMethod();
} }
return this.cache;
} }
} }
export function getPngMetadata(file: File): PNGMetadataPromise { interface PNGChunk {
return new PNGMetadataPromise((r, _) => { type: string,
const reader = new FileReader(); data: Uint8Array,
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);
// Check that the PNG signature is present enum ParseErrorKind {
if (dataView.getUint32(0) !== 0x89504e47) { PNGMalformedHeader,
console.error("Not a valid PNG file"); PNGMalformedTextChunk,
r(); JPEGNoEXIF,
return; JPEGNoUserComment,
} JPEGMalformedUserComment,
UnexpectedEOF,
UnsupportedFileType,
Other,
}
// Start searching for chunks after the PNG signature interface ParseError {
let offset = 8; kind: ParseErrorKind,
let txt_chunks = {}; error: any,
// Loop through the chunks in the PNG file }
while (offset < pngData.length) {
// Get the length of the chunk /*
const length = dataView.getUint32(offset); * This function was taken from the hdg userscript
// Get the chunk type */
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8)); function* pngChunks(bytes: Uint8Array): Generator<PNGChunk, ParseError | null> {
if (type === "tEXt") { const HEADER: number[] = [137, 80, 78, 71, 13, 10, 26, 10];
// Get the keyword const LENGTH_LEN = 4;
let keyword_end = offset + 8; const TYPE_LEN = 4;
while (pngData[keyword_end] !== 0) { const CRC_LEN = 4;
keyword_end++;
} let view = new DataView(bytes.buffer);
const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end)); let decoder = new TextDecoder("utf-8", { fatal: true });
// Get the text let pos = 0;
const text = String.fromCharCode(...pngData.slice(keyword_end + 1, offset + 8 + length));
txt_chunks[keyword] = text; 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 } type NodeIndex = { node: LGraphNode, index: number }

View File

@@ -660,7 +660,7 @@ export interface WidgetLayout extends IDragItem {
export type DragItemID = UUID; export type DragItemID = UUID;
type LayoutStateOps = { type LayoutStateOps = {
workflow: ComfyWorkflow, workflow: ComfyWorkflow | null,
addContainer: (parent: ContainerLayout | null, attrs: Partial<Attributes>, index?: number) => ContainerLayout, addContainer: (parent: ContainerLayout | null, attrs: Partial<Attributes>, index?: number) => ContainerLayout,
addWidget: (parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial<Attributes>, index?: number) => WidgetLayout, addWidget: (parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial<Attributes>, index?: number) => WidgetLayout,
@@ -700,11 +700,7 @@ export type SerializedDragItem = {
export type WritableLayoutStateStore = Writable<LayoutState> & LayoutStateOps; export type WritableLayoutStateStore = Writable<LayoutState> & LayoutStateOps;
function create(workflow: ComfyWorkflow): WritableLayoutStateStore { function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateStore {
if (get(layoutStates).all[workflow.id] != null) {
throw new Error(`Layout state already created! ${id}`)
}
const store: Writable<LayoutState> = writable({ const store: Writable<LayoutState> = writable({
root: null, root: null,
allItems: {}, allItems: {},
@@ -1160,7 +1156,7 @@ function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
function notifyWorkflowModified() { function notifyWorkflowModified() {
if (!get(store).isConfiguring) if (!get(store).isConfiguring)
workflow.notifyModified(); workflow?.notifyModified();
} }
const layoutStateStore: WritableLayoutStateStore = const layoutStateStore: WritableLayoutStateStore =
@@ -1185,6 +1181,16 @@ function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
notifyWorkflowModified 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 => { layoutStates.update(s => {
s.all[workflow.id] = layoutStateStore; s.all[workflow.id] = layoutStateStore;
return s; return s;
@@ -1233,6 +1239,7 @@ export type LayoutStateStores = {
export type LayoutStateStoresOps = { export type LayoutStateStoresOps = {
create: (workflow: ComfyWorkflow) => WritableLayoutStateStore, create: (workflow: ComfyWorkflow) => WritableLayoutStateStore,
createRaw: (workflow?: ComfyWorkflow | null) => WritableLayoutStateStore,
remove: (workflowID: WorkflowInstID) => void, remove: (workflowID: WorkflowInstID) => void,
getLayout: (workflowID: WorkflowInstID) => WritableLayoutStateStore | null, getLayout: (workflowID: WorkflowInstID) => WritableLayoutStateStore | null,
getLayoutByGraph: (graph: LGraph) => WritableLayoutStateStore | null, getLayoutByGraph: (graph: LGraph) => WritableLayoutStateStore | null,
@@ -1249,6 +1256,7 @@ const store = writable({
const layoutStates: WritableLayoutStateStores = { const layoutStates: WritableLayoutStateStores = {
...store, ...store,
create, create,
createRaw,
remove, remove,
getLayout, getLayout,
getLayoutByGraph, getLayoutByGraph,

View File

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