Vorher hiess das nur "kennt keine Animationen". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
10 KiB
JavaScript
213 lines
10 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 => {
|
|
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");
|
|
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}: ${[...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 `);
|
|
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 });
|
|
}
|