diff --git a/post1-alt.html b/post1-alt.html
index fe9c39c..5c31698 100644
--- a/post1-alt.html
+++ b/post1-alt.html
@@ -1558,7 +1558,11 @@
const paths = ["./assets/brand/logo.svg", "./assets/brand/logo-white.svg"]
.concat(SLIDES.filter(s => s.img).map(s => s.img))
.concat(SLIDES.filter(s => s.media2 && s.media2.img).map(s => s.media2.img));
- paths.push("./assets/screens/pipelines.png");
+ /* workflow.png hängt an keiner Slide mehr, die Laptop-Layouts fordern es
+ aber weiter an. Ohne diesen Eintrag stand src="undefined" im Bild und
+ der Export brach mit "1 Bild(er) nicht geladen" ab — betraf alle vier
+ Laptop-Layouts der Titel-Slide. */
+ paths.push("./assets/screens/pipelines.png", "./assets/screens/workflow.png");
// Optional: the layout falls back to a hint if the file is not there yet.
const optional = new Set(["./assets/mockups/laptop.png"]);
paths.push("./assets/mockups/laptop.png");
@@ -1660,34 +1664,6 @@
return problems.length === 0;
}
- /* Renders oversized, then scales down to exactly 1080x1350 in halving steps.
- Two separate wins: the browser rasterises transforms, clip-paths and the
- downscaled screenshots at a higher resolution, and Instagram never has to
- resize the upload itself (widths of 320 to 1080 keep their resolution;
- anything larger is resized and recompressed by Instagram, so quality is
- lost twice). */
- function downscaleTo(src, w, h) {
- let cur = src;
- while (cur.width >= w * 2 && cur.height >= h * 2) {
- const step = document.createElement("canvas");
- step.width = Math.max(w, Math.round(cur.width / 2));
- step.height = Math.max(h, Math.round(cur.height / 2));
- const c = step.getContext("2d");
- c.imageSmoothingEnabled = true;
- c.imageSmoothingQuality = "high";
- c.drawImage(cur, 0, 0, step.width, step.height);
- cur = step;
- }
- if (cur.width === w && cur.height === h) return cur;
- const out = document.createElement("canvas");
- out.width = w; out.height = h;
- const c = out.getContext("2d");
- c.imageSmoothingEnabled = true;
- c.imageSmoothingQuality = "high";
- c.drawImage(cur, 0, 0, w, h);
- return out;
- }
-
/* Die Bibliothek baut ein SVG in Knotengröße und zieht es beim Zeichnen auf
die Canvas-Größe hoch. WebKit rasterisiert das SVG dabei in seiner
*intrinsischen* Größe und interpoliert danach: Ein höherer pixelRatio
@@ -1737,21 +1713,69 @@
missing every few exports while the rest of the slide was fine. It is a
race, so the fix is not to hope but to verify: render, check that every
screenshot area actually carries pixels, and repeat if it does not. */
- const SHOT_BOXES = ".mock-inner, .laptop-screen, .hex-shot";
+ /* Geprüft wird jede Fläche, die ein Bild tragen muss — die Displays und die
+ Geräterahmen selbst. Die Rahmen fehlten in dieser Liste, deshalb blieb ein
+ Export ohne Macbook unbemerkt. */
+ const SHOT_BOXES =
+ ".mock-inner, .laptop-screen, .hex-shot, .laptop-frame";
const MAX_TRIES = 5;
+ // Ab hier gilt eine Fläche als leer. Da nur noch das Innere des Vierecks
+ // abgetastet wird, ist kein Beiwerk mehr dabei, das die Spanne aufbläht.
+ const LEER_SPANNE = 6;
- // Rect of a child inside the slide, in slide coordinates (the preview is
- // scaled, the canvas is not).
- function boxInSlide(el, child) {
+ /* Die vier Ecken eines Kindes in Slide-Koordinaten.
+ Vorher stand hier das achsenparallele Umrechteck. Das war der Fehler:
+ .szene-mb und .szene-ip stehen per matrix3d schräg im Raum, ihr Umrechteck
+ umfasst Raster, Hintergrund und das Nachbargerät. Darin fand die Prüfung
+ immer genug Helligkeitsspanne und winkte eine leere Displayfläche durch —
+ genau so kam ein Export ohne Screenshots durch die Kontrolle.
+ getBoxQuads gäbe die echten Ecken, kennt Chrome aber nicht. Also werden
+ vier 1-px-Marken in die Ecken gesetzt und ausgemessen: die Layout-Engine
+ rechnet dabei jede Transformation der Vorfahren korrekt mit. Die Marken
+ verschwinden vor dem Rendern wieder. */
+ function quadInSlide(el, child) {
const sr = el.getBoundingClientRect();
- const cr = child.getBoundingClientRect();
const k = sr.width / 1080;
- return {
- x: Math.round((cr.left - sr.left) / k),
- y: Math.round((cr.top - sr.top) / k),
- w: Math.round(cr.width / k),
- h: Math.round(cr.height / k),
- };
+ let host = child, stell = null;
+ /*
kann keine Kinder tragen. Also entsteht im Elternknoten ein
+ deckungsgleicher Stellvertreter — mit derselben Box UND derselben
+ eigenen Transformation. Ohne letztere fehlt genau der Versatz
+ verschobener Bilder: .mock-inner > img wird per scale und translate in
+ seinem Fenster verschoben, das Viereck läge sonst daneben und die
+ Messung geriete auf Rahmen statt auf Bild. */
+ if (child.tagName === "IMG") {
+ const eltern = child.offsetParent || child.parentElement;
+ if (!eltern) return null;
+ const cs = getComputedStyle(child);
+ stell = document.createElement("div");
+ stell.style.cssText = "position:absolute;margin:0;padding:0;border:0;" +
+ "pointer-events:none;left:" + child.offsetLeft + "px;top:" + child.offsetTop +
+ "px;width:" + child.offsetWidth + "px;height:" + child.offsetHeight +
+ "px;transform:" + cs.transform + ";transform-origin:" + cs.transformOrigin + ";";
+ eltern.appendChild(stell);
+ host = stell;
+ }
+ const bw = host.offsetWidth, bh = host.offsetHeight;
+ if (bw < 4 || bh < 4) { if (stell) stell.remove(); return null; }
+ // Absolute Marken brauchen einen positionierten Bezug.
+ const posVorher = host.style.position;
+ if (getComputedStyle(host).position === "static") host.style.position = "relative";
+ const marken = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([fx, fy]) => {
+ const m = document.createElement("div");
+ m.style.cssText = "position:absolute;margin:0;padding:0;border:0;width:1px;" +
+ "height:1px;pointer-events:none;left:" + (fx * bw) + "px;top:" + (fy * bh) + "px;";
+ host.appendChild(m);
+ return m;
+ });
+ const pts = marken.map(m => {
+ const r = m.getBoundingClientRect();
+ return { x: ((r.left + r.width / 2) - sr.left) / k,
+ y: ((r.top + r.height / 2) - sr.top) / k };
+ });
+ marken.forEach(m => m.remove());
+ host.style.position = posVorher;
+ if (stell) stell.remove();
+ return pts;
}
/* A screenshot area is never a flat surface: it carries UI, text and colour.
@@ -1761,19 +1785,99 @@
auf "genug Kontrast": Eine helle Formular-Oberfläche hat wenig Spanne, ist
aber nie gleichmäßig. Ein zu hoher Schwellenwert hat sie fälschlich als
fehlend gemeldet. */
- function areaRange(ctx, b) {
- const ix = Math.round(b.w * 0.08), iy = Math.round(b.h * 0.08);
- const x = Math.max(0, b.x + ix), y = Math.max(0, b.y + iy);
- const w = Math.min(1080 - x, b.w - ix * 2), h = Math.min(1350 - y, b.h - iy * 2);
- if (w < 8 || h < 8) return 255;
- const data = ctx.getImageData(x, y, w, h).data;
- let min = 255, max = 0;
- for (let i = 0; i < data.length; i += 16) { // jedes vierte Pixel
- const lum = (data[i] * 299 + data[i + 1] * 587 + data[i + 2] * 114) / 1000;
- if (lum < min) min = lum;
- if (lum > max) max = lum;
+ const quadMitte = q => ({ x: (q[0].x + q[1].x + q[2].x + q[3].x) / 4,
+ y: (q[0].y + q[1].y + q[2].y + q[3].y) / 4 });
+
+ function quadEinruecken(q, f) {
+ const m = quadMitte(q);
+ return q.map(p => ({ x: p.x + (m.x - p.x) * f, y: p.y + (m.y - p.y) * f }));
+ }
+
+ function quadFlaeche(q) {
+ let s = 0;
+ for (let i = 0; i < 4; i++) { const a = q[i], b = q[(i + 1) % 4]; s += a.x * b.y - b.x * a.y; }
+ return Math.abs(s) / 2;
+ }
+
+ function quadEnthaelt(q, p) {
+ let pos = 0, neg = 0;
+ for (let i = 0; i < 4; i++) {
+ const a = q[i], b = q[(i + 1) % 4];
+ const d = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
+ if (d > 0) pos++; else if (d < 0) neg++;
}
- return max - min;
+ return pos === 0 || neg === 0;
+ }
+
+ /* Messpunkte dort, wo Bild und Rahmen sich überlappen — und nur dort.
+ Beides einzeln führt in die Irre: Das Szenen-Display ist im Entwurfsraum
+ größer als der sichtbare Ausschnitt, weil Deckelblende, Tastaturschale und
+ das davorstehende iPad Teile verdecken; diese Randzonen tragen Kontrast
+ und täuschen ein vorhandenes Bild vor. Beim Feature-Mockup ist es
+ umgekehrt: das gezoomte Bild ragt weit über sein Fenster hinaus, dort
+ läge die Messung außerhalb der Slide. Das Raster wird über das kleinere
+ der beiden Vierecke gelegt und gegen das größere geprüft. */
+ function ueberlappPunkte(qBox, qImg) {
+ const A = quadEinruecken(qBox, 0.08), B = quadEinruecken(qImg, 0.08);
+ const raster = quadFlaeche(A) <= quadFlaeche(B) ? A : B;
+ const gegen = raster === A ? B : A;
+ const pts = [];
+ for (let a = 0; a <= 16; a++) {
+ for (let b = 0; b <= 16; b++) {
+ const u = a / 16, v = b / 16;
+ const tx = raster[0].x + (raster[1].x - raster[0].x) * u;
+ const ty = raster[0].y + (raster[1].y - raster[0].y) * u;
+ const bx = raster[3].x + (raster[2].x - raster[3].x) * u;
+ const by = raster[3].y + (raster[2].y - raster[3].y) * u;
+ const p = { x: Math.round(tx + (bx - tx) * v), y: Math.round(ty + (by - ty) * v) };
+ if (quadEnthaelt(gegen, p)) pts.push(p);
+ }
+ }
+ return pts;
+ }
+
+ function spanneAn(lum, pts) {
+ let min = 255, max = 0, n = 0;
+ for (const p of pts) {
+ const l = lum(p.x, p.y);
+ if (l < 0) continue;
+ if (l < min) min = l;
+ if (l > max) max = l;
+ n++;
+ }
+ // Zu wenige Punkte in der Slide: nichts zu beurteilen, also nicht bemängeln.
+ return n < 20 ? 255 : max - min;
+ }
+
+ /* Der Anteil dunkler Punkte, den eine Vorlage auf weißem Grund erzeugt.
+ Für die Geräterahmen ist Gleichmäßigkeit allein kein brauchbares Merkmal:
+ fehlt der Rahmen, bleiben die Rasterlinien des Hintergrunds stehen und
+ täuschen Spanne vor. Ein fehlendes Macbook lässt aber eine helle Fläche
+ zurück, und der Erwartungswert dafür wird aus der Vorlage selbst geeicht,
+ damit kein fester Schwellenwert an einer bestimmten Grafik klebt. */
+ function dunkelAnteilVorlage(im) {
+ const c = document.createElement("canvas");
+ c.width = c.height = 48;
+ const g = c.getContext("2d", { willReadFrequently: true });
+ g.fillStyle = "#fff";
+ g.fillRect(0, 0, 48, 48);
+ try { g.drawImage(im, 0, 0, 48, 48); } catch (_) { return 0; }
+ const d = g.getImageData(0, 0, 48, 48).data;
+ let dunkel = 0;
+ for (let i = 0; i < d.length; i += 4)
+ if ((d[i] * 299 + d[i + 1] * 587 + d[i + 2] * 114) / 1000 < 140) dunkel++;
+ return dunkel / (48 * 48);
+ }
+
+ function dunkelAn(lum, pts) {
+ let dunkel = 0, n = 0;
+ for (const p of pts) {
+ const l = lum(p.x, p.y);
+ if (l < 0) continue;
+ if (l < 140) dunkel++;
+ n++;
+ }
+ return n < 20 ? 1 : dunkel / n;
}
async function renderSlide(i) {
@@ -1796,23 +1900,72 @@
const broken = imgs.filter(im => !im.naturalWidth);
if (broken.length) throw new Error(broken.length + " Bild(er) nicht geladen");
- const boxes = [...el.querySelectorAll(SHOT_BOXES)].map(c => boxInSlide(el, c));
+ /* Jede prüfpflichtige Fläche mit ihrem Viereck und, bei Geräterahmen,
+ mit dem aus der Vorlage geeichten Erwartungswert. */
+ const ziele = [...el.querySelectorAll(SHOT_BOXES)].map(c => {
+ // Der Geräterahmen ist selbst ein Bild; sonst steckt es im Rahmen.
+ const rahmen = c.tagName === "IMG";
+ const bild = rahmen ? c : c.querySelector("img");
+ if (!bild) return null;
+ const qBox = quadInSlide(el, rahmen ? c.parentElement : c);
+ const qImg = quadInSlide(el, bild);
+ if (!qBox || !qImg) return null;
+ return {
+ name: c.className || c.tagName.toLowerCase(),
+ pts: ueberlappPunkte(qBox, qImg),
+ rahmen,
+ erwartetDunkel: rahmen ? dunkelAnteilVorlage(bild) : 0,
+ };
+ }).filter(Boolean);
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
- const raw = await htmlToImage.toCanvas(el, renderOptions());
- const canvas = downscaleTo(raw, 1080, 1350);
+ /* Die Slide entsteht bei Renderqualität k in 1080k × 1350k, und genau
+ das wird auch gespeichert. Hier stand ein downscaleTo auf 1080 × 1350
+ — das warf bei k = 3 zwei Drittel der linearen Auflösung wieder weg.
+ Eine Vorlage mit 2390 px landete dadurch mit 916 px im Ergebnis,
+ obwohl sie schon in 2748 px gerendert vorlag. Wer eine hoch
+ aufgelöste Aufnahme liefert, soll sie auch im Export wiederfinden;
+ kleiner rechnen kann jedes Zielsystem selbst. */
+ const canvas = await htmlToImage.toCanvas(el, renderOptions());
const ctx = canvas.getContext("2d", { willReadFrequently: true });
- const ranges = boxes.map(b => areaRange(ctx, b));
- const missing = ranges.filter(r => r <= 3).length;
+ // Die Messpunkte stehen in Slide-Koordinaten (1080 breit) und werden
+ // auf die tatsächliche Ausgabegröße umgerechnet.
+ const AW = canvas.width, AH = canvas.height, af = AW / 1080;
+ const px = ctx.getImageData(0, 0, AW, AH).data;
+ const lum = (x, y) => {
+ const sx = Math.round(x * af), sy = Math.round(y * af);
+ if (sx < 0 || sy < 0 || sx >= AW || sy >= AH) return -1;
+ const j = (sy * AW + sx) * 4;
+ return (px[j] * 299 + px[j + 1] * 587 + px[j + 2] * 114) / 1000;
+ };
- if (!missing) {
+ const befund = ziele.map(z => {
+ const spanne = spanneAn(lum, z.pts);
+ // Eine leere Fläche trägt die reine Hintergrundfarbe ihres Rahmens.
+ let fehlt = spanne <= LEER_SPANNE;
+ let dunkel = null;
+ // Beim Geräterahmen greift Gleichmäßigkeit nicht: fehlt er, bleibt
+ // der gerasterte Hintergrund stehen. Dann zählt, wie viel Dunkles
+ // ankommt, gemessen gegen die Vorlage.
+ if (!fehlt && z.rahmen && z.erwartetDunkel >= 0.08) {
+ dunkel = dunkelAn(lum, z.pts);
+ if (dunkel < z.erwartetDunkel * 0.5) fehlt = true;
+ }
+ return { name: z.name, spanne, dunkel, erwartet: z.erwartetDunkel, fehlt };
+ });
+ const fehlend = befund.filter(b => b.fehlt);
+
+ if (!fehlend.length) {
const blob = await new Promise(r => canvas.toBlob(r, "image/png"));
if (blob) return blob;
}
if (attempt < MAX_TRIES) {
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig. " +
- "Luminanzspannen je Bildflaeche: " + ranges.map(r => r.toFixed(1)).join(", "));
- setStatus("Slide " + (i + 1) + ": Screenshot fehlte, Versuch " + (attempt + 1) + " ...");
+ befund.map(b => b.name + " Spanne " + b.spanne.toFixed(1) +
+ (b.dunkel === null ? "" : ", dunkel " + b.dunkel.toFixed(3) +
+ " erwartet " + b.erwartet.toFixed(3)) + (b.fehlt ? " <== leer" : "")).join(" | "));
+ setStatus("Slide " + (i + 1) + ": " + fehlend.map(b => b.name).join(", ") +
+ " fehlte, Versuch " + (attempt + 1) + " ...");
await new Promise(r => setTimeout(r, 180 * attempt));
}
}
@@ -1824,10 +1977,14 @@
}
}
+ // Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit
+ // sichtbar ist, was tatsächlich auf der Platte landet.
+ const ausgabeGroesse = () => (1080 * superSample) + " × " + (1350 * superSample) + " px";
+
async function downloadOne() {
setStatus("Rendere Slide " + (current + 1) + " …");
downloadBlob(await renderSlide(current), fileName(current));
- setStatus("Slide " + (current + 1) + " gespeichert", "ok");
+ setStatus("Slide " + (current + 1) + " gespeichert, " + ausgabeGroesse(), "ok");
}
async function downloadAll() {
@@ -1840,7 +1997,7 @@
setStatus("Packe ZIP …");
const path = await downloadBlob(await zip.generateAsync({ type: "blob" }),
"hivegrid-post1-alt-" + variant + ".zip");
- setStatus("ZIP gespeichert: " + path.split("/").pop(), "ok");
+ setStatus("ZIP gespeichert: " + path.split("/").pop() + ", " + ausgabeGroesse(), "ok");
}
const guard = fn => () => fn().catch(err => {
diff --git a/post1.html b/post1.html
index 1f3fbb8..c149e33 100644
--- a/post1.html
+++ b/post1.html
@@ -632,26 +632,32 @@
0.005789,0.984772,0,0.000001055,
0,0,1,0, 4244.78,536.57,0,1);
}
+ /* Die Aufnahme fuellt das Display randlos, wie auf einem echten Bildschirm.
+ Vorher stand sie als kleineres Rechteck an per Hand gemessenen
+ Prozentwerten darin (Breite 78,877 %, Versatz 2,715 % / 14,645 %,
+ abgeleitet aus einem angenommenen "groessten vollstaendig sichtbaren
+ Rechteck" von 2726 x 1682 bei 74/154). Zwei Fehler steckten darin:
+ Die Konstanten stammten nicht aus der Glasflaeche des Mockups, deshalb
+ blieben 656 px rechts und 470 px unten leer waehrend die Werkzeugleiste
+ des Boards oben unter der Deckelblende verschwand. Und Prozentwerte in
+ translate() beziehen sich auf die Bildkante, nicht auf das Display —
+ Vorschau und Export loesten sie unterschiedlich auf, die Luecke war im
+ Export groesser als im Canvas.
+ object-fit uebernimmt das jetzt: kein Versatz, der auseinanderlaufen
+ koennte, und die Blende beschneidet von selbst. */
.szene-screen > img {
- position: absolute; top: 0; left: 0; width: 100%;
+ position: absolute; top: 0; left: 0;
+ width: 100%; height: 100%;
+ object-fit: cover; object-position: center;
transform-origin: top left;
}
- /* Beide Aufnahmen liegen originalgetreu auf ihrem Display: nichts gezoomt,
- nichts abgeschnitten. Was frei bleibt, traegt den Hintergrundton der
- Aufnahme und ist deshalb nicht als Rand zu erkennen.
- Vom Macbook-Display ist nicht alles zu sehen: oben die Blende des
- Deckels samt Kameraaussparung, unten die Tastaturschale, rechts das
- davorstehende iPad. Das groesste vollstaendig sichtbare Rechteck im
- Design-Raum ist 2726 x 1682 bei (74, 154) - gemessen, nicht geschaetzt.
- Das Board liegt darin zentriert, deshalb Breite 78,877 % und der
- Versatz unten. Der Chat steht mittig, sein Display verdeckt nichts. */
- /* Exakt der Hintergrundton der jeweiligen Aufnahme, aus deren Ecken
- gemessen. Schon fuenf Stufen Unterschied zeichnen die freie Flaeche
- als Rand nach. */
+ /* Der Hintergrundton bleibt als Rueckfall stehen, falls eine Aufnahme
+ einmal nicht ankommt — dann faellt es als Flaeche auf, nicht als Loch. */
.szene-mb { background: #F5F4F4; }
.szene-ip { background: #FAFAFA; }
- .szene-mb > img { width: 78.877%; transform: translate(2.715%, 14.645%); }
- .szene-ip > img { transform: translate(0, 16.49%); }
+ /* Das Board wird beim Fuellen seitlich beschnitten. Was wegfaellt, soll
+ rechts wegfallen: dort steht ohnehin das iPad davor. */
+ .szene-mb > img { object-position: left center; }
.laptop-missing {
width: 940px; padding: 80px 40px; text-align: center;
@@ -1650,34 +1656,6 @@
return problems.length === 0;
}
- /* Renders oversized, then scales down to exactly 1080x1350 in halving steps.
- Two separate wins: the browser rasterises transforms, clip-paths and the
- downscaled screenshots at a higher resolution, and Instagram never has to
- resize the upload itself (widths of 320 to 1080 keep their resolution;
- anything larger is resized and recompressed by Instagram, so quality is
- lost twice). */
- function downscaleTo(src, w, h) {
- let cur = src;
- while (cur.width >= w * 2 && cur.height >= h * 2) {
- const step = document.createElement("canvas");
- step.width = Math.max(w, Math.round(cur.width / 2));
- step.height = Math.max(h, Math.round(cur.height / 2));
- const c = step.getContext("2d");
- c.imageSmoothingEnabled = true;
- c.imageSmoothingQuality = "high";
- c.drawImage(cur, 0, 0, step.width, step.height);
- cur = step;
- }
- if (cur.width === w && cur.height === h) return cur;
- const out = document.createElement("canvas");
- out.width = w; out.height = h;
- const c = out.getContext("2d");
- c.imageSmoothingEnabled = true;
- c.imageSmoothingQuality = "high";
- c.drawImage(cur, 0, 0, w, h);
- return out;
- }
-
/* Die Bibliothek baut ein SVG in Knotengröße und zieht es beim Zeichnen auf
die Canvas-Größe hoch. WebKit rasterisiert das SVG dabei in seiner
*intrinsischen* Größe und interpoliert danach: Ein höherer pixelRatio
@@ -1727,21 +1705,69 @@
missing every few exports while the rest of the slide was fine. It is a
race, so the fix is not to hope but to verify: render, check that every
screenshot area actually carries pixels, and repeat if it does not. */
- const SHOT_BOXES = ".mock-inner, .laptop-screen, .hex-shot, .szene-screen";
+ /* Geprüft wird jede Fläche, die ein Bild tragen muss — die Displays und die
+ Geräterahmen selbst. Die Rahmen fehlten in dieser Liste, deshalb blieb ein
+ Export ohne Macbook unbemerkt. */
+ const SHOT_BOXES =
+ ".mock-inner, .laptop-screen, .hex-shot, .szene-screen, .szene-dev, .laptop-frame";
const MAX_TRIES = 5;
+ // Ab hier gilt eine Fläche als leer. Da nur noch das Innere des Vierecks
+ // abgetastet wird, ist kein Beiwerk mehr dabei, das die Spanne aufbläht.
+ const LEER_SPANNE = 6;
- // Rect of a child inside the slide, in slide coordinates (the preview is
- // scaled, the canvas is not).
- function boxInSlide(el, child) {
+ /* Die vier Ecken eines Kindes in Slide-Koordinaten.
+ Vorher stand hier das achsenparallele Umrechteck. Das war der Fehler:
+ .szene-mb und .szene-ip stehen per matrix3d schräg im Raum, ihr Umrechteck
+ umfasst Raster, Hintergrund und das Nachbargerät. Darin fand die Prüfung
+ immer genug Helligkeitsspanne und winkte eine leere Displayfläche durch —
+ genau so kam ein Export ohne Screenshots durch die Kontrolle.
+ getBoxQuads gäbe die echten Ecken, kennt Chrome aber nicht. Also werden
+ vier 1-px-Marken in die Ecken gesetzt und ausgemessen: die Layout-Engine
+ rechnet dabei jede Transformation der Vorfahren korrekt mit. Die Marken
+ verschwinden vor dem Rendern wieder. */
+ function quadInSlide(el, child) {
const sr = el.getBoundingClientRect();
- const cr = child.getBoundingClientRect();
const k = sr.width / 1080;
- return {
- x: Math.round((cr.left - sr.left) / k),
- y: Math.round((cr.top - sr.top) / k),
- w: Math.round(cr.width / k),
- h: Math.round(cr.height / k),
- };
+ let host = child, stell = null;
+ /*
kann keine Kinder tragen. Also entsteht im Elternknoten ein
+ deckungsgleicher Stellvertreter — mit derselben Box UND derselben
+ eigenen Transformation. Ohne letztere fehlt genau der Versatz
+ verschobener Bilder: .mock-inner > img wird per scale und translate in
+ seinem Fenster verschoben, das Viereck läge sonst daneben und die
+ Messung geriete auf Rahmen statt auf Bild. */
+ if (child.tagName === "IMG") {
+ const eltern = child.offsetParent || child.parentElement;
+ if (!eltern) return null;
+ const cs = getComputedStyle(child);
+ stell = document.createElement("div");
+ stell.style.cssText = "position:absolute;margin:0;padding:0;border:0;" +
+ "pointer-events:none;left:" + child.offsetLeft + "px;top:" + child.offsetTop +
+ "px;width:" + child.offsetWidth + "px;height:" + child.offsetHeight +
+ "px;transform:" + cs.transform + ";transform-origin:" + cs.transformOrigin + ";";
+ eltern.appendChild(stell);
+ host = stell;
+ }
+ const bw = host.offsetWidth, bh = host.offsetHeight;
+ if (bw < 4 || bh < 4) { if (stell) stell.remove(); return null; }
+ // Absolute Marken brauchen einen positionierten Bezug.
+ const posVorher = host.style.position;
+ if (getComputedStyle(host).position === "static") host.style.position = "relative";
+ const marken = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([fx, fy]) => {
+ const m = document.createElement("div");
+ m.style.cssText = "position:absolute;margin:0;padding:0;border:0;width:1px;" +
+ "height:1px;pointer-events:none;left:" + (fx * bw) + "px;top:" + (fy * bh) + "px;";
+ host.appendChild(m);
+ return m;
+ });
+ const pts = marken.map(m => {
+ const r = m.getBoundingClientRect();
+ return { x: ((r.left + r.width / 2) - sr.left) / k,
+ y: ((r.top + r.height / 2) - sr.top) / k };
+ });
+ marken.forEach(m => m.remove());
+ host.style.position = posVorher;
+ if (stell) stell.remove();
+ return pts;
}
/* A screenshot area is never a flat surface: it carries UI, text and colour.
@@ -1751,19 +1777,99 @@
auf "genug Kontrast": Eine helle Formular-Oberfläche hat wenig Spanne, ist
aber nie gleichmäßig. Ein zu hoher Schwellenwert hat sie fälschlich als
fehlend gemeldet. */
- function areaRange(ctx, b) {
- const ix = Math.round(b.w * 0.08), iy = Math.round(b.h * 0.08);
- const x = Math.max(0, b.x + ix), y = Math.max(0, b.y + iy);
- const w = Math.min(1080 - x, b.w - ix * 2), h = Math.min(1350 - y, b.h - iy * 2);
- if (w < 8 || h < 8) return 255;
- const data = ctx.getImageData(x, y, w, h).data;
- let min = 255, max = 0;
- for (let i = 0; i < data.length; i += 16) { // jedes vierte Pixel
- const lum = (data[i] * 299 + data[i + 1] * 587 + data[i + 2] * 114) / 1000;
- if (lum < min) min = lum;
- if (lum > max) max = lum;
+ const quadMitte = q => ({ x: (q[0].x + q[1].x + q[2].x + q[3].x) / 4,
+ y: (q[0].y + q[1].y + q[2].y + q[3].y) / 4 });
+
+ function quadEinruecken(q, f) {
+ const m = quadMitte(q);
+ return q.map(p => ({ x: p.x + (m.x - p.x) * f, y: p.y + (m.y - p.y) * f }));
+ }
+
+ function quadFlaeche(q) {
+ let s = 0;
+ for (let i = 0; i < 4; i++) { const a = q[i], b = q[(i + 1) % 4]; s += a.x * b.y - b.x * a.y; }
+ return Math.abs(s) / 2;
+ }
+
+ function quadEnthaelt(q, p) {
+ let pos = 0, neg = 0;
+ for (let i = 0; i < 4; i++) {
+ const a = q[i], b = q[(i + 1) % 4];
+ const d = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
+ if (d > 0) pos++; else if (d < 0) neg++;
}
- return max - min;
+ return pos === 0 || neg === 0;
+ }
+
+ /* Messpunkte dort, wo Bild und Rahmen sich überlappen — und nur dort.
+ Beides einzeln führt in die Irre: Das Szenen-Display ist im Entwurfsraum
+ größer als der sichtbare Ausschnitt, weil Deckelblende, Tastaturschale und
+ das davorstehende iPad Teile verdecken; diese Randzonen tragen Kontrast
+ und täuschen ein vorhandenes Bild vor. Beim Feature-Mockup ist es
+ umgekehrt: das gezoomte Bild ragt weit über sein Fenster hinaus, dort
+ läge die Messung außerhalb der Slide. Das Raster wird über das kleinere
+ der beiden Vierecke gelegt und gegen das größere geprüft. */
+ function ueberlappPunkte(qBox, qImg) {
+ const A = quadEinruecken(qBox, 0.08), B = quadEinruecken(qImg, 0.08);
+ const raster = quadFlaeche(A) <= quadFlaeche(B) ? A : B;
+ const gegen = raster === A ? B : A;
+ const pts = [];
+ for (let a = 0; a <= 16; a++) {
+ for (let b = 0; b <= 16; b++) {
+ const u = a / 16, v = b / 16;
+ const tx = raster[0].x + (raster[1].x - raster[0].x) * u;
+ const ty = raster[0].y + (raster[1].y - raster[0].y) * u;
+ const bx = raster[3].x + (raster[2].x - raster[3].x) * u;
+ const by = raster[3].y + (raster[2].y - raster[3].y) * u;
+ const p = { x: Math.round(tx + (bx - tx) * v), y: Math.round(ty + (by - ty) * v) };
+ if (quadEnthaelt(gegen, p)) pts.push(p);
+ }
+ }
+ return pts;
+ }
+
+ function spanneAn(lum, pts) {
+ let min = 255, max = 0, n = 0;
+ for (const p of pts) {
+ const l = lum(p.x, p.y);
+ if (l < 0) continue;
+ if (l < min) min = l;
+ if (l > max) max = l;
+ n++;
+ }
+ // Zu wenige Punkte in der Slide: nichts zu beurteilen, also nicht bemängeln.
+ return n < 20 ? 255 : max - min;
+ }
+
+ /* Der Anteil dunkler Punkte, den eine Vorlage auf weißem Grund erzeugt.
+ Für die Geräterahmen ist Gleichmäßigkeit allein kein brauchbares Merkmal:
+ fehlt der Rahmen, bleiben die Rasterlinien des Hintergrunds stehen und
+ täuschen Spanne vor. Ein fehlendes Macbook lässt aber eine helle Fläche
+ zurück, und der Erwartungswert dafür wird aus der Vorlage selbst geeicht,
+ damit kein fester Schwellenwert an einer bestimmten Grafik klebt. */
+ function dunkelAnteilVorlage(im) {
+ const c = document.createElement("canvas");
+ c.width = c.height = 48;
+ const g = c.getContext("2d", { willReadFrequently: true });
+ g.fillStyle = "#fff";
+ g.fillRect(0, 0, 48, 48);
+ try { g.drawImage(im, 0, 0, 48, 48); } catch (_) { return 0; }
+ const d = g.getImageData(0, 0, 48, 48).data;
+ let dunkel = 0;
+ for (let i = 0; i < d.length; i += 4)
+ if ((d[i] * 299 + d[i + 1] * 587 + d[i + 2] * 114) / 1000 < 140) dunkel++;
+ return dunkel / (48 * 48);
+ }
+
+ function dunkelAn(lum, pts) {
+ let dunkel = 0, n = 0;
+ for (const p of pts) {
+ const l = lum(p.x, p.y);
+ if (l < 0) continue;
+ if (l < 140) dunkel++;
+ n++;
+ }
+ return n < 20 ? 1 : dunkel / n;
}
async function renderSlide(i) {
@@ -1786,23 +1892,72 @@
const broken = imgs.filter(im => !im.naturalWidth);
if (broken.length) throw new Error(broken.length + " Bild(er) nicht geladen");
- const boxes = [...el.querySelectorAll(SHOT_BOXES)].map(c => boxInSlide(el, c));
+ /* Jede prüfpflichtige Fläche mit ihrem Viereck und, bei Geräterahmen,
+ mit dem aus der Vorlage geeichten Erwartungswert. */
+ const ziele = [...el.querySelectorAll(SHOT_BOXES)].map(c => {
+ // Der Geräterahmen ist selbst ein Bild; sonst steckt es im Rahmen.
+ const rahmen = c.tagName === "IMG";
+ const bild = rahmen ? c : c.querySelector("img");
+ if (!bild) return null;
+ const qBox = quadInSlide(el, rahmen ? c.parentElement : c);
+ const qImg = quadInSlide(el, bild);
+ if (!qBox || !qImg) return null;
+ return {
+ name: c.className || c.tagName.toLowerCase(),
+ pts: ueberlappPunkte(qBox, qImg),
+ rahmen,
+ erwartetDunkel: rahmen ? dunkelAnteilVorlage(bild) : 0,
+ };
+ }).filter(Boolean);
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
- const raw = await htmlToImage.toCanvas(el, renderOptions());
- const canvas = downscaleTo(raw, 1080, 1350);
+ /* Die Slide entsteht bei Renderqualität k in 1080k × 1350k, und genau
+ das wird auch gespeichert. Hier stand ein downscaleTo auf 1080 × 1350
+ — das warf bei k = 3 zwei Drittel der linearen Auflösung wieder weg.
+ Eine Vorlage mit 2390 px landete dadurch mit 916 px im Ergebnis,
+ obwohl sie schon in 2748 px gerendert vorlag. Wer eine hoch
+ aufgelöste Aufnahme liefert, soll sie auch im Export wiederfinden;
+ kleiner rechnen kann jedes Zielsystem selbst. */
+ const canvas = await htmlToImage.toCanvas(el, renderOptions());
const ctx = canvas.getContext("2d", { willReadFrequently: true });
- const ranges = boxes.map(b => areaRange(ctx, b));
- const missing = ranges.filter(r => r <= 3).length;
+ // Die Messpunkte stehen in Slide-Koordinaten (1080 breit) und werden
+ // auf die tatsächliche Ausgabegröße umgerechnet.
+ const AW = canvas.width, AH = canvas.height, af = AW / 1080;
+ const px = ctx.getImageData(0, 0, AW, AH).data;
+ const lum = (x, y) => {
+ const sx = Math.round(x * af), sy = Math.round(y * af);
+ if (sx < 0 || sy < 0 || sx >= AW || sy >= AH) return -1;
+ const j = (sy * AW + sx) * 4;
+ return (px[j] * 299 + px[j + 1] * 587 + px[j + 2] * 114) / 1000;
+ };
- if (!missing) {
+ const befund = ziele.map(z => {
+ const spanne = spanneAn(lum, z.pts);
+ // Eine leere Fläche trägt die reine Hintergrundfarbe ihres Rahmens.
+ let fehlt = spanne <= LEER_SPANNE;
+ let dunkel = null;
+ // Beim Geräterahmen greift Gleichmäßigkeit nicht: fehlt er, bleibt
+ // der gerasterte Hintergrund stehen. Dann zählt, wie viel Dunkles
+ // ankommt, gemessen gegen die Vorlage.
+ if (!fehlt && z.rahmen && z.erwartetDunkel >= 0.08) {
+ dunkel = dunkelAn(lum, z.pts);
+ if (dunkel < z.erwartetDunkel * 0.5) fehlt = true;
+ }
+ return { name: z.name, spanne, dunkel, erwartet: z.erwartetDunkel, fehlt };
+ });
+ const fehlend = befund.filter(b => b.fehlt);
+
+ if (!fehlend.length) {
const blob = await new Promise(r => canvas.toBlob(r, "image/png"));
if (blob) return blob;
}
if (attempt < MAX_TRIES) {
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig. " +
- "Luminanzspannen je Bildflaeche: " + ranges.map(r => r.toFixed(1)).join(", "));
- setStatus("Slide " + (i + 1) + ": Screenshot fehlte, Versuch " + (attempt + 1) + " ...");
+ befund.map(b => b.name + " Spanne " + b.spanne.toFixed(1) +
+ (b.dunkel === null ? "" : ", dunkel " + b.dunkel.toFixed(3) +
+ " erwartet " + b.erwartet.toFixed(3)) + (b.fehlt ? " <== leer" : "")).join(" | "));
+ setStatus("Slide " + (i + 1) + ": " + fehlend.map(b => b.name).join(", ") +
+ " fehlte, Versuch " + (attempt + 1) + " ...");
await new Promise(r => setTimeout(r, 180 * attempt));
}
}
@@ -1814,10 +1969,14 @@
}
}
+ // Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit
+ // sichtbar ist, was tatsächlich auf der Platte landet.
+ const ausgabeGroesse = () => (1080 * superSample) + " × " + (1350 * superSample) + " px";
+
async function downloadOne() {
setStatus("Rendere Slide " + (current + 1) + " …");
downloadBlob(await renderSlide(current), fileName(current));
- setStatus("Slide " + (current + 1) + " gespeichert", "ok");
+ setStatus("Slide " + (current + 1) + " gespeichert, " + ausgabeGroesse(), "ok");
}
async function downloadAll() {
@@ -1830,7 +1989,7 @@
setStatus("Packe ZIP …");
const path = await downloadBlob(await zip.generateAsync({ type: "blob" }),
"hivegrid-post1-loesung-" + variant + ".zip");
- setStatus("ZIP gespeichert: " + path.split("/").pop(), "ok");
+ setStatus("ZIP gespeichert: " + path.split("/").pop() + ", " + ausgabeGroesse(), "ok");
}
const guard = fn => () => fn().catch(err => {
diff --git a/rules.md b/rules.md
index 1595a58..7141180 100644
--- a/rules.md
+++ b/rules.md
@@ -570,24 +570,30 @@ keine Scrollbalken oder Cursor-Reste im Bild.
## 7. Export & Bildqualität
-### Die zwei Regeln, aus denen alles folgt
+### Der Export behält die gerenderte Auflösung
-**1. Genau 1080 px breit hochladen.** Instagram behält bei Breiten von 320 bis
-1080 die Originalauflösung. Alles darüber wird **von Instagram selbst**
-verkleinert und danach neu komprimiert — Qualitätsverlust in zwei Runden statt
-einer. Ein 4000-px-Upload ist deshalb schlechter als ein 1080-px-Upload, nicht
-besser.
-
-**2. Trotzdem größer rendern.** Wir rastern intern bei **2×** (2160 × 2700) und
-skalieren selbst auf exakt 1080 × 1350 herunter. Das ist kein Widerspruch zu
-Regel 1, sondern der eigentliche Trick: Wir kontrollieren das Downsampling,
-statt es Instagram zu überlassen.
+**Gespeichert wird in 1080·k × 1350·k**, wobei k die Renderqualität ist. Bei 3×
+sind das 3240 × 4050. Die Datei ist damit so groß wie das, was tatsächlich
+gerendert wurde.
```
-2160 × 2700 → Halbierung → 1080 × 1350 → Upload
- render exakt
+Renderqualität 1× → 1080 × 1350
+Renderqualität 2× → 2160 × 2700
+Renderqualität 3× → 3240 × 4050
```
+**Früher stand hier das Gegenteil**, und die Begründung war das Upload-Verhalten
+von Instagram: Breiten von 320 bis 1080 bleiben unangetastet, alles darüber wird
+von Instagram verkleinert und neu komprimiert, also lieber selbst sauber auf
+1080 herunterrechnen. Der Preis dafür war aber, dass jede gelieferte Aufnahme
+auf die Breite ihres Rahmens im 1080er Blatt eingedampft wurde — eine Vorlage
+mit 2390 px kam mit 916 px an, obwohl sie intern schon in 2748 px vorlag.
+
+**Das wiegt schwerer als der Upload-Pfad.** Wer eine hoch aufgelöste Aufnahme
+liefert, muss sie im Ergebnis wiederfinden. Kleiner rechnen kann jedes
+Zielsystem selbst, und wer bewusst exakt 1080 × 1350 will, stellt die
+Renderqualität auf 1×.
+
### Warum das Supersampling hier hilft
Nicht wegen des Textes — den rastert der Browser bei nativer Größe ohnehin
@@ -605,20 +611,20 @@ sauber, teils sogar schärfer als supersampled. Der Gewinn liegt woanders:
> ist die Stufe umschaltbar (1× / 2× / 3×), nicht fest verdrahtet. **2× ist der
> Standard**, 3× kostet spürbar Zeit und Speicher bei kaum sichtbarem Gewinn.
-### Warum stufenweises Halbieren
+### Falls doch einmal verkleinert werden muss
-Browser skalieren Canvas standardmäßig schnell statt gut: Pixel werden direkt
-übernommen, Nachbarinformationen gehen verloren, das Ergebnis rauscht. Zwei
-Gegenmaßnahmen, beide nötig:
+Nicht in einem Sprung. Browser skalieren Canvas standardmäßig schnell statt gut:
+Pixel werden direkt übernommen, Nachbarinformationen gehen verloren, das
+Ergebnis rauscht. Zwei Gegenmaßnahmen, beide nötig:
```js
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high"; // Default ist "low"
```
-Und: **in Halbierungsschritten** verkleinern statt in einem Sprung. Ein direkter
-Sprung von 3240 auf 1080 überspringt Zwischenpixel; zweimal halbieren mittelt
-jeweils über 2 × 2 und kommt einer echten Flächenmittelung nahe.
+Und **in Halbierungsschritten** verkleinern. Ein direkter Sprung von 3240 auf
+1080 überspringt Zwischenpixel; zweimal halbieren mittelt jeweils über 2 × 2 und
+kommt einer echten Flächenmittelung nahe.
`createImageBitmap()` mit `resizeQuality` wäre der elegantere Weg, ist aber in
Browsern uneinheitlich umgesetzt — teils wird die Option ignoriert. `drawImage`