Files
posts/animation.mjs
T
dejanandClaude Opus 5 c7c05a85cc Animation: eigener Bereich unter dem Canvas, getrennt vom Bildexport
Jedes Projekt hat jetzt unter dem Canvas einen Bereich "Animation", im selben
Fenster. Er zeigt jede Animation als echtes Blatt in Vorschau, mit Zeitregler,
Abspielen und "MP4 exportieren".

Eine Animation ist keine Slide. Sie steht in ANIMS und nicht in SLIDES, liegt
also nicht im Streifen, taucht in der Slide-Auswahl nicht auf und wird vom
Bildexport nicht angefasst. Im Carousel ist sie ein eigenes Element, und ob
sie mitkommt, entscheidet sich beim Posten. Ein erster Anlauf machte sie zur
sechsten Slide; damit war sie in der Zaehlung und im ZIP mit drin.

Der Export laeuft ueber den neuen Endpunkt POST /anim in serve.py. Er startet
animation.mjs in einem eigenen Faden, die Seite fragt den Stand ueber
GET /anim?id= ab und zeigt Fortschritt und Pfad. Ein Browser kann kein MP4
schreiben, und eine offene Anfrage ueber zwei Minuten geht nicht -- zumal sich
animation.mjs waehrenddessen selbst Seiten von diesem Server holt.

Aufgenommen wird im neuen Modus ?nur=anim&k=<key>: dort zeigt die Seite allein
das Blatt der Animation, in 1080 x 1350 an 0,0. Damit entfaellt jede
Ausschnittsrechnung und mit ihr die Falle, dass ein zu schmales Fenster ein
leeres Bild liefert.

Als erstes Beispiel post6-animChat, fuenf Sekunden: die Antwort wird Zeichen
fuer Zeichen getippt, abgeschickt, waechst als Blase in den Verlauf, die
Aufgabenkarte klappt auf, die Systemzeile bestaetigt, alles blendet zurueck
auf den Anfang. Getippt wird VERLAUF[3].text, also die Nachricht, die im
Werkzeug an dieser Stelle ohnehin folgt.

H.264 High, yuv420p, 1080 x 1350, 30 fps, stumme AAC-Spur, +faststart: das
nimmt Instagram fuer ein animiertes Element im Carousel an. GIF nicht, das
landet als Standbild. Ein Video im Carousel wird auch kein Reel, das passiert
nur bei einem einzeln geposteten Video.

Nebenbei:
- post4, post5 und post6 trugen im Panel noch den Kopf ihrer Vorlage.
- video/README.md und video/bauen.mjs behaupteten, ffmpeg sei hier nicht
  installiert. Stimmt seit 9.0.1 nicht mehr; die Bildsequenz bleibt dort
  trotzdem, mit Begruendung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:58:29 +02:00

187 lines
8.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`);
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 });
}