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

+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