{"id":102,"date":"2025-07-29T15:23:15","date_gmt":"2025-07-29T19:23:15","guid":{"rendered":"https:\/\/learnwithpatrick.casa\/?p=102"},"modified":"2025-07-29T15:23:15","modified_gmt":"2025-07-29T19:23:15","slug":"automating-cycling-videos-in-aws","status":"publish","type":"post","link":"https:\/\/learnwithpatrick.casa\/?p=102","title":{"rendered":"Automating Cycling Videos in AWS"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Sometimes you get an idea for a simple fix to make your life easier&#8230; and then that simple idea spirals out of control and becomes a massive project. I like to <a href=\"https:\/\/www.youtube.com\/channel\/UCKDZgGZp58ds98ZHqWyi7iA\" data-type=\"link\" data-id=\"https:\/\/www.youtube.com\/channel\/UCKDZgGZp58ds98ZHqWyi7iA\">record my bike rides<\/a> when I travel, but I don&#8217;t travel with a lot of storage media. I <em>do<\/em> have a NAS at home. The obvious solution would be to upload videos straight to my NAS, but I don&#8217;t want to expose my NAS to the Internet (I&#8217;m fully aware that there are secure ways to do this, but for the exercise&#8217;s sake, let&#8217;s ignore that).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">My not-as-obvious but almost-as-simple solution was to upload the videos to AWS S3, have that upload generate an SQS message, and have my NAS run a scheduled task to poll the SQS for videos to download. I did this easily, and it works great! Being honest, I spent more time getting my Synology NAS to run AWS CLI commands than I did writing the actual script to poll and download &#8211; but that adventure could be its own post. Instead, here&#8217;s the jq tidbit I used to extract the bucket name and key from the SQS message for the download commands:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>echo \"$MESSAGES\" | jq -c '.Messages[]?' | while read -r msg; do\n  RECEIPT_HANDLE=$(echo \"$msg\" | jq -r '.ReceiptHandle')\n  BODY=$(echo \"$msg\" | jq -r '.Body')\n  KEY=$(echo \"$BODY\" | jq -r '.Records&#91;0&#93;.s3.object.key')\n  BUCKET=$(echo \"$BODY\" | jq -r '.Records&#91;0&#93;.s3.bucket.name')<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #9CDCFE\">$MESSAGES<\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #D4D4D4\"> | <\/span><span style=\"color: #DCDCAA\">jq<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-c<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&#39;.Messages[]?&#39;<\/span><span style=\"color: #D4D4D4\"> | <\/span><span style=\"color: #C586C0\">while<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">read<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-r<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">msg<\/span><span style=\"color: #D4D4D4\">; <\/span><span style=\"color: #C586C0\">do<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #9CDCFE\">RECEIPT_HANDLE<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">$(<\/span><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #CE9178\"> &quot;<\/span><span style=\"color: #9CDCFE\">$msg<\/span><span style=\"color: #CE9178\">&quot; <\/span><span style=\"color: #D4D4D4\">|<\/span><span style=\"color: #CE9178\"> <\/span><span style=\"color: #DCDCAA\">jq<\/span><span style=\"color: #CE9178\"> <\/span><span style=\"color: #569CD6\">-r<\/span><span style=\"color: #CE9178\"> &#39;.ReceiptHandle&#39;)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #9CDCFE\">BODY<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">$(<\/span><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #CE9178\"> &quot;<\/span><span style=\"color: #9CDCFE\">$msg<\/span><span style=\"color: #CE9178\">&quot; <\/span><span style=\"color: #D4D4D4\">|<\/span><span style=\"color: #CE9178\"> <\/span><span style=\"color: #DCDCAA\">jq<\/span><span style=\"color: #CE9178\"> <\/span><span style=\"color: #569CD6\">-r<\/span><span style=\"color: #CE9178\"> &#39;.Body&#39;)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #9CDCFE\">KEY<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">$(<\/span><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #CE9178\"> &quot;<\/span><span style=\"color: #9CDCFE\">$BODY<\/span><span style=\"color: #CE9178\">&quot; <\/span><span style=\"color: #D4D4D4\">|<\/span><span style=\"color: #CE9178\"> <\/span><span style=\"color: #DCDCAA\">jq<\/span><span style=\"color: #CE9178\"> <\/span><span style=\"color: #569CD6\">-r<\/span><span style=\"color: #CE9178\"> &#39;.Records&#91;0&#93;.s3.object.key&#39;)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #9CDCFE\">BUCKET<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">$(<\/span><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #CE9178\"> &quot;<\/span><span style=\"color: #9CDCFE\">$BODY<\/span><span style=\"color: #CE9178\">&quot; <\/span><span style=\"color: #D4D4D4\">|<\/span><span style=\"color: #CE9178\"> <\/span><span style=\"color: #DCDCAA\">jq<\/span><span style=\"color: #CE9178\"> <\/span><span style=\"color: #569CD6\">-r<\/span><span style=\"color: #CE9178\"> &#39;.Records&#91;0&#93;.s3.bucket.name&#39;)<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">While I had solved my original issue, I hadn&#8217;t scratched the &#8220;build something cool&#8221; itch. Around the time I built this, I had also just finished the AWS Solutions Architect Associate certification. I don&#8217;t get many chances to use that knowledge in my workday, but I wanted to cement it &#8211; and the best way to do that is to build something real!<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When I upload ride videos to YouTube, I don&#8217;t upload the raw footage. I download the GPS and health telemetry data and place it in an overlay, matching the timing of the video with my effort and route. What if, instead of manually editing the video when I get home, I just have AWS do the overlay for me? I started out by playing with the software I use at home, <em><a href=\"https:\/\/goprotelemetryextractor.com\/\" data-type=\"link\" data-id=\"https:\/\/goprotelemetryextractor.com\/\">Telemetry Overlay<\/a><\/em>, but was unable to automate anything unless I wanted to do clicking macros. Once that proved to be a dead end, I sketched out the rough order of steps I thought I&#8217;d need the infrastructure to accomplish:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Accept an uploaded video<\/li>\n\n\n\n<li>Download the telemetry data from an API<\/li>\n\n\n\n<li>Extract the telemetry data and transform it into what needs to be in the video<\/li>\n\n\n\n<li>Burn telemetry into the uploaded video<\/li>\n\n\n\n<li>Upload finished video to a new bucket<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Now I had some &#8220;easy&#8221; bite-sized chunks to work with, as well as a proposed architecture.<\/p>\n\n\n\n<figure class=\"wp-block-image aligncenter size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"540\" height=\"161\" src=\"http:\/\/18.191.103.99\/wp-content\/uploads\/2025\/07\/image-2.png\" alt=\"\" class=\"wp-image-110\" srcset=\"https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-2.png 540w, https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-2-300x89.png 300w\" sizes=\"auto, (max-width: 540px) 100vw, 540px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Extracting and Burning Telemetry<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The first step was to extract the telemetry. Bike computers (and every fitness wearable for that matter) store data from activities in binary-encoded FIT files, and the <em><a href=\"https:\/\/github.com\/dtcooper\/python-fitparse\">fitparse<\/a><\/em> Python library can do all of the heavy lifting for me. The code eventually got more complex for reasons we&#8217;ll get to later, but to extract the telemetry from the FIT files, it looked like this:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>fitfile = FitFile(\"ride.fit\")\nrecords = []\n\nfor record in fitfile.get_messages(\"record\"):\n    fields = {f.name: f.value for f in record}\n    if \"timestamp\" in fields:\n        records.append(fields)\n\n# Save data every 0.5 seconds\ninterval = timedelta(seconds=0.5)\nlast_time = None\nframes = []\n\nfor r in records:\n    if last_time is None or (r&#91;\"timestamp\"&#93; - last_time) >= interval:\n        frames.append(r)\n        last_time = r&#91;\"timestamp\"&#93;<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #D4D4D4\">fitfile = FitFile(<\/span><span style=\"color: #CE9178\">&quot;ride.fit&quot;<\/span><span style=\"color: #D4D4D4\">)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">records = []<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #C586C0\">for<\/span><span style=\"color: #D4D4D4\"> record <\/span><span style=\"color: #C586C0\">in<\/span><span style=\"color: #D4D4D4\"> fitfile.get_messages(<\/span><span style=\"color: #CE9178\">&quot;record&quot;<\/span><span style=\"color: #D4D4D4\">):<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    fields = {f.name: f.value <\/span><span style=\"color: #C586C0\">for<\/span><span style=\"color: #D4D4D4\"> f <\/span><span style=\"color: #C586C0\">in<\/span><span style=\"color: #D4D4D4\"> record}<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">if<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;timestamp&quot;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">in<\/span><span style=\"color: #D4D4D4\"> fields:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        records.append(fields)<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #6A9955\"># Save data every 0.5 seconds<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">interval = timedelta(<\/span><span style=\"color: #9CDCFE\">seconds<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #B5CEA8\">0.5<\/span><span style=\"color: #D4D4D4\">)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">last_time = <\/span><span style=\"color: #569CD6\">None<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">frames = []<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #C586C0\">for<\/span><span style=\"color: #D4D4D4\"> r <\/span><span style=\"color: #C586C0\">in<\/span><span style=\"color: #D4D4D4\"> records:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">if<\/span><span style=\"color: #D4D4D4\"> last_time <\/span><span style=\"color: #569CD6\">is<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">None<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">or<\/span><span style=\"color: #D4D4D4\"> (r&#91;<\/span><span style=\"color: #CE9178\">&quot;timestamp&quot;<\/span><span style=\"color: #D4D4D4\">&#93; - last_time) &gt;= interval:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        frames.append(r)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        last_time = r&#91;<\/span><span style=\"color: #CE9178\">&quot;timestamp&quot;<\/span><span style=\"color: #D4D4D4\">&#93;<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Here we added all of the events in the FIT file to a list (records), then created a new list (frames) that only has updates from events half a second apart (a good video update cadence). Next up is putting the frames onto the video.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The only thing I <em>knew<\/em> at this point was that I&#8217;d be using FFmpeg to burn the frames in, but I wasn&#8217;t sure <em>how<\/em>. So I asked ChatGPT, which suggested using FFmpeg to burn chroma-keyed images onto the video. It was pretty simple to generate them using PIL, the Python Image Library:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>for i, r in enumerate(frames):\n    img = Image.new(\"RGB\", (1280, 720), color=(0, 255, 0))  # Chroma green background\n    draw = ImageDraw.Draw(img)\n    draw.text((50, 50), f\"Time: {r&#91;'timestamp'&#93;}\", font=font, fill=(0, 0, 0))\n    draw.text((50, 100), f\"Speed: {r.get('speed', 'N\/A')} m\/s\", font=font, fill=(0, 0, 0))\n    draw.text((50, 150), f\"HR: {r.get('heart_rate', 'N\/A')}\", font=font, fill=(0, 0, 0))\n    draw.text((50, 200), f\"Cadence: {r.get('cadence', 'N\/A')}\", font=font, fill=(0, 0, 0))\n    img.save(f\"frames\/frame_{i:05}.png\")<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #C586C0\">for<\/span><span style=\"color: #D4D4D4\"> i, r <\/span><span style=\"color: #C586C0\">in<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">enumerate<\/span><span style=\"color: #D4D4D4\">(frames):<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    img = Image.new(<\/span><span style=\"color: #CE9178\">&quot;RGB&quot;<\/span><span style=\"color: #D4D4D4\">, (<\/span><span style=\"color: #B5CEA8\">1280<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">720<\/span><span style=\"color: #D4D4D4\">), <\/span><span style=\"color: #9CDCFE\">color<\/span><span style=\"color: #D4D4D4\">=(<\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">255<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">))  <\/span><span style=\"color: #6A9955\"># Chroma green background<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    draw = ImageDraw.Draw(img)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    draw.text((<\/span><span style=\"color: #B5CEA8\">50<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">50<\/span><span style=\"color: #D4D4D4\">), <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;Time: <\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">r&#91;<\/span><span style=\"color: #CE9178\">&#39;timestamp&#39;<\/span><span style=\"color: #D4D4D4\">&#93;<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #9CDCFE\">font<\/span><span style=\"color: #D4D4D4\">=font, <\/span><span style=\"color: #9CDCFE\">fill<\/span><span style=\"color: #D4D4D4\">=(<\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">))<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    draw.text((<\/span><span style=\"color: #B5CEA8\">50<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">100<\/span><span style=\"color: #D4D4D4\">), <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;Speed: <\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">r.get(<\/span><span style=\"color: #CE9178\">&#39;speed&#39;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #CE9178\">&#39;N\/A&#39;<\/span><span style=\"color: #D4D4D4\">)<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\"> m\/s&quot;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #9CDCFE\">font<\/span><span style=\"color: #D4D4D4\">=font, <\/span><span style=\"color: #9CDCFE\">fill<\/span><span style=\"color: #D4D4D4\">=(<\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">))<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    draw.text((<\/span><span style=\"color: #B5CEA8\">50<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">150<\/span><span style=\"color: #D4D4D4\">), <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;HR: <\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">r.get(<\/span><span style=\"color: #CE9178\">&#39;heart_rate&#39;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #CE9178\">&#39;N\/A&#39;<\/span><span style=\"color: #D4D4D4\">)<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #9CDCFE\">font<\/span><span style=\"color: #D4D4D4\">=font, <\/span><span style=\"color: #9CDCFE\">fill<\/span><span style=\"color: #D4D4D4\">=(<\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">))<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    draw.text((<\/span><span style=\"color: #B5CEA8\">50<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">200<\/span><span style=\"color: #D4D4D4\">), <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;Cadence: <\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">r.get(<\/span><span style=\"color: #CE9178\">&#39;cadence&#39;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #CE9178\">&#39;N\/A&#39;<\/span><span style=\"color: #D4D4D4\">)<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #9CDCFE\">font<\/span><span style=\"color: #D4D4D4\">=font, <\/span><span style=\"color: #9CDCFE\">fill<\/span><span style=\"color: #D4D4D4\">=(<\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">))<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    img.save(<\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;frames\/frame_<\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">i<\/span><span style=\"color: #569CD6\">:05}<\/span><span style=\"color: #CE9178\">.png&quot;<\/span><span style=\"color: #D4D4D4\">)<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">This works! Ugly, but decent for a proof of concept.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"576\" src=\"https:\/\/18.191.103.99\/wp-content\/uploads\/2025\/07\/image-1024x576.png\" alt=\"Screenshot of generated frames\" class=\"wp-image-105\" srcset=\"https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-1024x576.png 1024w, https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-300x169.png 300w, https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-768x432.png 768w, https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-1536x863.png 1536w, https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image.png 1548w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">And then two lines of bash &#8211; one to generate a video from these frames, and a second to overlay them onto the original:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>ffmpeg -framerate 2 -i frames\/frame_%05d.png -c:v libx264 stats_overlay.mp4\nffmpeg -i main_video.mp4 -i stats_overlay.mp4 -filter_complex \"&#91;1:v&#93;chromakey=0x00FF00:0.1:0.0&#91;ckout&#93;;&#91;0:v&#93;&#91;ckout&#93;overlay&#91;out&#93;\" -map \"&#91;out&#93;\" -map 0:a? -c:a copy output.mp4<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #DCDCAA\">ffmpeg<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-framerate<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #B5CEA8\">2<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-i<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">frames\/frame_%05d.png<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-c:v<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">libx264<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">stats_overlay.mp4<\/span><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">ffmpeg<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-i<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">main_video.mp4<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-i<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">stats_overlay.mp4<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-filter_complex<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;&#91;1:v&#93;chromakey=0x00FF00:0.1:0.0&#91;ckout&#93;;&#91;0:v&#93;&#91;ckout&#93;overlay&#91;out&#93;&quot;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-map<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;&#91;out&#93;&quot;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-map<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #CE9178\">:a?<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-c:a<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">copy<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">output.mp4<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">This did work, but I was worried about keeping container size small between generating all those images and an extra video, since the end goal is to run this in the cloud. I remembered back to my days of taking torrented anime episodes and burning the subtitles in so I could watch them on my PSP, and realized it would be much more time and space-efficient to make a <em>subtitle file<\/em> and burn that in instead of making all these images and a video. ChatGPT put me on the right path, but my weebiness won this one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I did play around with some libraries for automating the creation of <em>Advanced SubStation Alpha<\/em> files, but at the end of the day, it was easiest to iterate through the FIT file and just add a new line when appropriate:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly> for r in records:\n        t = r&#91;\"deltaSeconds\"&#93;\n\n        #if it's been longer than the interval time make a new subs line\n        if t - lastOverlay >= subsInterval:\n            start = _formatASSTime(t)\n            end = _formatASSTime(t + subsInterval)\n\n            r&#91;'speed'&#93; = r&#91;'speed'&#93; * 2.23694 #convert m\/s to mph\n\n            text = ( #generate stats for the line\n                f\"Speed: {r.get('speed', 'N\/A'):.1f} mph  \" #the :.1f truncates the number\n                f\"HR: {r.get('heart_rate', 'N\/A')} bpm  \"\n                f\"Cadence: {r.get('cadence', 'N\/A')}  \"\n                f\"Power: {r.get('power', 'N\/A')} watts\"\n            )\n\n            #write this intervals stats to subs\n            dialogue = f\"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}\\n\"\n            lines.append(dialogue)\n            lastOverlay = t<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #C586C0\">for<\/span><span style=\"color: #D4D4D4\"> r <\/span><span style=\"color: #C586C0\">in<\/span><span style=\"color: #D4D4D4\"> records:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        t = r&#91;<\/span><span style=\"color: #CE9178\">&quot;deltaSeconds&quot;<\/span><span style=\"color: #D4D4D4\">&#93;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        <\/span><span style=\"color: #6A9955\">#if it&#39;s been longer than the interval time make a new subs line<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        <\/span><span style=\"color: #C586C0\">if<\/span><span style=\"color: #D4D4D4\"> t - lastOverlay &gt;= subsInterval:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            start = _formatASSTime(t)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            end = _formatASSTime(t + subsInterval)<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            r&#91;<\/span><span style=\"color: #CE9178\">&#39;speed&#39;<\/span><span style=\"color: #D4D4D4\">&#93; = r&#91;<\/span><span style=\"color: #CE9178\">&#39;speed&#39;<\/span><span style=\"color: #D4D4D4\">&#93; * <\/span><span style=\"color: #B5CEA8\">2.23694<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #6A9955\">#convert m\/s to mph<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            text = ( <\/span><span style=\"color: #6A9955\">#generate stats for the line<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;Speed: <\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">r.get(<\/span><span style=\"color: #CE9178\">&#39;speed&#39;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #CE9178\">&#39;N\/A&#39;<\/span><span style=\"color: #D4D4D4\">)<\/span><span style=\"color: #569CD6\">:.1f}<\/span><span style=\"color: #CE9178\"> mph  &quot;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #6A9955\">#the :.1f truncates the number<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;HR: <\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">r.get(<\/span><span style=\"color: #CE9178\">&#39;heart_rate&#39;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #CE9178\">&#39;N\/A&#39;<\/span><span style=\"color: #D4D4D4\">)<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\"> bpm  &quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;Cadence: <\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">r.get(<\/span><span style=\"color: #CE9178\">&#39;cadence&#39;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #CE9178\">&#39;N\/A&#39;<\/span><span style=\"color: #D4D4D4\">)<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\">  &quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;Power: <\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">r.get(<\/span><span style=\"color: #CE9178\">&#39;power&#39;<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #CE9178\">&#39;N\/A&#39;<\/span><span style=\"color: #D4D4D4\">)<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\"> watts&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            )<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            <\/span><span style=\"color: #6A9955\">#write this intervals stats to subs<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            dialogue = <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;Dialogue: 0,<\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">start<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\">,<\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">end<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\">,Default,,0,0,0,,<\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">text<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #D7BA7D\">\\n<\/span><span style=\"color: #CE9178\">&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            lines.append(dialogue)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            lastOverlay = t<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Write the header of the subtitles file along with <code>lines[]<\/code> and we&#8217;re golden! My local proof of concept was now complete.<\/p>\n\n\n\n<figure class=\"wp-block-image aligncenter size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"576\" src=\"https:\/\/18.191.103.99\/wp-content\/uploads\/2025\/07\/image-1-1024x576.png\" alt=\"\" class=\"wp-image-106\" srcset=\"https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-1-1024x576.png 1024w, https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-1-300x169.png 300w, https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-1-768x432.png 768w, https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/image-1.png 1365w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">&#8230;except I forgot that the FIT file doesn&#8217;t start when I start the camera &#8211; it starts when I start the ride!<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Metadata Metaproblems<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The solution to the timing problem is pretty simple when we&#8217;re running this locally: use the metadata from the original video to figure out which records we need from the FIT file. There&#8217;s a great library named <a href=\"https:\/\/mediaarea.net\/en\/MediaInfo\" data-type=\"link\" data-id=\"https:\/\/mediaarea.net\/en\/MediaInfo\"><em>MediaInfo<\/em><\/a> that allows you to extract metadata from most media files, and a Python wrapper for it. Getting this to run locally was a cinch:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>#method to get video metadata in datetime objects\ndef getVideoInfo(filename):\n    from pymediainfo import MediaInfo\n\n    videoinfo = MediaInfo.parse(filename)\n    for track in videoinfo.tracks:\n        if track.track_type == \"General\":\n            date, startTime = _parseTimefromVideo(track.encoded_date)\n            duration = timedelta(milliseconds=track.duration)\n            endTime = startTime + duration\n            videoMetadata = {\n                \"date\":date.isoformat(),\n                \"startTime\":startTime.isoformat(),\n                \"duration\":track.duration,\n                \"endTime\":endTime.isoformat(),\n                \"startEpoch\":startTime.timestamp(),\n                \"endEpoch\":endTime.timestamp()\n            }\n    return videoMetadata<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #6A9955\">#method to get video metadata in datetime objects<\/span><\/span>\n<span class=\"line\"><span style=\"color: #569CD6\">def<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">getVideoInfo<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">filename<\/span><span style=\"color: #D4D4D4\">):<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">from<\/span><span style=\"color: #D4D4D4\"> pymediainfo <\/span><span style=\"color: #C586C0\">import<\/span><span style=\"color: #D4D4D4\"> MediaInfo<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    videoinfo = MediaInfo.parse(filename)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">for<\/span><span style=\"color: #D4D4D4\"> track <\/span><span style=\"color: #C586C0\">in<\/span><span style=\"color: #D4D4D4\"> videoinfo.tracks:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        <\/span><span style=\"color: #C586C0\">if<\/span><span style=\"color: #D4D4D4\"> track.track_type == <\/span><span style=\"color: #CE9178\">&quot;General&quot;<\/span><span style=\"color: #D4D4D4\">:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            date, startTime = _parseTimefromVideo(track.encoded_date)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            duration = timedelta(<\/span><span style=\"color: #9CDCFE\">milliseconds<\/span><span style=\"color: #D4D4D4\">=track.duration)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            endTime = startTime + duration<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            videoMetadata = {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #CE9178\">&quot;date&quot;<\/span><span style=\"color: #D4D4D4\">:date.isoformat(),<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #CE9178\">&quot;startTime&quot;<\/span><span style=\"color: #D4D4D4\">:startTime.isoformat(),<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #CE9178\">&quot;duration&quot;<\/span><span style=\"color: #D4D4D4\">:track.duration,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #CE9178\">&quot;endTime&quot;<\/span><span style=\"color: #D4D4D4\">:endTime.isoformat(),<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #CE9178\">&quot;startEpoch&quot;<\/span><span style=\"color: #D4D4D4\">:startTime.timestamp(),<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">                <\/span><span style=\"color: #CE9178\">&quot;endEpoch&quot;<\/span><span style=\"color: #D4D4D4\">:endTime.timestamp()<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            }<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">return<\/span><span style=\"color: #D4D4D4\"> videoMetadata<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">But once again, this being a cloud project, I really needed to limit data transfers. In my head, the generation of the subtitles file would be done by a Lambda function; I certainly don&#8217;t want to send 9-12GB files over to Lambda just to read some metadata! So I learned a bit about MP4 files and where <em>MediaInfo<\/em> was grabbing this metadata from.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I&#8217;ll try to summarize from here, but suffice it to say it was a days-long process. It turns out there are <em>a lot<\/em> of ways to put metadata in an MP4, depending on how you encode it. The big piece I needed was <em>duration<\/em>, which would tell the subs file when to end. I tried reading the file header, then the first 128kb of the file, then the last 512kb, then concatenating the first and last parts, and kept striking out. Eventually, I learned that MediaInfo wasn&#8217;t getting duration from any special moov atom or mvhd flag &#8211; it was just counting the frames and adding them up. I eventually gave up on efficiently finding the metadata in the cloud. Instead, I wrote an uploader that sends the metadata in a JSON file along with the video.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now, two months divorced from the problem, I realize that all I ever needed was a start time, and therefore this metadata issue was never a real problem. Who cares if the sub file has extra data after the end of the video? It&#8217;s a few extra kilobytes at worst! Oh well, live and learn.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Downloading Telemetry from an API<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">So far, I&#8217;d been working with the same few test videos and test FIT files, but obviously, I need to grab these FIT files on the fly. If you&#8217;re familiar with endurance sports, then you&#8217;re probably familiar with <em>Strava<\/em> as well. Strava is a website that tracks fitness data, wrapping a social media site around it &#8211; and if you&#8217;re a tech-savvy endurance nerd, it&#8217;s probably your single source of truth for tracking fitness data. It integrates with all major wearables and has a fairly friendly API to work with if your device doesn&#8217;t integrate with it out of the box.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I went into this project with the assumption that Strava accepts the FIT file from a device and creates activities from that, keeping the original FIT file accessible. I ran into a lot of issues requesting the FIT file from the API before I realized I had two issues: 1. Strava only saves a FIT file for an activity if the device sends a FIT file; many devices (like my Hammerhead Karoo) integrate in a different manner so there&#8217;s no FIT file at all, and 2. GDPR had Strava change their API availability last November.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Giving up on pulling from Strava, I pivoted to my Hammerhead Karoo dashboard, but it lacked an API endpoint for activities. Trying to manually authenticate and download a file programmatically did not seem worth it when there were so many other options. The option I eventually landed on was <em><a href=\"https:\/\/intervals.icu\/\" data-type=\"link\" data-id=\"https:\/\/intervals.icu\/\">intervals.icu<\/a><\/em>, a much more data and statitistics-intensive site. I&#8217;d already been using intervals for years to track training metrics like my fitness via TSS, so all I had to do was reconfigure my Karoo to upload the FIT file directly to intervals instead of having Strava push the data to intervals.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Building the script to request the FIT file was super simple, too. intervals has an OAuth integration available if you need to work with several different accounts, but if you&#8217;re solo you can generate a token for your account and pass it as a header to <code>\/api\/v1\/activity\/{activity-id}\/fit-file<\/code> and it will automatically use your own account. Finding the correct activity ID wasn&#8217;t too bad either:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>def findActivity(targetDate, headers):\n    response = requests.get(\n        f'{URL}\/athlete\/0\/activities',\n        headers=headers,\n        params={'oldest': targetDate, 'newest': targetDate},\n        timeout=20\n    )\n\n    response.raise_for_status()\n    activities = response.json()\n\n    for activity in activities:\n        if activity&#91;'type'&#93; == \"Ride\":\n            return activity&#91;'id'&#93;, activity&#91;'name'&#93;\n\n    print (f\"No matched activity for {targetDate}\")\n    return None, None<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #569CD6\">def<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">findActivity<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">targetDate<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #9CDCFE\">headers<\/span><span style=\"color: #D4D4D4\">):<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    response = requests.get(<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        <\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&#39;<\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">URL<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\">\/athlete\/0\/activities&#39;<\/span><span style=\"color: #D4D4D4\">,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        <\/span><span style=\"color: #9CDCFE\">headers<\/span><span style=\"color: #D4D4D4\">=headers,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        <\/span><span style=\"color: #9CDCFE\">params<\/span><span style=\"color: #D4D4D4\">={<\/span><span style=\"color: #CE9178\">&#39;oldest&#39;<\/span><span style=\"color: #D4D4D4\">: targetDate, <\/span><span style=\"color: #CE9178\">&#39;newest&#39;<\/span><span style=\"color: #D4D4D4\">: targetDate},<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        <\/span><span style=\"color: #9CDCFE\">timeout<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #B5CEA8\">20<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    )<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    response.raise_for_status()<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    activities = response.json()<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">for<\/span><span style=\"color: #D4D4D4\"> activity <\/span><span style=\"color: #C586C0\">in<\/span><span style=\"color: #D4D4D4\"> activities:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">        <\/span><span style=\"color: #C586C0\">if<\/span><span style=\"color: #D4D4D4\"> activity&#91;<\/span><span style=\"color: #CE9178\">&#39;type&#39;<\/span><span style=\"color: #D4D4D4\">&#93; == <\/span><span style=\"color: #CE9178\">&quot;Ride&quot;<\/span><span style=\"color: #D4D4D4\">:<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">            <\/span><span style=\"color: #C586C0\">return<\/span><span style=\"color: #D4D4D4\"> activity&#91;<\/span><span style=\"color: #CE9178\">&#39;id&#39;<\/span><span style=\"color: #D4D4D4\">&#93;, activity&#91;<\/span><span style=\"color: #CE9178\">&#39;name&#39;<\/span><span style=\"color: #D4D4D4\">&#93;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #DCDCAA\">print<\/span><span style=\"color: #D4D4D4\"> (<\/span><span style=\"color: #569CD6\">f<\/span><span style=\"color: #CE9178\">&quot;No matched activity for <\/span><span style=\"color: #569CD6\">{<\/span><span style=\"color: #D4D4D4\">targetDate<\/span><span style=\"color: #569CD6\">}<\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #D4D4D4\">)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">return<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">None<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #569CD6\">None<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Using the metadata upload as a trigger, I built a Lambda function to query a list of all of my activities, download the FIT file that matches based on activity date, use the FIT file and metadata to generate the subtitles file, then upload the subtitles file to S3. Finally, the subtitle file upload will trigger the rendering of the final video!<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Container<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With the subtitle file generation working properly, the next step was to build the video-rendering container to be run by ECS Fargate. The container needed to do three things:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Download the raw video and subtitles file from S3<\/li>\n\n\n\n<li>Render the final video<\/li>\n\n\n\n<li>Save the video to S3<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The container&#8217;s only dependencies were Python, AWSCLI, and FFmpeg, so the Dockerfile was super simple. The script the container runs wasn&#8217;t much more complicated either:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>#!\/bin\/bash\n\n#environment variables passed to container at runtime\necho \"source bucket = {$BUCKET}\"\necho \"sub file = {$SUBS_KEY}\"\necho \"output bucket = {$OUTBUCKET}\"\n\n#derive filenames, input files have the same name but different extensions - we're working off the .ass file\nSUBS_FILENAME=$(basename \"$SUBS_KEY\")\nBASE_NAME=\"${SUBS_FILENAME%.*}\"\nVIDEO_KEY=\"${BASE_NAME}.mp4\"\nOUTPUT_KEY=\"${BASE_NAME}_burned.mp4\"\n\n#local file names for processing\nINPUT_VIDEO=\"input.mp4\"\nSUBS_FILE=\"subs.ass\"\nOUTPUT_VIDEO=\"output.mp4\"\n\n#download from s3\naws s3 cp \"s3:\/\/${BUCKET}\/${VIDEO_KEY}\" \"$INPUT_VIDEO\"\naws s3 cp \"s3:\/\/${BUCKET}\/${SUBS_KEY}\" \"$SUBS_FILE\"\n\n#burn subtitles\necho \"$INPUT_VIDEO + $SUBS_FILE = $OUTPUT_VIDEO\"\nffmpeg -y -i \"$INPUT_VIDEO\" -vf \"subtitles=${SUBS_FILE}\" -c:a copy \"$OUTPUT_VIDEO\"\n\n#upload result\naws s3 cp \"$OUTPUT_VIDEO\" \"s3:\/\/${OUTBUCKET}\/${OUTPUT_KEY}\"\n\necho \"Output uploaded to s3:\/\/${OUTBUCKET}\/${OUTPUT_KEY}\"<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #6A9955\">#!\/bin\/bash<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #6A9955\">#environment variables passed to container at runtime<\/span><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;source bucket = {<\/span><span style=\"color: #9CDCFE\">$BUCKET<\/span><span style=\"color: #CE9178\">}&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;sub file = {<\/span><span style=\"color: #9CDCFE\">$SUBS_KEY<\/span><span style=\"color: #CE9178\">}&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;output bucket = {<\/span><span style=\"color: #9CDCFE\">$OUTBUCKET<\/span><span style=\"color: #CE9178\">}&quot;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #6A9955\">#derive filenames, input files have the same name but different extensions - we&#39;re working off the .ass file<\/span><\/span>\n<span class=\"line\"><span style=\"color: #9CDCFE\">SUBS_FILENAME<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">$(<\/span><span style=\"color: #DCDCAA\">basename<\/span><span style=\"color: #CE9178\"> &quot;<\/span><span style=\"color: #9CDCFE\">$SUBS_KEY<\/span><span style=\"color: #CE9178\">&quot;)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #9CDCFE\">BASE_NAME<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">&quot;${<\/span><span style=\"color: #9CDCFE\">SUBS_FILENAME<\/span><span style=\"color: #D4D4D4\">%<\/span><span style=\"color: #CE9178\">.<\/span><span style=\"color: #D4D4D4\">*<\/span><span style=\"color: #CE9178\">}&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #9CDCFE\">VIDEO_KEY<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">&quot;${<\/span><span style=\"color: #9CDCFE\">BASE_NAME<\/span><span style=\"color: #CE9178\">}.mp4&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #9CDCFE\">OUTPUT_KEY<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">&quot;${<\/span><span style=\"color: #9CDCFE\">BASE_NAME<\/span><span style=\"color: #CE9178\">}_burned.mp4&quot;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #6A9955\">#local file names for processing<\/span><\/span>\n<span class=\"line\"><span style=\"color: #9CDCFE\">INPUT_VIDEO<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">&quot;input.mp4&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #9CDCFE\">SUBS_FILE<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">&quot;subs.ass&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #9CDCFE\">OUTPUT_VIDEO<\/span><span style=\"color: #D4D4D4\">=<\/span><span style=\"color: #CE9178\">&quot;output.mp4&quot;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #6A9955\">#download from s3<\/span><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">aws<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">s3<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">cp<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;s3:\/\/${<\/span><span style=\"color: #9CDCFE\">BUCKET<\/span><span style=\"color: #CE9178\">}\/${<\/span><span style=\"color: #9CDCFE\">VIDEO_KEY<\/span><span style=\"color: #CE9178\">}&quot;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #9CDCFE\">$INPUT_VIDEO<\/span><span style=\"color: #CE9178\">&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">aws<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">s3<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">cp<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;s3:\/\/${<\/span><span style=\"color: #9CDCFE\">BUCKET<\/span><span style=\"color: #CE9178\">}\/${<\/span><span style=\"color: #9CDCFE\">SUBS_KEY<\/span><span style=\"color: #CE9178\">}&quot;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #9CDCFE\">$SUBS_FILE<\/span><span style=\"color: #CE9178\">&quot;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #6A9955\">#burn subtitles<\/span><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #9CDCFE\">$INPUT_VIDEO<\/span><span style=\"color: #CE9178\"> + <\/span><span style=\"color: #9CDCFE\">$SUBS_FILE<\/span><span style=\"color: #CE9178\"> = <\/span><span style=\"color: #9CDCFE\">$OUTPUT_VIDEO<\/span><span style=\"color: #CE9178\">&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">ffmpeg<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-y<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-i<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #9CDCFE\">$INPUT_VIDEO<\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-vf<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;subtitles=${<\/span><span style=\"color: #9CDCFE\">SUBS_FILE<\/span><span style=\"color: #CE9178\">}&quot;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">-c:a<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">copy<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #9CDCFE\">$OUTPUT_VIDEO<\/span><span style=\"color: #CE9178\">&quot;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #6A9955\">#upload result<\/span><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">aws<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">s3<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">cp<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #9CDCFE\">$OUTPUT_VIDEO<\/span><span style=\"color: #CE9178\">&quot;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;s3:\/\/${<\/span><span style=\"color: #9CDCFE\">OUTBUCKET<\/span><span style=\"color: #CE9178\">}\/${<\/span><span style=\"color: #9CDCFE\">OUTPUT_KEY<\/span><span style=\"color: #CE9178\">}&quot;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #DCDCAA\">echo<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #CE9178\">&quot;Output uploaded to s3:\/\/${<\/span><span style=\"color: #9CDCFE\">OUTBUCKET<\/span><span style=\"color: #CE9178\">}\/${<\/span><span style=\"color: #9CDCFE\">OUTPUT_KEY<\/span><span style=\"color: #CE9178\">}&quot;<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Balancing the amount of resources the container needed to render the video vs the amount of compute time was the really challenging part. Most of my videos are in the 20-30 minute range. With the limited CPU types available in Fargate, the best speed I could bring it to render at (inside budget) was .7x. If I were ever to try to scale this up or open it to the public, I&#8217;d re-architect it to use EC2 instead of Fargate to leverage GPUs.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Tying it All Together<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">At this point in the project, I had working code to handle the telemetry data and working code to burn the video. However, all of the testing was still manual; to automate the whole process, I&#8217;d need to build some Step Functions and EventBridge notifications. And if I was going to go that far, why not do it all in Terraform? That way, I&#8217;d have it as a portfolio project as well! I&#8217;m afraid the Terraform part of this project doesn&#8217;t make for good reading since all of the clever bits were in the telemetry and rendering portion. Let&#8217;s just say it was 20% writing the Terraform, 75% asking ChatGPT for help formatting the different IAM policies, and 5% vetting those policies to ensure they properly secured everything. Some say I&#8217;m still stuck in the spiderweb of using JSON to make policy documents, role policies, roles, assume roles, and task execution roles.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Anyway, here&#8217;s the final workflow diagram. The full Terraform is available in the repository if it so tickles you.<\/p>\n\n\n\n<figure class=\"wp-block-image aligncenter size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"755\" height=\"392\" src=\"http:\/\/18.191.103.99\/wp-content\/uploads\/2025\/07\/bike-videos.drawio-1.png\" alt=\"\" class=\"wp-image-119\" srcset=\"https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/bike-videos.drawio-1.png 755w, https:\/\/learnwithpatrick.casa\/wp-content\/uploads\/2025\/07\/bike-videos.drawio-1-300x156.png 300w\" sizes=\"auto, (max-width: 755px) 100vw, 755px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">And the final workflow in text form:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Users run a local interactive script\u00a0<code>uploadtoS3.py<\/code>\u00a0that extracts metadata from the video, then uploads both the video and a\u00a0<code>json<\/code>\u00a0file with required metadata for the next step.<\/li>\n\n\n\n<li>A lambda function\u00a0<code>.\\lambdaFunctions\\lambda_function.py<\/code>triggers that\n<ul class=\"wp-block-list\">\n<li>Downloads the\u00a0<code>json<\/code>\u00a0file to temp storage.<\/li>\n\n\n\n<li>Examines the\u00a0<code>json<\/code>\u00a0to get the date of the activity.<\/li>\n\n\n\n<li>Finds the matching activity on\u00a0<code>intervals.icu<\/code>\u00a0&#8211; the API key is stored securely in AWS Parameter Store.<\/li>\n\n\n\n<li>Downloads the\u00a0<code>.fit<\/code>\u00a0file via API.<\/li>\n\n\n\n<li>Generates a\u00a0<code>.ass<\/code>\u00a0subtitle file with the heart rate, power, cadence, and speed telemetries timed to the activities in the FIT file.<\/li>\n\n\n\n<li>Uploads the subtitle file to s3.<\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>An EventBridge rule triggered by\u00a0<code>.ass<\/code>\u00a0upload starts a container\u00a0<code>.\/container\/burn_subs.sh<\/code>\u00a0via Step Functions to render the subtitles onto the original video.\n<ul class=\"wp-block-list\">\n<li>The container downloads the original\u00a0<code>.mp4<\/code>\u00a0and the\u00a0<code>.ass<\/code>\u00a0telemetry subtitles to temp storage.<\/li>\n\n\n\n<li>It burns the telemetry into a new\u00a0<code>.mp4<\/code>\u00a0video.<\/li>\n\n\n\n<li>After rendering finishes it uploads the new\u00a0<code>.mp4<\/code>\u00a0to the output bucket.<\/li>\n<\/ul>\n<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Repository: <a href=\"https:\/\/github.com\/pmalley130\/bike-videos-with-overlay\">https:\/\/github.com\/pmalley130\/bike-videos-with-overlay<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Sometimes you get an idea for a simple fix to make your life easier&#8230; and then that simple idea spirals out of control and becomes a massive project. I like to record my bike rides when I travel, but I don&#8217;t travel with a lot of storage media. I do have a NAS at home. [&hellip;]<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"site-container-style":"default","site-container-layout":"default","site-sidebar-layout":"default","disable-article-header":"default","disable-site-header":"default","disable-site-footer":"default","disable-content-area-spacing":"default","footnotes":""},"categories":[2],"tags":[8,9,18,21],"class_list":["post-102","post","type-post","status-publish","format-standard","hentry","category-projects","tag-automation","tag-aws","tag-python","tag-terraform"],"jetpack_featured_media_url":"","_links":{"self":[{"href":"https:\/\/learnwithpatrick.casa\/index.php?rest_route=\/wp\/v2\/posts\/102","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/learnwithpatrick.casa\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/learnwithpatrick.casa\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/learnwithpatrick.casa\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/learnwithpatrick.casa\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=102"}],"version-history":[{"count":0,"href":"https:\/\/learnwithpatrick.casa\/index.php?rest_route=\/wp\/v2\/posts\/102\/revisions"}],"wp:attachment":[{"href":"https:\/\/learnwithpatrick.casa\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=102"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/learnwithpatrick.casa\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=102"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/learnwithpatrick.casa\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=102"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}