Files
DemonGatanjieu-Anomalous_Mo…/web/modules/share_code.js
T
DemonGatanjieuandClaude Opus 5.5 875715e4a0 feat(share): short AMB2 share codes, one dialog to share and import
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>
2026-10-06 23:22:11 +08:00

281 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}