Or as my wife says: How I managed to turn 3 hours of boring GoPro footage into 30 seconds of boring GoPro footage

Road cycling has been my primary form of exercise, social outlet, therapy, wardrobe expense, and personality trait for almost a decade. I recently moved from Boston to New York, and my biggest anxiety was losing my 6:15am friend group that lapped to Dover or Blue Hills before work (WhatsApp group title: Boston WattsApp). Fortunately, the NYC cycling scene is awesome. I’ve made and rekindled many friendships, I have a new WattsApp group that’s great for Central Park laps before work, longer rides up 9W on the weekends, and the occasional Bear Mountain summit or 130+ mile ride to Montauk. There is no better way to start a day than 50 miles with friends and a cold brew + maple bacon log from Boxer Donut in Nyack.

Like many casual cyclists, I would do (or spend) anything to get faster at cycling, other than change my lifestyle in any way. I have all the tech and all the numbers: spandex, aero bike, carbon wheels, head unit, power meter, three separate heart rate monitors (Whoop, Garmin Watch, chest strap). Earlier this year I added a handlebar-mounted GoPro to the mix.

Of course, absolutely no one wants to watch 3 hours of being stuck behind Citibikes on West Side Highway. But I enjoy stitching together my favorite sections. I look through past footage, identify the fun parts, and put together a narrative to remember (remember, Strava is my personality). But since cycling is already time consuming, manually editing a highlight reel edit per ride was a nonstarter. So I built and open-sourced a pipeline called ride-recap to do it automatically.

Three months, 25 rides, 45 commits, and 1000 miles later, I can take hours of cycling footage plus a .fit file (time-series data with GPS, speed, heart rate, cadence, power, etc.) and turn it into a finished highlight reel. Every single second of the ride is scanned by gemini-3.5-flash, as is the clip ranking + curation. The whole thing costs about $0.04 per ride and takes 10 minutes. Here’s a finalized highlight:

2026-07-12: River Road to 9W Market

Architecture

The system has six steps and one command: ride-recap process data/raw/<YYYY-MM-DD>/

Pipeline architecture: GoPro video, Garmin FIT telemetry, and Strava segments flowing through detect, fuse, review, and compose stages into landscape and portrait highlight reels
Candidate generation from four different sources (GoPro video, Garmin telemetry, Strava segments, manual labels) fused into one chronological story.
  1. Ingest: pull GoPro data (.mp4 + .LRV) and Garmin .fit file into a ride folder
  2. Detect: four candidate sources identify interesting parts of the ride
    • Gemini vision: a two-pass frame scan for visually compelling segments (more on this later)
    • Telemetry: speed, climbs + descents, power + heart rate spikes
    • Strava API: popular segments on the route (e.g. State Line Sprint has 343 stars), this is the pavement every NYC cyclist knows
    • (Optional) Manual Labels: Streamlit app, occasionally I’ll hand-flag a moment as a “must include” (say, if I see a deer)
  3. Fuse: Generate candidate clips, rank them, penalize nearby moments (more on this later)
  4. (Optional) Review: Streamlit app, if I want to override the candidate ranking or rate clips
  5. Compose: Burn a ride telemetry overlay onto the selected clips
  6. Output: highlight_landscape.mp4 (60s, 16:9) for YouTube, highlight_portrait.mp4 (30s, 9:16) for Instagram

Here’s a pre/post for the step five burned overlay: you can see I’ve added live telemetry on the left, a GTA style mini-map + route trace on the right, and elevation profile + ride progress along the bottom.

Raw GoPro frame from the handlebar cam with no overlay: the road ahead and nothing else
Before: straight off the handlebar cam. Every number the ride generated is in a separate file.

What Makes Cycling Visuals Compelling?

Many have opined on the value of Taste™ in the post-AI era. Four months ago, I would have answered like Justice Potter Stewart describing pornography: I know it when I see it. I’ve now processed 180,430 seconds + frames through this pipeline. Here’s my strongest opinion on teaching taste to a LLM: taste develops through iteration count, you cannot specify taste in advance.

I started by sending every frame to Gemini with a prompt to “rate visual interest 1-5”. But a ride around New York could be interesting for so many reasons! A concrete jungle view from Hudson Yards or a Hudson River view from Peekskill, a climb up River Road or a descent down Bear Mountain, almost getting dropped in a paceline on 9W or almost getting hit by an e-bike going the wrong way on 9th Avenue.

Here’s an example of what I have in place now: every second of every ride goes through the same versioned prompt. I send Gemini six 848×480 proxy frames plus one line of telemetry, and I get one JSON object back. Flip through three calls from the July 12th ride below: a sprint through Hudson Yards, a beautiful view from under the GW Bridge, and two seconds stuck behind a black SUV. Any taste I developed came from me banging my head against JSON outputs I disagreed with.

