Files
posts/video/bauen.mjs
T
dejanandClaude Opus 5 ceb63fec38 Post 3: zweite Slide, und eine Komponenten-Library fuers Video
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>
2026-08-17 16:55:18 +02:00

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 });
}