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>
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-25 17:58:29 +02:00
1 parent ce87a7fb57
commit c7c05a85cc
14 files changed
+3295 -247

No files matched your search

+364 -3
View File
@@ -7,6 +7,11 @@
Bedienoberflaeche kurz auf. Deshalb hier oben und nicht im Hauptskript. */
if (location.search.indexOf("nur=streifen") >= 0)
document.documentElement.dataset.nur = "streifen";
/* Rendermodus fuer den Videobau: die Seite zeigt dann allein das Blatt der
Animation, in natuerlicher Groesse an 0,0. animation.mjs stellt das Fenster
auf 1080 x 1350 und nimmt es ohne Ausschnittsrechnung auf. */
if (location.search.indexOf("nur=anim") >= 0)
document.documentElement.dataset.nur = "anim";
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -468,6 +473,89 @@ if (location.search.indexOf("nur=streifen") >= 0)
schliessende Klammer, und danach im Browser nachsehen, ob
document.styleSheets noch alle Regeln kennt. */
/* Die Buehne war bisher eine einzige Flaeche, auf der Scroller und
Fussleiste uebereinander lagen. Jetzt sind es zwei Zeilen: oben die
Flaeche mit dem Streifen, darunter die Animation -- im selben Fenster,
unter dem Canvas. Das Umhaengen macht animLeisteBauen(), so bleibt der
HTML-Rumpf jeder Datei unveraendert. */
.stage { display: flex; flex-direction: column; }
#stageflaeche { position: relative; flex: 1 1 auto; min-height: 0; }
html[data-nur="streifen"] .stage { display: block; }
html[data-nur="streifen"] #stageflaeche { position: static; }
/* Farben wie der Rest der Oberflaeche: Panelgrund #17181F, Trennlinien
#24262F, Bedienfelder #1D1F26 mit Rand #2E313B. Hier standen einmal
Variablennamen, die es in dieser Datei nie gab -- eine ungueltige var()
ohne Ersatzwert laesst die ganze Angabe fallen, deshalb fehlten Rahmen
und Grund, ohne dass irgendwo ein Fehler auftauchte. */
#animleiste {
flex: 0 0 auto; border-top: 1px solid #24262F;
background: #14161C; padding: 13px 20px 16px;
max-height: 42vh; overflow-y: auto;
}
.animkopf { display: flex; align-items: baseline; gap: 10px; margin-bottom: 11px; }
.animkopf b {
font-size: 11px; font-weight: 700; letter-spacing: .09em;
text-transform: uppercase; color: #E8EAEE;
}
.animkopf span { font-size: 11.5px; color: #6E7480; }
.animreihe { display: flex; gap: 14px; flex-wrap: wrap; }
.animkarte {
display: flex; gap: 14px; align-items: stretch;
border: 1px solid #2E313B; border-radius: 10px;
padding: 12px; background: #1D1F26;
}
/* Die Vorschau ist das echte Blatt, nur kleiner gestellt. Kein Nachbau:
was hier laeuft, ist Zeichen fuer Zeichen dasselbe, was animation.mjs
aufnimmt. --animskala bestimmt, wie gross. */
.animbuehne {
--animskala: .215;
flex: none; position: relative; overflow: hidden; border-radius: 6px;
width: calc(1080px * var(--animskala));
height: calc(1350px * var(--animskala));
box-shadow: 0 0 0 1px #2E313B;
}
.animbuehne > .slide {
position: absolute; left: 0; top: 0;
transform: scale(var(--animskala)); transform-origin: top left;
}
.animrechts {
display: flex; flex-direction: column; gap: 7px;
min-width: 268px; max-width: 340px; align-self: start;
}
.animtitel { font-size: 12.5px; font-weight: 700; color: #E8EAEE; }
.animwerte { font-size: 11.5px; color: #6E7480; }
.animschieber { width: 100%; accent-color: #F1B729; margin: 3px 0 1px; }
.animknoepfe { display: flex; align-items: center; gap: 8px; }
.animknoepfe button {
font-family: inherit; font-size: 12px; font-weight: 700; cursor: pointer;
background: #262933; color: #C7CCD6; border: 1px solid #383C48;
border-radius: 7px; padding: 6px 13px;
}
.animknoepfe button:hover:not(:disabled) { background: #30343F; color: #E8EAEE; }
.animknoepfe button:disabled { opacity: .55; cursor: default; }
.animzeile {
font-size: 10.5px; line-height: 1.5; color: #6E7480; word-break: break-all;
}
.animzeile[data-ton="ok"] { color: #7BC48A; }
.animzeile[data-ton="err"] { color: #E5806E; }
.animzeile code, .animhin code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.animhin { font-size: 11.5px; line-height: 1.55; color: #6E7480; max-width: 68ch; }
.animhin code { font-size: 11px; color: #C7CCD6; }
/* ─── Rendermodus fuer den Videobau ──────────────────────────────────────
Die Seite zeigt allein das Blatt der Animation, an 0,0, in natuerlicher
Groesse. animation.mjs stellt das Fenster auf 1080 x 1350 und nimmt es
ohne Ausschnittsrechnung auf. */
html[data-nur="anim"], html[data-nur="anim"] body {
height: auto; min-height: 0; overflow: hidden;
background: #fff; display: block; padding: 0; margin: 0;
}
/* ─── Bearbeitung im Canvas ───────────────────────────────────────────────
Verschieben läuft über --ex/--ey, also über transform statt über den
Textfluss: So verschiebt ein Element nie seine Nachbarn und lässt sich
@@ -914,6 +1002,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
html[data-nur="streifen"] .menubar,
html[data-nur="streifen"] .panel,
html[data-nur="streifen"] .hud,
html[data-nur="streifen"] #animleiste,
html[data-nur="streifen"] .guides { display: none !important; }
/* Der Auswahlrahmen ist eine Bedienhilfe und hat in der Uebersicht nichts
verloren -- dort ist keine Slide "die aktuelle". */
@@ -1003,7 +1092,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
<div class="arbeitsflaeche">
<aside class="panel">
<h1>Post 2 · Alternative</h1>
<h1>Post 4 · Workflow-Engine</h1>
<p class="sub"><span id="kopfzeile">Carousel · 1080 × 1350 px</span><span class="saved" id="saved"></span><br><button class="forget" id="forget">Projekt zurücksetzen</button></p>
<div class="group">
@@ -2643,6 +2732,40 @@ if (location.search.indexOf("nur=streifen") >= 0)
}
/* ═══ Animationen ════════════════════════════════════════════════════════
Sie stehen mit Absicht nicht in SLIDES. Eine Animation ist kein Standbild
im Streifen: sie wird einzeln gebaut, einzeln exportiert und geht als
eigenes Element ins Carousel -- oder eben nicht mit, wenn nur die Bilder
gepostet werden. Der Bildexport laesst sie deshalb unberuehrt.
Aufbau sonst gleich wie eine Slide: dieselben Felder, dieselbe
Zeichenfunktion aus VIS, dasselbe Blatt in 1080 x 1350. Der Unterschied
ist `dauer` und der zweite Wert t, den die Zeichenfunktion bekommt. */
const ANIMS = [];
/* ═══ Zeitachse ══════════════════════════════════════════════════════════
Eine Slide mit `dauer` ist animiert. Ihre Zeichenfunktion bekommt dann
einen zweiten Wert: den Fortschritt t zwischen 0 und 1. Sonst aendert
sich nichts -- dieselbe Ueberschrift, derselbe Punktgrund, dieselbe
Schutzzone. Ein animiertes Bild ist hier eine Slide, die sich bewegt,
und kein zweites Format.
ANIM_T haelt je Slide den aktuellen Stand. Das Bauskript setzt ihn ueber
window.animSetzen() und schiesst dann ein Bild ab; die Vorschau in der
Seite laesst ihn ueber requestAnimationFrame laufen. Beide gehen durch
dieselbe Tuer, damit die Vorschau zeigt, was hinterher herauskommt. */
const ANIM_T = {};
const animT = j => ANIM_T[j] || 0;
/* Ein paar Verlaeufe. Linear sieht nach Maschine aus, deshalb sind Ein-
und Ausgang gedaempft. `stufe` blendet einen Abschnitt zwischen zwei
Zeitpunkten ein und liefert 0 davor und 1 danach. */
const daempfen = x => x <= 0 ? 0 : x >= 1 ? 1 : x < .5
? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
const rueck = x => x <= 0 ? 0 : x >= 1 ? 1
: 1 + 2.7 * Math.pow(x - 1, 3) + 1.7 * Math.pow(x - 1, 2);
const stufe = (t, von, bis) => t <= von ? 0 : t >= bis ? 1 : (t - von) / (bis - von);
const VIS = {
aufbau: () => visAufbau(),
ausloeser: () => visKatalog("ausloeser"),
@@ -3457,7 +3580,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
v.style.cssText =
"--vis-w:" + (sl.visW ?? 900) + "px;--vis-left:" + (sl.visLeft ?? 92) +
"px;--vis-bottom:" + (sl.visBottom ?? 96) + "px";
v.innerHTML = VIS[sl.vis](j);
v.innerHTML = VIS[sl.vis](j, animT(j));
page.appendChild(v);
}
@@ -3526,6 +3649,242 @@ if (location.search.indexOf("nur=streifen") >= 0)
return page;
}
/* ═══ Animation: Steuerung ════════════════════════════════════════════════
Eine Tuer fuer beide Wege. Das Bauskript setzt den Fortschritt und
schiesst ab, die Vorschau in der Seite laesst ihn laufen -- was hier
durchgeht, ist hinterher im Video zu sehen.
Neu gezeichnet wird nur die Zeichenflaeche, nicht das ganze Blatt: bei
150 Bildern macht das den Unterschied zwischen Minuten und einer
Viertelstunde. */
/* Das Blatt einer Animation. Es entsteht wie eine Slide, nur ohne Streifen,
ohne Nachbarn und ohne Bearbeiten -- eine Animation wird nicht im Canvas
geschoben, sie ist ein eigenes Stueck. Hintergrund, Variante und Muster
kommen von der ersten Slide, damit sie im Carousel dazugehoert. */
function animSlideBauen(i) {
const a = ANIMS[i];
const el = document.createElement("div");
el.className = "slide";
el.dataset.animi = String(i);
el.dataset.variant = variant;
el.dataset.tilt = "gerade";
el.dataset.bg = (bgState[0] || {}).bg || "formen";
el.dataset.int = (bgState[0] || {}).int || "dezent";
el.dataset.layout = "standard";
el.dataset.tagout = tagOut;
el.dataset.raster = rasterAn;
el.dataset.mitte = textMitte;
el.dataset.frame = frameStyle;
el.dataset.editmode = "aus";
el.style.setProperty("--slide-i", 0);
el.innerHTML = bgHtml();
const sheet = document.createElement("div");
sheet.className = "sheet";
sheet.style.setProperty("--sheet-w", "1080px");
const page = document.createElement("div");
page.className = "page";
page.dataset.layout = "standard";
page.style.left = "0px";
const content = document.createElement("div");
content.className = "content";
content.innerHTML = contentHtml(a, 0);
page.appendChild(content);
const v = document.createElement("div");
v.className = "vis vis-" + a.vis;
v.style.cssText =
"--vis-w:" + (a.visW ?? 900) + "px;--vis-left:" + (a.visLeft ?? 92) +
"px;--vis-bottom:" + (a.visBottom ?? 96) + "px";
v.innerHTML = VIS[a.vis](i, animT(i));
page.appendChild(v);
sheet.appendChild(page);
el.appendChild(sheet);
return el;
}
function animZeichnen(i) {
const a = ANIMS[i];
if (!a || !VIS[a.vis]) return;
document.querySelectorAll('.slide[data-animi="' + i + '"] .vis')
.forEach(v => { v.innerHTML = VIS[a.vis](i, animT(i)); });
}
window.ANIM_LISTE = () => ANIMS.map((a, i) =>
({ i, key: a.key, dauer: a.dauer || 0, titel: a.eyebrow || a.key }));
window.animSetzen = (i, t) => {
ANIM_T[i] = Math.max(0, Math.min(1, t));
animZeichnen(i);
const s = document.querySelector('input[data-animschieber="' + i + '"]');
if (s && document.activeElement !== s) s.value = String(Math.round(ANIM_T[i] * 1000));
return true;
};
/* Die Vorschau laeuft in derselben Geschwindigkeit wie das spaetere Video
und wiederholt sich, damit man sieht, ob der Stoss am Ende sitzt -- im
Carousel laeuft so ein Element in Schleife. */
let animLaeuft = null;
function animAbspielen(i, knopf) {
if (animLaeuft) {
cancelAnimationFrame(animLaeuft.rahmen);
const alt = animLaeuft.knopf;
animLaeuft = null;
if (alt) alt.textContent = "Abspielen";
if (alt === knopf) return;
}
const dauer = (ANIMS[i].dauer || 4) * 1000;
const start = performance.now() - ANIM_T[i] * dauer;
knopf.textContent = "Anhalten";
const schritt = jetzt => {
window.animSetzen(i, ((jetzt - start) % dauer) / dauer);
animLaeuft = { rahmen: requestAnimationFrame(schritt), knopf };
};
animLaeuft = { rahmen: requestAnimationFrame(schritt), knopf };
}
/* Der Export. Ein Browser kann kein MP4 schreiben, also stoesst die Seite
den Bau auf dem Server an und fragt danach den Stand ab. Bewusst getrennt
vom Bildexport: die Animation ist ein eigenes Element im Carousel und
soll auch einzeln herauskommen. */
async function animExport(i, knopf, zeile) {
const a = ANIMS[i];
knopf.disabled = true;
const alt = knopf.textContent;
knopf.textContent = "baut …";
zeile.textContent = "Chrome startet …";
zeile.dataset.ton = "";
try {
const an = await fetch("/anim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projekt: PROJECT, key: a.key }),
});
if (!an.ok) throw new Error("HTTP " + an.status + " — läuft serve.py?");
const { id } = await an.json();
for (;;) {
await new Promise(r => setTimeout(r, 1200));
const st = await (await fetch("/anim?id=" + encodeURIComponent(id))).json();
if (st.zeile) zeile.textContent = st.zeile;
if (st.laeuft) continue;
if (st.fehler) throw new Error(st.fehler);
zeile.textContent = st.pfad + " · " + st.groesse;
zeile.dataset.ton = "ok";
break;
}
} catch (e) {
zeile.textContent = String(e.message || e);
zeile.dataset.ton = "err";
} finally {
knopf.disabled = false;
knopf.textContent = alt;
}
}
/* Der Bereich unter dem Canvas. Er zeigt jede Animation als eigenes Blatt
in Vorschau, nicht als Eintrag in einer Liste: was hier laeuft, ist
dasselbe Blatt, das hinterher im Video steht. */
function animLeisteBauen() {
const liste = window.ANIM_LISTE();
let leiste = document.getElementById("animleiste");
if (!leiste) {
/* Scroller und Fussleiste liegen absolut in der Buehne. Ein Geschwister
danach landete deshalb oben im Fluss und lag ueber dem Streifen. Also
wandern beide in eine eigene Flaeche, und die Leiste kommt als zweite
Zeile darunter -- im selben Fenster, unter dem Canvas. */
const buehne = document.getElementById("stage");
if (buehne && !document.getElementById("stageflaeche")) {
const flaeche = document.createElement("div");
flaeche.id = "stageflaeche";
while (buehne.firstChild) flaeche.appendChild(buehne.firstChild);
buehne.appendChild(flaeche);
}
leiste = document.createElement("section");
leiste.id = "animleiste";
(buehne || document.body).appendChild(leiste);
}
const kopf = document.createElement("div");
kopf.className = "animkopf";
kopf.innerHTML = "<b>Animation</b><span>" +
(liste.length
? liste.length + (liste.length === 1 ? " Element" : " Elemente") +
" &middot; 1080 &times; 1350 &middot; 30 fps &middot; MP4, getrennt vom Bildexport"
: "noch keine in diesem Projekt") + "</span>";
leiste.innerHTML = "";
leiste.appendChild(kopf);
if (!liste.length) {
leiste.insertAdjacentHTML("beforeend",
'<p class="animhin">Eine Animation entsteht als Eintrag in <code>ANIMS</code>: ' +
'dieselben Felder wie eine Slide, dazu <code>dauer</code> in Sekunden. Ihre ' +
'Zeichenfunktion bekommt einen zweiten Wert <code>t</code> zwischen 0 und 1 und ' +
'zeichnet daraus den Stand. Sie liegt bewusst neben <code>SLIDES</code> und ' +
'nicht darin: der Bildexport lässt sie in Ruhe.</p>');
return;
}
const reihe = document.createElement("div");
reihe.className = "animreihe";
liste.forEach(a => {
const karte = document.createElement("article");
karte.className = "animkarte";
const buehne = document.createElement("div");
buehne.className = "animbuehne";
buehne.appendChild(animSlideBauen(a.i));
karte.appendChild(buehne);
const rechts = document.createElement("div");
rechts.className = "animrechts";
rechts.innerHTML =
'<div class="animtitel">' + a.titel + '</div>' +
'<div class="animwerte">' + a.dauer.toFixed(1) + " s &middot; " +
Math.round(a.dauer * 30) + " Bilder &middot; 1080 &times; 1350</div>" +
'<input class="animschieber" type="range" min="0" max="1000" value="0" ' +
'data-animschieber="' + a.i + '" aria-label="Zeitpunkt">' +
'<div class="animknoepfe">' +
'<button type="button" class="animspiel">Abspielen</button>' +
'<button type="button" class="animexport">MP4 exportieren</button>' +
"</div>" +
'<p class="animzeile"><code>node animation.mjs ' + PROJECT + ".html " +
a.key + "</code></p>";
karte.appendChild(rechts);
reihe.appendChild(karte);
const zeile = rechts.querySelector(".animzeile");
rechts.querySelector(".animspiel")
.addEventListener("click", e => animAbspielen(a.i, e.currentTarget));
rechts.querySelector(".animexport")
.addEventListener("click", e => animExport(a.i, e.currentTarget, zeile));
rechts.querySelector(".animschieber").addEventListener("input", e => {
if (animLaeuft) {
cancelAnimationFrame(animLaeuft.rahmen);
if (animLaeuft.knopf) animLaeuft.knopf.textContent = "Abspielen";
animLaeuft = null;
}
window.animSetzen(a.i, +e.currentTarget.value / 1000);
});
});
leiste.appendChild(reihe);
}
/* Der Rendermodus fuer animation.mjs: alles weg bis auf das eine Blatt.
Aufgebaut wird es erst hier, nach dem Laden von Schrift und Marken --
sonst misst das Bauskript eine halbfertige Seite. */
function animNurModus() {
if (document.documentElement.dataset.nur !== "anim") return;
const key = new URLSearchParams(location.search).get("k");
const i = ANIMS.findIndex(a => a.key === key);
document.body.innerHTML = "";
if (i < 0) return;
document.body.appendChild(animSlideBauen(i));
}
function renderPage(page, j) {
page.querySelector(".content").innerHTML = contentHtml(SLIDES[j], j);
if (SLIDES[j].type === "title") page.classList.add("title");
@@ -3537,7 +3896,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
dadurch wirkungslos geblieben. Der Zufallskeim ist gesetzt, das erneute
Zeichnen liefert also denselben Strich. */
const vv = page.querySelector(".vis");
if (vv && SLIDES[j].vis && VIS[SLIDES[j].vis]) vv.innerHTML = VIS[SLIDES[j].vis](j);
if (vv && SLIDES[j].vis && VIS[SLIDES[j].vis]) vv.innerHTML = VIS[SLIDES[j].vis](j, animT(j));
tagEditable(page);
applyEdits(page, j);
}
@@ -5384,6 +5743,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
press("views", "w", view);
anzahlNachziehen();
show(current);
animLeisteBauen();
if (verloren.length)
setStatus("Fehlende Bilder entfernt: " + verloren.join(", ") +
" · bitte neu importieren", "err");
@@ -5393,6 +5753,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
["dl-png", "dl-all"].forEach(id => document.getElementById(id).disabled = false);
if (checkCollisions()) setStatus("Bereit. " + SLIDES.length +
(SLIDES.length === 1 ? " Slide" : " Slides") + ", 1080 × 1350 px", "ok");
animNurModus();
} catch (err) {
console.error(err);
setStatus("Fehler beim Laden: " + err.message, "err");