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>
213 lines
9.4 KiB
JavaScript
213 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/* Rendert komponenten.html zu PNGs mit Alphakanal und zu Bildsequenzen.
|
|
*
|
|
* node video/bauen.mjs alles neu
|
|
* node video/bauen.mjs bauchbinde nur passende Schluessel
|
|
*
|
|
* Warum Node und nicht Python wie sonst im Repo: gebraucht wird eine
|
|
* Steuerverbindung zu Chrome (CDP, ueber WebSocket), und die hat Node ohne
|
|
* Zusatzpaket an Bord, Python nicht. Der Umweg ueber "Chrome pro Bild neu
|
|
* starten" waere die Alternative gewesen -- bei rund 400 Einzelbildern und
|
|
* anderthalb Sekunden Startzeit sind das zehn Minuten Warten fuer nichts.
|
|
*
|
|
* Es laeuft ueber den Server aus dem Repo und nicht ueber file://, weil die
|
|
* Seite Schrift und Marken per fetch holt und das lokale Dateiprotokoll das
|
|
* verbietet.
|
|
*
|
|
* Kein ffmpeg noetig. Ein Video mit echtem Alphakanal waere ProRes 4444; eine
|
|
* Bildsequenz kann Premiere genauso importieren, behaelt die Transparenz ohne
|
|
* Codec-Frage und laesst sich Bild fuer Bild ansehen. (ffmpeg liegt inzwischen
|
|
* auf der Maschine -- animation.mjs im Wurzelverzeichnis benutzt es fuer die
|
|
* MP4s der Posts. Hier braucht es das trotzdem nicht.)
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const HIER = path.dirname(fileURLToPath(import.meta.url));
|
|
const BASIS = "http://127.0.0.1:4321/video/komponenten.html";
|
|
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
const PORT = 9333;
|
|
|
|
/* Aufloesung. Die Komponenten sind fuer eine Zeitleiste in 1920 x 1080
|
|
entworfen -- eine Bauchbinde misst dort rund 500 px. Zweifach gerendert
|
|
traegt sie damit auch in 4K und haelt eine Vergroesserung im Schnitt aus,
|
|
ohne dass die Sequenzen den Ordner sprengen. Standbild und Sequenz haben
|
|
denselben Faktor, sonst passen sie im Schnitt nicht uebereinander. */
|
|
const FAKTOR = 2;
|
|
|
|
/* Die Ausblendung ist die Einblendung rueckwaerts, nur kuerzer. Ein eigener
|
|
Ausgang saehe eine Spur besser aus und kostete die doppelte Zahl an
|
|
Bildern; bei einer halben Sekunde sieht das niemand. */
|
|
const AUS_ANTEIL = 0.6;
|
|
|
|
const filter = process.argv.slice(2);
|
|
const passt = key => !filter.length || filter.some(f => key.includes(f));
|
|
|
|
/* ── CDP, das Noetigste ──────────────────────────────────────────────────── */
|
|
async function verbinden(port) {
|
|
let ziele = null;
|
|
for (let i = 0; i < 50; 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();
|
|
const 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 einmal = ereignis => new Promise(ok => {
|
|
const h = m => { if (m.method === ereignis) { horcher.splice(horcher.indexOf(h), 1); ok(m); } };
|
|
horcher.push(h);
|
|
});
|
|
return { ruf, einmal, zu: () => ws.close() };
|
|
}
|
|
|
|
/* Ein Ausdruck in der Seite. Fehler dort sollen hier ankommen und nicht als
|
|
stilles undefined durchrutschen. */
|
|
async function werte(cdp, ausdruck) {
|
|
const r = await cdp.ruf("Runtime.evaluate", {
|
|
expression: ausdruck, awaitPromise: true, returnByValue: true,
|
|
});
|
|
if (r.exceptionDetails) {
|
|
const d = r.exceptionDetails;
|
|
throw new Error("in der Seite: " + (d.exception?.description || d.text));
|
|
}
|
|
return r.result.value;
|
|
}
|
|
|
|
async function aufnehmen(cdp, kasten) {
|
|
const r = await cdp.ruf("Page.captureScreenshot", {
|
|
format: "png",
|
|
captureBeyondViewport: true,
|
|
clip: { x: kasten.x, y: kasten.y, width: kasten.w, height: kasten.h, scale: FAKTOR },
|
|
});
|
|
return Buffer.from(r.data, "base64");
|
|
}
|
|
|
|
/* ── Ablauf ──────────────────────────────────────────────────────────────── */
|
|
const chrome = spawn(CHROME, [
|
|
"--headless", "--disable-gpu", "--hide-scrollbars",
|
|
"--remote-debugging-port=" + PORT,
|
|
"--user-data-dir=" + path.join(HIER, ".chrome"),
|
|
"about:blank",
|
|
], { stdio: "ignore" });
|
|
|
|
let cdp;
|
|
try {
|
|
cdp = await verbinden(PORT);
|
|
await cdp.ruf("Page.enable");
|
|
await cdp.ruf("Runtime.enable");
|
|
// Ohne das steht Weiss unter der Seite und jedes PNG waere deckend.
|
|
await cdp.ruf("Emulation.setDefaultBackgroundColorOverride",
|
|
{ color: { r: 0, g: 0, b: 0, a: 0 } });
|
|
|
|
const laden = async adresse => {
|
|
await cdp.ruf("Page.navigate", { url: adresse });
|
|
await cdp.einmal("Page.loadEventFired");
|
|
await werte(cdp, "window.KOMP_BEREIT");
|
|
};
|
|
|
|
await laden(BASIS + "?k=");
|
|
const liste = (await werte(cdp, "window.KOMP_LISTE")).filter(k => passt(k.key));
|
|
if (!liste.length) throw new Error("kein Schluessel passt auf: " + filter.join(", "));
|
|
|
|
const pngDir = path.join(HIER, "png");
|
|
const seqDir = path.join(HIER, "sequenz");
|
|
await mkdir(pngDir, { recursive: true });
|
|
await mkdir(seqDir, { recursive: true });
|
|
|
|
let dateien = 0, bytes = 0;
|
|
const bericht = [];
|
|
|
|
for (const k of liste) {
|
|
// Die Buehne gross genug stellen, sonst schneidet der Viewport ab, bevor
|
|
// ueberhaupt gemessen werden kann.
|
|
await cdp.ruf("Emulation.setDeviceMetricsOverride", {
|
|
width: k.w, height: k.h, deviceScaleFactor: 1, mobile: false,
|
|
});
|
|
await laden(`${BASIS}?k=${encodeURIComponent(k.key)}&t=1`);
|
|
|
|
const kasten = await werte(cdp, "window.KOMP_KASTEN()");
|
|
|
|
// Sequenzen. Ein bis der Kasten steht, aus rueckwaerts und knapper.
|
|
const einN = k.bilder;
|
|
const ausN = Math.max(4, Math.round(k.bilder * AUS_ANTEIL));
|
|
let gehalten = null;
|
|
for (const [name, anzahl, richtung] of [["ein", einN, 1], ["aus", ausN, -1]]) {
|
|
const ordner = path.join(seqDir, k.key + "-" + name);
|
|
await rm(ordner, { recursive: true, force: true });
|
|
await mkdir(ordner, { recursive: true });
|
|
for (let i = 0; i < anzahl; i++) {
|
|
const p = i / (anzahl - 1);
|
|
await werte(cdp, "window.setT(" + (richtung > 0 ? p : 1 - p) + ")");
|
|
const bild = await aufnehmen(cdp, kasten);
|
|
if (richtung > 0 && i === anzahl - 1) gehalten = bild;
|
|
await writeFile(path.join(ordner, `${k.key}-${name}_${String(i).padStart(4, "0")}.png`), bild);
|
|
dateien++; bytes += bild.length;
|
|
}
|
|
}
|
|
|
|
/* Das Standbild ist das letzte Bild der Einblendung, nicht eine eigene
|
|
Aufnahme. Der ganze Dreiteiler im Schnitt haengt daran, dass -ein, das
|
|
Standbild und -aus an den Stoessen dasselbe Bild zeigen; als zweite
|
|
Aufnahme war das bisher nur wahr, weil es zufaellig stimmte.
|
|
|
|
Beim Wabenfeld stimmte es dann nicht mehr. Dort tragen die Zellen
|
|
waehrend der Bewegung ein transform-Attribut, das am Ende wieder
|
|
weggenommen wird -- danach rastert Chrome eine Spur anders als bei einem
|
|
Element, das nie eines hatte. Der Unterschied lag bei einer Stufe auf
|
|
verstreuten Pixeln, also unsichtbar, aber die Zusage war weg. So wird
|
|
sie eingehalten, statt geprueft: es ist dieselbe Datei. */
|
|
await writeFile(path.join(pngDir, k.key + ".png"), gehalten);
|
|
dateien++; bytes += gehalten.length;
|
|
const zeile = `${k.key.padEnd(24)} ${String(kasten.w * FAKTOR).padStart(5)} x ` +
|
|
`${String(kasten.h * FAKTOR).padEnd(5)} still + ${einN} ein + ${ausN} aus`;
|
|
console.log(" " + zeile);
|
|
bericht.push({ key: k.key, name: k.name, w: kasten.w * FAKTOR, h: kasten.h * FAKTOR,
|
|
ein: einN, aus: ausN });
|
|
}
|
|
|
|
/* Der Teilbau darf die Liste nicht auf sich selbst eindampfen: wer nur die
|
|
Bauchbinde neu baut, will danach trotzdem alle dreizehn darin finden.
|
|
Also wird zusammengefuehrt statt ueberschrieben, in der Reihenfolge der
|
|
Vorlage -- die bestimmt, was zusammengehoert, nicht der Zufall des
|
|
letzten Aufrufs. */
|
|
const vorher = JSON.parse(
|
|
await readFile(path.join(HIER, "liste.json"), "utf8").catch(() => '{"komponenten":[]}'));
|
|
const nachKey = new Map(vorher.komponenten.map(k => [k.key, k]));
|
|
bericht.forEach(k => nachKey.set(k.key, k));
|
|
const alle = (await werte(cdp, "window.KOMP_LISTE")).map(k => nachKey.get(k.key)).filter(Boolean);
|
|
await writeFile(path.join(HIER, "liste.json"),
|
|
JSON.stringify({ faktor: FAKTOR, komponenten: alle }, null, 1) + "\n");
|
|
|
|
console.log(`\n${liste.length} Komponenten, ${dateien} Dateien, ` +
|
|
`${(bytes / 1024 / 1024).toFixed(1)} MB`);
|
|
} finally {
|
|
if (cdp) cdp.zu();
|
|
chrome.kill();
|
|
/* Erst warten, bis Chrome wirklich weg ist. Ein Kill ist eine Bitte, kein
|
|
Vollzug: beim Herunterfahren schreibt der Browser noch in sein Profil,
|
|
und ein rm davor liess den Ordner wieder auferstehen. */
|
|
await new Promise(ok => chrome.once("exit", ok));
|
|
// Das Profil ist Wegwerfware und hat im Repo nichts verloren.
|
|
await rm(path.join(HIER, ".chrome"), { recursive: true, force: true });
|
|
}
|