Files
posts/animation.mjs
dejanandClaude Opus 5.5 5b768537a2 Post 9: Format 1:1 für die Anzeige und vier neue Varianten
- Format-Schalter 4:5 / 1:1 in Post 9, Stand auf 1:1 (Meta nimmt ein
  Carousel mit Video als Anzeige nur quadratisch), Video in 1:1
- animation.mjs baut das Format der Seite, Datei mit -1x1
- Variante 2/3: Kundengeschichte nach dem weclapp-Karussell, hell und dunkel
- Variante 4/5: Zahlen-Karussell nach HERO Insights mit KfW- und
  Bitkom-Zahlen, Noah als Teaser, hell und dunkel
- post9-varianten.html zeigt alle Varianten untereinander
- Menüs und Übersicht um die Varianten ergänzt, Regel zu 1:1 in rules.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:55:03 +01:00

230 lines
11 KiB
JavaScript

#!/usr/bin/env node
/* Baut aus einer animierten Slide ein MP4 fuers Carousel.
*
* node animation.mjs post6.html alle animierten Slides
* node animation.mjs post6.html 5 nur Slide 6 (nullbasiert: 5)
* node animation.mjs post9.html animWebinar 1x1 im Format 1:1
*
* WARUM MP4 UND NICHT GIF
* Instagram nimmt kein GIF an. Was im Feed als "kurzer animierter Beitrag"
* laeuft und trotzdem kein Reel ist, ist ein Video in einem Carousel: Meta
* schreibt "Carousels are limited to 10 images, videos, or a mix of the two",
* und ein Video darin bleibt ein Carousel-Element. Nur ein einzeln gepostetes
* Video wird seit 2022 zum Reel.
*
* DIE WERTE
* Behaelter MP4, H.264 High, yuv420p
* Bild 1080 x 1350, also 4:5 wie unsere Standbilder -- ein Carousel
* schneidet alle Elemente auf das Format des ersten zu.
* Kennt die Seite ein zweites Format (window.SLIDE_FORMAT, bisher
* nur Post 9 mit 1:1 fuer die Anzeige), gilt dessen Hoehe. Das
* Format kommt aus dem Projektstand oder als drittes Argument,
* die Datei traegt dann ein Kennzeichen (-1x1).
* Bildrate 30 fps
* Laenge 3 s ist das Minimum, 60 s je Element das Maximum; wir liegen
* bei 5
* Datenrate 12 Mbit/s. Instagram rechnet ohnehin neu; was man hineingibt,
* ist die Obergrenze dessen, was herauskommen kann
* Ton eine stille AAC-Spur. Ein Video ganz ohne Tonspur wird von
* manchen Uploadwegen abgelehnt, und still ist es so oder so
* faststart der moov-Atom nach vorn, sonst laedt das Video erst, wenn es
* ganz da ist
*
* Die Bilder kommen aus derselben Seite wie die Standbilder, ueber dieselbe
* Tuer wie die Vorschau (window.animSetzen). Was im Browser laeuft, ist also
* das, was im Video landet.
*/
import { spawn } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const SEITE = process.argv[2] || "post6.html";
/* Der Schluessel der Animation, nicht ihre Nummer. Eine Animation ist keine
Slide mehr und hat deshalb auch keine Position im Streifen, die sich
verschieben koennte. Ohne Angabe werden alle des Projekts gebaut. */
const NUR = process.argv[3] || null;
/* Format, wenn die Seite mehrere kennt. Ohne Angabe gilt der Projektstand. */
const FORMAT = process.argv[4] || null;
const ZUSATZ = FORMAT ? "&format=" + encodeURIComponent(FORMAT) : "";
const PROJEKT = SEITE.replace(/\.html$/, "");
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const PORT = 9530;
const FPS = 30;
const BREITE = 1080;
let HOEHE = 1350, DATEI_ZUSATZ = "";
const DATENRATE = "12M";
const ZIEL = path.join(process.cwd(), "animation");
/* ── CDP, das Noetigste ──────────────────────────────────────────────────── */
async function verbinden(port) {
let ziele = null;
for (let i = 0; i < 60; 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(), 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 werte = async ausdruck => {
const r = await ruf("Runtime.evaluate", { expression: ausdruck, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error("in der Seite: " +
(r.exceptionDetails.exception?.description || r.exceptionDetails.text));
return r.result.value;
};
return { ruf, werte, zu: () => ws.close() };
}
/* ── ffmpeg ──────────────────────────────────────────────────────────────── */
function kodieren(ordner, datei, bilder) {
return new Promise((ok, weg) => {
const f = spawn("ffmpeg", [
"-y", "-hide_banner", "-loglevel", "error",
"-framerate", String(FPS),
"-i", path.join(ordner, "%05d.png"),
// Eine stille Tonspur dazu, sonst mag mancher Uploadweg die Datei nicht.
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
"-shortest",
"-c:v", "libx264", "-profile:v", "high", "-level", "4.1",
"-pix_fmt", "yuv420p",
"-b:v", DATENRATE, "-maxrate", DATENRATE, "-bufsize", "24M",
// Ein Schluesselbild je Sekunde: die Schleife setzt sauber wieder an.
"-g", String(FPS), "-keyint_min", String(FPS), "-sc_threshold", "0",
"-c:a", "aac", "-b:a", "128k", "-ar", "44100",
"-movflags", "+faststart",
datei,
], { stdio: ["ignore", "inherit", "inherit"] });
f.on("exit", c => c === 0 ? ok() : weg(new Error("ffmpeg endete mit " + c)));
});
}
/* ── Ablauf ──────────────────────────────────────────────────────────────── */
const chrome = spawn(CHROME, [
"--headless", "--disable-gpu", "--hide-scrollbars",
"--remote-debugging-port=" + PORT,
"--user-data-dir=" + path.join(tmpdir(), "postgrid-anim"),
"about:blank",
], { stdio: "ignore" });
let cdp;
try {
cdp = await verbinden(PORT);
await cdp.ruf("Page.enable");
await cdp.ruf("Runtime.enable");
/* Das Fenster hat genau die Groesse eines Blattes. Im Modus ?nur=anim
zeigt die Seite allein die Animation, an 0,0 -- damit entfaellt jede
Ausschnittsrechnung, und mit ihr die Falle, dass ein zu schmales Fenster
fuer eine hintere Seite ein leeres Bild liefert. */
await cdp.ruf("Emulation.setDeviceMetricsOverride",
{ width: BREITE, height: HOEHE, deviceScaleFactor: 1, mobile: false });
const laden = async adresse => {
const antwort = await cdp.ruf("Page.navigate", { url: "http://127.0.0.1:4321/" + adresse });
/* Laeuft PostGrid nicht auf 4321, laedt die Seite gar nicht. Ohne diese
Pruefung hiess das spaeter nur "kennt keine Animationen". */
if (antwort && antwort.errorText) {
throw new Error("PostGrid ist unter http://127.0.0.1:4321/ nicht erreichbar (" +
antwort.errorText + "). PostGrid mit ./serve.sh starten und den Post dort oeffnen.");
}
await new Promise(r => setTimeout(r, 4000));
};
// Erst die Liste holen. Ohne k rendert der Modus nichts, das Skript laeuft
// aber vollstaendig durch und window.ANIM_LISTE steht bereit.
await laden(SEITE + "?nur=anim" + ZUSATZ);
const alle = await cdp.werte("window.ANIM_LISTE ? window.ANIM_LISTE() : null");
/* Das Format der Seite. Posts ohne zweites Format melden nichts und
bleiben bei 4:5. */
const fmt = await cdp.werte("window.SLIDE_FORMAT ? window.SLIDE_FORMAT() : null");
if (fmt) {
HOEHE = fmt.hoehe; DATEI_ZUSATZ = fmt.datei || "";
await cdp.ruf("Emulation.setDeviceMetricsOverride",
{ width: BREITE, height: HOEHE, deviceScaleFactor: 1, mobile: false });
}
if (!alle) throw new Error(SEITE + " kennt keine Animationen (window.ANIM_LISTE fehlt)");
const liste = alle.filter(a => NUR == null || a.key === NUR);
if (!liste.length) throw new Error("keine Animation" +
(NUR ? ' "' + NUR + '"' : "") + " in " + SEITE +
(alle.length ? " — vorhanden: " + alle.map(a => a.key).join(", ") : ""));
await mkdir(ZIEL, { recursive: true });
for (const a of liste) {
const anzahl = Math.round(a.dauer * FPS);
await laden(SEITE + "?nur=anim&k=" + encodeURIComponent(a.key) + ZUSATZ);
/* Nachsehen, dass wirklich ein Blatt dasteht und es die richtigen Masse
hat. Ein leeres Fenster faellt sonst erst am fertigen Video auf. */
const kasten = await cdp.werte(`(() => {
const el = document.querySelector(".slide[data-animi]");
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: Math.round(r.left), y: Math.round(r.top),
w: Math.round(r.width), h: Math.round(r.height) };
})()`);
if (!kasten) throw new Error(`${a.key}: kein Blatt im Modus ?nur=anim`);
if (kasten.w !== BREITE || kasten.h !== HOEHE)
throw new Error(`${a.key} misst ${kasten.w}x${kasten.h}, erwartet ${BREITE}x${HOEHE}`);
if (kasten.x !== 0 || kasten.y !== 0)
throw new Error(`${a.key} sitzt bei ${kasten.x},${kasten.y} statt 0,0`);
/* Dieselbe Umlautpruefung wie der Bildexport. Das Bewegtbild laeuft nicht
durch renderSlide und waere sonst die Luecke: auf einer Slide faellt
"Sanitaer" beim Export auf, in 180 Bildern niemandem.
Ueber den ganzen Ablauf und nicht nur einmal: der Text einer Animation
aendert sich mit t. Beim ersten Versuch lief die Pruefung nur auf dem
Anfangsbild und fand nichts -- die fehlerhaften Optionen erscheinen
erst ab t = 0,4. Vierundzwanzig Stellen decken jeden Zustand ab und
kosten zusammen weniger als eine Sekunde. */
const gesehen = new Set();
for (let p = 0; p < 24; p++) {
await cdp.werte(`window.animSetzen(${a.i}, ${p / 24})`);
const f = await cdp.werte("window.TEXT_PRUEFUNG ? window.TEXT_PRUEFUNG() : []");
(f || []).forEach(w => gesehen.add(w));
}
if (gesehen.size)
throw new Error(`${a.key}: ${[...gesehen].join(", ")}. ` +
"Fehlt ein Umlaut, schreib ihn; ist das Wort so richtig, gehoert es in " +
"UMLAUT_ERLAUBT. Einen Geviertstrich ersetzt ein Punkt oder ein Komma.");
const ordner = await mkdtemp(path.join(tmpdir(), "postgrid-bilder-"));
process.stdout.write(` ${a.key}: ${anzahl} Bilder, ${BREITE} x ${HOEHE} `);
for (let n = 0; n < anzahl; n++) {
/* Das letzte Bild ist nicht t = 1, sondern eins davor: bei einer
Schleife waeren t = 0 und t = 1 dasselbe Bild und es haekelte kurz. */
await cdp.werte(`window.animSetzen(${a.i}, ${n / anzahl})`);
const r = await cdp.ruf("Page.captureScreenshot", { format: "png" });
await writeFile(path.join(ordner, String(n + 1).padStart(5, "0") + ".png"),
Buffer.from(r.data, "base64"));
if (n % 15 === 0) process.stdout.write(".");
}
const datei = path.join(ZIEL, PROJEKT + "-" + a.key + DATEI_ZUSATZ + ".mp4");
process.stdout.write(" kodiere ");
await kodieren(ordner, datei, anzahl);
await rm(ordner, { recursive: true, force: true });
console.log("→ " + path.relative(process.cwd(), datei));
}
} finally {
if (cdp) cdp.zu();
chrome.kill();
await new Promise(ok => chrome.once("exit", ok));
await rm(path.join(tmpdir(), "postgrid-anim"), { recursive: true, force: true });
}