share_code.js: a workflow's lean form (node id, type, widget values, position to 10 px, only what differs from a fresh node; node-pack nodes keep their ports), links by slot, groups and the anomalous_* extras, deflated and spelled in Chinese characters (14 bits each) or base64url. Encoding rebuilds and compares; subgraphs, native reroutes or any difference share the whole workflow. AMB0/AMB1 still decode. About 4x shorter than AMB0 on ComfyUI's default workflow (657 vs 2521), 13x on an Easy-Use/rgthree graph. ui_share.js replaces the share code dialogs that lived in main.js: the code is made on opening with both copy buttons and the fingerprint notes, and pasting a code or JSON, or dropping/choosing a workflow file, an image or a backup .zip, imports it. tests/share_code.mjs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
13 KiB
JavaScript
281 lines
13 KiB
JavaScript
/**
|
||
* Workflow share codes (分享码): one workflow as a short text for a chat or a comment. No DOM;
|
||
* the dialog is ui_share.js.
|
||
*
|
||
* AMB2 keeps a workflow's lean form: each node's id, type, widget values and position (to
|
||
* 10 px), and only what differs from a fresh node of its type (title, mode, colours, size,
|
||
* collapse, properties; its inputs and outputs when they are not a fresh node's, or always for a
|
||
* node pack's node, so the links survive on a computer still lacking the pack), the links by
|
||
* slot, the groups, and the plugin's model fingerprints and links. It is compressed (deflate)
|
||
* and written as Chinese characters, 14 bits each ("汉字码"), or as base64url ("字母码").
|
||
* Encoding rebuilds the workflow from the lean form at once and compares; when anything differs
|
||
* (subgraphs, native reroutes, an unusual node) the code holds the whole workflow instead.
|
||
* AMB0 (the whole workflow) and AMB1 (without positions) codes still open.
|
||
*/
|
||
|
||
export const PREFIX = 'AMB2-';
|
||
const CJK_START = 0x4e00; // 16384 characters from here: U+4E00..U+8DFF, 14 bits each
|
||
const CJK_BITS = 14;
|
||
const GRID = 10; // positions and sizes are kept to this many pixels
|
||
const MAX_UNPACKED = 32 * 1024 * 1024;
|
||
const SEARCH_NAME = 'Node name for S&R';
|
||
|
||
// ---------- bytes ----------
|
||
|
||
async function deflate(text) {
|
||
const stream = new Blob([new TextEncoder().encode(text)]).stream().pipeThrough(new CompressionStream('deflate-raw'));
|
||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||
}
|
||
|
||
async function inflate(bytes) {
|
||
const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')).getReader();
|
||
const parts = [];
|
||
let size = 0;
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
size += value.length;
|
||
if (size > MAX_UNPACKED) throw new Error('The code unpacks to more than a workflow can be.');
|
||
parts.push(value);
|
||
}
|
||
return new TextDecoder().decode(await new Blob(parts).arrayBuffer());
|
||
}
|
||
|
||
export function toLetters(bytes) {
|
||
let binary = '';
|
||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||
}
|
||
|
||
export function fromLetters(text) {
|
||
const base64 = text.replace(/-/g, '+').replace(/_/g, '/');
|
||
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
|
||
return Uint8Array.from(binary, char => char.charCodeAt(0));
|
||
}
|
||
|
||
/** Bytes as Chinese characters, 14 bits each. A first byte says whether the last decoded byte is padding. */
|
||
export function toHanzi(bytes) {
|
||
const total = bytes.length + 1;
|
||
const count = Math.ceil((total * 8) / CJK_BITS);
|
||
const padding = Math.floor((count * CJK_BITS) / 8) - total; // 0 or 1
|
||
const data = new Uint8Array(total + padding);
|
||
data[0] = padding;
|
||
data.set(bytes, 1);
|
||
let out = '';
|
||
let buffer = 0;
|
||
let bits = 0;
|
||
for (const byte of data) {
|
||
buffer = (buffer << 8) | byte;
|
||
bits += 8;
|
||
while (bits >= CJK_BITS) {
|
||
bits -= CJK_BITS;
|
||
out += String.fromCharCode(CJK_START + ((buffer >> bits) & 0x3fff));
|
||
}
|
||
buffer &= (1 << bits) - 1;
|
||
}
|
||
if (bits) out += String.fromCharCode(CJK_START + ((buffer << (CJK_BITS - bits)) & 0x3fff));
|
||
return out;
|
||
}
|
||
|
||
export function fromHanzi(text) {
|
||
const bytes = [];
|
||
let buffer = 0;
|
||
let bits = 0;
|
||
for (const char of text) {
|
||
const value = char.codePointAt(0) - CJK_START;
|
||
if (value < 0 || value > 0x3fff) throw new Error('The code has a character that is not part of it.');
|
||
buffer = (buffer << CJK_BITS) | value;
|
||
bits += CJK_BITS;
|
||
while (bits >= 8) {
|
||
bits -= 8;
|
||
bytes.push((buffer >> bits) & 0xff);
|
||
}
|
||
buffer &= (1 << bits) - 1;
|
||
}
|
||
const padding = bytes[0];
|
||
if (padding !== 0 && padding !== 1) throw new Error('The code is damaged.');
|
||
return Uint8Array.from(bytes.slice(1, bytes.length - padding));
|
||
}
|
||
|
||
// ---------- the lean form ----------
|
||
|
||
const grid = (value) => Math.round((Number(value) || 0) / GRID);
|
||
const ports = (list, linkKey) => (list || []).map((port) => {
|
||
const copy = { ...port };
|
||
delete copy[linkKey];
|
||
delete copy.slot_index;
|
||
delete copy.localized_name;
|
||
return copy;
|
||
});
|
||
const sameShape = (a, b) => a.length === b.length && a.every((port, index) => port.name === b[index]?.name
|
||
&& String(port.type) === String(b[index]?.type) && (port.widget?.name || '') === (b[index]?.widget?.name || '') && !port.label);
|
||
|
||
/** Why a workflow cannot be shared in its lean form ('' when it can). */
|
||
function leanBlocker(workflow) {
|
||
if (workflow.definitions?.subgraphs?.length) return 'subgraph';
|
||
if (workflow.extra?.reroutes?.length || workflow.extra?.linkExtensions?.length) return 'reroute';
|
||
if (!Array.isArray(workflow.nodes) || !Array.isArray(workflow.links)) return 'shape';
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* The lean form of a workflow. `templateOf(type)`: a fresh node's { inputs, outputs, size } (null
|
||
* when the type is not installed); `isCore(type)`: whether the type comes with ComfyUI.
|
||
*/
|
||
export function leanWorkflow(workflow, { templateOf, isCore }) {
|
||
const nodes = workflow.nodes.map((node) => {
|
||
const template = templateOf(node.type);
|
||
const extra = {};
|
||
if (node.title !== undefined) extra.t = node.title;
|
||
if (node.mode) extra.m = node.mode;
|
||
if (node.color) extra.c = node.color;
|
||
if (node.bgcolor) extra.b = node.bgcolor;
|
||
if (node.flags && Object.keys(node.flags).length) extra.f = node.flags;
|
||
const size = Array.isArray(node.size) ? node.size : null;
|
||
if (size && (!template?.size || Math.abs(size[0] - template.size[0]) > GRID || Math.abs(size[1] - template.size[1]) > GRID)) {
|
||
extra.s = [grid(size[0]), grid(size[1])];
|
||
}
|
||
const properties = { ...(node.properties || {}) };
|
||
if (properties[SEARCH_NAME] === node.type) delete properties[SEARCH_NAME];
|
||
if (Object.keys(properties).length) extra.p = properties;
|
||
const inputs = ports(node.inputs, 'link');
|
||
const outputs = ports(node.outputs, 'links');
|
||
const keepPorts = !template || !isCore(node.type);
|
||
if (keepPorts || !sameShape(inputs, ports(template.inputs, 'link'))) extra.i = inputs;
|
||
if (keepPorts || !sameShape(outputs, ports(template.outputs, 'links'))) extra.o = outputs;
|
||
const pos = Array.isArray(node.pos) ? [grid(node.pos[0]), grid(node.pos[1])] : [0, 0];
|
||
const item = [node.id, node.type, node.widgets_values ?? [], pos];
|
||
if (Object.keys(extra).length) item.push(extra);
|
||
return item;
|
||
});
|
||
const links = workflow.links.filter(Boolean).map(link => [link[1], link[2], link[3], link[4], link[5]]);
|
||
const lean = { n: nodes, l: links };
|
||
const groups = (workflow.groups || []).map(group => [group.title || '', (group.bounding || []).map(grid), group.color || '', group.font_size || 0]);
|
||
if (groups.length) lean.g = groups;
|
||
const extra = Object.fromEntries(Object.entries(workflow.extra || {}).filter(([key]) => key.startsWith('anomalous')));
|
||
if (Object.keys(extra).length) lean.x = extra;
|
||
return lean;
|
||
}
|
||
|
||
/** The workflow a lean form stands for, ready for ComfyUI to load. */
|
||
export function rebuildWorkflow(lean, { templateOf }) {
|
||
const nodes = lean.n.map(([id, type, widgets, pos, extra = {}], order) => {
|
||
const template = templateOf(type) || { inputs: [], outputs: [], size: null };
|
||
const node = {
|
||
id, type, pos: [pos[0] * GRID, pos[1] * GRID],
|
||
size: extra.s ? [extra.s[0] * GRID, extra.s[1] * GRID] : (template.size ? [...template.size] : [300, 100]),
|
||
flags: extra.f || {}, order, mode: extra.m || 0,
|
||
inputs: (extra.i || ports(template.inputs, 'link')).map(port => ({ ...port, link: null })),
|
||
outputs: (extra.o || ports(template.outputs, 'links')).map(port => ({ ...port, links: [] })),
|
||
properties: { [SEARCH_NAME]: type, ...(extra.p || {}) },
|
||
widgets_values: widgets,
|
||
};
|
||
if (extra.t !== undefined) node.title = extra.t;
|
||
if (extra.c) node.color = extra.c;
|
||
if (extra.b) node.bgcolor = extra.b;
|
||
return node;
|
||
});
|
||
const byId = new Map(nodes.map(node => [node.id, node]));
|
||
const links = lean.l.map(([from, fromSlot, to, toSlot, type], index) => {
|
||
const id = index + 1;
|
||
const origin = byId.get(from)?.outputs[fromSlot];
|
||
const target = byId.get(to)?.inputs[toSlot];
|
||
if (origin) origin.links.push(id);
|
||
if (target) target.link = id;
|
||
return [id, from, fromSlot, to, toSlot, type];
|
||
});
|
||
for (const node of nodes) for (const output of node.outputs) if (!output.links.length) output.links = null;
|
||
return {
|
||
last_node_id: Math.max(0, ...nodes.map(node => Number(node.id) || 0)),
|
||
last_link_id: links.length,
|
||
nodes, links,
|
||
groups: (lean.g || []).map(([title, bounding, color, size]) => ({
|
||
title, bounding: bounding.map(value => value * GRID), ...(color ? { color } : {}), ...(size ? { font_size: size } : {}),
|
||
})),
|
||
config: {},
|
||
extra: { ...(lean.x || {}) },
|
||
version: 0.4,
|
||
};
|
||
}
|
||
|
||
/** Whether the rebuilt workflow is the shared one in all that matters (positions are rounded). */
|
||
export function sameWorkflow(original, rebuilt) {
|
||
const key = (link) => link && `${link[1]}:${link[2]}>${link[3]}:${link[4]}`;
|
||
const linksA = new Set(original.links.filter(Boolean).map(key));
|
||
const linksB = new Set(rebuilt.links.map(key));
|
||
if (linksA.size !== linksB.size || [...linksA].some(link => !linksB.has(link))) return false;
|
||
const names = (list) => JSON.stringify((list || []).map(port => [port.name, String(port.type), port.widget?.name || '', port.label || '']));
|
||
const rebuiltNodes = new Map(rebuilt.nodes.map(node => [node.id, node]));
|
||
return original.nodes.length === rebuilt.nodes.length && original.nodes.every((node) => {
|
||
const other = rebuiltNodes.get(node.id);
|
||
return other && other.type === node.type && JSON.stringify(node.widgets_values ?? []) === JSON.stringify(other.widgets_values ?? [])
|
||
&& (node.mode || 0) === (other.mode || 0) && node.title === other.title
|
||
&& names(node.inputs) === names(other.inputs) && names(node.outputs) === names(other.outputs);
|
||
});
|
||
}
|
||
|
||
// ---------- codes ----------
|
||
|
||
/** { hanzi, letters, whole, chars }: the two spellings of one code, and whether it holds the whole workflow. */
|
||
export async function encodeShareCode(workflow, helpers) {
|
||
let payload = null;
|
||
if (!leanBlocker(workflow)) {
|
||
const lean = leanWorkflow(workflow, helpers);
|
||
if (sameWorkflow(workflow, rebuildWorkflow(lean, helpers))) payload = { v: 2, ...lean };
|
||
}
|
||
const whole = !payload;
|
||
if (whole) {
|
||
const copy = JSON.parse(JSON.stringify(workflow));
|
||
if (copy.extra) delete copy.extra.ds;
|
||
payload = { v: 2, w: copy };
|
||
}
|
||
const bytes = await deflate(JSON.stringify(payload));
|
||
return { hanzi: PREFIX + toHanzi(bytes), letters: PREFIX + toLetters(bytes), whole };
|
||
}
|
||
|
||
/** The workflow a code holds; AMB0 and AMB1 codes too. Spaces and line breaks a chat added are ignored. */
|
||
export async function decodeShareCode(code, helpers) {
|
||
const text = String(code || '').replace(/[\s-]/g, '');
|
||
if (text.startsWith(PREFIX)) {
|
||
const body = text.slice(PREFIX.length);
|
||
const first = body.codePointAt(0) || 0;
|
||
const bytes = first >= CJK_START ? fromHanzi(body) : fromLetters(body);
|
||
const payload = JSON.parse(await inflate(bytes));
|
||
if (payload.w) return payload.w;
|
||
if (!Array.isArray(payload.n) || !Array.isArray(payload.l)) throw new Error('The code holds no workflow.');
|
||
return rebuildWorkflow(payload, helpers);
|
||
}
|
||
if (text.startsWith('AMB0-') || text.startsWith('AMB1-')) {
|
||
const binary = atob(text.slice(5));
|
||
const workflow = JSON.parse(await inflate(Uint8Array.from(binary, char => char.charCodeAt(0))));
|
||
return text.startsWith('AMB1-') ? layOut(workflow) : workflow;
|
||
}
|
||
throw new Error('This is not a share code (they start with AMB2-, AMB1- or AMB0-).');
|
||
}
|
||
|
||
/** AMB1 codes carry no positions: columns by link depth. */
|
||
function layOut(workflow) {
|
||
const nodes = workflow.nodes || [];
|
||
const depth = new Map(nodes.map(node => [node.id, 0]));
|
||
for (let pass = 0; pass < nodes.length; pass += 1) {
|
||
let moved = false;
|
||
for (const link of workflow.links || []) {
|
||
if (!link || !depth.has(link[1]) || !depth.has(link[3])) continue;
|
||
const next = depth.get(link[1]) + 1;
|
||
if (next > depth.get(link[3])) {
|
||
depth.set(link[3], next);
|
||
moved = true;
|
||
}
|
||
}
|
||
if (!moved) break;
|
||
}
|
||
const rows = new Map();
|
||
for (const node of nodes) {
|
||
const column = depth.get(node.id);
|
||
const row = rows.get(column) || 0;
|
||
rows.set(column, row + 1);
|
||
node.pos = [column * 400, row * 300];
|
||
}
|
||
return workflow;
|
||
}
|