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`