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>
124 lines
7.6 KiB
JavaScript
124 lines
7.6 KiB
JavaScript
// Workflow share codes (web/modules/share_code.js): the two spellings, the lean form and its
|
|
// self-check, the fall back to the whole workflow, and the old AMB0/AMB1 codes.
|
|
import assert from 'node:assert/strict';
|
|
import { deflateRawSync } from 'node:zlib';
|
|
import {
|
|
decodeShareCode, encodeShareCode, fromHanzi, fromLetters, leanWorkflow, rebuildWorkflow, sameWorkflow, toHanzi, toLetters,
|
|
} from '../web/modules/share_code.js';
|
|
|
|
// Bytes survive both spellings at every length.
|
|
for (let length = 0; length < 64; length += 1) {
|
|
const bytes = Uint8Array.from({ length }, (_, index) => (index * 97 + length * 31) % 256);
|
|
assert.deepEqual(fromHanzi(toHanzi(bytes)), bytes, `hanzi ${length}`);
|
|
assert.deepEqual(fromLetters(toLetters(bytes)), bytes, `letters ${length}`);
|
|
}
|
|
assert.throws(() => fromHanzi('abc'));
|
|
|
|
// Fresh nodes of each type, as the canvas would make them.
|
|
const port = (name, type, extra = {}) => ({ name, type, ...extra });
|
|
const TEMPLATES = {
|
|
CheckpointLoaderSimple: { inputs: [], outputs: [port('MODEL', 'MODEL', { links: null }), port('CLIP', 'CLIP', { links: null }), port('VAE', 'VAE', { links: null })], size: [315, 98] },
|
|
CLIPTextEncode: { inputs: [port('clip', 'CLIP', { link: null }), port('text', 'STRING', { widget: { name: 'text' }, link: null })], outputs: [port('CONDITIONING', 'CONDITIONING', { links: null })], size: [400, 200] },
|
|
KSampler: {
|
|
inputs: [port('model', 'MODEL', { link: null }), port('positive', 'CONDITIONING', { link: null }), port('negative', 'CONDITIONING', { link: null })],
|
|
outputs: [port('LATENT', 'LATENT', { links: null })], size: [315, 262],
|
|
},
|
|
'Lora Loader Stack (rgthree)': { inputs: [port('model', 'MODEL', { link: null }), port('clip', 'CLIP', { link: null })], outputs: [port('MODEL', 'MODEL', { links: null }), port('CLIP', 'CLIP', { links: null })], size: [340, 200] },
|
|
};
|
|
const CORE = new Set(['CheckpointLoaderSimple', 'CLIPTextEncode', 'KSampler']);
|
|
const here = { templateOf: type => TEMPLATES[type] || null, isCore: type => CORE.has(type) };
|
|
const nowhere = { templateOf: () => null, isCore: () => false }; // a computer without these node packs
|
|
|
|
const node = (id, type, widgets, pos, more = {}) => {
|
|
const template = TEMPLATES[type];
|
|
return {
|
|
id, type, pos, size: template.size, flags: {}, order: id, mode: 0,
|
|
inputs: template.inputs.map(input => ({ ...input })), outputs: template.outputs.map(output => ({ ...output, links: [] })),
|
|
properties: { 'Node name for S&R': type }, widgets_values: widgets, ...more,
|
|
};
|
|
};
|
|
function workflow() {
|
|
const nodes = [
|
|
node(4, 'CheckpointLoaderSimple', ['sd_xl_base_1.0.safetensors'], [26, 474]),
|
|
node(6, 'CLIPTextEncode', ['a photo of a cat, masterpiece, best quality'], [415, 186], { title: 'Positive' }),
|
|
node(7, 'CLIPTextEncode', ['blurry, lowres'], [413, 389]),
|
|
node(3, 'KSampler', [156680208700286, 'randomize', 20, 8, 'euler', 'normal', 1], [863, 186]),
|
|
node(10, 'Lora Loader Stack (rgthree)', ['cute.safetensors', 0.8, 'None', 1, 'None', 1, 'None', 1], [200, 700], { mode: 4 }),
|
|
];
|
|
const links = [[1, 4, 1, 6, 0, 'CLIP'], [2, 4, 1, 7, 0, 'CLIP'], [3, 6, 0, 3, 1, 'CONDITIONING'], [4, 7, 0, 3, 2, 'CONDITIONING'], [5, 4, 0, 10, 0, 'MODEL'], [6, 10, 0, 3, 0, 'MODEL']];
|
|
const byId = new Map(nodes.map(item => [item.id, item]));
|
|
for (const [id, from, fromSlot, to, toSlot] of links) {
|
|
byId.get(from).outputs[fromSlot].links.push(id);
|
|
byId.get(to).inputs[toSlot].link = id;
|
|
}
|
|
return {
|
|
last_node_id: 10, last_link_id: 6, nodes, links,
|
|
groups: [{ title: 'Prompts', bounding: [400, 150, 450, 450], color: '#3f789e', font_size: 24 }],
|
|
config: {}, version: 0.4,
|
|
extra: { ds: { scale: 1, offset: [0, 0] }, anomalous_hashes: { '4_sd_xl_base_1.0.safetensors': { hash: 'ab'.repeat(32), size: 6938078334 } } },
|
|
};
|
|
}
|
|
|
|
// The lean form rebuilds the same workflow; only differences from a fresh node are kept.
|
|
const original = workflow();
|
|
const lean = leanWorkflow(original, here);
|
|
const rebuilt = rebuildWorkflow(lean, here);
|
|
assert.ok(sameWorkflow(original, rebuilt));
|
|
assert.equal(lean.n.find(item => item[0] === 3).length, 4); // a plain KSampler: no extras at all
|
|
assert.ok(lean.n.find(item => item[0] === 10)[4].i, 'a node pack node keeps its ports');
|
|
assert.equal(rebuilt.nodes.find(item => item.id === 6).title, 'Positive');
|
|
assert.equal(rebuilt.nodes.find(item => item.id === 10).mode, 4);
|
|
assert.deepEqual(rebuilt.groups[0].bounding, [400, 150, 450, 450]);
|
|
assert.equal(rebuilt.extra.anomalous_hashes['4_sd_xl_base_1.0.safetensors'].hash, 'ab'.repeat(32));
|
|
assert.equal(rebuilt.extra.ds, undefined);
|
|
|
|
// A workflow as ComfyUI saves it leaves out the values of nodes without any: still the same.
|
|
const saved = rebuildWorkflow(lean, here);
|
|
for (const item of saved.nodes) if (!item.widgets_values.length) delete item.widgets_values;
|
|
assert.ok(sameWorkflow(saved, rebuilt) && sameWorkflow(rebuilt, saved));
|
|
|
|
// Codes: both spellings open to the same workflow, much shorter than the old full code.
|
|
const code = await encodeShareCode(original, here);
|
|
assert.equal(code.whole, false);
|
|
assert.ok(code.hanzi.startsWith('AMB2-') && code.letters.startsWith('AMB2-'));
|
|
const old = 'AMB0-' + Buffer.from(deflateRawSync(JSON.stringify(original))).toString('base64');
|
|
assert.ok(code.hanzi.length * 3 < old.length, `${code.hanzi.length} vs ${old.length}`);
|
|
assert.ok(code.hanzi.length < code.letters.length);
|
|
for (const spelling of [code.hanzi, code.letters]) assert.ok(sameWorkflow(original, await decodeShareCode(spelling, here)));
|
|
// A chat that wraps the text or adds spaces does not break it.
|
|
const wrapped = code.hanzi.replace(/(.{20})/gu, '$1\n ');
|
|
assert.ok(sameWorkflow(original, await decodeShareCode(wrapped, here)));
|
|
|
|
// On a computer without the node pack, its node still has its ports and links.
|
|
const elsewhere = await decodeShareCode(code.hanzi, { templateOf: type => (CORE.has(type) ? TEMPLATES[type] : null) });
|
|
const stack = elsewhere.nodes.find(item => item.id === 10);
|
|
assert.deepEqual(stack.inputs.map(input => input.link), [5, null]);
|
|
assert.deepEqual(stack.outputs[0].links, [6]);
|
|
assert.ok(sameWorkflow(original, elsewhere));
|
|
|
|
// A core node whose ports differ from a fresh one (an extra input) keeps them.
|
|
const grown = workflow();
|
|
grown.nodes.find(item => item.id === 3).inputs.push({ name: 'latent_image', type: 'LATENT', link: null });
|
|
const grownLean = leanWorkflow(grown, here);
|
|
assert.ok(grownLean.n.find(item => item[0] === 3)[4].i);
|
|
assert.ok(sameWorkflow(grown, rebuildWorkflow(grownLean, here)));
|
|
|
|
// What the lean form cannot hold goes whole: a subgraph, or nodes it cannot rebuild.
|
|
const withSubgraph = { ...workflow(), definitions: { subgraphs: [{ id: 'x' }] } };
|
|
const wholeCode = await encodeShareCode(withSubgraph, here);
|
|
assert.equal(wholeCode.whole, true);
|
|
assert.deepEqual((await decodeShareCode(wholeCode.letters, here)).definitions, withSubgraph.definitions);
|
|
const unknownHere = await encodeShareCode(workflow(), nowhere); // every node keeps its ports: still lean
|
|
assert.equal(unknownHere.whole, false);
|
|
|
|
// Old codes still open; AMB1 is laid out by link depth.
|
|
assert.ok(sameWorkflow(original, await decodeShareCode(old, here)));
|
|
const skeleton = workflow();
|
|
skeleton.nodes.forEach((item) => { delete item.pos; });
|
|
const amb1 = await decodeShareCode('AMB1-' + Buffer.from(deflateRawSync(JSON.stringify(skeleton))).toString('base64'), here);
|
|
assert.deepEqual(amb1.nodes.find(item => item.id === 3).pos, [800, 0]);
|
|
await assert.rejects(decodeShareCode('hello', here));
|
|
await assert.rejects(decodeShareCode('AMB2-' + '一'.repeat(3) + 'x', here));
|
|
|
|
console.log(`share_code: ok (hanzi ${code.hanzi.length}, letters ${code.letters.length}, old ${old.length})`);
|