biegen / bendmap.js
Pixelkram's picture
Deploy app
2fd30a7 verified
Raw History Blame Contribute Delete
39.5 kB
// The map of Stable Diffusion 1.5 and the tool slots. Runs as js_on_load of a gr.HTML component:
// `element`, `props` and `watch` come from Gradio. app.py replaces __CATALOG__ with the tools.
//
// props.value is JSON. This script owns {"state": {slots, rev}}; Python sends {"server": {...}} with
// the prompt's tokens and, after a run, the measured effect. Python never sends state back, so a
// change made while a run is going is kept.
const CAT = __CATALOG__;
const TOOLS = Object.fromEntries(CAT.tools.map((t) => [t.key, t]));
const COLORS = CAT.colors;
// Top to bottom: text encoding, the UNet inside the sampler loop, the VAE decoder, the timeline.
const W = 960, H = 1310, BW = 150, BH = 96;
const X0 = 260, X1 = 860; // the timeline's 0 .. 100 %
const LAT_Y = 1180, TL_AXIS = 1200, TL_LANE = 1214, LANE_H = 18, LANE_GAP = 28;
const LOOP_Y = 976; // the way back from the sampler to x_t, under the UNet
const FRAME = { x: 140, y: 350, w: 654, h: 606 };
const BLK = {};
["d0", "d1", "d2", "d3"].forEach((b, i) => { BLK[b] = { x: 150 + 44 * i, y: 384 + 116 * i }; });
BLK.mid = { x: 392, y: 848 };
["u0", "u1", "u2", "u3"].forEach((b, j) => { BLK[b] = { x: 634 - 44 * (3 - j), y: 384 + 116 * (3 - j) }; });
const META = {
d0: { label: "Abwärts 1", res: "64 × 64", ch: 320, R: 2, A: 2 },
d1: { label: "Abwärts 2", res: "32 × 32", ch: 640, R: 2, A: 2 },
d2: { label: "Abwärts 3", res: "16 × 16", ch: 1280, R: 2, A: 2 },
d3: { label: "Abwärts 4", res: "8 × 8", ch: 1280, R: 2, A: 0 },
mid: { label: "Mitte", res: "8 × 8", ch: 1280, R: 2, A: 1 },
u0: { label: "Aufwärts 1", res: "8 × 8", ch: 1280, R: 3, A: 0 },
u1: { label: "Aufwärts 2", res: "16 × 16", ch: 1280, R: 3, A: 3 },
u2: { label: "Aufwärts 3", res: "32 × 32", ch: 640, R: 3, A: 3 },
u3: { label: "Aufwärts 4", res: "64 × 64", ch: 320, R: 3, A: 3 },
};
const BLOCKS = Object.keys(META);
const SKIP_FROM = { u0: "d3", u1: "d2", u2: "d1", u3: "d0" };
const GLYPH = {
R: { dx: 6, w: 34, text: "Res" },
S: { dx: 44, w: 48, text: "Selbst" },
X: { dx: 96, w: 46, text: "Quer" },
};
const BOX = { // fixed boxes outside the UNet
prompt: { x: 16, y: 32, w: 600, h: 42 },
tok: { x: 16, y: 98, w: 292, h: 36 },
pos: { x: 324, y: 98, w: 292, h: 36 },
clip: { x: 16, y: 156, w: 600, h: 42 },
ctx: { x: 16, y: 218, w: 600, h: 96 },
promptB: { x: 640, y: 32, w: 304, h: 42 },
clipB: { x: 640, y: 156, w: 304, h: 42 },
ctxB: { x: 640, y: 218, w: 304, h: 96 },
latent: { x: 8, y: 396, w: 118, h: 60 },
eps: { x: 806, y: 396, w: 146, h: 56 },
cfg: { x: 806, y: 474, w: 146, h: 50 },
sampler: { x: 806, y: 546, w: 146, h: 50 },
time: { x: 818, y: 640, w: 124, h: 56 },
vae: { x: 300, y: 1000, w: 260, h: 52 },
image: { x: 590, y: 986, w: 140, h: 140 },
};
const TIPS = {
R: "Res: Faltungsschichten, die das Bild an jeder Stelle umrechnen. Hier hört das Netz auch die Zeit, also wie verrauscht das Bild noch ist.",
S: "Selbst-Aufmerksamkeit: jede Bildstelle schaut auf alle anderen Bildstellen und holt sich, was zu ihr passt.",
X: "Quer-Aufmerksamkeit: jede Bildstelle schaut auf die 77 Vektoren des Kontexts. Nur hier kommt der Text ins Bild.",
out: "Der Ausgang des Blocks, der an den nächsten Block weitergeht.",
prompt: "Prompt A, so wie ihr ihn oben eingebt.",
tok: "Wort-Tabelle: jedes Token wird zu einem Vektor mit 768 Zahlen.",
pos: "Positions-Tabelle: ein Vektor pro Platz 0 bis 76. Er sagt CLIP, an welcher Stelle ein Token steht.",
clip: "CLIP-Textencoder: 12 Transformer-Schichten mischen die Token-Vektoren miteinander.",
ctx: "Kontext: 77 Vektoren, einer pro Platz. Nach dem Prompt folgen das Ende-Token und Polster, die CLIP mit dem Sinn des ganzen Prompts füllt.",
promptB: "Prompt B wird genauso zu 77 Vektoren und ersetzt in einzelnen Blöcken den Kontext von Prompt A.",
latent: "Das Latent x_t: 4 Kanäle auf 64 × 64 Stellen. Es beginnt als Rauschen aus dem Seed, jeder Sampler-Schritt nimmt Rauschen weg.",
sampler: "Sampler-Schritt (Euler): zieht das vorhergesagte Rauschen ein Stück weit vom Latent ab. Das wiederholt sich für jeden Schritt.",
eps: "Das UNet sagt das Rauschen im Latent voraus, zweimal: einmal mit Prompt und einmal ohne.",
cfg: "Guidance: nimmt die Vorhersage ohne Prompt und geht den Unterschied zur Vorhersage mit Prompt g-mal weiter (g ist der Guidance-Regler, üblich 7,5). So folgt das Bild dem Prompt stärker.",
time: "Zeitpunkt t: Der Sampler sagt dem UNet bei jedem Schritt, wie verrauscht das Latent noch ist (999 = nur Rauschen, 0 = fertig). Jeder Res-Teil im UNet hört diese Zahl.",
vae: "VAE-Decoder: übersetzt das fertige Latent (64 × 64) in Pixel (512 × 512). Er läuft einmal, nach dem letzten Schritt.",
image: "Das erste Bild des letzten Laufs.",
};
let st = { slots: [], sel: 0, rev: 0 };
let srv = { prompt: "", prompt_b: "", tokens: [], tokens_b: [], max_len: 77, effect: null };
let hoverTool = null, hoverSlot = null, drag = null, chipBoxes = {}, root = null;
// ---------------------------------------------------------------- state
function parse(v) {
if (!v) return {};
if (typeof v !== "string") return v;
try { return JSON.parse(v); } catch (e) { return {}; }
}
function cleanSlots(slots) {
return (Array.isArray(slots) ? slots : []).filter((s) => s && TOOLS[s.tool]).slice(0, CAT.max_slots);
}
function absorb(v, initial) {
const d = parse(v);
if (d.server) Object.assign(srv, d.server);
if (d.state && initial) {
st.slots = cleanSlots(d.state.slots);
st.rev = Number.isInteger(d.state.rev) ? d.state.rev : 0;
}
}
function publish(changed) {
if (changed) st.rev += 1;
props.value = JSON.stringify({ state: { slots: st.slots, rev: st.rev } });
if (root) root.dataset.rev = st.rev;
}
function placeOf(slot) {
const t = TOOLS[slot.tool];
return t.places.find((p) => p.value === slot.place) || t.places[0];
}
function guessWord(prompt) {
const words = (prompt || "").toLowerCase().match(/[a-zäöüß]+/g) || [];
const of = words.findIndex((w, i) => w === "of" && ["a", "an", "the"].includes(words[i + 1]));
if (of >= 0 && words[of + 2]) return words[of + 2];
const at = words.findIndex((w) => ["in", "on", "with", "at", "under"].includes(w));
if (at > 0) return words[at - 1];
return words[words.length - 1] || "";
}
function newSlot(key) {
const t = TOOLS[key], p = t.places[0];
return { tool: key, place: p.value, value: p.default, start: p.start, end: p.end,
word: key === "excise" ? guessWord(srv.prompt) : "" };
}
function addTool(key) {
if (st.slots.length < CAT.max_slots) {
st.slots.push(newSlot(key));
st.sel = st.slots.length - 1;
} else {
const i = Math.min(st.sel, st.slots.length - 1);
st.slots[i] = newSlot(key);
st.sel = i;
}
publish(true);
renderAll();
}
function setPlace(i, value) {
const s = st.slots[i], t = TOOLS[s.tool];
const p = t.places.find((q) => q.value === value);
if (!p || s.place === value) return;
Object.assign(s, { place: p.value, value: p.default, start: p.start, end: p.end });
publish(true);
renderAll();
}
function removeSlot(i) {
st.slots.splice(i, 1);
st.sel = Math.max(0, Math.min(st.sel, st.slots.length - 1));
hoverSlot = null;
publish(true);
renderAll();
}
function siteClick(site) {
const s = st.slots[st.sel];
if (!s) return;
const t = TOOLS[s.tool];
const hits = t.places.filter((p) => p.sites.includes(site));
if (!hits.length) {
const owner = st.slots.findIndex((q) => placeOf(q).sites.includes(site));
if (owner >= 0 && owner !== st.sel) { st.sel = owner; renderAll(); }
return;
}
const k = hits.findIndex((p) => p.value === s.place);
setPlace(st.sel, hits[(k + 1) % hits.length].value);
}
// ---------------------------------------------------------------- helpers
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const tx = (pct) => X0 + (X1 - X0) * Math.min(Math.max(pct, 0), 100) / 100;
const num = (x) => (Math.round(x * 1000) / 1000).toString().replace(".", ",");
function pct(v) {
if (!(v > 0)) return "0 %";
if (v < 0.01) return "<1 %";
if (v > 9.99) return ">999 %";
return Math.round(v * 100) + " %";
}
function glyphBox(b, k) {
const g = GLYPH[k], p = BLK[b];
return { x: p.x + g.dx, y: p.y + 23, w: g.w, h: 20 };
}
function wordChips(word) {
const target = (word || "").toLowerCase().replace(/\s+/g, "");
const toks = srv.tokens || [];
const hit = [];
if (!target) return hit;
for (let k = 0; k < toks.length; k++) {
let acc = "";
for (let j = k; j < toks.length && acc.length < target.length; j++) {
acc += toks[j].toLowerCase();
if (acc === target) { for (let m = k; m <= j; m++) hit.push(m); break; }
}
}
return hit;
}
// Box (or line) to draw a site's highlight around. Word and padding chips depend on the slot.
function siteShapes(site, slot) {
if (BOX[site]) return [BOX[site]];
if (site === "ctx_word") return wordChips(slot && slot.word).map((k) => chipBoxes["t" + k]).filter(Boolean);
if (site === "ctx_pad") return [chipBoxes.end, chipBoxes.pad].filter(Boolean);
if (site.startsWith("skip.")) {
const u = site.slice(5), d = SKIP_FROM[u];
const y = BLK[d].y + 46;
return [{ line: true, x1: BLK[d].x + BW, x2: BLK[u].x, y }];
}
const [b, k] = site.split(".");
if (!BLK[b]) return [];
if (k === "out") return [{ x: BLK[b].x, y: BLK[b].y, w: BW, h: BH }];
if ((k === "S" || k === "X") && META[b].A === 0) return [];
return [glyphBox(b, k)];
}
function ring(shape, color, layer, style) {
const o = 3 + 4 * layer;
if (shape.line) {
const y = shape.y + (layer % 2 ? 1 : -1) * Math.ceil(layer / 2) * 5;
return `<line x1="${shape.x1}" y1="${y}" x2="${shape.x2 - 8}" y2="${y}" stroke="${color}" stroke-width="${style === "dash" ? 3 : 5}"
${style === "dash" ? 'stroke-dasharray="6 5"' : ""} marker-end="url(#bm-arrow-${color.slice(1)})" class="bm-hl"/>`;
}
const fill = style === "solid" && layer === 0 ? `fill="${color}" fill-opacity="0.16"` : 'fill="none"';
return `<rect x="${shape.x - o}" y="${shape.y - o}" width="${shape.w + 2 * o}" height="${shape.h + 2 * o}" rx="${6 + o}"
stroke="${color}" stroke-width="${style === "dash" ? 2 : 3}" ${style === "dash" ? 'stroke-dasharray="5 4"' : ""} ${fill} class="bm-hl"/>`;
}
function slotsAt(site) {
return st.slots.map((s, i) => [s, i]).filter(([s]) => placeOf(s).sites.includes(site));
}
function tipFor(base, site) {
const who = slotsAt(site).map(([s, i]) => `${i + 1} ${TOOLS[s.tool].label}`);
return esc(base + (who.length ? "\nHier greift ein: " + who.join(", ") : ""));
}
// ---------------------------------------------------------------- the map
function box(b, cls, label, sub, site, tip) {
return `<g class="bm-node ${cls}" data-site="${site}" data-tip="${tipFor(tip, site)}">
<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="8"/>
<text x="${b.x + 10}" y="${b.y + (sub ? 17 : b.h / 2 + 5)}" class="bm-t">${esc(label)}</text>
${sub ? `<text x="${b.x + 10}" y="${b.y + 33}" class="bm-s">${esc(sub)}</text>` : ""}</g>`;
}
function arrow(x1, y1, x2, y2, cls = "") {
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" class="bm-arrow ${cls}" marker-end="url(#bm-head)"/>`;
}
function chips(tokens, b, maxRows, withPad, fixedSite) {
const out = [], toks = tokens || [];
const items = [["start", "‹Start›"]].concat(toks.map((t, k) => ["t" + k, t.length > 12 ? t.slice(0, 11) + "…" : t]));
if (withPad) {
items.push(["end", "‹Ende›"]);
const pad = Math.max(0, (srv.max_len || 77) - 2 - toks.length);
if (pad) items.push(["pad", `‹Polster × ${pad}›`]);
}
let x = b.x + 8, y = b.y + 26, row = 0;
const boxes = {};
for (let n = 0; n < items.length; n++) {
const [id, text] = items[n];
const w = 12 + 6.6 * text.length;
if (x + w > b.x + b.w - 8) {
row += 1; x = b.x + 8; y += 23;
if (row >= maxRows) { out.push(`<text x="${x}" y="${y + 13}" class="bm-s">… und ${items.length - n} weitere</text>`); break; }
}
const special = id === "start" || id === "end" || id === "pad";
const site = fixedSite || (id === "end" || id === "pad" ? "ctx_pad" : id === "start" ? "ctx" : "ctx_word");
out.push(`<g class="bm-chip-svg ${special ? "bm-special" : ""}" data-site="${site}">
<rect x="${x}" y="${y}" width="${w}" height="20" rx="10"/><text x="${x + w / 2}" y="${y + 14.5}" text-anchor="middle">${esc(text)}</text></g>`);
boxes[id] = { x, y, w, h: 20 };
x += w + 4;
}
return [out.join(""), boxes];
}
function clip(text, n) {
return text.length > n ? text.slice(0, n - 1) + "…" : text;
}
function textLane() {
const usesB = st.slots.some((s) => s.tool === "layered");
const [ctxChips, boxes] = chips(srv.tokens, BOX.ctx, 3, true);
chipBoxes = boxes;
const stripes = (b) => Array.from({ length: 12 }, (_, k) =>
`<rect x="${b.x + b.w - 206 + k * 16}" y="${b.y + 9}" width="10" height="24" rx="2" class="bm-stripe"/>`).join("");
const [bChips] = chips(srv.tokens_b, BOX.ctxB, 3, false, "promptB");
const cx = BOX.ctx.x + BOX.ctx.w / 2, bx = BOX.ctxB.x + 120;
return `
<text x="16" y="22" class="bm-h">Text</text>
${box(BOX.prompt, "", "Prompt A", clip(srv.prompt || "", 80), "prompt", TIPS.prompt)}
${arrow(162, 74, 162, 96)} ${arrow(470, 74, 470, 96)}
${box(BOX.tok, "", "Wort-Tabelle", "", "tok", TIPS.tok)}
${box(BOX.pos, "", "Positionen", "", "pos", TIPS.pos)}
<text x="316" y="121" class="bm-t" text-anchor="middle">+</text>
${arrow(316, 134, 316, 154)}
<g class="bm-node" data-site="clip" data-tip="${tipFor(TIPS.clip, "clip")}">
<rect x="${BOX.clip.x}" y="${BOX.clip.y}" width="${BOX.clip.w}" height="${BOX.clip.h}" rx="8"/>
<text x="${BOX.clip.x + 10}" y="${BOX.clip.y + 18}" class="bm-t">CLIP-Textencoder</text>
<text x="${BOX.clip.x + 10}" y="${BOX.clip.y + 34}" class="bm-s">12 Schichten</text>${stripes(BOX.clip)}</g>
${arrow(316, 198, 316, 216)}
<g class="bm-node" data-site="ctx" data-tip="${tipFor(TIPS.ctx, "ctx")}">
<rect x="${BOX.ctx.x}" y="${BOX.ctx.y}" width="${BOX.ctx.w}" height="${BOX.ctx.h}" rx="8"/>
<text x="${BOX.ctx.x + 10}" y="${BOX.ctx.y + 17}" class="bm-t">Kontext · 77 Vektoren</text></g>
${ctxChips}
${arrow(cx, BOX.ctx.y + BOX.ctx.h, cx, FRAME.y - 2)}
<text x="${cx + 8}" y="${FRAME.y - 12}" class="bm-s">in jede <tspan class="bm-strong">Quer</tspan>-Aufmerksamkeit</text>
<g class="${usesB ? "" : "bm-off"}">
<text x="${BOX.promptB.x}" y="22" class="bm-s">nur für „Zweiter Prompt“</text>
${box(BOX.promptB, "", "Prompt B", clip(srv.prompt_b || "", 44), "promptB", TIPS.promptB)}
${arrow(bx, BOX.promptB.y + BOX.promptB.h, bx, BOX.clipB.y - 2)}
<g class="bm-node bm-thin" data-site="promptB" data-tip="${tipFor(TIPS.promptB, "promptB")}">
<rect x="${BOX.clipB.x}" y="${BOX.clipB.y}" width="${BOX.clipB.w}" height="${BOX.clipB.h}" rx="8"/>
<text x="${BOX.clipB.x + 10}" y="${BOX.clipB.y + 18}" class="bm-t">CLIP</text>
<text x="${BOX.clipB.x + 10}" y="${BOX.clipB.y + 34}" class="bm-s">dieselben Gewichte</text></g>
${arrow(bx, BOX.clipB.y + BOX.clipB.h, bx, BOX.ctxB.y - 2)}
<g class="bm-node" data-site="promptB" data-tip="${tipFor(TIPS.promptB, "promptB")}">
<rect x="${BOX.ctxB.x}" y="${BOX.ctxB.y}" width="${BOX.ctxB.w}" height="${BOX.ctxB.h}" rx="8"/>
<text x="${BOX.ctxB.x + 10}" y="${BOX.ctxB.y + 17}" class="bm-t">Kontext B</text></g>
${bChips}
<line x1="${bx}" y1="${BOX.ctxB.y + BOX.ctxB.h}" x2="${bx}" y2="${FRAME.y - 2}" class="bm-arrow bm-after" marker-end="url(#bm-head)"/>
<text x="${bx + 8}" y="${FRAME.y - 12}" class="bm-s">ersetzt Kontext A</text>
</g>`;
}
function heat(b) {
const e = srv.effect;
if (!e || e.none || !e.steps || !e.impact) return "";
const top = Math.max(1e-6, ...BLOCKS.map((k) => e.impact[k] || 0));
const a = 0.55 * (e.impact[b] || 0) / top * (e.rev !== st.rev ? 0.4 : 1);
return `<rect x="${BLK[b].x}" y="${BLK[b].y}" width="${BW}" height="${BH}" rx="10" class="bm-heatfill" fill-opacity="${a.toFixed(3)}"/>`;
}
function unetBlock(b) {
const p = BLK[b], m = META[b];
const glyphs = (m.A ? ["R", "S", "X"] : ["R"]).map((k) => {
const g = glyphBox(b, k), site = `${b}.${k}`;
const count = k === "R" ? m.R : m.A;
return `<g class="bm-glyph bm-g${k}" data-site="${site}" data-tip="${tipFor(`${m.label} · ${TIPS[k]} (${count} ×)`, site)}">
<rect x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="5"/>
<text x="${g.x + g.w / 2}" y="${g.y + 14}" text-anchor="middle">${GLYPH[k].text}</text></g>`;
}).join("");
const none = m.A ? "" : `<text x="${p.x + 48}" y="${p.y + 37}" class="bm-s">nur Faltungen</text>`;
return `<g class="bm-block" data-site="${b}.out" data-tip="${tipFor(`${m.label} · ${m.res} Stellen · ${m.ch} Kanäle. ${TIPS.out}`, `${b}.out`)}">
<rect x="${p.x}" y="${p.y}" width="${BW}" height="${BH}" rx="10" class="bm-block-bg"/>
${heat(b)}
<text x="${p.x + 8}" y="${p.y + 17}" class="bm-t">${m.label}</text>
<text x="${p.x + BW - 8}" y="${p.y + 17}" class="bm-s" text-anchor="end">${m.res}</text></g>
${glyphs}${none}`;
}
function unet() {
const parts = [`<rect x="${FRAME.x}" y="${FRAME.y}" width="${FRAME.w}" height="${FRAME.h}" rx="16" class="bm-frame"/>
<text x="${FRAME.x + 12}" y="${FRAME.y + 22}" class="bm-h">UNet</text>
<text x="${FRAME.x + 62}" y="${FRAME.y + 22}" class="bm-s">läuft einmal pro Schritt</text>`];
// skips first, so blocks sit on top
for (const [u, d] of Object.entries(SKIP_FROM)) {
const y = BLK[d].y + 46, x1 = BLK[d].x + BW, x2 = BLK[u].x;
parts.push(`<g class="bm-skip" data-site="skip.${u}" data-tip="${tipFor(`Skip-Verbindung: trägt die Merkmale aus ${META[d].label} direkt zu ${META[u].label}.`, `skip.${u}`)}">
<line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" class="bm-hit"/>
<line x1="${x1}" y1="${y}" x2="${x2 - 4}" y2="${y}" class="bm-arrow bm-skipline" marker-end="url(#bm-head)"/>
<text x="${(x1 + x2) / 2}" y="${y - 5}" text-anchor="middle" class="bm-s">Skip</text></g>`);
}
["d0", "d1", "d2"].forEach((b, i) => {
const p = BLK[b], q = BLK[["d1", "d2", "d3"][i]];
parts.push(arrow(p.x + 75, p.y + BH, q.x + 75, q.y - 2));
});
parts.push(arrow(BLK.d3.x + 75, BLK.d3.y + BH, BLK.mid.x + 30, BLK.mid.y - 2));
parts.push(arrow(BLK.mid.x + BW - 30, BLK.mid.y, BLK.u0.x + 75, BLK.u0.y + BH + 2));
["u0", "u1", "u2"].forEach((b, i) => {
const p = BLK[b], q = BLK[["u1", "u2", "u3"][i]];
parts.push(arrow(p.x + 75, p.y, q.x + 75, q.y + BH + 2));
});
BLOCKS.forEach((b) => parts.push(unetBlock(b)));
// the loop around the UNet
parts.push(box(BOX.latent, "", "Latent x_t", "Start: Rauschen", "latent", TIPS.latent));
parts.push(box(BOX.eps, "", "Vorhersage ε", "mit und ohne Prompt", "eps", TIPS.eps));
parts.push(box(BOX.cfg, "", "Guidance", "g × (mit − ohne)", "cfg", TIPS.cfg));
parts.push(box(BOX.sampler, "", "Sampler-Schritt", "Euler", "sampler", TIPS.sampler));
parts.push(box(BOX.time, "", "Zeitpunkt t", "an alle Res", "time", TIPS.time));
const ly = BLK.d0.y + 42, lx = BOX.latent.x + BOX.latent.w / 2, cx = BOX.eps.x + 60;
const rx = BOX.time.x + BOX.time.w + 6, ty = BOX.time.y + BOX.time.h / 2; // the way back runs right of t
parts.push(arrow(BOX.latent.x + BOX.latent.w, ly, BLK.d0.x - 2, ly));
parts.push(arrow(BLK.u3.x + BW, ly, BOX.eps.x - 2, ly));
parts.push(arrow(cx, BOX.eps.y + BOX.eps.h, cx, BOX.cfg.y - 2));
parts.push(arrow(cx, BOX.cfg.y + BOX.cfg.h, cx, BOX.sampler.y - 2));
parts.push(arrow(cx, BOX.sampler.y + BOX.sampler.h, cx, BOX.time.y - 2));
parts.push(arrow(BOX.time.x, ty, FRAME.x + FRAME.w + 2, ty));
parts.push(`<path d="M ${rx} ${BOX.sampler.y + BOX.sampler.h} L ${rx} ${LOOP_Y} L ${lx} ${LOOP_Y} L ${lx} ${BOX.latent.y + BOX.latent.h + 2}" class="bm-arrow" marker-end="url(#bm-head)"/>`);
parts.push(`<text x="${lx + 10}" y="${LOOP_Y - 6}" class="bm-s">nächster Schritt</text>`);
parts.push(`<text x="${lx + 8}" y="${BOX.latent.y + BOX.latent.h + 40}" class="bm-s">× Schritte</text>`);
// after the last step
const vx = BOX.vae.x + BOX.vae.w / 2;
parts.push(`<circle cx="${vx}" cy="${LOOP_Y}" r="3" class="bm-headpath"/>`);
parts.push(`<line x1="${vx}" y1="${LOOP_Y}" x2="${vx}" y2="${BOX.vae.y - 2}" class="bm-arrow bm-after" marker-end="url(#bm-head)"/>`);
parts.push(`<text x="${vx - 8}" y="${BOX.vae.y - 7}" class="bm-s" text-anchor="end">nach dem letzten Schritt</text>`);
parts.push(box(BOX.vae, "", "VAE-Decoder", "Latent 64 × 64 → Pixel 512 × 512", "vae", TIPS.vae));
parts.push(arrow(BOX.vae.x + BOX.vae.w, BOX.vae.y + BOX.vae.h / 2, BOX.image.x - 2, BOX.vae.y + BOX.vae.h / 2));
return parts.join("");
}
function imageNode() {
const e = srv.effect, b = BOX.image;
const inner = e && e.thumb
? `<image href="${esc(e.thumb)}" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" preserveAspectRatio="xMidYMid slice" clip-path="url(#bm-img-clip)"/>`
: `<text x="${b.x + b.w / 2}" y="${b.y + b.h / 2 + 5}" text-anchor="middle" class="bm-s">Bild</text>`;
return `<g class="bm-node" data-site="image" data-tip="${esc(TIPS.image)}">
<clipPath id="bm-img-clip"><rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="8"/></clipPath>
<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="8"/>${inner}</g>`;
}
function effectLayer() {
const e = srv.effect;
if (!e || e.none || !e.steps) {
const lines = e && e.none ? ["Letzter Lauf ohne Eingriff:", "nichts zu messen."]
: ["Nach dem ersten Lauf steht", "hier und in den Blöcken,", "wie stark sich jeder Block", "verändert hat."];
return lines.map((l, k) => `<text x="746" y="${1004 + 17 * k}" class="bm-s">${esc(l)}</text>`).join("");
}
const stale = e.rev !== st.rev;
const N = e.steps, parts = [];
const all = BLOCKS.flatMap((b) => e.blocks[b] || []);
const top = Math.max(1e-6, ...all);
for (const b of BLOCKS) {
const v = e.blocks[b] || [], p = BLK[b];
if (!v.length) continue;
const pts = v.map((y, i) => `${(p.x + 8 + 134 * (i + 0.5) / N).toFixed(1)},${(p.y + 89 - 20 * Math.min(1, y / top)).toFixed(1)}`).join(" ");
parts.push(`<line x1="${p.x + 8}" y1="${p.y + 89}" x2="${p.x + 142}" y2="${p.y + 89}" class="bm-base"/>`);
parts.push(`<polyline points="${pts}" class="bm-spark" pointer-events="none"/>`);
parts.push(`<text x="${p.x + 8}" y="${p.y + 66}" class="bm-s">Änderung</text>`);
parts.push(`<text x="${p.x + BW - 8}" y="${p.y + 66}" text-anchor="end" class="bm-s bm-val">bis ${pct(Math.max(...v))}</text>`);
}
// latent curve over the timeline
const lat = e.latent || [];
if (lat.length) {
const ltop = Math.max(1e-6, ...lat);
const pts = lat.map((y, i) => `${tx(100 * (i + 1) / N).toFixed(1)},${(LAT_Y - 26 * Math.min(1, y / ltop)).toFixed(1)}`).join(" ");
parts.push(`<polyline points="${tx(0)},${LAT_Y} ${pts}" class="bm-spark bm-latent" pointer-events="none"/>`);
parts.push(`<text x="${X1 + 6}" y="${LAT_Y - 26 * Math.min(1, lat[lat.length - 1] / ltop) + 4}" class="bm-s bm-val">${pct(lat[lat.length - 1])}</text>`);
}
const img = e.image || [];
const meanImg = img.length ? img.reduce((a, b) => a + b, 0) / img.length : 0;
const first = e.first === null || e.first === undefined ? "–" : `Schritt ${e.first + 1} von ${N}`;
parts.push(`<g class="bm-caption">
<text x="746" y="1000" class="bm-t">Gemessen</text>
${stale ? '<text x="746" y="1017" class="bm-s">(zum vorigen Eingriff)</text>' : ""}
<text x="746" y="1038" class="bm-s">Bild: ${pct(meanImg)} anders</text>
<text x="746" y="1060" class="bm-s">Füllung der Blöcke:</text>
<text x="746" y="1077" class="bm-s">erster veränderter Schritt</text>
<text x="746" y="1094" class="bm-s">(${first})</text>
<text x="746" y="1116" class="bm-s">Linie: alle Schritte</text></g>`);
return `<g class="bm-effect ${stale ? "bm-stale" : ""}">${parts.join("")}</g>`;
}
function timeline() {
const parts = [];
parts.push(`<text x="16" y="${TL_AXIS + 4}" class="bm-h">Zeit</text>`);
parts.push(`<text x="16" y="${LAT_Y - 14}" class="bm-s">Latent: Abstand zum</text><text x="16" y="${LAT_Y}" class="bm-s">Lauf ohne Eingriff</text>`);
parts.push(`<line x1="${X0}" y1="${LAT_Y}" x2="${X1}" y2="${LAT_Y}" class="bm-base"/>`);
parts.push(`<line x1="${X0}" y1="${TL_AXIS}" x2="${X1}" y2="${TL_AXIS}" class="bm-axis"/>`);
for (const p of [0, 25, 50, 75, 100]) {
parts.push(`<line x1="${tx(p)}" y1="${TL_AXIS - 3}" x2="${tx(p)}" y2="${TL_AXIS + 3}" class="bm-axis"/>`);
parts.push(`<text x="${tx(p)}" y="${TL_AXIS + 15}" text-anchor="middle" class="bm-s bm-tick">${p} %</text>`);
}
parts.push(`<text x="${X0 - 8}" y="${TL_AXIS + 4}" text-anchor="end" class="bm-s">Rauschen</text>`);
parts.push(`<text x="${X1 + 8}" y="${TL_AXIS + 4}" class="bm-s">Bild</text>`);
st.slots.forEach((s, i) => {
const t = TOOLS[s.tool], c = COLORS[i], y = TL_LANE + 8 + LANE_GAP * i;
const sel = i === st.sel ? "bm-lane-sel" : "";
parts.push(`<text x="16" y="${y + 14}" class="bm-t bm-lane-label ${sel}" style="fill:${c}">${i + 1} ${esc(clip(t.label, 27))}</text>`);
if (t.after_loop) {
parts.push(`<rect x="${X1 + 14}" y="${y}" width="${W - X1 - 22}" height="${LANE_H}" rx="5" fill="${c}" fill-opacity="0.85"/>`);
parts.push(`<text x="${X1 + 20}" y="${y + 13}" class="bm-on-band">danach</text>`);
return;
}
if (t.time_knob) {
const a = tx(s.value);
parts.push(`<rect x="${a}" y="${y}" width="${Math.max(0, X1 - a)}" height="${LANE_H}" rx="5" fill="${c}" fill-opacity="0.85" data-handle="${i}:knob" class="bm-band"/>`);
parts.push(`<rect x="${a - 5}" y="${y - 3}" width="10" height="${LANE_H + 6}" rx="3" class="bm-handle" style="stroke:${c}" data-handle="${i}:knob" data-tip="${esc(t.knob + ": ziehen")}"/>`);
parts.push(`<text x="${Math.min(a + 8, X1 - 60)}" y="${y + 13}" class="bm-on-band" pointer-events="none">ab ${num(s.value)} %</text>`);
return;
}
if (!t.window) {
parts.push(`<rect x="${X0}" y="${y}" width="${X1 - X0}" height="${LANE_H}" rx="5" fill="${c}" fill-opacity="0.35" class="bm-hatch"/>`);
parts.push(`<text x="${X0 + 8}" y="${y + 14}" class="bm-s">ganzer Lauf (${esc(t.whole)})</text>`);
return;
}
const a = tx(s.start), b = tx(s.end);
parts.push(`<rect x="${X0}" y="${y + LANE_H / 2 - 1}" width="${X1 - X0}" height="2" class="bm-base-lane"/>`);
if (b > a) parts.push(`<rect x="${a}" y="${y}" width="${b - a}" height="${LANE_H}" rx="5" fill="${c}" fill-opacity="0.85" data-handle="${i}:move" class="bm-band" data-tip="Fenster verschieben"/>`);
parts.push(`<rect x="${a - 5}" y="${y - 3}" width="10" height="${LANE_H + 6}" rx="3" class="bm-handle" style="stroke:${c}" data-handle="${i}:start" data-tip="Beginn ziehen"/>`);
parts.push(`<rect x="${b - 5}" y="${y - 3}" width="10" height="${LANE_H + 6}" rx="3" class="bm-handle" style="stroke:${c}" data-handle="${i}:end" data-tip="Ende ziehen"/>`);
if (t.envelope === "sine" && b > a) { // the strength swells and fades inside the window
const pts = Array.from({ length: 25 }, (_, k) => `${(a + (b - a) * k / 24).toFixed(1)},${(y + LANE_H - 2 - (LANE_H - 5) * Math.sin(Math.PI * k / 24)).toFixed(1)}`);
parts.push(`<polyline points="${pts.join(" ")}" class="bm-envelope" pointer-events="none"/>`);
}
parts.push(`<text x="${Math.min(Math.max(a, X0) + 10, X1 - 70)}" y="${y + 13}" class="bm-on-band" pointer-events="none">${num(s.start)}–${num(s.end)} %</text>`);
});
if (!st.slots.length) parts.push(`<text x="${X0}" y="${TL_LANE + 21}" class="bm-s">Noch kein Werkzeug gewählt.</text>`);
return parts.join("");
}
function highlights() {
const parts = [], layers = {};
const order = st.slots.map((s, i) => i).sort((a, b) => (a === st.sel) - (b === st.sel));
// other places of the selected slot: dashed, clickable on the map
const cur = st.slots[st.sel];
if (cur) {
const t = TOOLS[cur.tool], here = new Set(placeOf(cur).sites);
const other = new Set(t.places.filter((p) => p.value !== cur.place).flatMap((p) => p.sites).filter((x) => !here.has(x)));
other.forEach((site) => siteShapes(site, cur).forEach((sh) => parts.push(ring(sh, COLORS[st.sel], 0, "dash"))));
}
for (const i of order) {
const s = st.slots[i];
const dim = hoverSlot !== null && hoverSlot !== i;
if (dim) parts.push('<g opacity="0.25">');
for (const site of placeOf(s).sites) {
const n = layers[site] || 0;
layers[site] = n + 1;
siteShapes(site, s).forEach((sh) => parts.push(ring(sh, COLORS[i], n, "solid")));
}
if (dim) parts.push("</g>");
if (s.tool === "aphasia") parts.push(skippedLayers(s, COLORS[i]));
if (s.tool === "excise" && !wordChips(s.word).length) {
parts.push(`<text x="${BOX.ctx.x + 10}" y="${BOX.ctx.y + BOX.ctx.h - 8}" class="bm-s" style="fill:${COLORS[i]}">„${esc(s.word || "")}“ steht nicht in Prompt A</text>`);
}
}
if (hoverTool && TOOLS[hoverTool]) {
const seen = new Set();
TOOLS[hoverTool].places.forEach((p) => p.sites.forEach((site) => {
if (seen.has(site)) return;
seen.add(site);
siteShapes(site, { word: guessWord(srv.prompt) }).forEach((sh) => parts.push(ring(sh, "#868e96", 0, "dash")));
}));
}
return parts.join("");
}
// The CLIP layers an early read-out leaves out, over the layer stripes of the CLIP box.
function skippedLayers(s, color) {
const k = Math.min(12, Math.max(0, Math.round(+s.value || 0))), b = BOX.clip;
const out = [];
for (let j = 12 - k; j < 12; j++) {
out.push(`<rect x="${b.x + b.w - 206 + j * 16}" y="${b.y + 9}" width="10" height="24" rx="2" fill="${color}" fill-opacity="0.8" class="bm-hl"/>`);
}
const text = k ? `nach Schicht ${12 - k} ausgelesen` : "alle 12 Schichten";
out.push(`<text x="${b.x + 178}" y="${b.y + 26}" class="bm-s bm-hl" style="fill:${color}">${text}</text>`);
return out.join("");
}
function defs() {
const heads = COLORS.concat(["#868e96"]).map((c) =>
`<marker id="bm-arrow-${c.slice(1)}" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${c}"/></marker>`).join("");
return `<defs><marker id="bm-head" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="bm-headpath"/></marker>${heads}
<pattern id="bm-hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="8" stroke="currentColor" stroke-width="2" stroke-opacity="0.25"/></pattern></defs>`;
}
function paintMap() {
const svg = element.querySelector(".bm-svg");
if (!svg) return;
svg.innerHTML = defs() + textLane() + unet() + imageNode() + effectLayer() + highlights() + timeline();
}
// ---------------------------------------------------------------- palette and slots (HTML)
function renderPalette() {
const pal = element.querySelector(".bm-palette");
pal.innerHTML = CAT.groups.map((g) => `<div class="bm-group"><span class="bm-group-label">${esc(g)}</span>
${CAT.tools.filter((t) => t.group === g).map((t) => {
const used = st.slots.map((s, i) => (s.tool === t.key ? i : -1)).filter((i) => i >= 0);
const dots = used.map((i) => `<span class="bm-dot" style="background:${COLORS[i]}"></span>`).join("");
return `<button type="button" class="bm-tile ${used.length ? "bm-used" : ""}" data-key="${t.key}" title="${esc(t.what)}">
<span class="bm-ico">${t.icon}</span><span>${esc(t.label)}</span>${dots}</button>`;
}).join("")}</div>`).join("") +
`<div class="bm-pal-hint">${st.slots.length >= CAT.max_slots
? "Drei Werkzeuge sind das Maximum: ein weiteres ersetzt das markierte."
: "Klickt ein Werkzeug an, um es einzusetzen (bis zu drei). Mit der Maus darüber seht ihr auf der Karte, wo es wirken kann."}</div>`;
}
function slotCard(s, i) {
const t = TOOLS[s.tool], c = COLORS[i];
const places = t.places.length > 1
? `<div class="bm-places">${t.places.map((p) => `<button type="button" class="bm-pchip ${p.value === s.place ? "bm-on" : ""}"
data-act="place" data-place="${p.value}">${esc(p.label)}</button>`).join("")}</div>
<div class="bm-hint">Ort: hier wählen oder auf die gestrichelten Stellen der Karte klicken.</div>` : "";
const word = s.tool === "excise"
? `<label class="bm-row">Wort <input type="text" data-act="word" value="${esc(s.word || "")}" placeholder="Wort aus Prompt A"></label>` : "";
const lo = Math.min(t.lo, t.hi), hi = Math.max(t.lo, t.hi);
const knob = `<div class="bm-row bm-knob"><span>${esc(t.knob)}</span>
<input type="range" data-act="value" min="${lo}" max="${hi}" step="${t.step}" value="${s.value}">
<input type="number" data-act="value" step="any" value="${s.value}"></div>`;
const win = t.window
? `<div class="bm-row bm-win"><span>Zeitfenster</span><input type="number" data-act="start" step="any" value="${s.start}">
<span>bis</span><input type="number" data-act="end" step="any" value="${s.end}"><span>%</span></div>
<div class="bm-hint">Oder auf der Zeitleiste unter der Karte ziehen.</div>`
: t.time_knob ? `<div class="bm-hint">Den Zeitpunkt könnt ihr auch auf der Zeitleiste ziehen.</div>`
: t.after_loop ? `<div class="bm-hint">Wirkt nach der Schleife, beim Übersetzen in Pixel.</div>`
: `<div class="bm-hint">Wirkt im ganzen Lauf: ${esc(t.whole)}.</div>`;
return `<div class="bm-card ${i === st.sel ? "bm-sel" : ""}" data-i="${i}" style="--c:${c}">
<div class="bm-card-head"><span class="bm-badge">${i + 1}</span><span class="bm-ico">${t.icon}</span>
<b>${esc(t.label)}</b><button type="button" class="bm-x" data-act="remove" title="Werkzeug entfernen">✕</button></div>
<p class="bm-what">${esc(t.what)}</p>${places}${word}${knob}${win}
<p class="bm-foot">${esc(t.group)} · aus dem Neural-Bending-Rezept „${esc(t.preset)}“ · bei ${num(t.neutral)} keine Wirkung</p></div>`;
}
function renderSlots() {
const box = element.querySelector(".bm-slots");
const cards = st.slots.map(slotCard);
for (let i = st.slots.length; i < Math.min(st.slots.length + 1, CAT.max_slots); i++) {
cards.push(`<div class="bm-card bm-empty" data-i="${i}"><span class="bm-badge" style="--c:${COLORS[i]}">${i + 1}</span>
Freier Platz: oben ein Werkzeug wählen.</div>`);
}
box.innerHTML = cards.join("");
}
function renderAll() {
renderPalette();
renderSlots();
paintMap();
}
// Keep the other input of a pair in step without re-rendering the card (that would steal the focus).
function syncCard(i, except) {
const card = element.querySelector(`.bm-card[data-i="${i}"]`);
if (!card) return;
const s = st.slots[i];
card.querySelectorAll("[data-act]").forEach((inp) => {
if (inp === except || inp.tagName !== "INPUT") return;
const a = inp.dataset.act;
if (a === "value" || a === "start" || a === "end") inp.value = s[a];
});
}
// ---------------------------------------------------------------- events
element.querySelector(".bm-host").innerHTML = `<div class="bm">
<div class="bm-stage"><svg class="bm-svg" viewBox="0 0 ${W} ${H}" role="img" aria-label="Karte von Stable Diffusion 1.5"></svg>
<div class="bm-tip" hidden></div></div>
<div class="bm-side"><div class="bm-palette"></div><div class="bm-slots"></div></div></div>`;
root = element.querySelector(".bm");
const svg = element.querySelector(".bm-svg");
const tip = element.querySelector(".bm-tip");
root.addEventListener("click", (e) => {
const tile = e.target.closest(".bm-tile");
if (tile) { addTool(tile.dataset.key); return; }
const act = e.target.closest("[data-act]");
const card = e.target.closest(".bm-card");
if (act && card && act.dataset.act === "remove") { removeSlot(+card.dataset.i); return; }
if (act && card && act.dataset.act === "place") { st.sel = +card.dataset.i; setPlace(+card.dataset.i, act.dataset.place); return; }
if (card && !card.classList.contains("bm-empty") && +card.dataset.i !== st.sel && !e.target.closest("input")) {
st.sel = +card.dataset.i; renderAll(); return;
}
const site = e.target.closest("[data-site]");
if (site && svg.contains(site) && !e.target.closest("[data-handle]")) siteClick(site.dataset.site);
});
root.addEventListener("input", (e) => {
const inp = e.target, card = inp.closest(".bm-card");
if (!card || !inp.dataset.act) return;
const i = +card.dataset.i, s = st.slots[i], a = inp.dataset.act;
if (!s) return;
if (a === "word") s.word = inp.value;
else {
const v = parseFloat(String(inp.value).replace(",", "."));
if (!Number.isFinite(v)) return;
s[a] = v;
syncCard(i, inp);
}
publish(true);
paintMap();
});
root.addEventListener("pointerover", (e) => {
const tile = e.target.closest(".bm-tile"), card = e.target.closest(".bm-card:not(.bm-empty)");
const ht = tile ? tile.dataset.key : null, hs = card ? +card.dataset.i : null;
if (ht !== hoverTool || hs !== hoverSlot) { hoverTool = ht; hoverSlot = hs; paintMap(); }
});
root.addEventListener("pointerleave", () => { hoverTool = null; hoverSlot = null; paintMap(); });
function svgX(e) {
const pt = svg.createSVGPoint();
pt.x = e.clientX; pt.y = e.clientY;
return pt.matrixTransform(svg.getScreenCTM().inverse()).x;
}
svg.addEventListener("pointerdown", (e) => {
const h = e.target.closest("[data-handle]");
if (!h) return;
const [i, kind] = h.dataset.handle.split(":");
const s = st.slots[+i];
if (!s) return;
e.preventDefault();
st.sel = +i;
drag = { i: +i, kind, x0: svgX(e), start: s.start, end: s.end };
svg.setPointerCapture(e.pointerId);
renderPalette(); renderSlots();
});
svg.addEventListener("pointermove", (e) => {
if (drag) {
const s = st.slots[drag.i];
const d = (svgX(e) - drag.x0) / (X1 - X0) * 100;
const at = Math.round(Math.min(100, Math.max(0, (svgX(e) - X0) / (X1 - X0) * 100)));
if (drag.kind === "start") s.start = Math.min(at, s.end);
else if (drag.kind === "end") s.end = Math.max(at, s.start);
else if (drag.kind === "knob") s.value = at;
else {
const len = drag.end - drag.start;
const a = Math.round(Math.min(100 - len, Math.max(0, drag.start + d)));
s.start = a; s.end = a + len;
}
syncCard(drag.i, null);
paintMap();
return;
}
const t = e.target.closest("[data-tip]");
if (!t) { tip.hidden = true; return; }
const r = element.querySelector(".bm-stage").getBoundingClientRect();
tip.textContent = t.dataset.tip;
tip.hidden = false;
const x = e.clientX - r.left, y = e.clientY - r.top;
tip.style.left = Math.max(0, Math.min(x + 14, r.width - 310)) + "px";
tip.style.top = (y + 18) + "px";
});
function endDrag() {
if (!drag) return;
drag = null;
publish(true);
paintMap();
}
svg.addEventListener("pointerup", endDrag);
svg.addEventListener("pointercancel", endDrag);
svg.addEventListener("pointerleave", () => { tip.hidden = true; });
watch("value", () => {
absorb(props.value, false);
publish(false);
paintMap();
});
absorb(props.value, true);
publish(false);
renderAll();