Anatomy of one fine-pass call on a sprint through Hudson Yards: the versioned prompt as system instruction, six proxy frames and a telemetry line as the user message, and a JSON object scoring motion, scenery, and subject at 8
This Gemini call caught a sprint through Hudson Yards: rated 8s on motion, scenery, and subject, and shipped as the closing shot.

The best way to improve a prompt is to fix its mistakes. That requires ground truth, so I created a Streamlit app and manually labeled a Central Park ride. I reviewed the entire ride frame-by-frame alongside telemetry graphs, and ranked around 30 moments I found interesting or boring, plus a description of why.

The Ride Labeler Streamlit app: a video scrubber over a clip, a GPS route trace, visual and action score sliders with a must-include checkbox and description field, and full-ride power, heart rate, speed, cadence, and altitude graphs along the bottom
The Streamlit labeler: 18 clips, 207 minutes, 3134 frames. Video up top, ride telemetry along the bottom, I can compare my labels against Gemini scores and see where they diverge.
  • Gemini was impressed by any power output above 200W. I’m no Tadej Pogacar, but 200w isn’t that impressive.
    • Solution: athlete specific FTP/power zones injected into the system prompt. 200W means nothing without knowing who’s pushing the pedals.

      power_zones = (
          f"Athlete power zones (FTP={ftp}W):\n"
          f"  Z1 Active Recovery <{int(ftp * 0.55)}W | Z2 Endurance <{int(ftp * 0.75)}W | "
          f"Z3 Tempo <{int(ftp * 0.90)}W\n"
          f"  Z4 Threshold <{int(ftp * 1.05)}W | Z5 VO2max <{int(ftp * 1.20)}W | "
          f"Z6 Anaerobic <{int(ftp * 1.50)}W | Z7 Neuromuscular {int(ftp * 1.50)}W+\n"
          "Only Z5+ qualifies as genuinely hard effort. Z3-Z4 is moderate. Z1-Z2 is easy."
      )
  • Gemini loved clips of tunnels, and being stuck behind cars
    • Solution: negative examples — a real intersection from the April 14th ride, immortalized in the prompt as a permanent warning

      Stopped behind a delivery truck at a red light, never moves:
      {"light": 3, "composition": 2, "motion": 1, "scenery": 1, "subject": 1,
       "peak_offset": 0.5, "clip_type": "transition", "crop_x": 50,
       "reason": "Stuck behind delivery truck at red light"}
  • I liked identifying visuals: Brooklyn Bridge, State Line and 9W signs that are obvious to any cyclist
    • Solution: positive examples — show it what a 10 looks like, and what earns the score

      Cruising past Brooklyn Bridge at sunset, bridge biggest at end:
      {"light": 9, "composition": 8, "motion": 5, "scenery": 10, "subject": 8,
       "peak_offset": 0.8, "clip_type": "landmark", "crop_x": 70,
       "reason": "Sunset Brooklyn Bridge approach, friend alongside"}
  • Gemini often picked two clips right next to one another
    • Solution: penalize nearby moments, enforce greedy selection across the ride (more below)
  • Gemini picked clips where I was going 0mph
    • Solution: filter clips below 5mph

Obvious in retrospect, but there’s no substitute other than looking at the clips Gemini selects, being highly opinionated about what should / should not be included, being specific enough about why, and repeating until you can’t think of anything more to improve. As Hamel writes: gathering supporting data and breaking down workflows into smaller units makes automated grading easier. You cannot teach an LLM taste if you do not have taste yourself.

Here are some outputs from an experiment I ran: I took a handful of stills from a real ride and scored them through every combination of prompts and models: from v2 to v10, the current 3.5-flash, two other Gemini models, and Opus 4.6. You can see that the prompt matters much more than more powerful foundation models.

The George Washington Bridge tower reveal from River Road in golden morning light, scored highly by every model under both prompts
Everyone loves the GWB at sunrise. Every model, all prompts, all eras agree this is a landmark deserving top scores.

I’ve iterated nine times on the ranking prompt so far. It is the most important file in the repo, and I treat it that way: the prompts are version controlled with a mandatory rationale explaining what failure mode the new version fixes. This prompt registry is the historical record of how an LLM’s taste for cycling visuals evolves.

Timeline of the scan prompt from v2 in April to v10 in June, each version annotated with the failure that caused it
Nine prompt versions in three months, nearly every new version was caused by one poorly rated clip.

