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

+245 -34
View File
@@ -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.
+117 -10
View File
@@ -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,<br><span class="a">und schon im Projekt.</span>',
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=<key>`. 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
```
+186
View File
@@ -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 });
}
Binary file not shown.
+363 -2
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">
@@ -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 =>
'<div class="karte-halter">' + 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 = "<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");
@@ -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");
+363 -2
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">
@@ -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)
'</svg>';
}
/* ═══ 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 =>
'<div class="karte-halter">' + 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 = "<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");
@@ -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");
+363 -2
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">
@@ -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)
"</g></g></svg>";
}
/* ═══ 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 = "<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");
@@ -2702,7 +3061,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);
}
@@ -4597,6 +4956,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");
@@ -4606,6 +4966,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");
+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");
+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 5 · Projektmanagement</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">
@@ -2889,6 +2978,40 @@ if (location.search.indexOf("nur=streifen") >= 0)
return wfSvg(W, lh, g);
}
/* ═══ 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 = {
board: () => visBoard(),
karte: () => visKarte(),
@@ -3699,7 +3822,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);
}
@@ -3768,6 +3891,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");
@@ -3779,7 +4138,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);
}
@@ -5626,6 +5985,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");
@@ -5635,6 +5995,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");
+819 -184
View File
File diff suppressed because it is too large. Load diff
+100 -1
View File
@@ -24,7 +24,10 @@ import json
import os
import re
import socketserver
import subprocess
import threading
import urllib.parse
import uuid
from pathlib import Path
PORT = 4321
@@ -40,6 +43,15 @@ MAX_BILD = 40 * 1024 * 1024
# angefasst, deshalb bleibt ein PNG genau das PNG, das der Finder geliefert hat.
BILD_ENDUNGEN = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"}
BILD_ORDNER = ("assets/screens", "assets/eigene")
ANIM_DIR = ROOT / "animation"
SAFE_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9-]{0,39}$")
ANIM_FEHLER = re.compile(r"^[A-Za-z]*Error: ")
# Laufende und abgeschlossene Videobauten, nach Kennung. Der Bau dauert je
# nach Laenge ein bis drei Minuten -- zu lang fuer eine offene Anfrage, und
# animation.mjs holt sich waehrenddessen selbst Seiten von diesem Server.
# Deshalb laeuft er in einem eigenen Faden und die Seite fragt den Stand ab.
ANIM_LAEUFE = {}
ANIM_SPERRE = threading.Lock()
class Handler(http.server.SimpleHTTPRequestHandler):
@@ -104,11 +116,53 @@ class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
"""Vor der Dateiauslieferung nur die Bilderliste abfangen."""
if urllib.parse.urlparse(self.path).path == "/bilder":
zerlegt = urllib.parse.urlparse(self.path)
if zerlegt.path == "/bilder":
self._liste_bilder()
return
if zerlegt.path == "/anim":
self._anim_stand(urllib.parse.parse_qs(zerlegt.query).get("id", [""])[0])
return
super().do_GET()
def _anim_stand(self, kennung):
"""Stand eines Videobaus. Unbekannte Kennung ist kein Serverfehler,
sondern eine Antwort: der Lauf ist weg, mehr steht dazu nicht an."""
with ANIM_SPERRE:
lauf = ANIM_LAEUFE.get(kennung)
if lauf is None:
self._json(404, json.dumps({"laeuft": False, "fehler": "Lauf unbekannt"}))
return
self._json(200, json.dumps(lauf, ensure_ascii=False))
def _anim_bauen(self):
"""Ein einzelnes MP4 bauen lassen. Getrennt vom Bildexport: die
Animation ist ein eigenes Element im Carousel und soll auch einzeln
herauskommen."""
length = int(self.headers.get("Content-Length", 0))
if length <= 0 or length > 4096:
self._json(400, '{"error":"ungueltige Anfrage"}')
return
try:
wunsch = json.loads(self.rfile.read(length))
projekt, key = wunsch["projekt"], wunsch["key"]
except Exception:
self._json(400, '{"error":"kein gueltiges JSON"}')
return
if not SAFE_PROJECT.match(str(projekt)) or not SAFE_KEY.match(str(key)):
self._json(400, '{"error":"unzulaessiger Name"}')
return
if not (ROOT / (projekt + ".html")).is_file():
self._json(404, '{"error":"Projekt nicht gefunden"}')
return
kennung = uuid.uuid4().hex[:12]
with ANIM_SPERRE:
ANIM_LAEUFE[kennung] = {"laeuft": True, "zeile": "startet …"}
threading.Thread(target=_anim_lauf, args=(kennung, projekt, key),
daemon=True).start()
self._json(200, json.dumps({"id": kennung}))
def _liste_bilder(self):
"""Alle verwendbaren Bilder, damit die Auswahl im Generator nicht im
Code gepflegt werden muss und importierte sofort auftauchen."""
@@ -171,6 +225,9 @@ class Handler(http.server.SimpleHTTPRequestHandler):
if path == "/upload":
self._upload()
return
if path == "/anim":
self._anim_bauen()
return
if path != "/save":
self.send_error(404)
return
@@ -230,6 +287,48 @@ class Handler(http.server.SimpleHTTPRequestHandler):
super().log_message(fmt, *args)
def _anim_lauf(kennung, projekt, key):
"""animation.mjs in einem eigenen Faden. Seine Ausgabe wandert zeilenweise
in den Stand, damit die Seite den Fortschritt zeigen kann statt zwei
Minuten lang nichts."""
def melden(**neu):
with ANIM_SPERRE:
ANIM_LAEUFE.setdefault(kennung, {}).update(neu)
try:
proc = subprocess.Popen(
["node", "animation.mjs", projekt + ".html", key],
cwd=str(ROOT), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1)
ausgabe = []
for zeile in proc.stdout:
zeile = zeile.strip()
if zeile:
ausgabe.append(zeile)
melden(zeile=zeile)
code = proc.wait()
if code != 0:
# Die letzte Zeile ist bei node die Versionsangabe und sagt nichts,
# und der Auszug aus dem Quelltext steht vor der eigentlichen
# Meldung. Gesucht ist die Zeile, die mit dem Fehlernamen anfaengt.
grund = next((z for z in ausgabe if ANIM_FEHLER.match(z)), "")
melden(laeuft=False,
fehler=grund or (ausgabe[-1] if ausgabe else "node endete mit " + str(code)))
return
ziel = ANIM_DIR / (projekt + "-" + key + ".mp4")
if not ziel.is_file():
melden(laeuft=False, fehler="kein MP4 entstanden")
return
groesse = ziel.stat().st_size
melden(laeuft=False, pfad="animation/" + ziel.name,
groesse=f"{groesse/1024/1024:.1f} MB", zeile="fertig")
print(f"Animation gebaut: {ziel.name} ({groesse/1024/1024:.1f} MB)")
except FileNotFoundError:
melden(laeuft=False, fehler="node nicht gefunden")
except Exception as e: # der Faden darf den Server nicht mitnehmen
melden(laeuft=False, fehler=str(e))
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
+1 -1
View File
@@ -41,7 +41,7 @@
"werte": {}
},
{
"ref": "handy",
"ref": "mobil",
"werte": {}
}
],
+5 -2
View File
@@ -128,8 +128,11 @@ node video/bauen.mjs bauchbinde satz # nur passende Schlüssel
Ein paar Entscheidungen, die dahinterstehen:
- **Bildsequenz statt Video mit Alphakanal.** Ein Video mit echter
Transparenz wäre ProRes 4444, und dafür braucht es ffmpeg. Das ist hier
nicht installiert, und eine Bildsequenz kann Premiere genauso importieren.
Transparenz wäre ProRes 4444. Als das hier entstand, war kein ffmpeg auf der
Maschine; inzwischen schon (siehe `animation.mjs` im Wurzelverzeichnis).
Geblieben ist die Bildsequenz trotzdem: Premiere importiert sie genauso, sie
behält die Transparenz ohne Codec-Frage, und jedes Einzelbild lässt sich
ansehen. Ein ProRes-Ausgang wäre jetzt möglich, ist aber kein Gewinn.
- **Node statt Python**, anders als sonst im Repo. Gebraucht wird eine
Steuerverbindung zu Chrome, und die hat Node ohne Zusatzpaket an Bord.
Chrome pro Einzelbild neu zu starten wäre die Alternative gewesen — bei rund
+5 -3
View File
@@ -14,9 +14,11 @@
* Seite Schrift und Marken per fetch holt und das lokale Dateiprotokoll das
* verbietet.
*
* Kein ffmpeg noetig. Ein Video mit echtem Alphakanal waere ProRes 4444 und
* damit ein Encoder, den dieser Rechner nicht hat; eine Bildsequenz kann
* Premiere genauso importieren und behaelt die Transparenz.
* Kein ffmpeg noetig. Ein Video mit echtem Alphakanal waere ProRes 4444; eine
* Bildsequenz kann Premiere genauso importieren, behaelt die Transparenz ohne
* Codec-Frage und laesst sich Bild fuer Bild ansehen. (ffmpeg liegt inzwischen
* auf der Maschine -- animation.mjs im Wurzelverzeichnis benutzt es fuer die
* MP4s der Posts. Hier braucht es das trotzdem nicht.)
*/
import { spawn } from "node:child_process";