From 32142fe476878a354dda6e2d4b5ea98960de3ced Mon Sep 17 00:00:00 2001 From: rgthree Date: Fri, 21 Feb 2025 20:42:49 -0500 Subject: [PATCH] Fixes link_fixer which broke when ComfyUI frontend started bundling code rather than serving independant modules. Helps #371 --- src_web/common/comfyui_shim.ts | 49 +++ src_web/common/comfyui_shim_pnginfo.ts | 542 +++++++++++++++++++++++++ src_web/common/rgthree_api.ts | 23 ++ src_web/common/utils_workflow.ts | 2 +- src_web/link_fixer/index.html | 18 +- src_web/link_fixer/link_page.ts | 2 +- src_web/scripts_comfy/pnginfo.ts | 7 - src_web/typings/comfy.d.ts | 16 + web/common/comfyui_shim.js | 32 ++ web/common/comfyui_shim_pnginfo.js | 392 ++++++++++++++++++ web/common/rgthree_api.js | 11 + web/common/utils_workflow.js | 2 +- web/link_fixer/index.html | 18 +- web/link_fixer/link_page.js | 2 +- 14 files changed, 1101 insertions(+), 15 deletions(-) create mode 100644 src_web/common/comfyui_shim.ts create mode 100644 src_web/common/comfyui_shim_pnginfo.ts delete mode 100644 src_web/scripts_comfy/pnginfo.ts create mode 100644 web/common/comfyui_shim.js create mode 100644 web/common/comfyui_shim_pnginfo.js diff --git a/src_web/common/comfyui_shim.ts b/src_web/common/comfyui_shim.ts new file mode 100644 index 0000000..45befcf --- /dev/null +++ b/src_web/common/comfyui_shim.ts @@ -0,0 +1,49 @@ +/** + * [🤮] At some point the new ComfyUI frontend stopped loading it's source as modules and started + * bundling them together. This removed the ability for to import the individual modules (like the + * api.js or pnginfo.js) in stand alone pages, like link_fixer. + * + * So, what do we have to do? Well, we have to fork, hardcode, or port what we would want to load + * from ComfyUI as our own, independant files again, which is unforunate for several reasons; + * duplicate code, risk of falling behind, etc... + * + * Anyway, this file is a shim that will either detect we're in the ComfyUI app and pass through the + * bundled module from the ComfyUI global or load from our own code when that's not available + * (because we're not in the actual ComfyUI UI). + */ + +import type {getPngMetadata, getWebpMetadata} from "typings/comfy.js"; + +const shimCache = new Map(); + +async function shimComfyUiModule(moduleName: string, prop?: string) { + let module = shimCache.get(moduleName); + if (!module) { + if (window.comfyAPI?.[moduleName]) { + module = window.comfyAPI?.[moduleName]; + } else { + module = await import(`./comfyui_shim_${moduleName}.js`); + } + if (!module) { + throw new Error(`Module ${moduleName} could not be loaded.`); + } + shimCache.set(moduleName, module); + } + if (prop) { + if (!module[prop]) { + throw new Error(`Property ${prop} on module ${moduleName} could not be loaded.`); + } + return module[prop]; + } + return module; +} + +export async function getPngMetadata(file: File | Blob) { + const fn = (await shimComfyUiModule("pnginfo", "getPngMetadata")) as getPngMetadata; + return fn(file); +} + +export async function getWebpMetadata(file: File | Blob) { + const fn = (await shimComfyUiModule("pnginfo", "getWebpMetadata")) as getWebpMetadata; + return fn(file); +} diff --git a/src_web/common/comfyui_shim_pnginfo.ts b/src_web/common/comfyui_shim_pnginfo.ts new file mode 100644 index 0000000..5e86b48 --- /dev/null +++ b/src_web/common/comfyui_shim_pnginfo.ts @@ -0,0 +1,542 @@ + +/** + * [🤮] See `./comfyui_shim.ts`. + * + * This code has been forked from https://github.com/Comfy-Org/ComfyUI_frontend/blob/0937c1f2cd5026f390a6efa64f630e01ea414d1d/src/scripts/pnginfo.ts + * with some modifications made, such as removing unneeded exported functions, cleaning up trivial + * typing, etc. + */ + +import { rgthreeApi } from "./rgthree_api.js"; + +/** + * [🤮] A type to add to untypd portions of the code below where they were not yet typed in Comfy's + * code. + */ +type lazyComfyAny = any; + + +// [🤮] A shim for ComfyAPI getEmbeddings. +const api = { + async getEmbeddings(): Promise { + const resp = await rgthreeApi.fetchComfyApi('/embeddings', { cache: 'no-store' }) + return await resp.json(); + } +} + + +function getFromPngBuffer(buffer: ArrayBuffer) { + // Get the PNG data as a Uint8Array + const pngData = new Uint8Array(buffer) + const dataView = new DataView(pngData.buffer) + + // Check that the PNG signature is present + if (dataView.getUint32(0) !== 0x89504e47) { + console.error('Not a valid PNG file') + return + } + + // Start searching for chunks after the PNG signature + let offset = 8 + let txt_chunks: Record = {} + // 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' || type == 'comf' || type === 'iTXt') { + // 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 contentArraySegment = pngData.slice( + keyword_end + 1, + offset + 8 + length + ) + const contentJson = new TextDecoder('utf-8').decode(contentArraySegment) + txt_chunks[keyword] = contentJson + } + + offset += 12 + length + } + return txt_chunks +} + +function getFromPngFile(file: File) { + return new Promise>((r) => { + const reader = new FileReader() + reader.onload = (event) => { + r(getFromPngBuffer((event.target as lazyComfyAny).result as ArrayBuffer) as lazyComfyAny) + } + + reader.readAsArrayBuffer(file) + }) +} + +function parseExifData(exifData: lazyComfyAny) { + // Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian) + const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === 'II' + + // Function to read 16-bit and 32-bit integers from binary data + function readInt(offset: lazyComfyAny, isLittleEndian: lazyComfyAny, length: lazyComfyAny) { + let arr = exifData.slice(offset, offset + length) + if (length === 2) { + return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16( + 0, + isLittleEndian + ) + } else if (length === 4) { + return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32( + 0, + isLittleEndian + ) + } + // lazyComfyAny + throw new Error('Shouldn\'t get here.'); + } + + // Read the offset to the first IFD (Image File Directory) + const ifdOffset = readInt(4, isLittleEndian, 4) + + function parseIFD(offset: lazyComfyAny) { + const numEntries = readInt(offset, isLittleEndian, 2) as lazyComfyAny; + const result = {} as lazyComfyAny + + for (let i = 0; i < numEntries; i++) { + const entryOffset = offset + 2 + i * 12 + const tag = readInt(entryOffset, isLittleEndian, 2) as lazyComfyAny + const type = readInt(entryOffset + 2, isLittleEndian, 2) + const numValues = readInt(entryOffset + 4, isLittleEndian, 4) + const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4) as lazyComfyAny; + + // Read the value(s) based on the data type + let value + if (type === 2) { + // ASCII string + value = new TextDecoder('utf-8').decode( + exifData.subarray(valueOffset, valueOffset + numValues - 1) + ) + } + + result[tag] = value + } + + return result + } + + // Parse the first IFD + const ifdData = parseIFD(ifdOffset) + return ifdData +} + +function splitValues(input: lazyComfyAny) { + var output = {} as lazyComfyAny + for (var key in input) { + var value = input[key] + var splitValues = value.split(':', 2) + output[splitValues[0]] = splitValues[1] + } + return output +} + +export function getPngMetadata(file: File): Promise> { + return getFromPngFile(file) +} + +export function getWebpMetadata(file: lazyComfyAny) { + return new Promise>((r) => { + const reader = new FileReader() + reader.onload = (event) => { + const webp = new Uint8Array((event.target as lazyComfyAny).result as ArrayBuffer) + const dataView = new DataView(webp.buffer) + + // Check that the WEBP signature is present + if ( + dataView.getUint32(0) !== 0x52494646 || + dataView.getUint32(8) !== 0x57454250 + ) { + console.error('Not a valid WEBP file') + r({}) + return + } + + // Start searching for chunks after the WEBP signature + let offset = 12 + let txt_chunks = {} as lazyComfyAny + // Loop through the chunks in the WEBP file + while (offset < webp.length) { + const chunk_length = dataView.getUint32(offset + 4, true) + const chunk_type = String.fromCharCode( + ...webp.slice(offset, offset + 4) + ) + if (chunk_type === 'EXIF') { + if ( + String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) == + 'Exif\0\0' + ) { + offset += 6 + } + let data = parseExifData( + webp.slice(offset + 8, offset + 8 + chunk_length) + ) + for (var key in data) { + const value = data[key] as string + if (typeof value === 'string') { + const index = value.indexOf(':') + txt_chunks[value.slice(0, index)] = value.slice(index + 1) + } + } + break + } + + offset += 8 + chunk_length + } + + r(txt_chunks) + } + + reader.readAsArrayBuffer(file) + }) +} + +export function getLatentMetadata(file: lazyComfyAny) { + return new Promise((r) => { + const reader = new FileReader() + reader.onload = (event) => { + const safetensorsData = new Uint8Array((event.target as lazyComfyAny).result as ArrayBuffer) + const dataView = new DataView(safetensorsData.buffer) + let header_size = dataView.getUint32(0, true) + let offset = 8 + let header = JSON.parse( + new TextDecoder().decode( + safetensorsData.slice(offset, offset + header_size) + ) + ) + r(header.__metadata__) + } + + var slice = file.slice(0, 1024 * 1024 * 4) + reader.readAsArrayBuffer(slice) + }) +} + + +export async function importA1111(graph: lazyComfyAny, parameters: lazyComfyAny) { + const p = parameters.lastIndexOf('\nSteps:') + if (p > -1) { + const embeddings = await api.getEmbeddings() + const opts = parameters + .substr(p) + .split('\n')[1] + .match( + new RegExp('\\s*([^:]+:\\s*([^"\\{].*?|".*?"|\\{.*?\\}))\\s*(,|$)', 'g') + ) + .reduce((p: lazyComfyAny, n: lazyComfyAny) => { + const s = n.split(':') + if (s[1].endsWith(',')) { + s[1] = s[1].substr(0, s[1].length - 1) + } + p[s[0].trim().toLowerCase()] = s[1].trim() + return p + }, {}) + const p2 = parameters.lastIndexOf('\nNegative prompt:', p) + if (p2 > -1) { + let positive = parameters.substr(0, p2).trim() + let negative = parameters.substring(p2 + 18, p).trim() + + const ckptNode = LiteGraph.createNode('CheckpointLoaderSimple') + const clipSkipNode = LiteGraph.createNode('CLIPSetLastLayer') + const positiveNode = LiteGraph.createNode('CLIPTextEncode') + const negativeNode = LiteGraph.createNode('CLIPTextEncode') + const samplerNode = LiteGraph.createNode('KSampler') + const imageNode = LiteGraph.createNode('EmptyLatentImage') + const vaeNode = LiteGraph.createNode('VAEDecode') + const vaeLoaderNode = LiteGraph.createNode('VAELoader') + const saveNode = LiteGraph.createNode('SaveImage') + let hrSamplerNode = null as lazyComfyAny + let hrSteps = null + + const ceil64 = (v: lazyComfyAny) => Math.ceil(v / 64) * 64 + + const getWidget = (node: lazyComfyAny, name: lazyComfyAny) => { + return node.widgets.find((w: lazyComfyAny) => w.name === name) + } + + const setWidgetValue = (node: lazyComfyAny, name: lazyComfyAny, value: lazyComfyAny, isOptionPrefix?: lazyComfyAny) => { + const w = getWidget(node, name) + if (isOptionPrefix) { + const o = w.options.values.find((w: lazyComfyAny) => w.startsWith(value)) + if (o) { + w.value = o + } else { + console.warn(`Unknown value '${value}' for widget '${name}'`, node) + w.value = value + } + } else { + w.value = value + } + } + + const createLoraNodes = (clipNode:lazyComfyAny, text: lazyComfyAny, prevClip: lazyComfyAny, prevModel: lazyComfyAny) => { + const loras = [] as lazyComfyAny + text = text.replace(/]+)>/g, function (m: lazyComfyAny, c: lazyComfyAny) { + const s = c.split(':') + const weight = parseFloat(s[1]) + if (isNaN(weight)) { + console.warn('Invalid LORA', m) + } else { + loras.push({ name: s[0], weight }) + } + return '' + }) + + for (const l of loras) { + const loraNode = LiteGraph.createNode('LoraLoader') + graph.add(loraNode) + setWidgetValue(loraNode, 'lora_name', l.name, true) + setWidgetValue(loraNode, 'strength_model', l.weight) + setWidgetValue(loraNode, 'strength_clip', l.weight) + prevModel.node.connect(prevModel.index, loraNode, 0) + prevClip.node.connect(prevClip.index, loraNode, 1) + prevModel = { node: loraNode, index: 0 } + prevClip = { node: loraNode, index: 1 } + } + + prevClip.node.connect(1, clipNode, 0) + prevModel.node.connect(0, samplerNode, 0) + if (hrSamplerNode) { + prevModel.node.connect(0, hrSamplerNode, 0) + } + + return { text, prevModel, prevClip } + } + + const replaceEmbeddings = (text: lazyComfyAny) => { + if (!embeddings.length) return text + return text.replaceAll( + new RegExp( + '\\b(' + + embeddings + .map((e: lazyComfyAny) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('\\b|\\b') + + ')\\b', + 'ig' + ), + 'embedding:$1' + ) + } + + const popOpt = (name: lazyComfyAny) => { + const v = opts[name] + delete opts[name] + return v + } + + graph.clear() + graph.add(ckptNode) + graph.add(clipSkipNode) + graph.add(positiveNode) + graph.add(negativeNode) + graph.add(samplerNode) + graph.add(imageNode) + graph.add(vaeNode) + graph.add(vaeLoaderNode) + graph.add(saveNode) + + ckptNode.connect(1, clipSkipNode, 0) + clipSkipNode.connect(0, positiveNode, 0) + clipSkipNode.connect(0, negativeNode, 0) + ckptNode.connect(0, samplerNode, 0) + positiveNode.connect(0, samplerNode, 1) + negativeNode.connect(0, samplerNode, 2) + imageNode.connect(0, samplerNode, 3) + vaeNode.connect(0, saveNode, 0) + samplerNode.connect(0, vaeNode, 0) + vaeLoaderNode.connect(0, vaeNode, 1) + + const handlers = { + model(v: lazyComfyAny) { + setWidgetValue(ckptNode, 'ckpt_name', v, true) + }, + vae(v: lazyComfyAny) { + setWidgetValue(vaeLoaderNode, 'vae_name', v, true) + }, + 'cfg scale'(v: lazyComfyAny) { + setWidgetValue(samplerNode, 'cfg', +v) + }, + 'clip skip'(v: lazyComfyAny) { + setWidgetValue(clipSkipNode, 'stop_at_clip_layer', -v) + }, + sampler(v: lazyComfyAny) { + let name = v.toLowerCase().replace('++', 'pp').replaceAll(' ', '_') + if (name.includes('karras')) { + name = name.replace('karras', '').replace(/_+$/, '') + setWidgetValue(samplerNode, 'scheduler', 'karras') + } else { + setWidgetValue(samplerNode, 'scheduler', 'normal') + } + const w = getWidget(samplerNode, 'sampler_name') + const o = w.options.values.find( + (w: lazyComfyAny) => w === name || w === 'sample_' + name + ) + if (o) { + setWidgetValue(samplerNode, 'sampler_name', o) + } + }, + size(v: lazyComfyAny) { + const wxh = v.split('x') + const w = ceil64(+wxh[0]) + const h = ceil64(+wxh[1]) + const hrUp = popOpt('hires upscale') + const hrSz = popOpt('hires resize') + hrSteps = popOpt('hires steps') + let hrMethod = popOpt('hires upscaler') + + setWidgetValue(imageNode, 'width', w) + setWidgetValue(imageNode, 'height', h) + + if (hrUp || hrSz) { + let uw, uh + if (hrUp) { + uw = w * hrUp + uh = h * hrUp + } else { + const s = hrSz.split('x') + uw = +s[0] + uh = +s[1] + } + + let upscaleNode + let latentNode + + if (hrMethod.startsWith('Latent')) { + latentNode = upscaleNode = LiteGraph.createNode('LatentUpscale') + graph.add(upscaleNode) + samplerNode.connect(0, upscaleNode, 0) + + switch (hrMethod) { + case 'Latent (nearest-exact)': + hrMethod = 'nearest-exact' + break + } + setWidgetValue(upscaleNode, 'upscale_method', hrMethod, true) + } else { + const decode = LiteGraph.createNode('VAEDecodeTiled') + graph.add(decode) + samplerNode.connect(0, decode, 0) + vaeLoaderNode.connect(0, decode, 1) + + const upscaleLoaderNode = + LiteGraph.createNode('UpscaleModelLoader') + graph.add(upscaleLoaderNode) + setWidgetValue(upscaleLoaderNode, 'model_name', hrMethod, true) + + const modelUpscaleNode = LiteGraph.createNode( + 'ImageUpscaleWithModel' + ) + graph.add(modelUpscaleNode) + decode.connect(0, modelUpscaleNode, 1) + upscaleLoaderNode.connect(0, modelUpscaleNode, 0) + + upscaleNode = LiteGraph.createNode('ImageScale') + graph.add(upscaleNode) + modelUpscaleNode.connect(0, upscaleNode, 0) + + const vaeEncodeNode = (latentNode = + LiteGraph.createNode('VAEEncodeTiled')) + graph.add(vaeEncodeNode) + upscaleNode.connect(0, vaeEncodeNode, 0) + vaeLoaderNode.connect(0, vaeEncodeNode, 1) + } + + setWidgetValue(upscaleNode, 'width', ceil64(uw)) + setWidgetValue(upscaleNode, 'height', ceil64(uh)) + + hrSamplerNode = LiteGraph.createNode('KSampler') + graph.add(hrSamplerNode) + ckptNode.connect(0, hrSamplerNode, 0) + positiveNode.connect(0, hrSamplerNode, 1) + negativeNode.connect(0, hrSamplerNode, 2) + latentNode.connect(0, hrSamplerNode, 3) + hrSamplerNode.connect(0, vaeNode, 0) + } + }, + steps(v: lazyComfyAny) { + setWidgetValue(samplerNode, 'steps', +v) + }, + seed(v: lazyComfyAny) { + setWidgetValue(samplerNode, 'seed', +v) + } + } + + for (const opt in opts) { + if (opt in handlers) { + ((handlers as lazyComfyAny)[opt] as lazyComfyAny)(popOpt(opt)) + } + } + + if (hrSamplerNode) { + setWidgetValue( + hrSamplerNode, + 'steps', + hrSteps ? +hrSteps : getWidget(samplerNode, 'steps').value + ) + setWidgetValue( + hrSamplerNode, + 'cfg', + getWidget(samplerNode, 'cfg').value + ) + setWidgetValue( + hrSamplerNode, + 'scheduler', + getWidget(samplerNode, 'scheduler').value + ) + setWidgetValue( + hrSamplerNode, + 'sampler_name', + getWidget(samplerNode, 'sampler_name').value + ) + setWidgetValue( + hrSamplerNode, + 'denoise', + +(popOpt('denoising strength') || '1') + ) + } + + let n = createLoraNodes( + positiveNode, + positive, + { node: clipSkipNode, index: 0 }, + { node: ckptNode, index: 0 } + ) + positive = n.text + n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel) + negative = n.text + + setWidgetValue(positiveNode, 'text', replaceEmbeddings(positive)) + setWidgetValue(negativeNode, 'text', replaceEmbeddings(negative)) + + graph.arrange() + + for (const opt of [ + 'model hash', + 'ensd', + 'version', + 'vae hash', + 'ti hashes', + 'lora hashes', + 'hashes' + ]) { + delete opts[opt] + } + + console.warn('Unhandled parameters:', opts) + } + } +} diff --git a/src_web/common/rgthree_api.ts b/src_web/common/rgthree_api.ts index a659e16..e1ddefd 100644 --- a/src_web/common/rgthree_api.ts +++ b/src_web/common/rgthree_api.ts @@ -4,6 +4,7 @@ type ModelInfoType = "loras"; class RgthreeApi { private baseUrl: string; + private comfyBaseUrl: string; getCheckpointsPromise: Promise | null = null; getSamplersPromise: Promise | null = null; getSchedulersPromise: Promise | null = null; @@ -12,6 +13,14 @@ class RgthreeApi { constructor(baseUrl?: string) { this.baseUrl = baseUrl || "./rgthree/api"; + + // Calculate the comfyUI api base path by checkin gif we're on an rgthree independant page (as + // we'll always use '/rgthree/' prefix) and, if so, assume the path before `/rgthree/` is the + // base path. If we're not, then just use the same pathname logic as the ComfyUI api.js uses. + const comfyBasePathname = location.pathname.includes("/rgthree/") + ? location.pathname.split("rgthree/")[0]! + : location.pathname; + this.comfyBaseUrl = comfyBasePathname.split("/").slice(0, -1).join("/"); } apiURL(route: string) { @@ -115,6 +124,20 @@ class RgthreeApi { {cache: "no-store", method: "POST", body}, ); } + + /** + * [🤮] Fetches from the ComfyUI given a similar functionality to the real ComfyUI API + * implementation, but can be available on independant pages outside of the ComfyUI UI. This is + * because ComfyUI frontend stopped serving its modules independantly and opted for a giant bundle + * instead which no longer allows us to load its `api.js` file separately. + */ + fetchComfyApi(route: string, options?: any): Promise { + const url = this.comfyBaseUrl + "/api" + route; + options = options || {}; + options.headers = options.headers || {}; + options.cache = options.cache || "no-cache"; + return fetch(url, options); + } } export const rgthreeApi = new RgthreeApi(); diff --git a/src_web/common/utils_workflow.ts b/src_web/common/utils_workflow.ts index 7b7d6a2..5156948 100644 --- a/src_web/common/utils_workflow.ts +++ b/src_web/common/utils_workflow.ts @@ -1,5 +1,5 @@ import { getResolver } from "./shared_utils.js"; -import { getPngMetadata, getWebpMetadata } from "scripts/pnginfo.js"; +import { getPngMetadata, getWebpMetadata } from "./comfyui_shim.js"; import type { SerializedGraph } from "typings/index.js"; import type { ComfyApiFormat } from "typings/comfy.js"; diff --git a/src_web/link_fixer/index.html b/src_web/link_fixer/index.html index 698a634..2e1b66e 100644 --- a/src_web/link_fixer/index.html +++ b/src_web/link_fixer/index.html @@ -53,6 +53,12 @@ text-align: left; line-height: 1.25; } + .box > small { + margin: 0 0 .6em; + text-align: left; + line-height: 1.25; + opacity: 0.75; + } picture > img { display: block; @@ -102,8 +108,16 @@

