Files
DemonGatanjieuandClaude Opus 5.5 a127987348 Current node: compare the node alone, the models page's cards in the picker, no click-through
Three fixes from the user's use of the current-node panel:

- ⇄ Compare on a saved set listed the whole workflow's values. It now
  compares that node alone: a copy of the node holding the set's values,
  nothing wired, against the canvas, under the node's id so the two pair
  (a set's workflow numbered otherwise, with two prompt nodes, paired
  neither). compareParams takes `only` for this; the comparison window
  takes `nodeId` and leaves out the structure badge. Every set can now be
  compared; the unused "could not read the set's workflow" text is gone.
- The Change model picker drew cards of its own: full-size covers and
  videos without a still, so most showed blank, and only file names. It
  now shows the models page's card face (modelCardFace, split out of
  ui_grid.js): the cover thumbnail, a video's still until pointed at, the
  base badge, version, readable name and file name; "current" and the
  chosen outline stay. Versions are not folded. Its type and base chips
  and their texts are gone.
- In the generated-pictures gallery (current node, View all), once
  scrolled, "Close gallery" opened the picture scrolled up under it:
  Chrome's hit-testing again (lessons.md). Its scroll box and the
  picker's card area get clip-path: inset(0).

Checked in an isolated ComfyUI: the prompt node's compare lists only its
text; the picker shows covers and names, selects, replaces; the models
page's cards keep their buttons and open the details; a 13-step scroll
of the gallery leaves every click on the close button. Full suite and
structure check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:41:52 +08:00