This code is worth reading directly instead of describing. Check out how the prompt has grown: v2 is 20 lines and scores on two axes, v10 is 244 lines.

You are a cycling video editor reviewing GoPro handlebar-cam frames.
Your job is to flag frames worth including in a highlight reel.

VISUAL CUES for handlebar-cam footage:
  Strong: motion blur on pavement, corners with lean angle, rider wheels nearby,
  high-contrast light (shade/tunnels/reflections), landmarks (skylines/bridges/vistas),
  sprint bounce, urban elements rushing past, weather drama, close calls, unexpected events.
  Weak: steady cadence with nothing nearby, flat overcast light, stopped at lights.

Score each frame on TWO dimensions:
  VISUAL (1-5): How does the frame look? Light, composition, landmarks.
  ACTION (1-5): How much is happening? Effort, proximity, speed, events.
  5 = lead the edit. 4 = strong candidate. 3 = filler. 2 = unremarkable. 1 = dead.

Assign ONE clip_type per frame:
  "scenery" | "action" | "urban" | "climb" | "descent" | "group_riding" | "incident" | "landmark" | "transition"

Telemetry context is provided per batch — use it to disambiguate (plain frame at 350W = effort worth including).

Omit frames where BOTH visual < {min_visual} AND action < {min_action}.
Return ONLY a JSON array, no prose:
[{{"frame_index": N, "visual": 1-5, "action": 1-5, "clip_type": "...", "reason": "max 10 words"}}]
Return [] if nothing qualifies.
I originally had "High-contrast light (shade/tunnels/reflections)" listed as a plus. This was a mistake. Gemini picked a lot of tunnels.

From Clips to a Reel

The candidate generation process ranks every single potential three second clip (around ~1000 per hour). The simplest approach to fill a 60 second reel is to select the top 20 ranked candidates. That results in an extremely repetitive edit, because the top ranked candidates are often right next to one another. A Bear Mountain descent is ~7 minutes, which results in ~100 different clips of a fast descent with beautiful views of the Hudson, all of which score highly on a per-clip basis, but are not independently interesting.

Teaching a LLM to accurately score one clip is a challenge, but at least each clip can be evaluated on its own. Candidate selection is even harder: How DOES a great editor select the footage to tell a great story? From my perspective, here are traits of a great NYC cycling edit:

  • Set the tone with the start and finish: for me, usually slow pedaling up West Side Highway, and back down through the skyscrapers of Hudson Yards
  • Order the clips chronologically, sampling uniformly across the ride (within reason)
  • Include landmarks, especially landmarks beloved by cyclists
  • Clips within a minute of one another are probably repetitive
  • Clips going <5mph are probably boring
  • Clips with friends in them are more interesting
  • Shoot for diversity: include the steepest climb, the fastest descent, the most powerful sprint, the sun shining through the forest in the Palisades, the NYC concrete jungle, The State Line sign, 9W Market

Similar to scoring, I’ve rebuilt the candidate fusion step six times, with each version fixing something from a prior iteration. The current selection process works like this:

  1. “Must include” manual labels are picked first
  2. A Gemini LLM narrative pass picks 20 clips to best tell the story of the ride, boosting “cross-source agreements” (if a human label + telemetry + Strava + Gemini all agree that a clip is interesting)
  3. Greedy re-ranking with a crowding penalty to avoid clips too close to something I’ve already picked
    1. First, a “quality” phase that picks best-first until the best remaining candidate is net-negative with crowding penalty
    2. Second, a “coverage” fill that guarantees the exact clip count by placing the liveliest available clips into the biggest timeline holes.

Here is the LLM narrative pass prompt and crowding penalty:

You are editing a cycling highlight video. Select exactly {n_clips} clips
from the candidates below for a {budget_secs:.0f}-second highlight reel.

COUNT — READ THIS FIRST:
- Your answer MUST contain EXACTLY {n_clips} indices. Not fewer.
- The reel is {budget_secs:.0f} seconds at ~3 seconds per clip, so it needs
  all {n_clips} clips to fill the runtime. A short list leaves dead air that
  gets padded with worse clips — so returning fewer than {n_clips} makes the
  video WORSE, not tighter.
- If you think fewer clips tell a better story, you are wrong for THIS format:
  pick the {n_clips} best and order them well.

SELECTION RULES:
- Clips play in chronological ride order — pick clips that tell the ride's story as it unfolded.
- Ensure VARIETY in clip_type — no more than 3 of the same type.
- Spread selections across the ride timeline — don't cluster in one section.
- USER LABELS ARE EDITORIAL GROUND TRUTH. Any candidate whose sources include "label"
  with visual>=4 or action>=4 represents a moment the rider personally flagged as
  important. Strongly prefer these — include them unless doing so would badly break
  the variety/spread rules above. If you must drop one, prefer dropping gemini-only
  candidates instead.