rgthree's Workflow Link Fixer

-

Early versions of the reroute node would occasionally leave behind stale node-linking data in the graph, which could sometimes cause erratic workflow loading. This tool will look at the metadata and attempt to fix these errors.

-

Drag and drop a comfy-generated image or workflow json into this window to check its serialized links and fix.

+

+ Drag and drop a comfy-generated image or workflow json into this window to check its + serialized links and attempt to fix. +

+ + Sometimes as you have complex workflows the internal data can become corrupt, and the + ComfyUI doesn't always understand or display correctly. Maybe links disappear, or reconnect + to another node when changing something. Load it here to detect and, if possible, attempt + to fix it (sometimes, however, fixing it just isn't feasible, so fingers crossed). +
diff --git a/src_web/link_fixer/link_page.ts b/src_web/link_fixer/link_page.ts index d266ef2..4e43fa2 100644 --- a/src_web/link_fixer/link_page.ts +++ b/src_web/link_fixer/link_page.ts @@ -1,6 +1,6 @@ import type { SerializedGraph, BadLinksData } from "typings/index.js"; import { fixBadLinks } from "../common/link_fixer.js"; -import { getPngMetadata } from "scripts/pnginfo.js"; +import { getPngMetadata } from "../common/comfyui_shim.js"; function wait(ms = 16, value?: any) { return new Promise((resolve) => { diff --git a/src_web/scripts_comfy/pnginfo.ts b/src_web/scripts_comfy/pnginfo.ts deleted file mode 100644 index 7288c07..0000000 --- a/src_web/scripts_comfy/pnginfo.ts +++ /dev/null @@ -1,7 +0,0 @@ -export declare const getPngMetadata: (file: File | Blob) => { workflow?: string; prompt?: string }; -export declare const getWebpMetadata: (file: File | Blob) => { - Workflow?: string; - workflow?: string; - Prompt?: string; - prompt?: string; -}; diff --git a/src_web/typings/comfy.d.ts b/src_web/typings/comfy.d.ts index bbecb80..3e84035 100644 --- a/src_web/typings/comfy.d.ts +++ b/src_web/typings/comfy.d.ts @@ -7,8 +7,24 @@ declare global { const LGraphNode: typeof TLGraphNode; const LGraphCanvas: typeof TLGraphCanvas; const LGraphGroup: typeof TLGraphGroup; + interface Window { + // Used in the common/comfyui_shim to determine if we're in the app or not. + comfyAPI: { + // So much more stuffed in here, add as needed. + [key: string]: any; + } + } } +export type getPngMetadata = (file: File | Blob) => { workflow?: string; prompt?: string }; +export type getWebpMetadata = (file: File | Blob) => { + Workflow?: string; + workflow?: string; + Prompt?: string; + prompt?: string; +}; + + // @rgthree: Types on ComfyApp as needed. export interface ComfyApp { extensions: ComfyExtension[]; diff --git a/web/common/comfyui_shim.js b/web/common/comfyui_shim.js new file mode 100644 index 0000000..54007ba --- /dev/null +++ b/web/common/comfyui_shim.js @@ -0,0 +1,32 @@ +const shimCache = new Map(); +async function shimComfyUiModule(moduleName, prop) { + var _a, _b; + let module = shimCache.get(moduleName); + if (!module) { + if ((_a = window.comfyAPI) === null || _a === void 0 ? void 0 : _a[moduleName]) { + module = (_b = window.comfyAPI) === null || _b === void 0 ? void 0 : _b[moduleName]; + } + else { + module = await import(`./comfyui_shim_${moduleName}.js`); + } + if (!module) { + throw new Error(`Module ${moduleName} could not be loaded.`); + } + shimCache.set(moduleName, module); + } + if (prop) { + if (!module[prop]) { + throw new Error(`Property ${prop} on module ${moduleName} could not be loaded.`); + } + return module[prop]; + } + return module; +} +export async function getPngMetadata(file) { + const fn = (await shimComfyUiModule("pnginfo", "getPngMetadata")); + return fn(file); +} +export async function getWebpMetadata(file) { + const fn = (await shimComfyUiModule("pnginfo", "getWebpMetadata")); + return fn(file); +} diff --git a/web/common/comfyui_shim_pnginfo.js b/web/common/comfyui_shim_pnginfo.js new file mode 100644 index 0000000..7ef8e6b --- /dev/null +++ b/web/common/comfyui_shim_pnginfo.js @@ -0,0 +1,392 @@ +import { rgthreeApi } from "./rgthree_api.js"; +const api = { + async getEmbeddings() { + const resp = await rgthreeApi.fetchComfyApi('/embeddings', { cache: 'no-store' }); + return await resp.json(); + } +}; +function getFromPngBuffer(buffer) { + const pngData = new Uint8Array(buffer); + const dataView = new DataView(pngData.buffer); + if (dataView.getUint32(0) !== 0x89504e47) { + console.error('Not a valid PNG file'); + return; + } + let offset = 8; + let txt_chunks = {}; + while (offset < pngData.length) { + const length = dataView.getUint32(offset); + const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8)); + if (type === 'tEXt' || type == 'comf' || type === 'iTXt') { + let keyword_end = offset + 8; + while (pngData[keyword_end] !== 0) { + keyword_end++; + } + const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end)); + const contentArraySegment = pngData.slice(keyword_end + 1, offset + 8 + length); + const contentJson = new TextDecoder('utf-8').decode(contentArraySegment); + txt_chunks[keyword] = contentJson; + } + offset += 12 + length; + } + return txt_chunks; +} +function getFromPngFile(file) { + return new Promise((r) => { + const reader = new FileReader(); + reader.onload = (event) => { + r(getFromPngBuffer(event.target.result)); + }; + reader.readAsArrayBuffer(file); + }); +} +function parseExifData(exifData) { + const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === 'II'; + function readInt(offset, isLittleEndian, length) { + let arr = exifData.slice(offset, offset + length); + if (length === 2) { + return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(0, isLittleEndian); + } + else if (length === 4) { + return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(0, isLittleEndian); + } + throw new Error('Shouldn\'t get here.'); + } + const ifdOffset = readInt(4, isLittleEndian, 4); + function parseIFD(offset) { + const numEntries = readInt(offset, isLittleEndian, 2); + const result = {}; + for (let i = 0; i < numEntries; i++) { + const entryOffset = offset + 2 + i * 12; + const tag = readInt(entryOffset, isLittleEndian, 2); + const type = readInt(entryOffset + 2, isLittleEndian, 2); + const numValues = readInt(entryOffset + 4, isLittleEndian, 4); + const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4); + let value; + if (type === 2) { + value = new TextDecoder('utf-8').decode(exifData.subarray(valueOffset, valueOffset + numValues - 1)); + } + result[tag] = value; + } + return result; + } + const ifdData = parseIFD(ifdOffset); + return ifdData; +} +function splitValues(input) { + var output = {}; + for (var key in input) { + var value = input[key]; + var splitValues = value.split(':', 2); + output[splitValues[0]] = splitValues[1]; + } + return output; +} +export function getPngMetadata(file) { + return getFromPngFile(file); +} +export function getWebpMetadata(file) { + return new Promise((r) => { + const reader = new FileReader(); + reader.onload = (event) => { + const webp = new Uint8Array(event.target.result); + const dataView = new DataView(webp.buffer); + if (dataView.getUint32(0) !== 0x52494646 || + dataView.getUint32(8) !== 0x57454250) { + console.error('Not a valid WEBP file'); + r({}); + return; + } + let offset = 12; + let txt_chunks = {}; + while (offset < webp.length) { + const chunk_length = dataView.getUint32(offset + 4, true); + const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4)); + if (chunk_type === 'EXIF') { + if (String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) == + 'Exif\0\0') { + offset += 6; + } + let data = parseExifData(webp.slice(offset + 8, offset + 8 + chunk_length)); + for (var key in data) { + const value = data[key]; + if (typeof value === 'string') { + const index = value.indexOf(':'); + txt_chunks[value.slice(0, index)] = value.slice(index + 1); + } + } + break; + } + offset += 8 + chunk_length; + } + r(txt_chunks); + }; + reader.readAsArrayBuffer(file); + }); +} +export function getLatentMetadata(file) { + return new Promise((r) => { + const reader = new FileReader(); + reader.onload = (event) => { + const safetensorsData = new Uint8Array(event.target.result); + const dataView = new DataView(safetensorsData.buffer); + let header_size = dataView.getUint32(0, true); + let offset = 8; + let header = JSON.parse(new TextDecoder().decode(safetensorsData.slice(offset, offset + header_size))); + r(header.__metadata__); + }; + var slice = file.slice(0, 1024 * 1024 * 4); + reader.readAsArrayBuffer(slice); + }); +} +export async function importA1111(graph, parameters) { + const p = parameters.lastIndexOf('\nSteps:'); + if (p > -1) { + const embeddings = await api.getEmbeddings(); + const opts = parameters + .substr(p) + .split('\n')[1] + .match(new RegExp('\\s*([^:]+:\\s*([^"\\{].*?|".*?"|\\{.*?\\}))\\s*(,|$)', 'g')) + .reduce((p, n) => { + const s = n.split(':'); + if (s[1].endsWith(',')) { + s[1] = s[1].substr(0, s[1].length - 1); + } + p[s[0].trim().toLowerCase()] = s[1].trim(); + return p; + }, {}); + const p2 = parameters.lastIndexOf('\nNegative prompt:', p); + if (p2 > -1) { + let positive = parameters.substr(0, p2).trim(); + let negative = parameters.substring(p2 + 18, p).trim(); + const ckptNode = LiteGraph.createNode('CheckpointLoaderSimple'); + const clipSkipNode = LiteGraph.createNode('CLIPSetLastLayer'); + const positiveNode = LiteGraph.createNode('CLIPTextEncode'); + const negativeNode = LiteGraph.createNode('CLIPTextEncode'); + const samplerNode = LiteGraph.createNode('KSampler'); + const imageNode = LiteGraph.createNode('EmptyLatentImage'); + const vaeNode = LiteGraph.createNode('VAEDecode'); + const vaeLoaderNode = LiteGraph.createNode('VAELoader'); + const saveNode = LiteGraph.createNode('SaveImage'); + let hrSamplerNode = null; + let hrSteps = null; + const ceil64 = (v) => Math.ceil(v / 64) * 64; + const getWidget = (node, name) => { + return node.widgets.find((w) => w.name === name); + }; + const setWidgetValue = (node, name, value, isOptionPrefix) => { + const w = getWidget(node, name); + if (isOptionPrefix) { + const o = w.options.values.find((w) => w.startsWith(value)); + if (o) { + w.value = o; + } + else { + console.warn(`Unknown value '${value}' for widget '${name}'`, node); + w.value = value; + } + } + else { + w.value = value; + } + }; + const createLoraNodes = (clipNode, text, prevClip, prevModel) => { + const loras = []; + text = text.replace(/]+)>/g, function (m, c) { + const s = c.split(':'); + const weight = parseFloat(s[1]); + if (isNaN(weight)) { + console.warn('Invalid LORA', m); + } + else { + loras.push({ name: s[0], weight }); + } + return ''; + }); + for (const l of loras) { + const loraNode = LiteGraph.createNode('LoraLoader'); + graph.add(loraNode); + setWidgetValue(loraNode, 'lora_name', l.name, true); + setWidgetValue(loraNode, 'strength_model', l.weight); + setWidgetValue(loraNode, 'strength_clip', l.weight); + prevModel.node.connect(prevModel.index, loraNode, 0); + prevClip.node.connect(prevClip.index, loraNode, 1); + prevModel = { node: loraNode, index: 0 }; + prevClip = { node: loraNode, index: 1 }; + } + prevClip.node.connect(1, clipNode, 0); + prevModel.node.connect(0, samplerNode, 0); + if (hrSamplerNode) { + prevModel.node.connect(0, hrSamplerNode, 0); + } + return { text, prevModel, prevClip }; + }; + const replaceEmbeddings = (text) => { + if (!embeddings.length) + return text; + return text.replaceAll(new RegExp('\\b(' + + embeddings + .map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('\\b|\\b') + + ')\\b', 'ig'), 'embedding:$1'); + }; + const popOpt = (name) => { + const v = opts[name]; + delete opts[name]; + return v; + }; + graph.clear(); + graph.add(ckptNode); + graph.add(clipSkipNode); + graph.add(positiveNode); + graph.add(negativeNode); + graph.add(samplerNode); + graph.add(imageNode); + graph.add(vaeNode); + graph.add(vaeLoaderNode); + graph.add(saveNode); + ckptNode.connect(1, clipSkipNode, 0); + clipSkipNode.connect(0, positiveNode, 0); + clipSkipNode.connect(0, negativeNode, 0); + ckptNode.connect(0, samplerNode, 0); + positiveNode.connect(0, samplerNode, 1); + negativeNode.connect(0, samplerNode, 2); + imageNode.connect(0, samplerNode, 3); + vaeNode.connect(0, saveNode, 0); + samplerNode.connect(0, vaeNode, 0); + vaeLoaderNode.connect(0, vaeNode, 1); + const handlers = { + model(v) { + setWidgetValue(ckptNode, 'ckpt_name', v, true); + }, + vae(v) { + setWidgetValue(vaeLoaderNode, 'vae_name', v, true); + }, + 'cfg scale'(v) { + setWidgetValue(samplerNode, 'cfg', +v); + }, + 'clip skip'(v) { + setWidgetValue(clipSkipNode, 'stop_at_clip_layer', -v); + }, + sampler(v) { + let name = v.toLowerCase().replace('++', 'pp').replaceAll(' ', '_'); + if (name.includes('karras')) { + name = name.replace('karras', '').replace(/_+$/, ''); + setWidgetValue(samplerNode, 'scheduler', 'karras'); + } + else { + setWidgetValue(samplerNode, 'scheduler', 'normal'); + } + const w = getWidget(samplerNode, 'sampler_name'); + const o = w.options.values.find((w) => w === name || w === 'sample_' + name); + if (o) { + setWidgetValue(samplerNode, 'sampler_name', o); + } + }, + size(v) { + const wxh = v.split('x'); + const w = ceil64(+wxh[0]); + const h = ceil64(+wxh[1]); + const hrUp = popOpt('hires upscale'); + const hrSz = popOpt('hires resize'); + hrSteps = popOpt('hires steps'); + let hrMethod = popOpt('hires upscaler'); + setWidgetValue(imageNode, 'width', w); + setWidgetValue(imageNode, 'height', h); + if (hrUp || hrSz) { + let uw, uh; + if (hrUp) { + uw = w * hrUp; + uh = h * hrUp; + } + else { + const s = hrSz.split('x'); + uw = +s[0]; + uh = +s[1]; + } + let upscaleNode; + let latentNode; + if (hrMethod.startsWith('Latent')) { + latentNode = upscaleNode = LiteGraph.createNode('LatentUpscale'); + graph.add(upscaleNode); + samplerNode.connect(0, upscaleNode, 0); + switch (hrMethod) { + case 'Latent (nearest-exact)': + hrMethod = 'nearest-exact'; + break; + } + setWidgetValue(upscaleNode, 'upscale_method', hrMethod, true); + } + else { + const decode = LiteGraph.createNode('VAEDecodeTiled'); + graph.add(decode); + samplerNode.connect(0, decode, 0); + vaeLoaderNode.connect(0, decode, 1); + const upscaleLoaderNode = LiteGraph.createNode('UpscaleModelLoader'); + graph.add(upscaleLoaderNode); + setWidgetValue(upscaleLoaderNode, 'model_name', hrMethod, true); + const modelUpscaleNode = LiteGraph.createNode('ImageUpscaleWithModel'); + graph.add(modelUpscaleNode); + decode.connect(0, modelUpscaleNode, 1); + upscaleLoaderNode.connect(0, modelUpscaleNode, 0); + upscaleNode = LiteGraph.createNode('ImageScale'); + graph.add(upscaleNode); + modelUpscaleNode.connect(0, upscaleNode, 0); + const vaeEncodeNode = (latentNode = + LiteGraph.createNode('VAEEncodeTiled')); + graph.add(vaeEncodeNode); + upscaleNode.connect(0, vaeEncodeNode, 0); + vaeLoaderNode.connect(0, vaeEncodeNode, 1); + } + setWidgetValue(upscaleNode, 'width', ceil64(uw)); + setWidgetValue(upscaleNode, 'height', ceil64(uh)); + hrSamplerNode = LiteGraph.createNode('KSampler'); + graph.add(hrSamplerNode); + ckptNode.connect(0, hrSamplerNode, 0); + positiveNode.connect(0, hrSamplerNode, 1); + negativeNode.connect(0, hrSamplerNode, 2); + latentNode.connect(0, hrSamplerNode, 3); + hrSamplerNode.connect(0, vaeNode, 0); + } + }, + steps(v) { + setWidgetValue(samplerNode, 'steps', +v); + }, + seed(v) { + setWidgetValue(samplerNode, 'seed', +v); + } + }; + for (const opt in opts) { + if (opt in handlers) { + handlers[opt](popOpt(opt)); + } + } + if (hrSamplerNode) { + setWidgetValue(hrSamplerNode, 'steps', hrSteps ? +hrSteps : getWidget(samplerNode, 'steps').value); + setWidgetValue(hrSamplerNode, 'cfg', getWidget(samplerNode, 'cfg').value); + setWidgetValue(hrSamplerNode, 'scheduler', getWidget(samplerNode, 'scheduler').value); + setWidgetValue(hrSamplerNode, 'sampler_name', getWidget(samplerNode, 'sampler_name').value); + setWidgetValue(hrSamplerNode, 'denoise', +(popOpt('denoising strength') || '1')); + } + let n = createLoraNodes(positiveNode, positive, { node: clipSkipNode, index: 0 }, { node: ckptNode, index: 0 }); + positive = n.text; + n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel); + negative = n.text; + setWidgetValue(positiveNode, 'text', replaceEmbeddings(positive)); + setWidgetValue(negativeNode, 'text', replaceEmbeddings(negative)); + graph.arrange(); + for (const opt of [ + 'model hash', + 'ensd', + 'version', + 'vae hash', + 'ti hashes', + 'lora hashes', + 'hashes' + ]) { + delete opts[opt]; + } + console.warn('Unhandled parameters:', opts); + } + } +} diff --git a/web/common/rgthree_api.js b/web/common/rgthree_api.js index bebc816..3671d6e 100644 --- a/web/common/rgthree_api.js +++ b/web/common/rgthree_api.js @@ -6,6 +6,10 @@ class RgthreeApi { this.getLorasPromise = null; this.getWorkflowsPromise = null; this.baseUrl = baseUrl || "./rgthree/api"; + const comfyBasePathname = location.pathname.includes("/rgthree/") + ? location.pathname.split("rgthree/")[0] + : location.pathname; + this.comfyBaseUrl = comfyBasePathname.split("/").slice(0, -1).join("/"); } apiURL(route) { return `${this.baseUrl}${route}`; @@ -72,5 +76,12 @@ class RgthreeApi { body.append("json", JSON.stringify(data)); return await this.fetchApiJsonOrNull(`/${type}/info?file=${encodeURIComponent(file)}`, { cache: "no-store", method: "POST", body }); } + fetchComfyApi(route, options) { + const url = this.comfyBaseUrl + "/api" + route; + options = options || {}; + options.headers = options.headers || {}; + options.cache = options.cache || "no-cache"; + return fetch(url, options); + } } export const rgthreeApi = new RgthreeApi(); diff --git a/web/common/utils_workflow.js b/web/common/utils_workflow.js index b8c61cf..5dc2963 100644 --- a/web/common/utils_workflow.js +++ b/web/common/utils_workflow.js @@ -1,5 +1,5 @@ import { getResolver } from "./shared_utils.js"; -import { getPngMetadata, getWebpMetadata } from "../../scripts/pnginfo.js"; +import { getPngMetadata, getWebpMetadata } from "./comfyui_shim.js"; function parseWorkflowJson(stringJson) { stringJson = stringJson || "null"; stringJson = stringJson.replace(/:\s*NaN/g, ": null"); diff --git a/web/link_fixer/index.html b/web/link_fixer/index.html index 698a634..2e1b66e 100644 --- a/web/link_fixer/index.html +++ b/web/link_fixer/index.html @@ -53,6 +53,12 @@ text-align: left; line-height: 1.25; } + .box > small { + margin: 0 0 .6em; + text-align: left; + line-height: 1.25; + opacity: 0.75; + } picture > img { display: block; @@ -102,8 +108,16 @@

rgthree's Workflow Link Fixer

-

Early versions of the reroute node would occasionally leave behind stale node-linking data in the graph, which could sometimes cause erratic workflow loading. This tool will look at the metadata and attempt to fix these errors.

-

Drag and drop a comfy-generated image or workflow json into this window to check its serialized links and fix.

+

+ Drag and drop a comfy-generated image or workflow json into this window to check its + serialized links and attempt to fix. +

+ + Sometimes as you have complex workflows the internal data can become corrupt, and the + ComfyUI doesn't always understand or display correctly. Maybe links disappear, or reconnect + to another node when changing something. Load it here to detect and, if possible, attempt + to fix it (sometimes, however, fixing it just isn't feasible, so fingers crossed). +
diff --git a/web/link_fixer/link_page.js b/web/link_fixer/link_page.js index 6fb5494..dabf3fb 100644 --- a/web/link_fixer/link_page.js +++ b/web/link_fixer/link_page.js @@ -1,5 +1,5 @@ import { fixBadLinks } from "../common/link_fixer.js"; -import { getPngMetadata } from "../../scripts/pnginfo.js"; +import { getPngMetadata } from "../common/comfyui_shim.js"; function wait(ms = 16, value) { return new Promise((resolve) => { setTimeout(() => {