Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_param_compare.js
T
DemonGatanjieuandClaude Opus 5.5 ffe0cb4f40 Compare: say which side is which
A and B each get their own colour, shown on the side cards, the column heads and every
text line. Prompts list the tags only A has and only B has (each once) instead of
removed/added; long texts take one line per side with the differing part marked; values
wrap whole instead of ending in an ellipsis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:37:01 +08:00

361 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The parameter comparison window: two output images, or an image and the canvas, as
* param_compare.js reads them. It says whether both are the same node structure and lists
* each value that differs (prompts by the tags taken out and put in, seeds apart); with the
* canvas on one side, the ticked values of the image are written onto the canvas as one
* Ctrl+Z step, with Undo. Also the gallery's pick: the ⇄ of one card, then another's or the canvas.
*/
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { recordCanvasStep } from './canvas_history.js';
import { detachedGraph } from './detached_graph.js';
import { applyNodeMaterialValues } from './node_material_actions.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { compareParams, isLongText, readParams, shownValue, textDifference } from './param_compare.js';
const MODE_KEYS = { 0: 'compareModeOn', 2: 'compareModeMuted', 4: 'compareModeBypassed' };
const SHOWN_TAGS = 10; // per side: a prompt swapped whole lists every tag, the rest wait behind a button
const ROLE_KEYS = { positive: 'recipePromptRolePositive', negative: 'recipePromptRoleNegative', both: 'recipePromptRoleBoth' };
let openWindow = null;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
const canvasGraph = () => app.rootGraph || app.graph;
const imageQuery = image => `filename=${encodeURIComponent(image.filename)}&subfolder=${encodeURIComponent(image.subfolder || '')}`;
const sideName = side => (side.canvas ? t('compareCanvas') : side.image.filename);
/** One side's parameters: the canvas as it is now, or the workflow kept in an output image. */
async function readSide(side, signal) {
if (side.canvas) return readParams(canvasGraph());
const response = await fetch('/anomalous/inspect_image_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_image: { type: 'output', ...side.image } }),
signal,
});
if (response.status === 400) throw new Error(t('compareNoWorkflow'));
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { workflow } = await response.json();
if (!workflow) throw new Error(t('compareNoWorkflow'));
return readParams(detachedGraph(workflow));
}
function sideCard(side, letter) {
const card = el('div', 'anomalous-compare-side');
if (side.canvas) card.append(el('div', 'anomalous-compare-thumb is-canvas', '▦'));
else {
const img = el('img', 'anomalous-compare-thumb');
img.src = `/anomalous/output_thumbnail?${imageQuery(side.image)}`;
img.alt = '';
card.append(img);
}
const name = el('div', 'anomalous-compare-side-name', sideName(side));
name.title = sideName(side);
card.append(letterBadge(letter), name);
return card;
}
/** A or B in its own colour, the same in the side cards, the column heads and the text lines. */
const letterBadge = letter => el('span', `anomalous-compare-letter is-${letter.toLowerCase()}`, letter);
/** One side's line of a long text: its letter, then its tags or its text with the differing part marked. */
function textLine(letter, parts) {
const line = el('div', `anomalous-compare-text-line is-${letter.toLowerCase()}`);
line.append(letterBadge(letter), ...parts);
return line;
}
/** A long text's difference, one line per side: the tags only that side has, or its differing part in place. */
function textCell(row) {
const cell = el('div', 'anomalous-compare-text');
const diff = textDifference(row.aText, row.bText);
if (diff.tags) {
for (const [letter, tags] of [['A', diff.onlyA], ['B', diff.onlyB]]) {
if (!tags.length) continue;
const chips = tags.map(tag => el('span', 'anomalous-compare-tag', tag));
const parts = [el('span', 'anomalous-compare-line-label', t('compareTagsOnly')), ...chips.slice(0, SHOWN_TAGS)];
if (chips.length > SHOWN_TAGS) {
const more = button('anomalous-compare-more', t('compareMoreTags', { count: chips.length - SHOWN_TAGS }), () => more.replaceWith(...chips.slice(SHOWN_TAGS)));
parts.push(more);
}
cell.append(textLine(letter, parts));
}
return cell;
}
for (const [letter, part] of [['A', diff.aPart], ['B', diff.bPart]]) {
const text = el('span', 'anomalous-compare-text-body');
// A side with nothing where the other has more shows no mark: the other's line marks it.
text.append(document.createTextNode(diff.before), ...(part ? [el('mark', '', part)] : []), document.createTextNode(diff.after));
cell.append(textLine(letter, [text]));
}
return cell;
}
const cellText = (row, value) => (row.mode ? t(MODE_KEYS[value] || 'compareModeOn') : shownValue(value));
function rowLine(row, tick) {
const line = el('div', 'anomalous-compare-row');
const label = el('label', 'anomalous-compare-name');
if (tick) label.append(tick);
label.append(document.createTextNode(row.mode ? t('compareState') : row.name));
if (ROLE_KEYS[row.role]) label.append(el('span', `anomalous-compare-role is-${row.role}`, t(ROLE_KEYS[row.role])));
line.append(label);
if (!row.mode && isLongText(row)) {
line.append(textCell(row));
return line;
}
for (const [value, className] of [[row.a, 'is-a'], [row.b, 'is-b']]) {
const text = cellText(row, value);
const cell = el('div', `anomalous-compare-value ${className}`, text);
cell.title = text;
line.append(cell);
}
return line;
}
function columnHeads() {
const line = el('div', 'anomalous-compare-row is-head');
const head = (letter) => {
const cell = el('span');
cell.append(letterBadge(letter));
return cell;
};
line.append(el('span', '', t('compareParameter')), head('A'), head('B'));
return line;
}
function nodeBlock(node, rows, ticks) {
const block = el('div', 'anomalous-compare-node');
const ids = node.aId === node.bId ? `#${node.bId}` : `A #${node.aId} · B #${node.bId}`;
block.append(el('div', 'anomalous-compare-node-title', `${node.title} ${ids}`));
for (const row of rows) block.append(rowLine(row, ticks?.get(row)));
return block;
}
function nodeList(title, nodes) {
const block = el('div', 'anomalous-compare-node is-structure');
block.append(el('div', 'anomalous-compare-node-title', title));
block.append(el('div', 'anomalous-compare-structure-list', nodes.map(node => `${node.title} #${node.id}`).join('、')));
return block;
}
/**
* Writes the ticked rows' image values onto the live canvas nodes, one Ctrl+Z step for all.
* `fromKey`: the image side ('a' or 'b'); the canvas's node is the other side's id. Returns { done, skipped, undo }.
*/
function applyRows(entries, fromKey) {
const graph = canvasGraph();
const byNode = new Map();
for (const { node, row } of entries) {
const id = fromKey === 'a' ? node.bId : node.aId;
if (!byNode.has(id)) byNode.set(id, { id, node, rows: [] });
byNode.get(id).rows.push(row);
}
const results = [];
let done = 0;
let skipped = 0;
for (const { id, node, rows } of byNode.values()) {
const live = graph?.getNodeById?.(Number(id)) ?? graph?.getNodeById?.(id);
const values = live?.type === node.type ? rows.map(row => ({ index: live.widgets?.findIndex(widget => widget?.name === row.name), value: row[fromKey] }))
.filter(entry => entry.index >= 0) : [];
try {
if (!values.length) throw new Error('materialNoCompatibleValues');
results.push(applyNodeMaterialValues(app, live, values, { record: false }));
done += values.length;
skipped += rows.length - values.length;
} catch (_) {
skipped += rows.length;
}
}
if (results.length) recordCanvasStep(app);
const undo = () => {
for (const result of results.reverse()) {
try { result.undo(); } catch (_) { /* the node changed since: leave it */ }
}
recordCanvasStep(app);
};
return { done, skipped, undo };
}
/** Opens the comparison of `left` and `right`, each { image: { filename, subfolder, type? } } or { canvas: true }. */
export function openParamCompare(left, right) {
openWindow?.close();
const controller = new AbortController();
const sides = [left, right];
let read = null;
const overlay = el('div', 'anomalous-dialog-overlay anomalous-compare-overlay');
const dialog = el('div', 'anomalous-compare-dialog');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
const head = el('div', 'anomalous-compare-head');
const sidesRow = el('div', 'anomalous-compare-sides');
const summary = el('div', 'anomalous-compare-summary', t('compareLoading'));
const body = el('div', 'anomalous-compare-body');
const footer = el('div', 'anomalous-compare-footer');
const close = () => {
controller.abort();
overlay.remove();
document.removeEventListener('keydown', onKey, true);
if (openWindow?.close === close) openWindow = null;
};
const onKey = (event) => {
if (event.key !== 'Escape' || document.querySelectorAll('.anomalous-dialog-overlay').length > 1) return;
event.stopPropagation();
close();
};
head.append(el('h3', 'anomalous-compare-title', t('compareTitle')), button('anomalous-compare-close', '×', close));
head.lastChild.title = t('close');
const render = () => {
const [a, b] = read;
const result = compareParams(a, b);
const canvasKey = sides[0].canvas ? 'a' : (sides[1].canvas ? 'b' : null);
const fromKey = canvasKey === 'a' ? 'b' : 'a';
sidesRow.replaceChildren(sideCard(sides[0], 'A'), button('anomalous-compare-swap', '⇄', () => {
sides.reverse();
read.reverse();
render();
}), sideCard(sides[1], 'B'));
sidesRow.children[1].title = t('compareSwap');
const seeds = [];
const ticks = new Map();
const entries = [];
let diffCount = 0;
body.replaceChildren(columnHeads());
for (const node of result.nodes) {
const rows = node.rows.filter(row => !row.volatile);
node.rows.filter(row => row.volatile).forEach(row => seeds.push({ node, row }));
if (!rows.length) continue;
diffCount += rows.length;
if (canvasKey) {
for (const row of rows.filter(item => !item.mode)) {
const tick = el('input');
tick.type = 'checkbox';
tick.checked = true;
tick.onchange = () => updateApply();
ticks.set(row, tick);
entries.push({ node, row, tick });
}
}
body.append(nodeBlock(node, rows, ticks));
}
if (!diffCount) body.append(el('div', 'anomalous-compare-empty', t(result.compared ? 'compareNoDifference' : 'compareNothingPaired')));
if (seeds.length) {
const details = el('details', 'anomalous-compare-seeds');
details.append(el('summary', '', t('compareSeeds')));
for (const { node, row } of seeds) details.append(nodeBlock(node, [row]));
body.append(details);
}
if (result.onlyA.length) body.append(nodeList(t('compareOnlyIn', { side: 'A' }), result.onlyA));
if (result.onlyB.length) body.append(nodeList(t('compareOnlyIn', { side: 'B' }), result.onlyB));
if (result.rewired.length) body.append(nodeList(t('compareRewired'), result.rewired));
summary.replaceChildren(
el('span', `anomalous-compare-badge ${result.sameStructure ? 'is-same' : 'is-other'}`, t(result.sameStructure ? 'compareSameStructure' : 'compareOtherStructure')),
document.createTextNode(t('compareSummary', { diff: diffCount, same: result.sameCount })),
);
const apply = button('anomalous-compare-apply', '', () => {
const picked = entries.filter(entry => entry.tick.checked);
if (!picked.length) return;
const { done, skipped, undo } = applyRows(picked, fromKey);
const message = t('compareApplied', { count: done }) + (skipped ? t('compareApplySkipped', { count: skipped }) : '');
showWorkbenchToast(message, done ? { label: t('compareUndo'), run: () => { undo(); refreshCanvas(); } } : null);
refreshCanvas();
});
const updateApply = () => {
const count = entries.filter(entry => entry.tick.checked).length;
apply.textContent = count ? t('compareApply', { count }) : t('compareApplyNone');
apply.disabled = !count;
};
updateApply();
footer.replaceChildren();
if (canvasKey) footer.append(el('div', 'anomalous-compare-hint', t('compareCanvasHint')));
footer.append(button('anomalous-compare-done', t('compareClose'), close));
if (canvasKey && entries.length) footer.append(apply);
};
const refreshCanvas = () => {
if (!overlay.isConnected) return;
read = read.map((params, index) => (sides[index].canvas ? readParams(canvasGraph()) : params));
render();
};
overlay.onclick = (event) => { if (event.target === overlay) close(); };
dialog.append(head, sidesRow, summary, body, footer);
overlay.append(dialog);
document.body.append(overlay);
document.addEventListener('keydown', onKey, true);
openWindow = { close };
sidesRow.append(sideCard(left, 'A'), sideCard(right, 'B'));
footer.append(button('anomalous-compare-done', t('compareClose'), close));
Promise.all(sides.map(side => readSide(side, controller.signal))).then((params) => {
if (controller.signal.aborted) return;
read = params;
render();
}).catch((error) => {
if (controller.signal.aborted) return;
summary.textContent = t('compareLoadFailed', { error: error?.message || String(error) });
summary.classList.add('is-error');
});
return close;
}
// ---- The gallery's pick: the first image waits for a second one or the canvas. ----
const pickKey = image => `${image.subfolder || ''}\n${image.filename}`;
export const isComparePicked = (owner, image) => owner.comparePick && pickKey(owner.comparePick) === pickKey(image);
function clearPick(owner) {
owner.comparePick = null;
owner.galleryPanel?.querySelectorAll('.anomalous-gallery-card.is-compare-picked').forEach(card => card.classList.remove('is-compare-picked'));
owner.galleryPanel?.querySelector('.anomalous-compare-bar')?.remove();
}
function showPickBar(owner) {
owner.galleryPanel?.querySelector('.anomalous-compare-bar')?.remove();
const image = owner.comparePick;
const bar = el('div', 'anomalous-compare-bar');
bar.append(
el('span', 'anomalous-compare-bar-text', t('comparePicked', { name: image.filename })),
button('anomalous-compare-bar-btn', t('compareWithCanvas'), () => {
clearPick(owner);
openParamCompare({ image }, { canvas: true });
}),
button('anomalous-compare-bar-btn is-quiet', t('dialogCancel'), () => clearPick(owner)),
);
owner.galleryPanel?.append(bar);
}
/** The ⇄ of a card: the first press picks the image, a second image's opens the comparison. */
export function pressCompare(owner, image, card) {
if (isComparePicked(owner, image)) {
clearPick(owner);
return;
}
if (owner.comparePick) {
const first = owner.comparePick;
clearPick(owner);
openParamCompare({ image: first }, { image });
return;
}
owner.comparePick = { filename: image.filename, subfolder: image.subfolder || '' };
card.classList.add('is-compare-picked');
showPickBar(owner);
}