Export behaelt die volle Aufloesung, Kontrolle erkennt fehlende Bilder

Der Export staucht nicht mehr auf 1080 x 1350. Die Slide entstand bei
Renderqualitaet k longst in 1080k x 1350k und wurde unmittelbar vor dem
Speichern wieder heruntergerechnet -- eine Vorlage mit 2390 px kam so mit
916 px an, obwohl sie bereits in 2748 px gerendert vorlag. Gespeichert wird
jetzt, was gerendert wurde; bei 3x sind das 3240 x 4050. Die Statuszeile
nennt die Groesse, und wer exakt 1080 will, stellt die Stufe auf 1x.

Die Kontrolle vor dem Speichern hat ihren Zweck verfehlt. Sie prueft, ob
jede Bildflaeche im Ergebnis Pixel traegt, sah dafuer aber das
achsenparallele Umrechteck an. Die Szenen-Displays stehen per matrix3d
schraeg im Raum, in ihrem Umrechteck liegen Rasterlinien, Hintergrund und
das Nachbargeraet -- deren Kontrast reichte immer, um durchzukommen. Eine
Slide mit voellig leerem Display wurde deshalb stumm gespeichert.

Jetzt werden die echten vier Ecken ausgemessen (ueber 1-px-Marken, Chrome
kennt getBoxQuads nicht) und nur die Ueberlappung von Bild und Rahmen
abgetastet. Beides einzeln fuehrt in die Irre: das Szenen-Display ist
groesser als der sichtbare Ausschnitt, das Feature-Mockup umgekehrt. Die
Geraeterahmen stehen neu mit auf der Liste, geprueft ueber den aus der
Vorlage geeichten Dunkelanteil. Nachgestellt mit opacity 0: vorher still
gespeichert, jetzt Abbruch mit Nennung der Flaeche. 11 von 11 echten
Ausfuhren laufen ohne Fehlalarm und ohne Wiederholung durch.

Die Szenen-Screenshots fuellen ihr Display randlos. Sie lagen als kleinere
Rechtecke an handgemessenen Prozentwerten darin, die nicht aus der
Glasflaeche des Mockups stammten: 656 px blieben rechts frei, 470 px unten,
und die Werkzeugleiste des Boards verschwand oben trotzdem unter der
Blende. Weil Prozentwerte in translate() an der Bildkante haengen, loesten
Vorschau und Export sie unterschiedlich auf -- die Luecke war im Export
groesser als im Canvas. object-fit uebernimmt das jetzt, es bleibt kein
Versatz, der auseinanderlaufen koennte.

Dazu behoben: post1-alt.html konnte vier Laptop-Layouts und Slide 2
ueberhaupt nicht exportieren. workflow.png haengt an keiner Slide mehr und
fehlte in der Ladeliste, im Bild stand src="undefined".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-06 20:03:59 +02:00
1 parent be1a81a984
commit 3832f82a65
3 files changed
+480 -158

No files matched your search

+219 -62
View File
@@ -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;
/* <img> 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 => {
+235 -76
View File
@@ -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;
/* <img> 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 => {
+26 -20
View File
@@ -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`