import { app } from "../../scripts/app.js";
import { createBrowserEntry } from "./modules/browser_entry.js";
import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js";
import { isModelFilename } from "./modules/model_source_links.js";
export { setAbyssalScarletTheme };
const browserEntry = createBrowserEntry({ translate: t, getCurrentLanguage });
app.registerExtension({
name: "Anomalous.ModelBrowser",
settings: [...browserEntry.settings, ...createInterfaceSettings()],
actionBarButtons: browserEntry.actionBarButtons,
commands: browserEntry.commands,
keybindings: browserEntry.keybindings,
menuCommands: browserEntry.menuCommands,
setup: browserEntry.setup
});
// --- INJECTED WORKFLOW SHARE MODULE ---
// Workflow Share and Preview Modal for Anomalous_Model_Browser
const AMB_WorkflowShare = {
// ----------------------------------------------------------------------
// 1. Data Compression & Base64 Utils
// ----------------------------------------------------------------------
strToU8(str) {
return new TextEncoder().encode(str);
},
u8ToStr(u8) {
return new TextDecoder().decode(u8);
},
u8ToBase64(u8) {
let binary = '';
const len = u8.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(u8[i]);
}
return window.btoa(binary);
},
base64ToU8(b64) {
const binary = window.atob(b64);
const len = binary.length;
const u8 = new Uint8Array(len);
for (let i = 0; i < len; i++) {
u8[i] = binary.charCodeAt(i);
}
return u8;
},
async compress(str) {
const stream = new Blob([this.strToU8(str)]).stream();
const compressedStream = stream.pipeThrough(new CompressionStream('deflate-raw'));
const response = new Response(compressedStream);
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
return new Uint8Array(buffer);
},
async decompress(u8) {
const stream = new Blob([u8]).stream();
const decompressedStream = stream.pipeThrough(new DecompressionStream('deflate-raw'));
const response = new Response(decompressedStream);
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
return this.u8ToStr(new Uint8Array(buffer));
},
// ----------------------------------------------------------------------
// 2. Skeleton Generation (Strip visual / default data)
// ----------------------------------------------------------------------
skeletonize(workflowJson) {
const wf = JSON.parse(JSON.stringify(workflowJson)); // deep copy
// ComfyUI workflow JSON format has "nodes" array
if (wf.nodes && Array.isArray(wf.nodes)) {
wf.nodes.forEach(node => {
// Delete layout coords and styles
delete node.pos;
delete node.size;
delete node.color;
delete node.bgcolor;
delete node.shape;
delete node.flags;
// Delete empty properties
if (node.properties && Object.keys(node.properties).length === 0) {
delete node.properties;
}
});
}
// Remove view metadata
if (wf.extra) {
delete wf.extra.ds; // scale/offset
}
return wf;
},
// ----------------------------------------------------------------------
// 3. Auto-Layout Algorithm
// ----------------------------------------------------------------------
autoLayout(workflowJson) {
if (!workflowJson.nodes || !Array.isArray(workflowJson.nodes)) return workflowJson;
const nodes = workflowJson.nodes;
// 1. Build adjacency list and in-degrees
const adj = new Map();
const inDegree = new Map();
nodes.forEach(n => {
adj.set(n.id, []);
if (!inDegree.has(n.id)) inDegree.set(n.id, 0);
});
// Check links
if (workflowJson.links) {
workflowJson.links.forEach(link => {
if (!link) return;
const fromId = link[1];
const toId = link[3];
if (adj.has(fromId) && adj.has(toId)) {
adj.get(fromId).push(toId);
inDegree.set(toId, inDegree.get(toId) + 1);
}
});
}
// 2. Topological sort with depth levels
const depthMap = new Map(); // id -> depth
const queue = [];
nodes.forEach(n => {
if (inDegree.get(n.id) === 0) {
queue.push(n.id);
depthMap.set(n.id, 0);
}
});
while (queue.length > 0) {
const curr = queue.shift();
const currDepth = depthMap.get(curr);
const neighbors = adj.get(curr);
if (neighbors) {
neighbors.forEach(nxt => {
// Reduce in-degree
const ind = inDegree.get(nxt) - 1;
inDegree.set(nxt, ind);
// Update depth to be max(existing depth, currDepth + 1)
const existingDepth = depthMap.get(nxt) || 0;
depthMap.set(nxt, Math.max(existingDepth, currDepth + 1));
if (ind === 0) {
queue.push(nxt);
}
});
}
}
// Handle cycles (nodes not reached)
nodes.forEach(n => {
if (!depthMap.has(n.id)) {
depthMap.set(n.id, 0);
}
});
// 3. Assign X, Y coordinates
const nodesByDepth = {};
nodes.forEach(n => {
const d = depthMap.get(n.id);
if (!nodesByDepth[d]) nodesByDepth[d] = [];
nodesByDepth[d].push(n);
});
// Spacing constants
const X_SPACING = 400;
const Y_SPACING = 300;
Object.keys(nodesByDepth).forEach(d => {
const levelNodes = nodesByDepth[d];
const depth = parseInt(d);
levelNodes.forEach((n, idx) => {
// Approximate size
n.pos = [
depth * X_SPACING,
idx * Y_SPACING
];
});
});
return workflowJson;
},
// ----------------------------------------------------------------------
// 4. Encode / Decode
// ----------------------------------------------------------------------
async encodeShareCode(workflowJson, isSkeleton) {
let targetJson = workflowJson;
if (isSkeleton) {
targetJson = this.skeletonize(workflowJson);
}
const jsonStr = JSON.stringify(targetJson);
const compressedU8 = await this.compress(jsonStr);
const base64Str = this.u8ToBase64(compressedU8);
const prefix = isSkeleton ? 'AMB1-' : 'AMB0-';
return prefix + base64Str;
},
async decodeShareCode(shareCode) {
if (!shareCode.startsWith('AMB0-') && !shareCode.startsWith('AMB1-')) {
throw new Error('Invalid Share Code Format.');
}
const isSkeleton = shareCode.startsWith('AMB1-');
const base64Str = shareCode.substring(5);
const compressedU8 = this.base64ToU8(base64Str);
const jsonStr = await this.decompress(compressedU8);
let workflowJson = JSON.parse(jsonStr);
if (isSkeleton) {
workflowJson = this.autoLayout(workflowJson);
}
return workflowJson;
},
// ----------------------------------------------------------------------
// 5. UI Modals
// ----------------------------------------------------------------------
showToast(message, color) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed; bottom: 30px; right: 30px; background: var(--amb-bg-card-hover); color: ${color || '#fff'};
padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
font-family: Arial, sans-serif; font-size: 14px; z-index: 9999999;
opacity: 0; transition: opacity 0.3s ease; border-left: 4px solid ${color || '#fff'};
`;
document.body.appendChild(toast);
setTimeout(() => toast.style.opacity = '1', 10);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
},
/** A dimmed overlay with one dialog box; looks come from .anomalous-share-* in the styles. */
createShareDialog(id, titleText, variant = '') {
const overlay = document.createElement('div');
if (id) overlay.id = id;
overlay.className = 'anomalous-share-overlay';
const content = document.createElement('div');
content.className = `anomalous-share-dialog${variant ? ` ${variant}` : ''}`;
const title = document.createElement('h2');
title.className = 'anomalous-share-title';
title.textContent = titleText;
content.appendChild(title);
overlay.appendChild(content);
return { overlay, content };
},
shareButton(label, variant = '') {
const button = document.createElement('button');
button.type = 'button';
button.className = `anomalous-share-btn${variant ? ` ${variant}` : ''}`;
button.textContent = label;
return button;
},
/** What a workflow tells others about its models: how many carry a fingerprint, how many links. */
provenanceSummary(workflow) {
const models = new Set();
for (const node of workflow.nodes || []) {
for (const value of Array.isArray(node.widgets_values) ? node.widgets_values : []) {
if (isModelFilename(value)) models.add(`${node.id}_${value}`);
}
}
const hashes = workflow.extra?.anomalous_hashes || {};
const hashed = [...models].filter(key => hashes[key]?.hash).length;
return { models: models.size, hashed, links: Object.keys(workflow.extra?.anomalous_model_sources || {}).length };
},
provenanceNote(summary) {
if (!summary.models) return '';
if (localStorage.getItem('anomalous_inject_hash') === 'false') return t('mainShareProvenanceOff');
const missing = summary.models - summary.hashed;
return [t('mainShareFingerprints', { hashed: summary.hashed, total: summary.models }),
missing ? t('mainShareNoFingerprint', { count: missing }) : ''].filter(Boolean).join(' ');
},
showExportModal() {
const { overlay, content } = this.createShareDialog('amb-export-modal', t('mainExportTitle'));
const summary = this.provenanceSummary(app.graph.serialize());
const note = document.createElement('p');
note.className = 'anomalous-share-note';
note.textContent = this.provenanceNote(summary);
note.hidden = !note.textContent;
const linksLabel = document.createElement('label');
linksLabel.className = 'anomalous-share-check';
const linksBox = document.createElement('input');
linksBox.type = 'checkbox';
linksBox.checked = true;
linksLabel.append(linksBox, ` ${t('mainShareLinks', { count: summary.links })}`);
linksLabel.hidden = !summary.links;
const typeSelectContainer = document.createElement('div');
typeSelectContainer.className = 'anomalous-share-options';
typeSelectContainer.innerHTML = `
`;
const textArea = document.createElement('textarea');
textArea.className = 'anomalous-share-code';
textArea.readOnly = true;
const btnGroup = document.createElement('div');
btnGroup.className = 'anomalous-share-actions';
const generateBtn = this.shareButton(t('mainGenerate'), 'is-primary');
const copyBtn = this.shareButton(t('mainCopyClipboard'));
copyBtn.hidden = true;
const closeBtn = this.shareButton(t('mainClose'), 'is-quiet');
closeBtn.onclick = () => overlay.remove();
generateBtn.onclick = async () => {
const isSkeleton = document.querySelector('input[name="amb-share-type"]:checked').value === 'skeleton';
// Get current workflow from app graph
const p = await app.graphToPrompt();
const workflowJson = p.workflow;
if (!linksBox.checked) delete workflowJson.extra?.anomalous_model_sources;
try {
const code = await AMB_WorkflowShare.encodeShareCode(workflowJson, isSkeleton);
textArea.value = code;
copyBtn.hidden = false;
} catch (err) {
textArea.value = 'Error generating code: ' + err.message;
}
};
copyBtn.onclick = () => {
textArea.select();
document.execCommand('copy');
AMB_WorkflowShare.showToast(t('mainCopied'), '#5cb85c');
};
btnGroup.append(generateBtn, copyBtn, closeBtn);
content.append(typeSelectContainer, note, linksLabel, textArea, btnGroup);
document.body.appendChild(overlay);
},
showImportModal() {
const { overlay, content } = this.createShareDialog('amb-import-modal', t('mainImportTitle'), 'is-wide');
const inputArea = document.createElement('textarea');
inputArea.className = 'anomalous-share-code is-input';
inputArea.placeholder = t('mainSharePlaceholder');
const btnGroup = document.createElement('div');
btnGroup.className = 'anomalous-share-actions';
const loadBtn = this.shareButton(t('mainImportLoad'), 'is-primary');
const closeBtn = this.shareButton(t('mainCancel'), 'is-quiet');
closeBtn.onclick = () => overlay.remove();
loadBtn.onclick = async () => {
const code = inputArea.value.trim();
if (!code) {
AMB_WorkflowShare.showToast(t('mainShareEmpty'), '#ff6b6b');
return;
}
try {
const pendingWorkflow = await AMB_WorkflowShare.decodeShareCode(code);
app.loadGraphData(pendingWorkflow);
overlay.remove();
const nodesCount = pendingWorkflow.nodes ? pendingWorkflow.nodes.length : 0;
AMB_WorkflowShare.showToast(t('mainImportedNodes', { count: nodesCount }), '#5cb85c');
// Auto close the main browser panel
const mainCloseBtn = document.getElementById('anomalous-close');
if (mainCloseBtn) mainCloseBtn.click();
} catch (err) {
AMB_WorkflowShare.showToast(t('mainDecodeFailed') + err.message, '#ff6b6b');
}
};
btnGroup.append(loadBtn, closeBtn);
content.append(inputArea, btnGroup);
document.body.appendChild(overlay);
},
showUnifiedModal() {
const { overlay, content } = this.createShareDialog('', t('mainUnifiedTitle'), 'is-menu');
const exportBtn = this.shareButton(t('mainExportWorkflow'), 'is-primary is-large');
exportBtn.onclick = () => { overlay.remove(); this.showExportModal(); };
const importBtn = this.shareButton(t('mainImportWorkflow'), 'is-large');
importBtn.onclick = () => { overlay.remove(); this.showImportModal(); };
const closeBtn = this.shareButton(t('mainClose'), 'is-quiet');
closeBtn.onclick = () => overlay.remove();
content.append(exportBtn, importBtn, closeBtn);
document.body.appendChild(overlay);
}
};
window.AMB_WorkflowShare = AMB_WorkflowShare;