From 3832f82a65a61fab0aa4c50749a85af05a331362 Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Thu, 6 Aug 2026 20:03:59 +0200 Subject: [PATCH] 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) --- post1-alt.html | 281 ++++++++++++++++++++++++++++++++++---------- post1.html | 311 +++++++++++++++++++++++++++++++++++++------------ rules.md | 46 ++++---- 3 files changed, 480 insertions(+), 158 deletions(-) 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`