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.

Werkzeugwofür
FFmpeg 7.1das Arbeitspferd: Zuschnitt auf 9:16, Untertitel einbrennen, Hook und Overlay zeichnen, Farbraum wandeln, encodieren
FFprobeMetadaten lesen — Aufnahmezeit, GPS, Rotation, Farbraum, Dauer
faster-whisper 1.2Transkription, auf Basis von CTranslate2
Whisper large-v3-turbo, auf Schweizerdeutsch feinabgestimmterkennt Dialekt und gibt direkt Hochdeutsch aus
libass (über FFmpegs subtitles-Filter)Untertitel und Daten-Overlay im ASS-Format
libx264 / AACVideo- und Audio-Encoding
zimg (zscale) + tonemapHDR nach BT.709
Python 3.13die Pipeline selbst — Standardbibliothek plus PyYAML
fitparse, garminconnectVelo-Erkennung über Sensor-Seriennummern aus der Original-Aufzeichnung
Strava-APIFahrdaten, Sekunden-Streams und Segment-Höhenprofile
swisstopo-Höhenmodell (profile.json)Steigung entlang des GPS-Tracks — genauer als der Barometer des Velocomputers
YouTube Data APIVeröffentlichen mit Publish-Zeit, Untertitel und Playlist per Skript
DejaVu Sans / DejaVu Sans MonoSchrift 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-APINur im Garmin-FIT (Original-Aufzeichnung)
Sekunden-Streams: Watt, Puls, Tempo, Trittfrequenz, Temperatur, GPS, HöheSensor-Seriennummern (Powermeter, Di2) → der Velo-Fingerabdruck
Segment-Höhenprofile aus dem GeländemodellDi2-Gangwahl vorne/hinten, Sekunde für Sekunde
Titel, Beschreibung, Velo-Zuordnung — auch schreibbarrohe 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:

gesprochenBasismodellFine-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:

erkanntrichtigQuelle der Korrektur
„33 Watt"330 WFahrdaten
„Fight to work"Bike to WorkVokabular
„von Kamm auf Zürich"von Cham nach ZürichOrtskenntnis
„Zwei-Training"Zone-2-TrainingKontext

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:

FensterErgebnis
50 m0,8 %
200 m2,5 %
600 m5,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:

AbschnittHöhenmodellVelocomputer
200–100 m vor dem Clip7,8 %2,6 %
100–0 m vor dem Clip10,3 %7,6 %
0–100 m nach dem Clip9,4 %7,8 %
100–200 m nach dem Clip10,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:

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):

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