Unterwegs entstehen kurze Clips auf dem Handy, gesprochen auf Schweizerdeutsch. Daraus wird automatisch ein fertiges Reel: 9:16, Untertitel auf Hochdeutsch eingebrannt, Hook im ersten Frame, Cover fürs Grid, Bildtext mit den Fahrdaten.
Der Code dazu liegt offen — jedes Skript mit einer Kurzbeschreibung, was es tut:
github.com/thomhug/bike2work/tree/main/code
(reel.py ist die Video-Pipeline selbst).
Clip finden ─▶ Fahrt zuordnen ─▶ transkribieren ─▶ Zahlen korrigieren
─▶ Untertitel einbrennen ─▶ Hook + Cover ─▶ Caption
Womit
Alles Open Source, alles lokal auf dem eigenen Rechner — kein Cloud-Dienst, keine laufenden Kosten.
| Werkzeug | wofür |
|---|---|
| FFmpeg 7.1 | das Arbeitspferd: Zuschnitt auf 9:16, Untertitel einbrennen, Hook und Overlay zeichnen, Farbraum wandeln, encodieren |
| FFprobe | Metadaten lesen — Aufnahmezeit, GPS, Rotation, Farbraum, Dauer |
| faster-whisper 1.2 | Transkription, auf Basis von CTranslate2 |
| Whisper large-v3-turbo, auf Schweizerdeutsch feinabgestimmt | erkennt Dialekt und gibt direkt Hochdeutsch aus |
libass (über FFmpegs subtitles-Filter) | Untertitel und Daten-Overlay im ASS-Format |
| libx264 / AAC | Video- und Audio-Encoding |
zimg (zscale) + tonemap | HDR nach BT.709 |
| Python 3.13 | die Pipeline selbst — Standardbibliothek plus PyYAML |
| fitparse, garminconnect | Velo-Erkennung über Sensor-Seriennummern aus der Original-Aufzeichnung |
| Strava-API | Fahrdaten, Sekunden-Streams und Segment-Höhenprofile |
swisstopo-Höhenmodell (profile.json) | Steigung entlang des GPS-Tracks — genauer als der Barometer des Velocomputers |
| YouTube Data API | Veröffentlichen mit Publish-Zeit, Untertitel und Playlist per Skript |
| DejaVu Sans / DejaVu Sans Mono | Schrift für Untertitel und Overlay |
Die eingesetzten FFmpeg-Filter im Einzelnen: scale, crop, overlay, gblur
(unscharfer Hintergrund), drawtext (Hook und Cover-Titel), subtitles
(Untertitel und Overlay), zscale und tonemap (Farbraum), setparams
(Farb-Tags), setsar, format.
Ein 50-Sekunden-Clip braucht auf 20 CPU-Kernen rund 30 Sekunden für die Transkription und knapp eine Minute fürs Rendern — ohne Grafikkarte.
Strava-Stream oder Garmin-FIT?
Nicht alle Fahrdaten liegen am selben Ort — und das entscheidet, was ein Overlay überhaupt zeigen kann.
| Kommt aus der Strava-API | Nur im Garmin-FIT (Original-Aufzeichnung) |
|---|---|
| Sekunden-Streams: Watt, Puls, Tempo, Trittfrequenz, Temperatur, GPS, Höhe | Sensor-Seriennummern (Powermeter, Di2) → der Velo-Fingerabdruck |
| Segment-Höhenprofile aus dem Geländemodell | Di2-Gangwahl vorne/hinten, Sekunde für Sekunde |
| Titel, Beschreibung, Velo-Zuordnung — auch schreibbar | rohe Geräte-Events |
Fürs Rendern heisst das: Watt-, Puls-, Tempo- und Temperatur-Overlays speisen sich aus dem Strava-Stream und sind sofort da. Was Strava nicht führt — etwa welchen Gang die Di2 gerade fährt — steckt nur im FIT und braucht den Umweg über Garmin Connect.
Und dort sitzt ein Rate-Limit. Garmin Connect drosselt Downloads (HTTP 429), sobald zu viele Abrufe kommen. Deshalb läuft die Velo-Erkennung sparsam: ein Abruf pro neuer Fahrt, sonst bleibt alles bei der Strava-API. Ein spontanes Di2-Gang-Overlay ist damit nicht immer sofort baubar — es hängt daran, ob der FIT-Download gerade durchgeht.
Schweizerdeutsch → Hochdeutsch
Whisper kann das erstaunlich gut, aber es lohnt sich, das richtige Modell zu nehmen. Ein auf Schweizerdeutsch feinabgestimmtes Modell schlägt das Basismodell bei gleicher Grösse und Geschwindigkeit deutlich:
| gesprochen | Basismodell | Fine-Tune |
|---|---|---|
| über die Helmkapuze | „Helm kaputzen" | „Helmkapuze" ✓ |
| mitten im Anstieg, keuchend | „schon 6 Minuten pro 10" | „noch sechs Minuten von zehn" ✓ |
Der zweite Fall ist der interessante: Bei Atemnot und Fahrtwind liefert das Fine-Tune noch verwertbaren — und inhaltlich richtigen — Text.
Ein weiterer Unterschied zeigte sich bei einem Clip ohne Sprache (20 Sekunden Wasserrauschen beim Velowaschen): Das Fine-Tune gab korrekt nichts zurück, das Basismodell halluzinierte „Wir sehen uns beim nächsten Mal." Genau der Fehler, der unbemerkt in einen Post rutscht.
Zahlen verhauen beide Modelle zuverlässig — und ausgerechnet die machen die Videos glaubwürdig:
| erkannt | richtig | Quelle der Korrektur |
|---|---|---|
| „33 Watt" | 330 W | Fahrdaten |
| „Fight to work" | Bike to Work | Vokabular |
| „von Kamm auf Zürich" | von Cham nach Zürich | Ortskenntnis |
| „Zwei-Training" | Zone-2-Training | Kontext |
Deshalb die Arbeitsteilung: Das Modell liefert die Wörter, die Fahrdaten liefern die Zahlen.
Ein Beispiel, wo Nachsehen besser war als Korrigieren: Im Video war von „17 Grad" die Rede, während die Fahrt im Mittel 15,8 °C hatte. Naheliegend wäre gewesen, das zu „korrigieren" — der Temperaturstream zeigte zum Zeitpunkt des Clips aber tatsächlich 17 °C. Der Sensor kühlte von 21 °C (drinnen) langsam herunter.
Live-Daten im Bild
Oben rechts laufen im Sekundentakt die Messwerte mit: korrigierte Watt, Puls, Tempo und Steigung. Korrigiert heisst: Anzeige plus gemessener Geräteoffset, also 335 statt 314 Watt. Das kann kein Standard-Tool, weil dafür der Offset des konkreten Messers bekannt sein muss.
Umgesetzt als zweiter Untertitel-Stil (ASS, Alignment 9) statt hunderter
drawtext-Filter — eine Filterstufe statt zweihundert.
Zwei Fallstricke, die stundenlang kosteten
Der Array-Index eines Aktivitäts-Streams ist nicht die Sekunde. Die Testfahrt
hatte 3831 Datenpunkte über 3893 Sekunden — 63 Sekunden Lücken. Direkt indexiert
lag das Overlay 47 bis 63 Sekunden daneben, also 186 Meter Fahrstrecke. Aufgefallen
ist es nur, weil die GPS-Koordinate des Clips gegen die Position im Stream gehalten
wurde. Richtig ist der Umweg über den time-Stream; danach: 3 bis 29 Meter
Abweichung, das ist die GPS-Genauigkeit des Handys.
Diese Gegenprobe läuft seither bei jedem Overlay mit und warnt ab 60 Metern. Ein falsch ausgerichtetes Overlay wäre sonst unsichtbar falsch — die Zahlen sähen plausibel aus und kämen von woanders.
Der Höhenstream einer Aktivität taugt nicht für Steigungen. Er ist barometrisch und auf kurzen Distanzen stark geglättet. An einer Stelle mit real 7,8 % lieferte er:
| Fenster | Ergebnis |
|---|---|
| 50 m | 0,8 % |
| 200 m | 2,5 % |
| 600 m | 5,8 % |
Global stimmt der Stream (454 gegen 445 gemeldete Höhenmeter) — nur die kurzfristige Ableitung ist unbrauchbar. Die erste Lösung war das Höhenprofil des Strava-Segments, das aus dem Geländemodell stammt. Über ein 150-Meter-Fenster darauf stimmt die Steigung mit der physikalisch aus Leistung, Tempo und Masse zurückgerechneten auf 0,1 bis 0,5 Prozentpunkte überein.
Nur: Nicht jede Stelle liegt in einem Segment. Am 16.09.2026 zeigte das Overlay an
einem kurzen Anstieg auf dem Arbeitsweg 8 %, gesagt wurde im Video „10 Prozent".
Nachgemessen auf dem swisstopo-Höhenmodell (DTM, api3.geo.admin.ch) entlang des
GPS-Tracks, in 100-Meter-Schritten:
| Abschnitt | Höhenmodell | Velocomputer |
|---|---|---|
| 200–100 m vor dem Clip | 7,8 % | 2,6 % |
| 100–0 m vor dem Clip | 10,3 % | 7,6 % |
| 0–100 m nach dem Clip | 9,4 % | 7,8 % |
| 100–200 m nach dem Clip | 10,0 % | 9,2 % |
Der barometrische Höhenmesser glättet kurze Rampen um zwei bis drei Prozentpunkte und hinkt rund 100 Meter hinterher: Er sah +31 statt +40 Höhenmeter. Der steile Kern ist 425 m mit 9,5 % im Schnitt und 12,5 % in der Spitze. Seither holt die Pipeline die Steigung direkt aus dem Höhenmodell — für jeden Punkt des Tracks, einmal pro Fahrt abgefragt und gecacht; Segmentprofil und Barometer sind nur noch Rückfallebenen. Im Video steht jetzt an der Stelle: +10,0 %.
HDR: warum alles blass aussah
Das iPhone filmt in HLG/BT.2020 (Dolby Vision). Das gerenderte Video behielt diese Tags, sodass es auf dem eigenen Rechner richtig aussah — auf Instagram aber sichtbar fahler als das Original. Die Plattform rekodiert und ignoriert dabei die HDR-Kennzeichnung auf einem 8-Bit-Stream.
Die Lösung ist eine echte Tonwert-Abbildung nach BT.709, vor allen anderen Filtern:
zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,
tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p
Zwei Details, die dabei aufhalten:
- Die
-color_primaries/-color_trc/-colorspace-Flags greifen bei-filter_complexnicht. Die Datei behielt die HDR-Tags der Quelle, obwohl die Pixel längst BT.709 waren — ein Player hätte die Umwandlung ein zweites Mal angewendet. setparamsmuss ans Ende der Filterkette. Innerhalb der Tonemap-Stufe reicht es nicht, weil Overlay-, Untertitel- unddrawtext-Filter die Farbeigenschaften nicht weitergeben.
Kontrolle: ffprobe muss bt709/bt709/bt709 zeigen. Man sieht es dem Bild lokal
nicht an — die Prüfung ist der einzige verlässliche Weg.
YouTube: veröffentlichen per API
Seit September 2026 laufen die Reels auch als Shorts auf
YouTube @velo-tom. Anders als bei Instagram und
TikTok (dort bleibt der Upload Handarbeit) geht bei YouTube alles per Skript
(yt_api.py, Data API v3):
- Upload als
privatemitpublishAt— das Video geht zur geplanten Ortszeit von selbst live, ohne dass jemand um 07:30 am Rechner sitzt. - Titel, Beschreibung, Tags, Playlist und die SRT-Untertitel werden mitgegeben. YouTube indexiert den Untertiteltext — das ist der Suchhebel, den Instagram und TikTok nicht haben.
- Der Titel ist nicht der Caption-Hook: Auf Instagram wird gewischt, auf YouTube gesucht. Also Suchbegriff und Zahl nach vorn („417 Watt am Berg — …"), der Pointensatz kommt in die erste Zeile der Beschreibung.
Was nicht per API geht: das Thumbnail bei Shorts. thumbnails.set meldet Erfolg,
das Bild erscheint aber nie — ein bekannter YouTube-Fehler
(#381127084). Dasselbe JPG von Hand
in Studio hochgeladen sitzt sofort. Also: alles per Skript, das Thumbnail von Hand.
Und die Zahlen: Der Kanal-RSS zeigt nur öffentliche Videos und hängt der Studio-Ansicht nach (am 04.09. für dasselbe Video: Kanalseite 55, RSS 287, Studio 351). Für Trends reicht der RSS, für belastbare Werte Studio → Inhalte.
Kleinigkeiten mit Wirkung
Untertitel-Position. Sie sassen zunächst auf Kinnhöhe, weil libass ohne
Auflösungsangabe die Skalierung rät. Mit explizitem PlayResX/Y von 1080×1920
sind Schriftgrad und Rand echte Pixel. Ausserdem müssen sie hoch genug liegen, um
nicht in der Instagram-Bedienzone zu verschwinden.
Umbruch an Satzgrenzen. Zeilen, die auf „Es" oder „mit in die" enden, lesen sich schlecht. Der Text wird deshalb zuerst in Sätze zerlegt und dann innerhalb umgebrochen.
Zweizeilige Hooks bündig setzen. Jede Zeile ist ein eigener drawtext mit
eigener halbtransparenter Box. Ist der Zeilenabstand kleiner als die Boxhöhe,
überlappen sich die Kästen und der Überlappungsstreifen wird sichtbar dunkler. Die
Regel: Abstand = Schriftgrad + 2 × Randbreite.
Querformat. Hochkant gefilmte Clips füllen 9:16 von selbst. Querformat mit unscharfem Hintergrund lässt nur etwa ein Drittel Bildhöhe übrig — ein quadratischer Zuschnitt kommt auf rund 56 % und ist der bessere Kompromiss. Die Erkennung liest die Rotation aus der Display Matrix; iPhone-Clips haben mehrere Metadatenblöcke, und nur dieser enthält den Wert.
Atomar schreiben. Gerendert wird in eine temporäre Datei, erst am Ende wird verschoben. Sonst liegt eine halbfertige Datei im synchronisierten Ordner — und zwei gleichzeitige Läufe auf dasselbe Ziel vermischen sich.
Alle Angaben stammen aus eigenen Fahrten, Messwerten oder veröffentlichten Studien — Belege sind verlinkt. Was sich nicht belegen liess, steht als solches gekennzeichnet da. Quellenregel