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>
187 lines
8.7 KiB
JavaScript
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 });
|
|
}
|