171 lines
8.5 KiB
JavaScript
Raw Permalink 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.
/**
* Two sets of workflow parameters side by side (an output image's workflow, the canvas):
* whether they are the same node structure, and which node values differ. Pure: takes
* graph-like objects (`_nodes` or `nodes`, each with `id`, `type`, `title`, `mode`,
* `widgets`, `inputs`; `links` to read the wiring), no DOM. The view is ui_param_compare.js.
*/
import { valueText } from './activity_diff.js';
import { isVolatileWidget } from './node_material_actions.js';
import { promptBoxes } from './prompt_boxes.js';
const CONTEXT_CHARS = 40;
const MIN_TAGS = 3;
function linkOrigin(graph, linkId) {
if (linkId === null || linkId === undefined) return '';
const link = graph?.links?.get?.(linkId) ?? graph?.links?.[linkId];
if (!link) return '';
const origin = Array.isArray(link) ? [link[1], link[2]] : [link.origin_id, link.origin_slot];
return `${origin[0]}:${origin[1]}`;
}
/**
* `Map(node id → { id, type, title, mode, wiring, widgets: [{ name, index, value, text, volatile, role }] })`
* in canvas order. Buttons and nameless widgets hold no value. `wiring`: where each input comes from;
* `role`: a prompt box's 'positive' / 'negative' / 'both' (prompt_boxes.js), else ''.
*/
export function readParams(graph) {
const nodes = new Map();
for (const node of graph?._nodes || graph?.nodes || []) {
if (node?.id === undefined || node.id === null) continue;
const widgets = [];
const roles = new Map(promptBoxes(node).map(box => [box.index, box.role]));
(node.widgets || []).forEach((widget, index) => {
if (!widget?.name || widget.type === 'button') return;
widgets.push({ name: widget.name, index, value: widget.value, text: valueText(widget.value), volatile: isVolatileWidget(node, widget, index), role: roles.get(index) || '' });
});
const wiring = (node.inputs || []).map(input => `${input?.name || ''}<${linkOrigin(graph, input?.link)}`).join('|');
const id = String(node.id);
nodes.set(id, { id, type: node.type || '', title: node.title || node.type || '', mode: node.mode ?? 0, wiring, widgets });
}
return nodes;
}
const nodeRef = node => ({ id: node.id, type: node.type, title: node.title });
/** One node pair's values: the differing ones (`rows`; equal ones too with `includeSame`), the starred ones equal or not (`keys`). */
function compareValues(left, right, isKey, includeSame) {
const rows = [];
const keys = [];
let same = 0;
if (left.mode !== right.mode) rows.push({ name: '', mode: true, a: left.mode, b: right.mode });
const rightByName = new Map(right.widgets.map(widget => [widget.name, widget]));
for (const widget of left.widgets) {
const other = rightByName.get(widget.name);
if (!other) continue;
const equal = other.text === widget.text;
if (equal) same += 1;
const row = { name: widget.name, index: other.index, a: widget.value, b: other.value, aText: widget.text, bText: other.text, volatile: widget.volatile || other.volatile, role: other.role || widget.role, same: equal };
if (isKey(right.type, widget.name, right.id, left.id)) keys.push(row);
else if (!equal || includeSame) rows.push(row);
}
return { rows, keys, same };
}
/** Unpaired nodes of a type found once on each side: two workflows' only KSampler, say. */
function pairsByType(leftovers, others) {
const once = nodes => {
const byType = new Map();
for (const node of nodes) byType.set(node.type, byType.has(node.type) ? null : node);
return byType;
};
const left = once(leftovers);
const right = once(others);
return [...left].filter(([type, node]) => node && right.get(type)).map(([type, node]) => [node, right.get(type)]);
}
/**
* What differs from `a` to `b` (both from readParams). Nodes pair by id and type; nodes left
* over pair by type when each side has one node of it. Returns { sameStructure, nodes: [{ id,
* aId, bId, type, title, rows }], keys, onlyA, onlyB, rewired, sameCount, compared } (`id` is `bId`):
* a row is { name, index, a, b, aText, bText, volatile, role, same } or, for a node bypassed or
* muted on one side only, { name: '', mode: true, a, b }. Values `isKey(type, widget, bId, aId)` stars are
* not in `nodes` but in `keys` ([{ node, row }], equal ones too); with `includeSame` the other
* equal values are in `nodes` too (`same: true`). `sameCount` counts the values equal on both
* sides, `compared` all values set side by side (none: nothing could be paired). `only`
* ({ side: 'a' | 'b', id }) keeps one node: every node still pairs, but the values, the counts
* and onlyA / onlyB are that node's alone, without `rewired` (`sameStructure` stays the whole workflow's).
*/
export function compareParams(a, b, { isKey = () => false, includeSame = false, only = null } = {}) {
const nodes = [];
const keys = [];
const rewired = [];
let sameCount = 0;
let differing = 0;
const pair = (left, right) => {
const node = { id: right.id, aId: left.id, bId: right.id, type: right.type, title: right.title };
if (only && node[`${only.side}Id`] !== only.id) return;
const values = compareValues(left, right, isKey, includeSame);
sameCount += values.same;
differing += [...values.rows, ...values.keys].filter(row => !row.same).length;
if (values.rows.length) nodes.push({ ...node, rows: values.rows });
keys.push(...values.keys.map(row => ({ node, row })));
};
const leftovers = [];
for (const [id, left] of a) {
const right = b.get(id);
if (!right || right.type !== left.type) {
leftovers.push(left);
continue;
}
if (left.wiring !== right.wiring) rewired.push(nodeRef(right));
pair(left, right);
}
const others = [...b.values()].filter(right => a.get(right.id)?.type !== right.type);
const paired = new Set();
for (const [left, right] of pairsByType(leftovers, others)) {
pair(left, right);
paired.add(left).add(right);
}
const mine = (side, refs) => (only ? refs.filter(ref => side === only.side && ref.id === only.id) : refs);
const onlyA = leftovers.filter(node => !paired.has(node)).map(nodeRef);
const onlyB = others.filter(node => !paired.has(node)).map(nodeRef);
const sameStructure = !leftovers.length && !others.length && !rewired.length;
return {
sameStructure, nodes, keys, onlyA: mine('a', onlyA), onlyB: mine('b', onlyB), rewired: only ? [] : rewired,
sameCount, compared: sameCount + differing,
};
}
const tagsOf = text => String(text).split(/[,,\n]/).map(tag => tag.trim()).filter(Boolean);
/**
* How two texts differ, for showing: prompts written as tags by the tags only one side has
* ({ tags: true, onlyA, onlyB }, each tag once), anything else by the part that differs with a
* little of what surrounds it ({ tags: false, before, aPart, bPart, after }, cut ends marked …).
*/
export function textDifference(aText, bText) {
const aTags = tagsOf(aText);
const bTags = tagsOf(bText);
if (aTags.length >= MIN_TAGS && bTags.length >= MIN_TAGS) {
const inA = new Set(aTags);
const inB = new Set(bTags);
const onlyA = [...inA].filter(tag => !inB.has(tag));
const onlyB = [...inB].filter(tag => !inA.has(tag));
if (onlyA.length || onlyB.length) return { tags: true, onlyA, onlyB };
}
const a = String(aText);
const b = String(bText);
let start = 0;
while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
let end = 0;
while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end += 1;
return {
tags: false,
before: (start > CONTEXT_CHARS ? '…' : '') + a.slice(Math.max(0, start - CONTEXT_CHARS), start),
aPart: a.slice(start, a.length - end),
bPart: b.slice(start, b.length - end),
after: a.slice(a.length - end, a.length - end + CONTEXT_CHARS) + (end > CONTEXT_CHARS ? '…' : ''),
};
}
/** A value as it reads in the comparison: long decimals rounded, nothing as —. */
export function shownValue(value) {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'number' && !Number.isInteger(value)) return String(Math.round(value * 10000) / 10000);
return typeof value === 'string' ? value : valueText(value);
}
/** Long text is shown by textDifference rather than whole. */
export const isLongText = row => typeof row.a === 'string' && typeof row.b === 'string' && Math.max(row.a.length, row.b.length) > 60;