- Prefer clips confirmed by multiple sources (telemetry + gemini + label).
- {layout_guidance}

CANDIDATES (sorted by ride time):
{candidate_text}

Return a JSON array of EXACTLY {n_clips} candidate indices, in the order they
should appear. Example shape (yours must have {n_clips} entries):
[2, 5, 9, 14, 17, 21, 26, 30, 33, 38, 41, 47, 52, 55, 58]
Return ONLY the JSON array, nothing else.
The count section exists because v2 asked for 20 clips, consistently returned ~10, and the shortfall got silently backfilled with filler.

The 60 second July 12th highlight video at the top of this post is the output. Here’s a visual of the input: every candidate clip from the July 12th ride to 9W Market, plotted by time and score. The sprint you see as the closing shot is the orange dot on the right; the black SUV is the one at the bottom, ranked 70th of 70. Note that we didn’t pick the top 20 clips from the highest scoring section, but instead we selected 20 highly ranked clips spread somewhat evenly across the ride.

Scatter plot of every candidate clip across the ride timeline, score on the vertical axis, with the selected clips highlighted and spread evenly across the ride rather than clustered on the highest-scoring section
Every candidate, plotted by time and score.

What’s Next?

I’m now three months of Claude Max, ~1000 miles, and $60 in Gemini API into this project. I have a YouTube channel with minimal traffic and a few pity subscribers, mostly related to me or in my WattsApp groups. I’ve gotten a few “wow, cool!” on my personal Instagram but again… mostly pity. I’m starting to feel done with this project, which scares me a bit! When I watch my videos, increasingly, they feel “right” to me, which might also mean my taste has plateaued and I’m complacent. I haven’t been iterating as quickly, and iteration is the whole key.

Obviously, I have registered a domain and have a harebrained business plan in my head. Hypothetically I could charge cyclists per ride to upload video footage + a .fit file and get back a finished edit.

Unfortunately, I think the TAM for customers who:

  • Road bike frequently
  • Are willing to buy a GoPro, Garmin, mount both to their bike, charge and record everything
  • Really want 60 second edits of their footage for Strava + Instagram
  • Do NOT want to make those edits themselves

Is one person, and it’s me.

But guess what! I love cycling, and I love building for me! Through a decade of managing data scientists, I have learned people are 10x more willing to go deep on something they are passionate about. This project scratched my itch. I’ve learned a ton about building LLM pipelines and coding agents. This post hasn’t even scratched the surface on what I’ve learned about:

  • Working with video files: .LRV proxy files, 5.3k HEVC, inter-frame compression, ffmpeg, and parallel processing to downscale and decode frames, VideoToolbox, RBGA overlays with Pillow.
  • Gemini billing, API failures, caching prompt results, identifying the cheapest model relative to a performance bar, evaluating foundation models within pipelines and as their own discrete steps, general token cost management.
  • Clock drift across GoPro + Garmin: the most brutally frustrating part of this project was when my offset math and the ride overlay felt off by a few seconds. I turned off wifi + bluetooth on the GoPro to save battery, and the clock drifts a few seconds per week, and occasionally within a ride.
  • Cropping a 16:9 frame (landscape) for a 9:16 (portrait) reel, and identifying the best place to crop the portrait (I only crop away from the middle to highlight other road cyclists in the frame).
  • Adding a blur-to-title opener + outro recap card.
  • “Out of distribution” clips: a deer, a dog doing Central Park laps, anything interesting for reasons that have nothing to do with cycling.
  • Calibrating LLM outputs (I used to have a light dimension that returned a 4 no matter what).

Some of my to-do list involves better evals for the learned ranker, testing non-Gemini models, learning more about video editing (color grading? motion blur? audio edits?). I’ve open-sourced ride-recap and will share it in a few communities, to see if anyone jumps on it or can push me in a new direction. New frontier models will come out, I’ll see what I can improve. There’s always more to do.

I now have personal software that I use a few times a week that brings me joy. The quality came through the iterations. This wouldn’t have been possible without coding agents, but it also wouldn’t have been possible without the version of “taste” I believe in now. Taste is not what you start with, it’s what you develop by watching thousands of three second clips, iterating, reviewing v25, being unsatisfied and shipping v26.

A Finder window listing 27 test overlay renders of the same GW Bridge clip: test_overlay.mp4, then test_overlay_v2.mp4 through test_overlay_v26.mp4, including a v25b
27 versions of the overlay, rendered on the same 3-second clip of the GW Bridge.