During the winter months I enjoy riding in my shed using Rouvy’s indoor cycling app, but I miss some of my regular routes during the summer days. So the prospect of being able to upload my own routes and footage got me very excited.
My excitement was short-lived after realising it only supported GoPro cameras with GPS built it. At this point I only had the DJI Action 4 which is a great bit of kit but it does not embed GPS into the video file like the GoPro. I went out and purchased the DJI Osmo Action GPS Bluetooth Remote Controller which does indeed and the GPS track to the video file. Now came the tricky part.
Fortunately, with a lot of persistence I successfully managed to upload a modified video to Rouvy Route Creator and it passed all the validation.
Below is a rough guide how I achieved this goal taken from very rough notes I made during the process. I will update this guide at a later date (probably the winter now) when i have actually got some routes in to Rouvy.
There are some caveats to this guide which are listed below:
- For this guide i used the DJI Osmo Action 4 with the GPS remote. I cannot confirm the same process for other cameras but the general principles should be similar.
- At the time i writing I never found time to actually record the routes i wanted to add and my Rouvy subscription has since expired. Therefore at this point in time i haven’t actually managed to get one of my routes on Rouvy other than get my test video to pass all the validation rules in Rouvy Route Creator.
- WARNING: The guide gets quite technical. Be sure to read through it first to determine if you feel comfortable with the process.
Step 1 – Extract the GPX from the DJI video
Use pyosmogps on the command line to extract the GPX from the DJI Video. Example:
python -m pyosmogps --frequency 18 --resampling-method lpf extract DJI_20251012143147_0001_D.mp4 DJI_20251012143147_0001_D.gpx
Step 2 – Convert the GPX to GPMF video output track
This is where it gets tricky (and a bit hazy). I cloned gpmf-write and created the gpx_to_gpmf code below that uses gpmf-write to generate a new mp4 file that contains the gps track in GoPro gpmf format.
I have added this code to a Github repository.
/*
* GPX to GPMF Converter with Enhanced GPS Metadata
* Converts GPX files directly to GoPro GPMF format in MP4
* Includes GPS fix quality and precision data for compatibility with tools like Rouvy
*
* Compile (from gpmf-write directory):
* gcc -o gpx_to_gpmf gpx_to_gpmf.c \
* GPMF_writer.c demo/GPMF_mp4writer.c demo/GPMF_parser.c \
* demo/GPMF_print.c -I. -Idemo -lm -D_FILE_OFFSET_BITS=64
*
* Usage: ./gpx_to_gpmf input.gpx output.mp4
*/
#define _XOPEN_SOURCE 700
#define _GNU_SOURCE
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <time.h>
#include <ctype.h>
#include "GPMF_common.h"
#include "GPMF_writer.h"
#include "GPMF_parser.h"
#include "GPMF_mp4writer.h"
#define MAX_LINE_LENGTH 1024
#define GPS_SAMPLES_PER_PAYLOAD 18
extern void PrintGPMF(GPMF_stream *);
typedef struct {
uint64_t timestamp_us; // microseconds since epoch
int32_t latitude; // scaled by 10000000
int32_t longitude; // scaled by 10000000
int32_t altitude; // scaled by 1000 (millimeters)
int32_t speed2d; // scaled by 1000
int32_t speed3d; // scaled by 1000
uint16_t gps_fix; // 0=no fix, 2=2D, 3=3D
uint16_t gps_precision; // HDOP * 100
} GPSData;
// Simple XML tag extractor
char* extract_tag_content(const char *line, const char *tag) {
static char content[256];
char open_tag[64], close_tag[64];
snprintf(open_tag, sizeof(open_tag), "<%s>", tag);
snprintf(close_tag, sizeof(close_tag), "</%s>", tag);
char *start = strstr(line, open_tag);
if (!start) return NULL;
start += strlen(open_tag);
char *end = strstr(start, close_tag);
if (!end) return NULL;
int len = end - start;
if (len >= sizeof(content)) len = sizeof(content) - 1;
strncpy(content, start, len);
content[len] = '\0';
return content;
}
// Parse ISO 8601 timestamp to microseconds
uint64_t parse_timestamp(const char *ts_str) {
struct tm tm = {0};
char *ptr;
int microseconds = 0;
// Parse main timestamp: 2025-10-12T20:31:48
ptr = strptime(ts_str, "%Y-%m-%dT%H:%M:%S", &tm);
if (!ptr) return 0;
// Parse fractional seconds if present
if (*ptr == '.') {
ptr++;
char frac[7] = {0};
int i = 0;
while (*ptr >= '0' && *ptr <= '9' && i < 6) {
frac[i++] = *ptr++;
}
while (i < 6) frac[i++] = '0';
microseconds = atoi(frac);
}
tm.tm_isdst = -1; // Let mktime determine DST
time_t seconds = mktime(&tm);
return (uint64_t)seconds * 1000000 + microseconds;
}
// Read GPS data from GPX file
int read_gps_gpx(const char *filename, GPSData **data, int *count) {
FILE *fp = fopen(filename, "r");
if (!fp) {
printf("Error: Cannot open file %s\n", filename);
return -1;
}
char line[MAX_LINE_LENGTH];
int capacity = 1000;
*data = (GPSData *)malloc(capacity * sizeof(GPSData));
*count = 0;
int in_trkpt = 0;
double lat = 0, lon = 0, ele = 0, speed = 0, hdop = 1.0;
char timestamp[64] = {0};
int fix_type = 3; // Default to 3D fix
printf("Parsing GPX file: %s\n", filename);
while (fgets(line, sizeof(line), fp)) {
// Check for track point start with lat/lon attributes
if (strstr(line, "<trkpt") || strstr(line, "<wpt")) {
in_trkpt = 1;
// Reset for new point
ele = 0;
speed = 0;
hdop = 1.0;
timestamp[0] = '\0';
fix_type = 3;
// Extract lat/lon from attributes
char *lat_str = strstr(line, "lat=\"");
char *lon_str = strstr(line, "lon=\"");
if (lat_str && lon_str) {
sscanf(lat_str, "lat=\"%lf\"", &lat);
sscanf(lon_str, "lon=\"%lf\"", &lon);
}
}
if (in_trkpt) {
char *content;
// Extract elevation
if ((content = extract_tag_content(line, "ele"))) {
ele = atof(content);
}
// Extract time
if ((content = extract_tag_content(line, "time"))) {
strncpy(timestamp, content, sizeof(timestamp) - 1);
}
// Extract speed (might be in extensions)
if ((content = extract_tag_content(line, "speed"))) {
speed = atof(content);
}
// Extract HDOP (horizontal dilution of precision)
if ((content = extract_tag_content(line, "hdop"))) {
hdop = atof(content);
}
// Extract fix type
if ((content = extract_tag_content(line, "fix"))) {
if (strcmp(content, "3d") == 0) fix_type = 3;
else if (strcmp(content, "2d") == 0) fix_type = 2;
else if (strcmp(content, "none") == 0) fix_type = 0;
}
// Check for end of track point
if (strstr(line, "</trkpt>") || strstr(line, "</wpt>")) {
in_trkpt = 0;
// Only add point if we have valid data
if (timestamp[0] && lat != 0 && lon != 0) {
if (*count >= capacity) {
capacity *= 2;
*data = (GPSData *)realloc(*data, capacity * sizeof(GPSData));
}
(*data)[*count].timestamp_us = parse_timestamp(timestamp);
(*data)[*count].latitude = (int32_t)(lat * 10000000.0);
(*data)[*count].longitude = (int32_t)(lon * 10000000.0);
(*data)[*count].altitude = (int32_t)(ele * 1000.0);
(*data)[*count].speed2d = (int32_t)(speed * 1000.0);
(*data)[*count].speed3d = (int32_t)(speed * 1000.0);
(*data)[*count].gps_fix = fix_type;
(*data)[*count].gps_precision = (uint16_t)(hdop * 100.0);
// Debug first few samples
if (*count < 3) {
printf("Sample %d: timestamp=%s\n", *count, timestamp);
printf(" Raw: lat=%.8f, lon=%.8f, ele=%.3f, speed=%.3f\n",
lat, lon, ele, speed);
printf(" Fix: %d, HDOP: %.2f\n", fix_type, hdop);
printf(" Scaled: lat=%d, lon=%d, alt=%d, speed=%d\n",
(*data)[*count].latitude, (*data)[*count].longitude,
(*data)[*count].altitude, (*data)[*count].speed2d);
}
(*count)++;
}
}
}
}
fclose(fp);
printf("\nRead %d GPS samples from %s\n", *count, filename);
if (*count > 0) {
printf("First timestamp: %llu us\n", (*data)[0].timestamp_us);
printf("Last timestamp: %llu us\n", (*data)[*count-1].timestamp_us);
printf("Duration: %.2f seconds\n",
((*data)[*count-1].timestamp_us - (*data)[0].timestamp_us) / 1000000.0);
}
return 0;
}
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("Usage: %s <input.gpx> <output.mp4>\n", argv[0]);
printf("\nConverts GPX track data directly to GPMF format in MP4\n");
printf("Includes GPS fix quality and precision data for better compatibility\n");
return -1;
}
GPSData *gps_data = NULL;
int gps_count = 0;
printf("=== GPX to GPMF Converter ===\n\n");
// Read GPS data from GPX
if (read_gps_gpx(argv[1], &gps_data, &gps_count) != 0) {
return -1;
}
if (gps_count == 0) {
printf("Error: No valid GPS data found in GPX file\n");
free(gps_data);
return -1;
}
printf("\n=== Initializing GPMF Writer ===\n");
// Initialize GPMF writer
size_t gpmfhandle = GPMFWriteServiceInit();
size_t mp4_handle = OpenMP4Export(argv[2], 1000, 1001);
if (!gpmfhandle) {
printf("Error: Failed to initialize GPMF service\n");
free(gps_data);
return -1;
}
printf("GPMF service initialized: handle=%zu\n", gpmfhandle);
if (!mp4_handle) {
printf("Error: Failed to create MP4 file\n");
GPMFWriteServiceClose(gpmfhandle);
free(gps_data);
return -1;
}
printf("MP4 file created: %s (handle=%zu)\n", argv[2], mp4_handle);
// Create GPS stream
char gps_buffer[32768];
size_t gps_handle = GPMFWriteStreamOpen(gpmfhandle, GPMF_CHANNEL_TIMED,
GPMF_DEVICE_ID_CAMERA, "GoPro",
gps_buffer, sizeof(gps_buffer));
if (!gps_handle) {
printf("Error: Failed to create GPS stream\n");
CloseExport(mp4_handle);
GPMFWriteServiceClose(gpmfhandle);
free(gps_data);
return -1;
}
printf("GPS stream created: handle=%zu\n", gps_handle);
printf("\n=== Adding Stream Metadata ===\n");
// Add GPS5 stream metadata
char stream_name[] = "GPS (Latitude, Longitude, Altitude, 2D speed, 3D speed)";
int ret = GPMFWriteStreamStore(gps_handle, GPMF_KEY_STREAM_NAME,
GPMF_TYPE_STRING_ASCII, strlen(stream_name), 1,
stream_name, GPMF_FLAGS_STICKY);
printf("Stream name: %s (ret=%d)\n", stream_name, ret);
char gps_type[] = "lllll";
ret = GPMFWriteStreamStore(gps_handle, GPMF_KEY_TYPE,
GPMF_TYPE_STRING_ASCII, strlen(gps_type), 1,
gps_type, GPMF_FLAGS_STICKY);
printf("GPS type: %s (ret=%d)\n", gps_type, ret);
int32_t gps_scale[5] = {10000000, 10000000, 1000, 1000, 1000};
ret = GPMFWriteStreamStore(gps_handle, GPMF_KEY_SCALE,
GPMF_TYPE_SIGNED_LONG, sizeof(int32_t), 5,
gps_scale, GPMF_FLAGS_STICKY);
printf("GPS scale: [%d, %d, %d, %d, %d] (ret=%d)\n",
gps_scale[0], gps_scale[1], gps_scale[2], gps_scale[3], gps_scale[4], ret);
char units[] = "deg\0deg\0m\0m/s\0m/s";
ret = GPMFWriteStreamStore(gps_handle, GPMF_KEY_SI_UNITS,
GPMF_TYPE_STRING_ASCII, sizeof(units), 1,
units, GPMF_FLAGS_STICKY);
printf("GPS units added (ret=%d)\n", ret);
// Flush sticky data
char buffer[32768];
uint32_t *payload = NULL;
uint32_t payload_size = 0;
GPMFWriteGetPayload(gpmfhandle, GPMF_CHANNEL_TIMED,
(uint32_t *)buffer, sizeof(buffer),
&payload, &payload_size);
printf("Flushed sticky data: %d bytes\n", payload_size);
// Write GPS samples
printf("\n=== Writing GPS Samples ===\n");
uint64_t first_timestamp = gps_data[0].timestamp_us;
int payload_count = 0;
int samples_written = 0;
for (int i = 0; i < gps_count; i++) {
int32_t gps5_data[5];
gps5_data[0] = gps_data[i].latitude;
gps5_data[1] = gps_data[i].longitude;
gps5_data[2] = gps_data[i].altitude;
gps5_data[3] = gps_data[i].speed2d;
gps5_data[4] = gps_data[i].speed3d;
uint64_t rel_timestamp = gps_data[i].timestamp_us - first_timestamp;
// Write GPS5 data
ret = GPMFWriteStreamStoreStamped(gps_handle, STR2FOURCC("GPS5"),
GPMF_TYPE_SIGNED_LONG, sizeof(int32_t) * 5,
1, gps5_data, GPMF_FLAGS_NONE,
rel_timestamp);
if (ret != GPMF_OK) {
printf("Error writing GPS5 sample %d: ret=%d\n", i, ret);
} else {
samples_written++;
}
// Write GPS fix quality (GPSF)
ret = GPMFWriteStreamStoreStamped(gps_handle, STR2FOURCC("GPSF"),
GPMF_TYPE_UNSIGNED_SHORT, sizeof(uint16_t),
1, &gps_data[i].gps_fix, GPMF_FLAGS_NONE,
rel_timestamp);
// Write GPS precision (GPSP)
ret = GPMFWriteStreamStoreStamped(gps_handle, STR2FOURCC("GPSP"),
GPMF_TYPE_UNSIGNED_SHORT, sizeof(uint16_t),
1, &gps_data[i].gps_precision, GPMF_FLAGS_NONE,
rel_timestamp);
// Debug first few samples
if (i < 3) {
printf("Sample %d written:\n", i);
printf(" GPS5: [%d, %d, %d, %d, %d]\n",
gps5_data[0], gps5_data[1], gps5_data[2],
gps5_data[3], gps5_data[4]);
printf(" Fix: %d, Precision: %d\n",
gps_data[i].gps_fix, gps_data[i].gps_precision);
printf(" Timestamp: %llu us (%.6f sec)\n",
rel_timestamp, rel_timestamp / 1000000.0);
}
// Write payload periodically
if ((i > 0 && i % GPS_SAMPLES_PER_PAYLOAD == 0) || i == gps_count - 1) {
uint64_t window_time = rel_timestamp;
GPMFWriteGetPayloadWindow(gpmfhandle, GPMF_CHANNEL_TIMED,
(uint32_t *)buffer, sizeof(buffer),
&payload, &payload_size, window_time);
if (payload_size > 0) {
ExportPayload(mp4_handle, payload, payload_size);
payload_count++;
printf("Payload #%d: %d bytes, time=%.3f sec, samples=%d\n",
payload_count, payload_size, window_time / 1000000.0,
(i % GPS_SAMPLES_PER_PAYLOAD == 0) ? GPS_SAMPLES_PER_PAYLOAD : (i % GPS_SAMPLES_PER_PAYLOAD + 1));
// Print first payload structure
if (payload_count == 1) {
printf("\n=== First Payload GPMF Structure ===\n");
GPMF_stream gs;
if (GPMF_OK == GPMF_Init(&gs, payload, payload_size)) {
GPMF_ResetState(&gs);
do {
PrintGPMF(&gs);
} while (GPMF_OK == GPMF_Next(&gs, GPMF_RECURSE_LEVELS));
}
printf("=====================================\n\n");
}
}
}
}
// Cleanup
printf("\n=== Finalizing ===\n");
printf("Total samples written: %d/%d\n", samples_written, gps_count);
printf("Total payloads exported: %d\n", payload_count);
GPMFWriteStreamClose(gps_handle);
CloseExport(mp4_handle);
GPMFWriteServiceClose(gpmfhandle);
free(gps_data);
printf("\nDone! Created %s\n", argv[2]);
printf("\nTo combine with video:\n");
printf("ffmpeg -i video.mp4 -i %s -map 0:v:0 -map 0:a:0? -map 1:d:0 -c copy -tag:s:0 gpmd output_final.mp4\n", argv[2]);
return 0;
}
You’ll need to compile this using gcc (I did this on a linux machine). Example below:
gcc -o gps_to_gpmf gps_to_gpmf.c GPMF_writer.c demo/GPMF_mp4writer.c demo/GPMF_parser.c demo/GPMF_print.c -I. -Idemo -lm -D_FILE_OFFSET_BITS=64
You can then run the following command to create the output file:
./gpx_to_gpmf DJI_20251012143147_0001_D_RideWithGPS.gpx output.mp4
Step 3 – Add the GPMF track to the original file
For this you’ll need ffmpeg. It’s as simple as adding the track to the original file to create new file with the GPMF track embedded. Example below:
ffmpeg -i C:\temp\Rouvy\DJI_20251012143147_0001_D.mp4 -i C:\temp\Rouvy\output.mp4 -map 0:v:0 -map 0:a:0? -map 1:d:0 -c copy -tag:s:0 gpmd C:\temp\Rouvy\DJI_20251012143147_0001_D_final.mp4
That’s all there is to it! I’m sure there are other ways to achive this but this is my method. Good luck!
