POST 3 Slide 2 zeigt den Aufbau: HiveGrid in der Mitte, die eigenen Module auf dem gelben Ring, die fremden Programme aussen am gestrichelten Kreis. Nachbau des Schaubilds aus dem Prototyp, aber in den Markenfarben -- Magenta ist keine (rules.md 2) -- und ohne Produktnamen im Kern. Achtung: das ist eine andere Aussage als in post2-alt.html, wo in der Mitte das Branchenprogramm liegt und HiveGrid ausdruecklich der Ring ist. Steht als Warnung im Code. Die Ueberschrift "Der Kern bleibt." passt seit dem Wechsel der Mitte nicht mehr zum Bild und ist noch offen. Slide 1 traegt nur noch die Ueberschrift; ein leerer Fliesstext erzeugt jetzt keinen leeren Absatz mehr. Das Wabenfeld ist enger geschnitten (Faktor 1,301 statt 1,227) und steht am Anschlag: 96 px zur Slide-Kante, die Sicherheitszone liegt bei 92. Ueberschriften in 76 px und 3 px versetzt wie in post2-alt -- gemessen, nicht geschaetzt. Zwei Fehler, die dabei aufgefallen sind: - Die Andock-Kacheln lagen auf denselben Strahlen wie die Module und beruehrten sich. Dieselbe Ueberlappung hat der Exportpruefung eine Spanne von 37 vorgetaeuscht, wo eine fehlende Marke 0 ergeben muss -- der Gegentest kam durch, obwohl die Marke fehlte. 22,5 Grad Versatz behebt beides. - Die Pruefung kannte nur den Ausschnitt des Wabenfelds und haette auf Slide 2 an der falschen Stelle gemessen. Der Ausschnitt wandert jetzt mit in die Ablage, wabenZonen heisst darum markenZonen. VIDEO Neuer Ordner video/ mit 13 Einblendungen fuer den Schnitt, jede als Standbild und als Bildsequenz mit Ein- und Ausblendung, alle mit Alphakanal. Gerendert aus komponenten.html von bauen.mjs ueber CDP; kein ffmpeg noetig, weil eine Bildsequenz dieselbe Transparenz traegt wie ProRes 4444. Der Schatten fehlte zunaechst ganz: die Karte trug clip-path fuer die Ecken und box-shadow fuer den Schatten, und clip-path beschneidet ihn mit. Genau so steht es in rules.md 3. Sitzt jetzt als drop-shadow auf einer Huelle. NEBENBEI - Das ZIP hiess in allen Projektdateien "hivegrid-post1-loesung", weil sie voneinander kopiert wurden. Kommt jetzt aus PROJECT. - Das rechte Element-Feld liess sich nicht schliessen, wenn es nicht ganz ins Fenster passte: sein Knopf rutschte als erster hinaus. Knopf nach vorn, Escape hebt die Auswahl auf, max-width auf den Feldern. - uebersicht.html schrieb "1 Slides". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
200 lines
8.5 KiB
JavaScript
200 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/* Rendert komponenten.html zu PNGs mit Alphakanal und zu Bildsequenzen.
|
|
*
|
|
* node video/bauen.mjs alles neu
|
|
* node video/bauen.mjs bauchbinde nur passende Schluessel
|
|
*
|
|
* Warum Node und nicht Python wie sonst im Repo: gebraucht wird eine
|
|
* Steuerverbindung zu Chrome (CDP, ueber WebSocket), und die hat Node ohne
|
|
* Zusatzpaket an Bord, Python nicht. Der Umweg ueber "Chrome pro Bild neu
|
|
* starten" waere die Alternative gewesen -- bei rund 400 Einzelbildern und
|
|
* anderthalb Sekunden Startzeit sind das zehn Minuten Warten fuer nichts.
|
|
*
|
|
* Es laeuft ueber den Server aus dem Repo und nicht ueber file://, weil die
|
|
* Seite Schrift und Marken per fetch holt und das lokale Dateiprotokoll das
|
|
* verbietet.
|
|
*
|
|
* Kein ffmpeg noetig. Ein Video mit echtem Alphakanal waere ProRes 4444 und
|
|
* damit ein Encoder, den dieser Rechner nicht hat; eine Bildsequenz kann
|
|
* Premiere genauso importieren und behaelt die Transparenz.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const HIER = path.dirname(fileURLToPath(import.meta.url));
|
|
const BASIS = "http://127.0.0.1:4321/video/komponenten.html";
|
|
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
const PORT = 9333;
|
|
|
|
/* Aufloesung. Die Komponenten sind fuer eine Zeitleiste in 1920 x 1080
|
|
entworfen -- eine Bauchbinde misst dort rund 500 px. Zweifach gerendert
|
|
traegt sie damit auch in 4K und haelt eine Vergroesserung im Schnitt aus,
|
|
ohne dass die Sequenzen den Ordner sprengen. Standbild und Sequenz haben
|
|
denselben Faktor, sonst passen sie im Schnitt nicht uebereinander. */
|
|
const FAKTOR = 2;
|
|
|
|
/* Die Ausblendung ist die Einblendung rueckwaerts, nur kuerzer. Ein eigener
|
|
Ausgang saehe eine Spur besser aus und kostete die doppelte Zahl an
|
|
Bildern; bei einer halben Sekunde sieht das niemand. */
|
|
const AUS_ANTEIL = 0.6;
|
|
|
|
const filter = process.argv.slice(2);
|
|
const passt = key => !filter.length || filter.some(f => key.includes(f));
|
|
|
|
/* ── CDP, das Noetigste ──────────────────────────────────────────────────── */
|
|
async function verbinden(port) {
|
|
let ziele = null;
|
|
for (let i = 0; i < 50; i++) {
|
|
ziele = await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()).catch(() => null);
|
|
if (ziele && ziele.some(t => t.type === "page")) break;
|
|
await new Promise(r => setTimeout(r, 200));
|
|
}
|
|
const seite = ziele && ziele.find(t => t.type === "page");
|
|
if (!seite) throw new Error("Chrome antwortet nicht auf " + port);
|
|
|
|
const ws = new WebSocket(seite.webSocketDebuggerUrl);
|
|
const offen = new Map();
|
|
const horcher = [];
|
|
ws.onmessage = e => {
|
|
const m = JSON.parse(e.data);
|
|
if (m.id && offen.has(m.id)) { offen.get(m.id)(m); offen.delete(m.id); }
|
|
else if (m.method) horcher.forEach(h => h(m));
|
|
};
|
|
await new Promise((ok, weg) => { ws.onopen = ok; ws.onerror = weg; });
|
|
|
|
let n = 0;
|
|
const ruf = (method, params = {}) => new Promise((ok, weg) => {
|
|
const id = ++n;
|
|
offen.set(id, m => m.error ? weg(new Error(method + ": " + m.error.message)) : ok(m.result));
|
|
ws.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
const einmal = ereignis => new Promise(ok => {
|
|
const h = m => { if (m.method === ereignis) { horcher.splice(horcher.indexOf(h), 1); ok(m); } };
|
|
horcher.push(h);
|
|
});
|
|
return { ruf, einmal, zu: () => ws.close() };
|
|
}
|
|
|
|
/* Ein Ausdruck in der Seite. Fehler dort sollen hier ankommen und nicht als
|
|
stilles undefined durchrutschen. */
|
|
async function werte(cdp, ausdruck) {
|
|
const r = await cdp.ruf("Runtime.evaluate", {
|
|
expression: ausdruck, awaitPromise: true, returnByValue: true,
|
|
});
|
|
if (r.exceptionDetails) {
|
|
const d = r.exceptionDetails;
|
|
throw new Error("in der Seite: " + (d.exception?.description || d.text));
|
|
}
|
|
return r.result.value;
|
|
}
|
|
|
|
async function aufnehmen(cdp, kasten) {
|
|
const r = await cdp.ruf("Page.captureScreenshot", {
|
|
format: "png",
|
|
captureBeyondViewport: true,
|
|
clip: { x: kasten.x, y: kasten.y, width: kasten.w, height: kasten.h, scale: FAKTOR },
|
|
});
|
|
return Buffer.from(r.data, "base64");
|
|
}
|
|
|
|
/* ── Ablauf ──────────────────────────────────────────────────────────────── */
|
|
const chrome = spawn(CHROME, [
|
|
"--headless", "--disable-gpu", "--hide-scrollbars",
|
|
"--remote-debugging-port=" + PORT,
|
|
"--user-data-dir=" + path.join(HIER, ".chrome"),
|
|
"about:blank",
|
|
], { stdio: "ignore" });
|
|
|
|
let cdp;
|
|
try {
|
|
cdp = await verbinden(PORT);
|
|
await cdp.ruf("Page.enable");
|
|
await cdp.ruf("Runtime.enable");
|
|
// Ohne das steht Weiss unter der Seite und jedes PNG waere deckend.
|
|
await cdp.ruf("Emulation.setDefaultBackgroundColorOverride",
|
|
{ color: { r: 0, g: 0, b: 0, a: 0 } });
|
|
|
|
const laden = async adresse => {
|
|
await cdp.ruf("Page.navigate", { url: adresse });
|
|
await cdp.einmal("Page.loadEventFired");
|
|
await werte(cdp, "window.KOMP_BEREIT");
|
|
};
|
|
|
|
await laden(BASIS + "?k=");
|
|
const liste = (await werte(cdp, "window.KOMP_LISTE")).filter(k => passt(k.key));
|
|
if (!liste.length) throw new Error("kein Schluessel passt auf: " + filter.join(", "));
|
|
|
|
const pngDir = path.join(HIER, "png");
|
|
const seqDir = path.join(HIER, "sequenz");
|
|
await mkdir(pngDir, { recursive: true });
|
|
await mkdir(seqDir, { recursive: true });
|
|
|
|
let dateien = 0, bytes = 0;
|
|
const bericht = [];
|
|
|
|
for (const k of liste) {
|
|
// Die Buehne gross genug stellen, sonst schneidet der Viewport ab, bevor
|
|
// ueberhaupt gemessen werden kann.
|
|
await cdp.ruf("Emulation.setDeviceMetricsOverride", {
|
|
width: k.w, height: k.h, deviceScaleFactor: 1, mobile: false,
|
|
});
|
|
await laden(`${BASIS}?k=${encodeURIComponent(k.key)}&t=1`);
|
|
|
|
const kasten = await werte(cdp, "window.KOMP_KASTEN()");
|
|
|
|
// Standbild: der gehaltene Zustand.
|
|
const still = await aufnehmen(cdp, kasten);
|
|
await writeFile(path.join(pngDir, k.key + ".png"), still);
|
|
dateien++; bytes += still.length;
|
|
|
|
// Sequenzen. Ein bis der Kasten steht, aus rueckwaerts und knapper.
|
|
const einN = k.bilder;
|
|
const ausN = Math.max(4, Math.round(k.bilder * AUS_ANTEIL));
|
|
for (const [name, anzahl, richtung] of [["ein", einN, 1], ["aus", ausN, -1]]) {
|
|
const ordner = path.join(seqDir, k.key + "-" + name);
|
|
await rm(ordner, { recursive: true, force: true });
|
|
await mkdir(ordner, { recursive: true });
|
|
for (let i = 0; i < anzahl; i++) {
|
|
const p = i / (anzahl - 1);
|
|
await werte(cdp, "window.setT(" + (richtung > 0 ? p : 1 - p) + ")");
|
|
const bild = await aufnehmen(cdp, kasten);
|
|
await writeFile(path.join(ordner, `${k.key}-${name}_${String(i).padStart(4, "0")}.png`), bild);
|
|
dateien++; bytes += bild.length;
|
|
}
|
|
}
|
|
const zeile = `${k.key.padEnd(24)} ${String(kasten.w * FAKTOR).padStart(5)} x ` +
|
|
`${String(kasten.h * FAKTOR).padEnd(5)} still + ${einN} ein + ${ausN} aus`;
|
|
console.log(" " + zeile);
|
|
bericht.push({ key: k.key, name: k.name, w: kasten.w * FAKTOR, h: kasten.h * FAKTOR,
|
|
ein: einN, aus: ausN });
|
|
}
|
|
|
|
/* Der Teilbau darf die Liste nicht auf sich selbst eindampfen: wer nur die
|
|
Bauchbinde neu baut, will danach trotzdem alle dreizehn darin finden.
|
|
Also wird zusammengefuehrt statt ueberschrieben, in der Reihenfolge der
|
|
Vorlage -- die bestimmt, was zusammengehoert, nicht der Zufall des
|
|
letzten Aufrufs. */
|
|
const vorher = JSON.parse(
|
|
await readFile(path.join(HIER, "liste.json"), "utf8").catch(() => '{"komponenten":[]}'));
|
|
const nachKey = new Map(vorher.komponenten.map(k => [k.key, k]));
|
|
bericht.forEach(k => nachKey.set(k.key, k));
|
|
const alle = (await werte(cdp, "window.KOMP_LISTE")).map(k => nachKey.get(k.key)).filter(Boolean);
|
|
await writeFile(path.join(HIER, "liste.json"),
|
|
JSON.stringify({ faktor: FAKTOR, komponenten: alle }, null, 1) + "\n");
|
|
|
|
console.log(`\n${liste.length} Komponenten, ${dateien} Dateien, ` +
|
|
`${(bytes / 1024 / 1024).toFixed(1)} MB`);
|
|
} finally {
|
|
if (cdp) cdp.zu();
|
|
chrome.kill();
|
|
/* Erst warten, bis Chrome wirklich weg ist. Ein Kill ist eine Bitte, kein
|
|
Vollzug: beim Herunterfahren schreibt der Browser noch in sein Profil,
|
|
und ein rm davor liess den Ordner wieder auferstehen. */
|
|
await new Promise(ok => chrome.once("exit", ok));
|
|
// Das Profil ist Wegwerfware und hat im Repo nichts verloren.
|
|
await rm(path.join(HIER, ".chrome"), { recursive: true, force: true });
|
|
}
|