diff --git a/POST-6.md b/POST-6.md
index 4eceda1..362e453 100644
--- a/POST-6.md
+++ b/POST-6.md
@@ -8,21 +8,35 @@ Hell, Theme `modern`, Punktgrund, Text zentriert — dieselbe Anlage wie Post 4
und 5. Datei `post6.html`, Stand in `state/post6.json`, erreichbar über das
Projektmenü jeder Seite und über `uebersicht.html`.
-**Fünf Slides.** DSGVO und deutsche Server hätten einen sechsten Punkt
-ergeben, dafür gibt es aber keinen Bildschirm. Der steht in der
+**Fünf Slides, dazu ein Bewegtbild.** DSGVO und deutsche Server hätten einen
+weiteren Punkt ergeben, dafür gibt es aber keinen Bildschirm. Der steht in der
Bildunterschrift, wo eine Aussage ohne Bild hingehört.
+Dazu kommt die erste Animation im Repo. Sie ist **keine Slide**: sie steht in
+`ANIMS` statt in `SLIDES`, liegt nicht im Streifen und wird vom Bildexport
+nicht angefasst. Im Carousel ist sie ein eigenes Element, das mitkommen kann
+oder eben nicht. Bedient wird sie im Bereich unter dem Canvas, gebaut mit
+einem eigenen Knopf. Wie das funktioniert, steht im
+[README unter „Animation"](./README.md#animation); was diese eine zeigt, in
+§7 weiter unten.
+
---
-## 1. Die vier Slides
+## 1. Die fünf Slides und das Bewegtbild
| # | Tag | Überschrift | Was zu sehen ist |
|---|---|---|---|
| 1 | Team-Chat | Büro und Baustelle, **endlich im selben Chat.** | Kanalliste und Verlauf nebeneinander, mit Filterzeile und Eingabefeld |
-| 2 | Die Kanäle | Ein Kanal **pro Projekt.** | Die Chatliste groß: Projektkanäle, Direktnachrichten, Gruppe |
+| 2 | Die Kanäle | Ein Kanal **pro Projekt.** | Links das Durcheinander als Zeichnung, rechts die Kanalliste; die Leitung beginnt schon auf Slide 1 |
| 3 | Die Aufgabe | Foto rein, **Aufgabe raus.** | Baustellenfotos, die Aufgabe daraus, die Bestätigung im Projekt |
-| 4 | Das Pluszeichen | Nicht nur **Text.** | Das Menü hinter dem Plus im Eingabefeld |
-| 5 | Bald verfügbar | Der Chat für unterwegs **wird gerade gebaut.** | Das Handy-Mockup der Website, verwischt, mit Absperrband |
+| 4 | Das Pluszeichen | Nicht nur **Text.** | Das Menü hinter dem Plus, auf dem Stapel |
+| 5 | Bald verfügbar | Der Chat für unterwegs **wird gerade gebaut.** | Zwei Ausschnitte der Oberfläche, versetzt übereinander, mit dem Absperrband darüber |
+
+Dazu, außerhalb des Streifens:
+
+| Schlüssel | Tag | Überschrift | Was zu sehen ist |
+|---|---|---|---|
+| `animChat` | Bewegtbild | Getippt, **und schon im Projekt.** | 5 s Animation: die Nachricht wird geschrieben, abgeschickt und landet mit ihrer Aufgabe im Verlauf |
**Slide 3 ist der Kern.** Der Redaktionsplan verlangt „Foto rein, Aufgabe
raus", und genau das liegt im Werkzeug als Abfolge vor: Tobias schickt die
@@ -95,6 +109,11 @@ dem Gewerk: Wärmepumpe Müller, Montagetermin, Baustellenfotos.
## 4. Wo bewusst abgewichen wird
+**Slide 4 zeigt das Menü ohne das Eingabefeld, an dem es hängt.** Das Feld
+stand einmal darunter, damit man sieht, woher das Menü kommt. Es ist weg: die
+Slide zeigt, was man anhängen kann, und dafür braucht es das Feld nicht. Der
+Stapel sagt ohnehin, dass hier ein Bildschirm liegt und kein Kasten im Nichts.
+
**Slide 1 zeigt drei von sechs Nachrichten, Slide 2 fünf von acht Kanälen.**
Was nicht ins Bild passt, fällt weg; ein Chat und eine Liste scrollen beide.
Slide 2 behält alle drei Sorten: Projektkanal, Direktnachricht, Gruppe.
@@ -111,50 +130,64 @@ Beitrag zu setzen, der von der eigenen Baustelle spricht, wäre eine Behauptung
| | |
|---|---|
-| Slides | 5 |
+| Slides | 5, dazu 1 Animation außerhalb des Streifens |
| Format | 1080 × 1350, Ausgabe zweifach: 2160 × 2700 |
-| Luft zwischen Text und Zeichnung | 116 / 79 / 190 / 141 / 77 px (Grenze 40) |
+| Luft zwischen Text und Zeichnung | 118 / 170 / 153 / 155 / 55 px, Animation 84 (Grenze 40) |
| Zeichnung ragt über die Seite | nein, alle innerhalb der 92-px-Schutzzone |
| Breiteste Überschriftzeile | 818 px von 896 |
-| Stylesheet vollständig | 214 Regeln, `[data-edit]` vorhanden |
| Konsole | keine Fehler |
-| Ausgabeweg | geprüft, `01-chat-hell.png` bis `05-handy-hell.png` |
+| Überschneidungen | keine, `kollision.mjs` über alle Slides |
+| Ausgabeweg | geprüft, `01-chat-hell.png` bis `05-mobil-hell.png` |
+| Bewegtbild | `animation/post6-animChat.mp4`, ffprobe siehe §7 |
Geprüft wird mit den Hilfsskripten aus dem Sitzungsordner: `pruef.mjs` misst
über `getBBox()` die tatsächlich gezeichnete Fläche je Slide und die Breite
jeder Überschriftzeile, zählt die Regeln im Stylesheet und liest die Konsole
mit; `schuss.mjs` rendert einzelne Slides; `export.mjs` fährt den echten
-Ausgabeweg und fängt `/save` ab.
+Ausgabeweg und fängt `/save` ab; `leiste.mjs` prüft in jedem Projekt, ob die
+Animationsleiste an der richtigen Stelle steht und ob die Seite fehlerfrei
+anläuft.
---
## 5a. Slide 5 kommt von der Website, nicht von mir
-**Das Gerät ist eine bewusste Abweichung.** Die Website zeichnet nur einen
-hellen Umriss (`border: 4px solid #D1D5DB`). Auf einer Slide, die selbst hell
-ist, fällt der in sich zusammen und sieht nach Platzhalter aus. Hier steht
-deshalb ein richtiges Gerät: dunkler Korpus, heller Grat, Schirm eingelassen,
-Tasten an der Seite. Die Abweichung betrifft den Rahmen, nicht den Inhalt. Der
-Schirm wird auch schwächer verwischt als dort: auf der Website liegt das Handy
-groß neben Fließtext und darf unlesbar sein, hier ist es klein, und bei voller
-Stärke blieb nur Brei.
+**Kein Gerät.** Vorbild ist die Machart, die Signal für genau solche Slides
+benutzt: zwei Ausschnitte der Oberfläche, versetzt übereinander, jeder
+mit eigenem Schatten. Man sieht zwei Zustände nebeneinander, ohne dass ein
+Telefonrahmen die halbe Fläche frisst.
-**Die App gibt es noch nicht, und die Website sagt das bereits.** In
+Ein Zwischenstand hatte hier ein nachgebautes Handy samt Insel und Tasten,
+ganz verwischt. Das kostet viel Platz für etwas, das nichts über das Produkt
+sagt, und der Rahmen war ohnehin frei erfunden. Die Unschärfe ist damit auch
+weg: das Band sagt deutlich genug, dass es die App noch nicht gibt, und die
+Ausschnitte sollen lesbar sein.
+
+Links die Kanalliste von Slide 2, rechts der Verlauf von Slide 1, beide schmal
+gesetzt und am Rand angeschnitten. Gezeigt wird also nichts, was nicht vorher
+schon zu sehen war. Beide sind **leicht verwischt** (`stdDeviation 2.2`):
+erkennbar, aber nicht zu lesen. Scharf bleibt allein das Band, und genau das
+soll es auch sein.
+
+**Das Absperrband kommt von der Website.** In
`components/sections/ChatDemoSection.tsx` steht unter der Fahne `comingSoon`
-genau dieser Zustand: ein Handy-Mockup, das mit `blur(3px) saturate(0.92)`
-verwischt wird, und darüber ein **Absperrband** aus dem Bau, gedreht um
-**-13°**, in `--accent-yellow` `#F1B729` mit Kanten in `#171106`, darauf immer
-wieder Schutzhelm und **BALD VERFÜGBAR**.
+genau dieses Element: ein **Absperrband** aus dem Bau, gedreht um **-13°**, in
+`--accent-yellow` `#F1B729` mit Kanten in `#171106`, darauf immer wieder
+Schutzhelm und **BALD VERFÜGBAR**. Es liegt über beiden Ausschnitten und ragt
+beidseitig hinaus: es sperrt die ganze Sache ab, nicht eine Fläche darin.
Ein Zwischenstand hatte hier einen selbst erfundenen gelben Aufkleber
„In Arbeit". Das war eine eigene Bildsprache neben einer vorhandenen.
Übernommen ist jetzt die der Website, samt Winkel, Farben und Wortlaut.
-Auch der Inhalt des Schirms kommt von dort und **nicht** aus dem Pitch-Paket:
-Heizungsprojekt Schulz, „4 Mitglieder · 2 online", die vier Nachrichten aus
-`seedMessages`, die Blasenfarben `#0D6E6E` für eigene und `#FFFFFF` mit Rand
-`#E9E7E5` für fremde, der warme Grund `hsl(30 17% 95.5%)`. Auf der Website
-steht dieses Handy für den mobilen Chat, also zeigt es hier dasselbe.
+**Vom Absperrband abgesehen kommt der Schirm aus dem Pitch-Paket.** Eine
+Zwischenfassung zeigte dort den Chat der Website (Heizungsprojekt Schulz,
+`seedMessages`, Blasen in `#0D6E6E`). Der ist echt, gehört aber zu einer
+anderen Oberfläche, und der Beitrag zeigt sonst durchgehend das Werkzeug.
+Jetzt steht auf dem Schirm derselbe Verlauf wie auf Slide 1 und 3, gezeichnet
+mit derselben `blase()`, nur schmaler. Man sieht es kaum, weil das Gerät
+verwischt ist — das ist kein Grund, etwas anderes hinzulegen: was unter der
+Unschärfe liegt, soll stimmen.
Überschrift und Fließtext sind ebenfalls die der Website: „Der Chat für
unterwegs wird gerade gebaut." und „Die mobile App legen wir gerade auf die
@@ -163,6 +196,83 @@ in Kürze." Der Em-Dash der Vorlage ist durch ein Komma ersetzt, `rules.md` §4.
---
+## 5d. Slide 2 stellt gegenüber statt zu zeigen
+
+Links das Durcheinander als Zeichnung, rechts die Oberfläche als Ausschnitt,
+dazwischen ein Pfeil. Die Aussage steckt in der Gegenüberstellung und nicht in
+einem der beiden Teile — der Satz der Website ist genau diese Zweiteilung:
+„Schluss mit dem einen Verlauf, in dem alles durcheinandergeht. Ein Kanal pro
+Projekt."
+
+**Die linke Hälfte gibt es in diesem Repo schon**, in `post2.html` auf Slide 2
+(`visProzesse`): beschriftete Plättchen, die eine geschlängelte gestrichelte
+Linie verbindet. Dort ist sie im Zeichenblock-Stil mit schiefen Kanten, hier
+glatt — das Theme ist ein anderes, und dieselbe Form zweimal in zwei
+Anmutungen wäre schlechter als zwei Formen mit derselben Idee.
+
+Die fünf Plättchen (Angebot, Baustellenfoto, Termin, Rückfrage, Materialliste)
+sind **Redaktionstext und keine Produktstrings**. Sie beschreiben die
+Wirklichkeit im Betrieb, nicht die Oberfläche; dieselbe Lage wie bei
+„E-Mails" und „Zettel im Auto" in Post 2. Gewählt sind Wörter, die im Beitrag
+ohnehin vorkommen.
+
+**Die Zeichnung liegt über beiden Slides.** Zwei Plättchen — „Angebot" und
+„Termin" — stehen auf Slide 1, drei auf Slide 2, und die Leitung läuft
+hindurch. Möglich macht das `visLeft: -634`: die Zeichnung fängt vor der Seite
+an, und alles davor landet auf der Nachbarslide.
+
+**Kein Plättchen schneidet die Kante.** Die Reihenfolge in `DURCHEINANDER` ist
+danach gewählt: „Angebot" und „Termin" sind kurz genug, um ganz vor
+`SLIDE_KANTE` zu passen. Ein halbes Wort am Rand liest sich als Fehler, nicht
+als Fortsetzung.
+
+**Die beiden Plättchen liegen über der Kante des Chatfensters, nicht daneben.**
+Ein Zwischenstand schrumpfte das Fenster auf zwei Drittel, um rechts Platz zu
+schaffen — gemessen stand der Kanaltitel dann auf 10,8 px und die Projektnummer
+auf 8,3. So klein trägt die Titelslide nicht mehr, denn im Profilraster ist sie
+eine Kachel von rund 150 px. Jetzt bleibt das Fenster groß (15,3 px / 11,7 px),
+und die Plättchen hängen an seiner rechten Kante über. Ein weiterer
+Zwischenstand setzte sie mitten auf die Nachrichten; das las sich als
+Kollision. In den Vorbildern überlappen Flächen an den Kanten, nicht quer über
+Text — senkrecht sitzen sie deshalb dort, wo im Fenster am wenigsten steht. Das ist derselbe Überstand, vor dem
+`rules.md` §5 sonst warnt: `.page` hat kein `overflow`, also landet alles, was
+über die 1080 px hinausragt, auf der Nachbarslide. Hier ist es gewollt, und
+`pruef.mjs` weiß es: ein negativer `--vis-left` gilt ihm als Ansage und wird
+als „Überstand nach links (gewollt)" gemeldet statt als Fehler.
+
+**Kein Pfeil zwischen den Hälften.** Er stand einmal dort und machte die Sache
+zu ordentlich — eine Zeichnung, die Durcheinander zeigt, braucht keinen
+Wegweiser, und die Leitung läuft ohnehin nach rechts. Die Plättchen sind aus
+demselben Grund gestreut und leicht gekippt statt in einer geraden Spalte
+gesetzt. Die Werte dafür stehen fest im Code und sind nicht gewürfelt: ein
+Zufallsgenerator gäbe bei jedem Neubau ein anderes Bild, und die Slide soll
+zweimal gleich aussehen.
+
+**Was es kostet:** die Kanalliste steht jetzt in der halben Breite. Die
+Projektnummer misst dadurch 12,4 px statt 17,4. Sie ist lesbar, aber knapp —
+wer sie noch einmal verkleinert, nimmt der Slide ihre eigene Aussage.
+
+---
+
+## 5c. Der ausbrechende Rahmen auf Slide 3
+
+Aus den Slack-Beiträgen kommt noch ein zweites Mittel, das mehr trägt als die
+bloße Anordnung: **ein dicker, runder Rahmen, der über die Fläche hinausragt
+und auf genau eine Sache zeigt.** Bei Slack ist er violett und liegt über einem
+Handy; hier ist er gelb und liegt über der Anhangkarte der Aufgabe. Die Marke
+gibt den Ton vor, nicht das Vorbild.
+
+Er sitzt dort, weil diese Karte den Satz der Slide trägt: aus der Nachricht
+wird etwas mit Frist und Namen. Seitlich bricht er aus der Blase aus, oben und
+unten bleibt er knapp — rundum gleich viel Luft schnitt den Satz darüber und
+die Uhrzeit darunter mitten durch.
+
+Seine Lage kommt aus `blase()` selbst: die Funktion merkt sich, wo sie die
+Anhangkarte gezeichnet hat. Die Rechnung ein zweites Mal zu führen hätte
+geheißen, dass beide auseinanderlaufen, sobald sich an der Karte etwas ändert.
+
+---
+
## 5b. Woher die Tiefe kommt
Zwei Anläufe waren falsch, bevor es saß.
@@ -199,10 +309,9 @@ Er trägt zwei Aufgaben: er gibt Tiefe, die ein Schatten allein nicht schafft,
und er sagt, dass hinter der einen Ansicht weitere liegen. Die hinteren
Blätter sind deshalb **leer** — Andeutung, keine Kopie.
-Auf Slide 1 und 2 liegt er, weil dort ein Bildschirm zu sehen ist. Auf Slide 3
-nicht: ein Ausschnitt aus einer Unterhaltung ist kein Blatt, dort tragen die
-Blasen ihre eigene Erhebung. Slide 4 ist eine Überlagerung und trägt
-`--shadow-overlay`.
+Auf Slide 1, 2 und 4 liegt er, weil dort ein Bildschirm oder ein Menü zu sehen
+ist. Auf Slide 3 nicht: ein Ausschnitt aus einer Unterhaltung ist kein Blatt,
+dort tragen die Blasen ihre eigene Erhebung.
Dazu zwei Dinge aus der Quelle, die der Unterhaltung Struktur geben, ohne eine
Fläche zu behaupten: die Datumsmarke **Heute · 30.07.2026** über dem ersten
@@ -215,7 +324,7 @@ violett.** Im Original steht dort `bg-surface-neutral-primary` mit Rand und
---
-## 6. Fünf Fallen aus diesem Bau
+## 6. Vierzehn Fallen aus diesem Bau
**Die Listenzeile hat drei Zeilen, nicht zwei.** Der erste Anlauf setzte Titel
und Vorschau untereinander und schob Nadel und Ungelesen-Punkt rechts neben
@@ -224,6 +333,11 @@ Im Original steht sie als dritte Zeile: `P-2026-0042` links, die Nadel rechts.
Das war nicht nur ein Layoutfehler: der Fließtext behauptete „Jeder Kanal hängt
an seiner Projektnummer", und die Nummer war nirgends zu sehen.
+**Eine Anhangkarte auf eine Zeile zu zwingen kürzt Zitate.** In der schmalen
+Spalte auf Slide 5 stand „Materialliste prüfen · fälli…". Der Titel darf jetzt
+auf zwei Zeilen, und die Karte wächst mit; die Blasenhöhe rechnet das mit.
+Ebenso bricht die Systemzeile um, statt aus der Fläche zu laufen.
+
**Eine feste Obergrenze für die Blasenbreite hat ein Zitat gekürzt.** Für die
Titelslide musste sie klein sein, damit die Unterhaltung neben die Liste passt.
Auf Slide 3 stand deshalb „Materialliste prüfen · fälli…" statt
@@ -238,6 +352,26 @@ Nachgemessen sind es 818 px von 896 erlaubten: sie passt. Das Wort ist wieder
drin. Dafür gibt es jetzt `breite.mjs`, das eine Formulierung misst, bevor sie
in die Datei kommt.
+**Eine Verschiebung mitten in der Zeichenfunktion erfasst nur die Hälfte.**
+Die Zeile, die den Inhalt um `PAD` einrückt, stand mitten in `visChat()`.
+Alles, was danach gezeichnet wurde — Datumsmarke, Blasen, Tippzeile,
+Eingabefeld — lag um 26 Einheiten daneben, und die Kürzel schnitten die
+Trennlinie zwischen den Spalten. Auf der ganzen Slide sieht man das nicht, im
+Ausschnitt sofort. Die Verschiebung steht jetzt unmittelbar vor der Rückgabe.
+
+Dafür gibt es `kollision.mjs`: es nimmt jedes gezeichnete Element, rechnet
+seinen Kasten in Nutzereinheiten und meldet, was über den viewBox hinausragt
+oder die senkrechte Trennlinie schneidet. Die Stapelblätter sind ausgenommen,
+sie dürfen alles schneiden.
+
+**Ein Prüfskript, das in den falschen Einheiten rechnet, ist schlimmer als
+keines.** `kollision.mjs` benutzte zuerst `getCTM()`. Das enthält die
+Skalierung der viewBox auf die Anzeigebreite — die Maße kamen also in
+Bildschirmpixeln heraus und wurden gegen die viewBox in Nutzereinheiten
+geprüft. Auf Slide 1 fiel es nicht auf, weil der Faktor dort zufällig bei 0,93
+lag; auf Slide 2 meldete es 26 Fehler, die keine waren. Gerechnet wird jetzt
+über `svg.getScreenCTM().inverse().multiply(el.getScreenCTM())`.
+
**Gedrehte Blätter ragen aus dem Kasten.** Der Stapel dreht das hinterste
Blatt um 3,2° und verschiebt es um 3 % und 5,5 % der eigenen Größe. Die Ecken
stehen damit über das vordere Blatt hinaus, und die Zeichnung lag bis zu 24 px
@@ -246,6 +380,26 @@ geschätzt: `stapelRand()` schickt die vier Ecken durch dieselbe Kette wie das
Blatt und nimmt den größten Wert. `stapelKasten()` macht den Kasten
entsprechend größer.
+**Ein Maß, das aus einer Konstante kommt statt aus dem Gezeichneten, veraltet
+still.** Der Pfeil auf Slide 2 rechnete seine Lage aus einer Blockbreite. Als
+die Plättchen weiter nach rechts rückten, stimmte sie nicht mehr, und der Pfeil
+zerfiel in zwei Striche. Er leitet seine Lage jetzt aus den Plättchen selbst
+ab, so wie der Rahmen auf Slide 3 seine aus `blase()` bekommt.
+
+**Eine Sicherung, die zu spät greift, sichert den Fehler mit.** `export.mjs`
+schaltete die Slides mit der Pfeiltaste weiter und schrieb dabei im
+Bearbeiten-Modus `vis`-Versätze in `state/post6.json`. Die Gegenmaßnahme war,
+den Stand vor dem Lauf zu lesen und danach zurückzuschreiben — nur stand die
+Drift beim nächsten Lauf schon in der Datei und wurde mitgesichert. Jetzt
+werden die Slides über `#slides-nav` angeklickt statt durchgetastet; ohne
+Tasten gibt es das Problem gar nicht erst.
+
+**Ein gedrehtes Element wandert seitlich aus, wenn der Drehpunkt woanders
+liegt.** Das Band sitzt 24 Einheiten unter der Mitte der Zeichnung, gedreht
+wurde aber um die Mitte. Dadurch verschiebt sich sein Kasten um
+24·sin(13°) ≈ 5 Einheiten zur Seite und stand über dem Rand. Gedreht wird
+jetzt um die eigene Mitte des Bandes.
+
**`getBBox()` kennt keine Beschneidung.** Das Absperrband war zuerst auf die
175 % der Website gezogen und per `clip-path` am Gerät abgeschnitten. Im Bild
stimmte das, die Randmessung meldete aber einen Überstand von 146 px, den es
@@ -257,3 +411,60 @@ also `B / cos(13°)` plus Zugabe. Messung und Bild sagen wieder dasselbe.
einzelne Slide ein Fenster von 1400 px. Im durchgehenden Blatt liegt Seite 3
aber bei x = 2160, und `captureBeyondViewport` fing das nicht zuverlässig ab.
Die Bühne steht jetzt immer auf 7000 px.
+
+**Eine ungültige `var()` fällt lautlos durch.** Die Animationsleiste war in
+Farben gesetzt, die es in diesen Dateien nie gab — `var(--line)`,
+`var(--panel)`, `var(--ink)`. Eine Variable ohne Wert und ohne Ersatzwert macht
+nicht etwa die Farbe kaputt, sondern lässt **die ganze Angabe** fallen: kein
+Rahmen, kein Grund, keine Meldung in der Konsole, kein Fehler in irgendeinem
+Prüfskript. Aufgefallen ist es erst auf dem Bildschirmfoto. Die Leiste trägt
+jetzt dieselben festen Werte wie der Rest der Oberfläche (`#17181F`, `#24262F`,
+`#1D1F26`, `#2E313B`).
+
+**Ein Geschwister hinter einem absolut liegenden Element landet nicht
+darunter.** Die Leiste wurde als nächstes Element nach `#scroller` eingehängt
+und lag prompt oben über dem Streifen: `.scroller` ist `position: absolute;
+inset: 0` und nimmt keinen Platz im Fluss ein. Die Bühne ist deshalb jetzt eine
+Spalte aus zwei Zeilen — oben eine neu eingezogene `#stageflaeche` mit Scroller
+und Fußleiste, darunter die Leiste. Eingezogen wird sie beim Start per Skript,
+damit der HTML-Rumpf jeder Datei unverändert bleibt.
+
+---
+
+## 7. Das Bewegtbild
+
+Fünf Sekunden, 150 Bilder, `animation/post6-animChat.mp4`. Gezeichnet wird es
+von `animChat(i, t)` in `post6.html`, mit denselben Bausteinen wie jede Slide —
+nur bekommt die Funktion neben der Nummer den Fortschritt `t` und leitet daraus
+alles ab. Der Eintrag steht in `ANIMS`, nicht in `SLIDES`.
+
+| Zeit | Was passiert |
+|---|---|
+| 0,3 – 2,2 s | Die Antwort wird Zeichen für Zeichen ins Eingabefeld getippt, der Cursor blinkt. Sobald Text darin steht, wird aus dem Mikrofon der Senden-Knopf |
+| 2,2 – 2,5 s | Das Feld leert sich |
+| 2,4 – 3,3 s | Die Blase wächst von unten in den Verlauf, der Verlauf schiebt sich nach oben |
+| 3,2 – 4,0 s | Die Aufgabenkarte klappt in der Blase auf, die Blase wächst mit |
+| 3,9 – 4,6 s | Die Systemzeile „Aufgabe „Materialliste prüfen" im Projekt erstellt" erscheint darunter |
+| 4,6 – 5,0 s | Alles blendet zurück auf den Anfangsstand, damit die Schleife schließt |
+
+Vier Entscheidungen dahinter:
+
+- **Der Verlauf hängt am unteren Rand, nicht am oberen.** Damit erzeugt jedes
+ Wachstum das Hochschieben von selbst, ohne eine zweite Rechnung für das
+ Scrollen. Die drei vorherigen Nachrichten sind dieselben wie auf Slide 1 und
+ stammen aus `GeneralTeamChatPage.tsx`, nicht aus einem erfundenen Szenario.
+- **Sie liegt neben den Slides, nicht darin.** Ein erster Anlauf machte sie zur
+ sechsten Slide im Streifen. Damit wanderte sie in die Slide-Auswahl, in die
+ Zählung „Carousel, 6 Slides" und in den Bildexport, der sie als Standbild
+ mitschrieb. Ein Video, das man nur zusammen mit den Bildern herausbekommt,
+ ist im Carousel aber nicht getrennt einsetzbar.
+- **Getippt wird ein Satz, der ohnehin im Verlauf steht.** `ANIM_TIPP` ist
+ `VERLAUF[3].text` — die Nachricht, die im Werkzeug an dieser Stelle folgt.
+ Kein Text ist für die Animation erfunden worden, auch keine Uhrzeit.
+- **Das letzte Bild ist nicht das erste.** Der Fortschritt läuft über
+ `t = n / anzahl`; bei `n / (anzahl − 1)` wären Anfang und Ende dasselbe Bild
+ und der Stoß der Schleife stünde eine Dreißigstelsekunde still.
+
+Nachgeprüft mit `ffprobe`: H.264 High, `yuv420p`, 1080 × 1350, 30 fps, Dauer
+exakt 5,000000 s, stumme AAC-Spur, `moov` am Dateianfang, 1,1 MB.
+
diff --git a/README.md b/README.md
index eb694ab..db44835 100644
--- a/README.md
+++ b/README.md
@@ -221,10 +221,10 @@ Baustellenfotos.
| Slide | Was sie leistet |
|---|---|
| 1 | Kanalliste und Verlauf nebeneinander, mit Filterzeile und Eingabefeld |
-| 2 | die Chatliste groß: Projektkanäle, Direktnachrichten, Gruppe |
+| 2 | links das Durcheinander als Zeichnung, rechts die Kanalliste |
| 3 | Fotos, die Aufgabe daraus, die Bestätigung im Projekt |
| 4 | das Menü hinter dem Plus im Eingabefeld |
-| 5 | das Handy-Mockup der Website, verwischt, mit Absperrband |
+| 5 | zwei Ausschnitte der Oberfläche, versetzt übereinander, mit Absperrband |
**Slide 3 ist der Kern**, und sie ist keine Konstruktion: im Werkzeug folgen
die drei Nachrichten genau so aufeinander. Tobias schickt die Baustellenfotos,
@@ -232,13 +232,35 @@ darauf eine Nachricht mit angehängter Aufgabe, darauf die Systemzeile
„Aufgabe „Materialliste prüfen" im Projekt erstellt". Die letzte Zeile macht
den Unterschied: die Aufgabe liegt danach im Projekt, nicht nur im Verlauf.
-**Slide 5 kommt von der Website, nicht aus dem Pitch-Paket.** Die App gibt es
-noch nicht, und `ChatDemoSection.tsx` hat den Zustand bereits: ein Handy-Mockup
-mit `blur(3px)` und darüber ein **Absperrband** aus dem Bau, -13° gedreht, in
-`#F1B729` mit Kanten in `#171106`, darauf Schutzhelm und „BALD VERFÜGBAR".
-Auch Inhalt, Blasenfarben, Überschrift und Fließtext sind die der Website. Ein
-Zwischenstand hatte hier einen selbst erfundenen Aufkleber; das war eine eigene
-Bildsprache neben einer vorhandenen.
+**Slide 5 zeigt zwei Ausschnitte statt eines Geräts.** Die Machart ist die,
+die Signal für solche Slides benutzt: zwei Teile der Oberfläche,
+versetzt übereinander, jeder mit eigenem Schatten. Ein Zwischenstand hatte hier
+ein nachgebautes Handy samt Insel und Tasten; das kostet viel Platz für etwas,
+das nichts über das Produkt sagt, und der Rahmen war frei erfunden.
+
+**Slide 2 stellt gegenüber statt zu zeigen:** links das Durcheinander als
+Zeichnung, rechts die Oberfläche als Ausschnitt. Die linke Hälfte gibt es in
+diesem Repo schon, in `post2.html` auf Slide 2 (`visProzesse`) — dort im
+Zeichenblock-Stil, hier glatt. Die Plättchen sind Redaktionstext und keine
+Produktstrings; sie beschreiben die Wirklichkeit im Betrieb.
+
+**Die Leitung läuft über den Wisch hinweg:** sie beginnt links außerhalb der
+Slide, und mit `visLeft: -102` landet dieses Stück auf Slide 1. Das ist der
+Überstand, vor dem `rules.md` §5 sonst warnt — `.page` hat kein `overflow` —
+hier aber gewollt. `pruef.mjs` liest einen negativen `--vis-left` als Ansage
+und meldet ihn nicht mehr als Fehler.
+
+Beide Ausschnitte auf Slide 5 sind leicht verwischt: erkennbar, aber nicht zu lesen.
+Scharf bleibt allein das Band. Von Slack kommt ein zweites Mittel, das auf
+Slide 3 sitzt: **ein dicker gelber Rahmen, der seitlich aus der Blase
+ausbricht** und auf die Anhangkarte der Aufgabe zeigt, weil die den Satz der
+Slide trägt.
+
+Dass die App noch nicht da ist, sagt das **Absperrband** von der Website:
+`ChatDemoSection.tsx` hat es unter der Fahne `comingSoon`, -13° gedreht, in
+`#F1B729` mit Kanten in `#171106`, darauf Schutzhelm und „BALD VERFÜGBAR". Auch
+Überschrift und Fließtext sind die der Website; der Inhalt der Ausschnitte
+kommt aus dem Pitch-Paket wie auf allen anderen Slides.
**Die Tiefe kommt aus dem Stapel und gestaffelten Schatten, nicht aus
Flächen.** Ein Zwischenstand legte graue Flächen hinter die Elemente; das nimmt
@@ -339,6 +361,89 @@ sonst sieht ihn niemand sonst. Gibt es die Datei noch nicht, wird der Stand
aus dem Browser einmalig hineingeschrieben.
+## Animation
+
+Unter dem Canvas liegt in **jedem** Projekt, im selben Fenster, ein Bereich
+„Animation". Er zeigt jede Animation als eigenes 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. Das ist der ganze Punkt: im Carousel
+ist sie ein eigenes Element, und ob sie mitkommt, entscheidest du beim Posten.
+
+```js
+const ANIMS = [
+ { key: "animChat", type: "feature", dauer: 5,
+ eyebrow: "Bewegtbild",
+ head: 'Getippt, und schon im Projekt.',
+ body: "Eine Nachricht, ein Klick, und die Aufgabe hängt am richtigen Projekt.",
+ vis: "animChat", visW: 700, visLeft: 190, visBottom: 120 },
+];
+```
+
+Dieselben Felder wie bei einer Slide, dazu `dauer` in Sekunden. Die
+Zeichenfunktion aus `VIS` bekommt einen zweiten Wert `t` zwischen 0 und 1 und
+zeichnet daraus den Stand zu diesem Zeitpunkt. Blatt, Überschrift,
+Punktgrund und Schutzzone sind dieselben wie überall.
+
+### Exportieren
+
+**`MP4 exportieren`** im Bereich unter dem Canvas. Der Browser kann kein MP4
+schreiben, also stößt die Seite den Bau auf dem Server an (`POST /anim`) und
+fragt den Stand ab; die Zeile unter den Knöpfen zeigt den Fortschritt und am
+Ende den Pfad. Getrennt vom Bildexport, in beide Richtungen: „Alle Slides als
+ZIP" enthält kein Video, und dieser Knopf rendert kein Bild.
+
+Auf der Kommandozeile geht dasselbe:
+
+```sh
+./serve.sh # muss laufen
+node animation.mjs post6.html # alle Animationen des Projekts
+node animation.mjs post6.html animChat # nur diese
+```
+
+Heraus kommt `animation/post6-animChat.mp4`. Angesprochen wird die Animation
+über ihren Schlüssel, nicht über eine Nummer: sie hat keine Position im
+Streifen, die sich verschieben könnte.
+
+Für die Aufnahme lädt das Skript die Seite als `?nur=anim&k=`. In diesem
+Modus zeigt sie allein das Blatt der Animation, in natürlicher Größe an 0,0 —
+damit entfällt jede Ausschnittsrechnung.
+
+### Was Instagram bei so einem Beitrag tatsächlich annimmt
+
+Kurz: **ein MP4, kein GIF.**
+
+| | |
+|---|---|
+| Format | MP4, H.264 High, `yuv420p`, `+faststart` |
+| Maße | 1080 × 1350 (4:5), wie die Standbilder |
+| Bildrate | 30 fps, Schlüsselbild jede Sekunde |
+| Datenrate | rund 12 Mbit/s |
+| Länge | mindestens 3 s, höchstens 60 s je Carousel-Element |
+| Ton | eine stumme AAC-Spur |
+
+Vier Dinge, die den Aufbau bestimmen:
+
+- **GIF geht nicht.** Instagram nimmt keine GIF-Uploads an; die Datei landet
+ als Standbild. Was in der App wie ein animierter Beitrag aussieht, ist ein
+ kurzes Video.
+- **Ein Video im Carousel wird kein Reel.** Seit 2022 wandert nur ein
+ *einzeln* geposteter Video-Beitrag automatisch in die Reels. Liegt das Video
+ in einem Carousel, bleibt der Beitrag ein Beitrag.
+- **Der erste Beitrag im Carousel bestimmt den Ausschnitt für alle.** Ein
+ Element in einem anderen Seitenverhältnis wird beschnitten, nicht
+ eingepasst. Deshalb rendert die Animation in genau denselben 1080 × 1350 wie
+ jedes Standbild.
+- **Eine stumme Tonspur muss mit.** Manche Upload-Wege lehnen ein Video ohne
+ Audiostrom ab. Die Spur kostet 128 kbit/s und erspart die Fehlersuche.
+
+Die Bewegung läuft in Schleife. Das letzte Bild ist deshalb **nicht** dasselbe
+wie das erste (`t = n / anzahl`, nicht `n / (anzahl − 1)`) — sonst steht der
+Stoß eine Dreißigstelsekunde still.
+
+
## Bedienung
| Taste | Wirkung |
@@ -400,7 +505,9 @@ assets/
└── fonts/ Manrope 400–800, lokal
vendor/ html-to-image, JSZip
state/ Projektstände, gehören ins Repo
-serve.py Statischer Server + /save, /state, /upload, /bilder
+animation.mjs baut aus einem ANIMS-Eintrag ein MP4 (braucht ffmpeg)
+animation/ die fertigen MP4s, ein Carousel-Element je Datei
+serve.py Statischer Server + /save, /state, /upload, /bilder, /anim
rules.md Gestaltungsregeln, Farbwerte, Export-Wissen
```
diff --git a/animation.mjs b/animation.mjs
new file mode 100644
index 0000000..b311b16
--- /dev/null
+++ b/animation.mjs
@@ -0,0 +1,186 @@
+#!/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 });
+}
diff --git a/animation/post6-animChat.mp4 b/animation/post6-animChat.mp4
new file mode 100644
index 0000000..22ab40f
Binary files /dev/null and b/animation/post6-animChat.mp4 differ
diff --git a/post2-alt.html b/post2-alt.html
index d97502e..4882e15 100644
--- a/post2-alt.html
+++ b/post2-alt.html
@@ -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";
@@ -531,6 +536,89 @@ if (location.search.indexOf("nur=streifen") >= 0)
);
}
+ /* 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
@@ -973,6 +1061,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". */
@@ -2513,6 +2602,40 @@ if (location.search.indexOf("nur=streifen") >= 0)
return glatt(p, true);
}
+ /* ═══ 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 = {
karte: j =>
'
' + visKarte(layoutState[j]) +
@@ -3323,7 +3446,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);
}
@@ -3392,6 +3515,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 = "Animation" +
+ (liste.length
+ ? liste.length + (liste.length === 1 ? " Element" : " Elemente") +
+ " · 1080 × 1350 · 30 fps · MP4, getrennt vom Bildexport"
+ : "noch keine in diesem Projekt") + "";
+ leiste.innerHTML = "";
+ leiste.appendChild(kopf);
+
+ if (!liste.length) {
+ leiste.insertAdjacentHTML("beforeend",
+ '
Eine Animation entsteht als Eintrag in ANIMS: ' +
+ 'dieselben Felder wie eine Slide, dazu dauer in Sekunden. Ihre ' +
+ 'Zeichenfunktion bekommt einen zweiten Wert t zwischen 0 und 1 und ' +
+ 'zeichnet daraus den Stand. Sie liegt bewusst neben SLIDES und ' +
+ 'nicht darin: der Bildexport lässt sie in Ruhe.
";
+ 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");
@@ -3403,7 +3762,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);
}
@@ -5250,6 +5609,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");
@@ -5259,6 +5619,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");
diff --git a/post2.html b/post2.html
index 6ccd8a3..47b558e 100644
--- a/post2.html
+++ b/post2.html
@@ -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";
@@ -450,6 +455,89 @@ if (location.search.indexOf("nur=streifen") >= 0)
.karte-name span { font-size: 27px; font-weight: 700; letter-spacing: -.03em; line-height: 1.15; }
.karte-name small { font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
+ /* 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
@@ -866,6 +954,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". */
@@ -2297,6 +2386,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 = {
karte: j =>
'
' + visKarte(layoutState[j]) +
@@ -2953,7 +3076,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);
}
@@ -3022,6 +3145,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 = "Animation" +
+ (liste.length
+ ? liste.length + (liste.length === 1 ? " Element" : " Elemente") +
+ " · 1080 × 1350 · 30 fps · MP4, getrennt vom Bildexport"
+ : "noch keine in diesem Projekt") + "";
+ leiste.innerHTML = "";
+ leiste.appendChild(kopf);
+
+ if (!liste.length) {
+ leiste.insertAdjacentHTML("beforeend",
+ '
Eine Animation entsteht als Eintrag in ANIMS: ' +
+ 'dieselben Felder wie eine Slide, dazu dauer in Sekunden. Ihre ' +
+ 'Zeichenfunktion bekommt einen zweiten Wert t zwischen 0 und 1 und ' +
+ 'zeichnet daraus den Stand. Sie liegt bewusst neben SLIDES und ' +
+ 'nicht darin: der Bildexport lässt sie in Ruhe.
";
+ 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");
@@ -3033,7 +3392,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);
}
@@ -4854,6 +5213,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");
@@ -4863,6 +5223,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");
diff --git a/post3.html b/post3.html
index ad8d262..0e40b28 100644
--- a/post3.html
+++ b/post3.html
@@ -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";
@@ -429,6 +434,89 @@ if (location.search.indexOf("nur=streifen") >= 0)
nicht -- es beschneidet sich selbst ueber einen clipPath, weil sonst die
Waben ausserhalb des Ausschnitts mitgezeichnet wuerden. */
+ /* 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
@@ -886,6 +974,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". */
@@ -1806,6 +1895,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 = {
wabenfeld: j => visWabenfeld(j),
aufbau: j => visAufbau(j),
@@ -2622,7 +2745,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);
}
@@ -2691,6 +2814,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 = "Animation" +
+ (liste.length
+ ? liste.length + (liste.length === 1 ? " Element" : " Elemente") +
+ " · 1080 × 1350 · 30 fps · MP4, getrennt vom Bildexport"
+ : "noch keine in diesem Projekt") + "";
+ leiste.innerHTML = "";
+ leiste.appendChild(kopf);
+
+ if (!liste.length) {
+ leiste.insertAdjacentHTML("beforeend",
+ '
Eine Animation entsteht als Eintrag in ANIMS: ' +
+ 'dieselben Felder wie eine Slide, dazu dauer in Sekunden. Ihre ' +
+ 'Zeichenfunktion bekommt einen zweiten Wert t zwischen 0 und 1 und ' +
+ 'zeichnet daraus den Stand. Sie liegt bewusst neben SLIDES und ' +
+ 'nicht darin: der Bildexport lässt sie in Ruhe.