Files
posts/animation.mjs
T
dejanandClaude Opus 5 344645cb77 Umlaute: der Export prueft sie, statt sich aufs Erinnern zu verlassen
Auf einer Slide stand "Sanitaer" und "Klima / Lueftung". Beides kam
woertlich aus dem Klickdummy, wo es schludrig eingetippt ist, und beides
las sich im fertigen Beitrag wie ein Fehler -- weil es einer ist.
Gefunden hat es niemand beim Bauen, sondern jemand, der auf den fertigen
Post geschaut hat.

Ein reines Muster genuegt als Pruefung nicht: Deutsch schreibt "neue",
"Dauer" und "Michael" voellig zu Recht mit ae/oe/ue. Nachgezaehlt ueber
den gezeichneten Text aller sieben Projekte sind es aber nur acht solche
Woerter, alle davon richtig oder gewollt (die Kanal-Handles). Also
Muster plus kurze Ausnahmeliste -- null Fehlalarme, "Sanitaer" faellt
trotzdem auf.

Bei ss ist es umgekehrt: 15 Treffer im gezeichneten Text, alle richtig
(dass, Schluss, Netzanschluss). Deshalb dort eine Liste der wenigen
Woerter, die ein scharfes s brauchen, statt eines Musters.

Geprueft wird an zwei Stellen, weil es zwei Wege zum fertigen Bild gibt:

  renderSlide()   bricht ab, bevor ein PNG entsteht
  animation.mjs   an 24 Stellen ueber den Ablauf

Der zweite Punkt war ein eigener Fehler: der erste Anlauf prueft nur den
Anfangszustand und fand nichts, weil die falschen Woerter dort erst ab
t = 0,4 erscheinen. Der Text einer Animation aendert sich mit der Zeit,
die Pruefung muss es auch.

Beide Tore sind mit absichtlich wieder eingebautem "Sanitaer" scheitern
gesehen worden. Eine Pruefung, die man nie hat scheitern sehen, ist
keine -- der erste Test lief durch, weil mein Testskript htmlToImage
direkt aufruft und renderSlide dabei umgeht.

Regel und Begruendung stehen in rules.md 4, die Falle in 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7RZ6ZpLD8Dp84Rtt4KHfv
2026-09-01 19:45:08 +02:00

206 lines
9.7 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)
*
* 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
* 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;
const PROJEKT = SEITE.replace(/\.html$/, "");
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const PORT = 9530;
const FPS = 30;
const BREITE = 1080, HOEHE = 1350;
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 => {
await cdp.ruf("Page.navigate", { url: "http://127.0.0.1:4321/" + adresse });
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");
const alle = await cdp.werte("window.ANIM_LISTE ? window.ANIM_LISTE() : null");
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));
/* 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}: Umlaut fehlt bei ${[...gesehen].join(", ")}. ` +
"Wenn das Wort so richtig ist, gehoert es in UMLAUT_ERLAUBT.");
const ordner = await mkdtemp(path.join(tmpdir(), "postgrid-bilder-"));
process.stdout.write(` ${a.key}: ${anzahl} Bilder `);
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 + ".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 });
}