<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
      <title>Keanu Czirjak</title>
      <link>https://keanuc.net</link>
      <description></description>
      <generator>Zola</generator>
      <language>en</language>
      <atom:link href="https://keanuc.net/rss.xml" rel="self" type="application/rss+xml"/>
      <lastBuildDate>Wed, 10 Dec 2025 22:48:05 +0000</lastBuildDate>
      <item>
          <title>Creating the world&#x27;s best Adobe Connect recording downloader for fun</title>
          <pubDate>Wed, 10 Dec 2025 22:48:05 +0000</pubDate>
          <author>Keanu Czirjak</author>
          <link>https://keanuc.net/blog/creating-the-world-s-best-adobe-connect-recording-downloader-for-fun/</link>
          <guid>https://keanuc.net/blog/creating-the-world-s-best-adobe-connect-recording-downloader-for-fun/</guid>
          <description xml:base="https://keanuc.net/blog/creating-the-world-s-best-adobe-connect-recording-downloader-for-fun/">&lt;p&gt;Today I had my last lectures for each of my two module this semester. I’ve had eight from each so far for the past two months, so some of the info went in, but some of it didn’t!&lt;&#x2F;p&gt;
&lt;p&gt;My university allows us to view the recordings for each of our lectures and watch them back. However… for whatever reason, instead of being sane and using a standard classroom platform like Google Classroom or Blackboard or what have you - my university still uses Adobe Connect to host their lectures.&lt;&#x2F;p&gt;
&lt;p&gt;And, rather annoyingly, Adobe Connect’s lecture recording viewer is not the nicest.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-default&quot;&gt;The default&lt;a class=&quot;post-anchor&quot; href=&quot;#the-default&quot; aria-label=&quot;Anchor link for: the-default&quot;&gt;&lt;span aria-hidden=&quot;true&quot;&gt;#&lt;&#x2F;span&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;figure&gt;
&lt;img src=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-09-59.dcdfbb729d3b0ea6.png&quot; alt=&quot;A screenshot of Adobe Connect&amp;#x27;s recording viewer React app in all its glory&quot;
     width=&quot;1280&quot; height=&quot;797&quot;
     sizes=&quot;(min-width: 920px) 784px, (min-width: 700px) calc(82vw + 46px), calc(100vw - 40px)&quot; 
     srcset=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-09-59.7d4059bb3c62d3b8.png 640w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-09-59.c6e9913d08a8ae1c.png 784w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-09-59.982a1460541493ea.png 1280w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-09-59.64c6190c25e0ec70.png 1920w&quot;
     loading=&quot;lazy&quot;&gt;
    &lt;figcaption&gt;
        &lt;p&gt;The React app in question..&lt;&#x2F;p&gt;
    &lt;&#x2F;figcaption&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;It’s a bloated React app with a custom HTML5 player - which isn’t the worst in the grand scheme of things, but I missed the ability to download them to actually have them on my machine - e.g. to  catch up on when I’m in a region with little to no internet connectivity (like on a long-haul flight for example).&lt;&#x2F;p&gt;
&lt;p&gt;Somewhat mercifully, Adobe’s recording player does let you turn on closed captions which were recorded with the lecture, and even lets you view the transcript and jump to different points in the video via this method.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;having-a-closer-look&quot;&gt;Having a closer look&lt;a class=&quot;post-anchor&quot; href=&quot;#having-a-closer-look&quot; aria-label=&quot;Anchor link for: having-a-closer-look&quot;&gt;&lt;span aria-hidden=&quot;true&quot;&gt;#&lt;&#x2F;span&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;I poked around the Network tab with Inspect Element to see how this video player worked.&lt;&#x2F;p&gt;
&lt;figure&gt;
&lt;img src=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-29-32.3fc6c7441add449b.png&quot; alt=&quot;A screenshot of the Network tab in Firefox Developer Tools showing the MP4 video file and VTT captions file being requested by the Adobe Connect recording player&quot;
     width=&quot;920&quot; height=&quot;318&quot;
     sizes=&quot;(min-width: 920px) 784px, (min-width: 700px) calc(82vw + 46px), calc(100vw - 40px)&quot; 
     srcset=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-29-32.88f2ad284c2b92b6.png 640w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-29-32.ff88e29a76f87066.png 784w&quot;
     loading=&quot;lazy&quot;&gt;
    &lt;figcaption&gt;
        &lt;p&gt;Hooray! MP4 and.. XML files?&lt;&#x2F;p&gt;
    &lt;&#x2F;figcaption&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;And sure enough, it seemed that all this React app did was fetch some URL pointing to an MP4, and just slowly buffer through this. Adobe didn’t even implement any funny Widevine or DRM chunking strategies that other companies like Netflix&#x2F;Vimeo do - you can just simply download the entire MP4 recording and save it to disk!&lt;&#x2F;p&gt;
&lt;img src=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-34-15.2399f206146d4455.png&quot; alt=&quot;A screenshot of the MP4 video file with the URL visible&quot;
     width=&quot;1280&quot; height=&quot;795&quot;
     sizes=&quot;(min-width: 920px) 784px, (min-width: 700px) calc(82vw + 46px), calc(100vw - 40px)&quot; 
     srcset=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-34-15.cfb64f9661c6781a.png 640w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-34-15.2b7dbeb4fb7d9056.png 784w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-34-15.7aff5e71121fe3ba.png 1280w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-34-15.aeecf209968969c4.png 1920w&quot;
     loading=&quot;lazy&quot;&gt;
&lt;p&gt;However.. even though I’d found a manual workaround to solve my problem, I wondered if it would be possible to actually download the closed captions associated with the recording, and to make some sort of command line utility to bundle the two together.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;has-anyone-else-made-a-tool-like-this-before&quot;&gt;Has anyone else made a tool like this before??&lt;a class=&quot;post-anchor&quot; href=&quot;#has-anyone-else-made-a-tool-like-this-before&quot; aria-label=&quot;Anchor link for: has-anyone-else-made-a-tool-like-this-before&quot;&gt;&lt;span aria-hidden=&quot;true&quot;&gt;#&lt;&#x2F;span&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Of course, before embarking on coding a whole command line utility for myself, I wondered if anyone else had created such a utility. I spam searched the depths of page 2 and 3 on Google, queried Perplexity.. and I came across a few projects, like &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;HosseinShams00&#x2F;AdobeConnectDownloader&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;HosseinShams00&#x2F;AdobeConnectDownloader&lt;&#x2F;a&gt;, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;soroushamdg&#x2F;acd&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;soroushamdg&#x2F;acd&lt;&#x2F;a&gt;, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;MRT-77&#x2F;AdobeConnectRecord&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;MRT-77&#x2F;AdobeConnectRecord&lt;&#x2F;a&gt;, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;Franck-Dernoncourt&#x2F;adobe-connect-video-downloader&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;Franck-Dernoncourt&#x2F;adobe-connect-video-downloader&lt;&#x2F;a&gt;, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;sina-rostami&#x2F;Adobe-Connect-Meetings-Downloader&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;sina-rostami&#x2F;Adobe-Connect-Meetings-Downloader&lt;&#x2F;a&gt;. But all of them are 4 years old, and all of them had a really complex GUI or went about downloading the file a strange way.&lt;&#x2F;p&gt;
&lt;figure&gt;
&lt;img src=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;Screenshot 2025-12-10 at 23-57-40.a04b863eb7460c2a.png&quot; alt=&quot;A screenshot of a GitHub repository named AdobeConnectDownloader by HosseinShams00&quot;
     width=&quot;1280&quot; height=&quot;647&quot;
     sizes=&quot;(min-width: 920px) 784px, (min-width: 700px) calc(82vw + 46px), calc(100vw - 40px)&quot; 
     srcset=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;Screenshot 2025-12-10 at 23-57-40.6756c6c200a53ecd.png 640w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;Screenshot 2025-12-10 at 23-57-40.72c5f01d88d94881.png 784w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;Screenshot 2025-12-10 at 23-57-40.771f42d3c27a1f83.png 1280w&quot;
     loading=&quot;lazy&quot;&gt;
    &lt;figcaption&gt;
        &lt;p&gt;This project seems promising..&lt;&#x2F;p&gt;
    &lt;&#x2F;figcaption&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;I came across &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;superuser.com&#x2F;a&#x2F;1403970&quot;&gt;one very insightful answer on Superuser&lt;&#x2F;a&gt; though - apparently, Adobe Connect is even more ghastly than I thought… when someone is hosting &amp;amp; recording a lecture, Adobe Connect silently records.. Flash videos in the background for each of the components in the UI??? I thought Flash died 5 years ago 😭&lt;&#x2F;p&gt;
&lt;img src=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;superuser.82dcf0f8721ed262.png&quot; alt=&quot;A screenshot of an answer given by Franck Dernoncourt on Superuser regarding how one would go about downloading an Adobe Connect lecture&quot;
     width=&quot;759&quot; height=&quot;1075&quot;
     sizes=&quot;(min-width: 920px) 784px, (min-width: 700px) calc(82vw + 46px), calc(100vw - 40px)&quot; 
     srcset=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;superuser.1acf4e721a8a1609.png 640w&quot;
     loading=&quot;lazy&quot;&gt;
&lt;p&gt;On one hand, Franck Dernoncourt’s answer did confuse me, as according to him, the only way to synthesise a lecture recording was to use ffmpeg to merge the individual Flash video files.&lt;&#x2F;p&gt;
&lt;section class=&quot;alert note&quot; role=&quot;note&quot; aria-labelledby=&quot;NkzjxYNY&quot;&gt;
    &lt;div class=&quot;alert-icon alert-icon-note&quot;&gt;&lt;&#x2F;div&gt;
    &lt;div class=&quot;alert-content&quot; role=&quot;presentation&quot;&gt;
        &lt;strong id=&quot;NkzjxYNY&quot; class=&quot;alert-title&quot; aria-hidden=&quot;true&quot;&gt;NOTE&lt;&#x2F;strong&gt;
        &lt;p&gt;Of course, as I discovered, and as of 3 years ago, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;blogs.connectusers.com&#x2F;connectsupport&#x2F;download-enhanced-av-recordings-using-the-xml-api&#x2F;&quot;&gt;this no longer seems to be the case&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;

    &lt;&#x2F;div&gt;
&lt;&#x2F;section&gt;
&lt;p&gt;On the other hand, something else caught my eye. I noticed that contained within the zip directory listing he posted, there were files such as “transcriptstream.xml”. And that led me to remember that in the devtools console on the Adobe Connect recording viewer page, I noticed lines related to downloading a VTT file..&lt;&#x2F;p&gt;
&lt;img src=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-44-50.c6358d135951e8a3.png&quot; alt=&quot;A screenshot of the DevTools Console in Firefox showing lines pertaining to debug logs from the React app about downloading a VTT file for closed caption support&quot;
     width=&quot;917&quot; height=&quot;161&quot;
     sizes=&quot;(min-width: 920px) 784px, (min-width: 700px) calc(82vw + 46px), calc(100vw - 40px)&quot; 
     srcset=&quot;https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-44-50.40aa36772188970f.png 640w,
             https:&#x2F;&#x2F;keanuc.net&#x2F;processed_images&#x2F;firefox_2025-12-10_23-44-50.2ce1d032e78a74b4.png 784w&quot;
     loading=&quot;lazy&quot;&gt;
&lt;p&gt;After double checking the recording, I realised that the mp4 by default had no captions embedded into it. That got me thinking - surely it wouldn’t be that hard to embed the VTT into the mp4 with a tool like ffmpeg?&lt;&#x2F;p&gt;
&lt;p&gt;Cue the vibe coding spree.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;producing-the-tool&quot;&gt;Producing the tool&lt;a class=&quot;post-anchor&quot; href=&quot;#producing-the-tool&quot; aria-label=&quot;Anchor link for: producing-the-tool&quot;&gt;&lt;span aria-hidden=&quot;true&quot;&gt;#&lt;&#x2F;span&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;It had been a while since I had worked on a proper Golang project. Since I wanted to make this tool more out of practicality than approaching it from an angle of complete curiosity, I initially bootstrapped the app by using OpenAI Codex and instructing it to use the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;spf13&#x2F;cobra&quot;&gt;github.com&#x2F;spf13&#x2F;cobra&lt;&#x2F;a&gt; library to build out a CLI (a library that I had personally used in the past &amp;amp; that is used by many Go CLI applications).&lt;&#x2F;p&gt;
&lt;p&gt;I’d also saved a bunch of the network requests that I’d intercepted in Firefox Dev Tools from the network tab pertaining to the MP4 recording&#x2F;VTT caption URLs and other relevant useful data into a HAR file and passed it as context to Codex 5.1 initially (the best model available at the time).&lt;&#x2F;p&gt;
      

&lt;div class=&quot;simple-carousel&quot; aria-label=&quot;Post screenshots carousel&quot;&gt;
  &lt;div class=&quot;sc-viewport&quot;&gt;
    &lt;button class=&quot;sc-btn sc-prev&quot; aria-label=&quot;Previous&quot;&gt;&amp;#8249;&lt;&#x2F;button&gt;
    &lt;div
      class=&quot;sc-track&quot;
      data-images=&quot;2025-12-15_19-27.png,2025-12-15_19-28.png,2025-12-15_19-28_1.png,2025-12-15_19-29.png,2025-12-15_19-29_1.png,2025-12-15_19-29_2.png,2025-12-15_19-29_3.png,2025-12-15_19-30.png,2025-12-15_19-31.png,2025-12-15_19-31_1.png&quot;
      data-captions=&quot;&quot;
    &gt;
      
    &lt;&#x2F;div&gt;
    &lt;button class=&quot;sc-btn sc-next&quot; aria-label=&quot;Next&quot;&gt;&amp;#8250;&lt;&#x2F;button&gt;
  &lt;&#x2F;div&gt;
  &lt;div class=&quot;sc-caption-outer&quot;&gt;&lt;&#x2F;div&gt;
  &lt;div class=&quot;sc-dots&quot; aria-hidden=&quot;false&quot;&gt;&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;

&lt;style&gt;
  .simple-carousel {
    max-width: 960px;
    margin: 1.5rem auto;
  }
  .sc-viewport {
    position: relative;
    overflow: hidden;
    transition: height 0.35s ease;
  }
  .sc-track {
    display: flex;
    transition: transform 0.35s ease;
    align-items: flex-start;
  }
  .sc-slide-wrapper {
    min-width: 100%;
    max-width: 100%;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    align-items: center;
  }
  .sc-slide {
    max-width: 100%;
    height: auto;
    object-fit: contain;
    border-radius: 6px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
  }
  .sc-caption-outer {
    text-align: center;
    font-size: 0.9rem;
    color: #888;
    font-style: italic;
    min-height: 1.4em;
    margin-top: 0.5rem;
  }
  .sc-caption-outer:empty {
    display: none;
  }
  .sc-btn {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    background: rgba(0, 0, 0, 0.6);
    color: #fff;
    border: 0;
    padding: 0.5rem 0.8rem;
    border-radius: 4px;
    cursor: pointer;
    z-index: 10;
    font-size: 1.5rem;
    line-height: 1;
  }
  .sc-btn:hover {
    background: rgba(0, 0, 0, 0.8);
  }
  .sc-prev {
    left: 8px;
  }
  .sc-next {
    right: 8px;
  }
  .sc-dots {
    display: flex;
    gap: 0.4rem;
    justify-content: center;
    margin-top: 0.6rem;
  }
  .sc-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    border: none;
    background: rgba(150, 150, 150, 0.4);
    display: inline-block;
    cursor: pointer;
    padding: 0;
  }
  .sc-dot.active {
    background: rgba(200, 200, 200, 0.9);
  }
&lt;&#x2F;style&gt;

&lt;script&gt;
  document.addEventListener(&quot;DOMContentLoaded&quot;, function () {
    document.querySelectorAll(&quot;.simple-carousel&quot;).forEach(function (carousel) {
      const viewport = carousel.querySelector(&quot;.sc-viewport&quot;);
      const track = carousel.querySelector(&quot;.sc-track&quot;);
      let slides = Array.from(track.querySelectorAll(&quot;.sc-slide-wrapper&quot;));
      const prev = carousel.querySelector(&quot;.sc-prev&quot;);
      const next = carousel.querySelector(&quot;.sc-next&quot;);
      const dotsWrap = carousel.querySelector(&quot;.sc-dots&quot;);
      const captionOuter = carousel.querySelector(&quot;.sc-caption-outer&quot;);

      &#x2F;&#x2F; Store captions separately — always parse from data attribute
      let captionsArr = [];
      const captionsData = track.dataset.captions || &quot;&quot;;
      if (captionsData) {
        const trimmedCaptions = captionsData.trim();
        if (
          trimmedCaptions.startsWith(&quot;[&quot;) ||
          trimmedCaptions.startsWith(&quot;{&quot;)
        ) {
          try {
            captionsArr = JSON.parse(trimmedCaptions);
          } catch (e) {
            captionsArr = [];
          }
        } else {
          captionsArr = trimmedCaptions
            .split(&quot;,&quot;)
            .map((s) =&gt; s.trim())
            .filter(Boolean);
        }
      }

      if (!slides.length) {
        const data = track.dataset.images || &quot;&quot;;
        let imgsArr = [];
        if (data) {
          const trimmed = data.trim();
          if (trimmed.startsWith(&quot;[&quot;) || trimmed.startsWith(&quot;{&quot;)) {
            try {
              imgsArr = JSON.parse(trimmed);
            } catch (e) {
              imgsArr = [];
            }
          } else {
            imgsArr = trimmed
              .split(&quot;,&quot;)
              .map((s) =&gt; s.trim())
              .filter(Boolean);
          }
        }
        imgsArr.forEach((src, i) =&gt; {
          const wrapper = document.createElement(&quot;div&quot;);
          wrapper.className = &quot;sc-slide-wrapper&quot;;

          const img = document.createElement(&quot;img&quot;);
          img.className = &quot;sc-slide&quot;;
          img.src = src;
          img.alt = &quot;Screenshot &quot; + (i + 1);
          img.loading = &quot;lazy&quot;;
          wrapper.appendChild(img);

          track.appendChild(wrapper);
        });
        slides = Array.from(track.querySelectorAll(&quot;.sc-slide-wrapper&quot;));
      }

      if (!slides.length) return;

      let index = 0;

      const getSlideHeight = (slide) =&gt; {
        const img = slide.querySelector(&quot;.sc-slide&quot;);
        return img ? img.offsetHeight : 0;
      };

      const updateHeight = () =&gt; {
        const h = getSlideHeight(slides[index]);
        if (h &gt; 0) {
          viewport.style.height = h + &quot;px&quot;;
        }
      };

      const updateCaption = () =&gt; {
        if (captionOuter) {
          captionOuter.textContent = captionsArr[index] || &quot;&quot;;
        }
      };

      const render = () =&gt; {
        track.style.transform = `translateX(-${index * 100}%)`;
        Array.from(dotsWrap.children).forEach((d, i) =&gt;
          d.classList.toggle(&quot;active&quot;, i === index),
        );
        updateHeight();
        updateCaption();
      };

      &#x2F;&#x2F; Update height when any image loads
      slides.forEach((wrapper) =&gt; {
        const img = wrapper.querySelector(&quot;.sc-slide&quot;);
        if (img) {
          img.addEventListener(&quot;load&quot;, () =&gt; updateHeight());
        }
      });

      &#x2F;&#x2F; Build dots
      dotsWrap.innerHTML = &quot;&quot;;
      slides.forEach((s, i) =&gt; {
        const d = document.createElement(&quot;button&quot;);
        d.className = &quot;sc-dot&quot;;
        d.type = &quot;button&quot;;
        d.setAttribute(&quot;aria-label&quot;, &quot;Go to slide &quot; + (i + 1));
        d.addEventListener(&quot;click&quot;, () =&gt; {
          index = i;
          render();
        });
        dotsWrap.appendChild(d);
      });

      prev.addEventListener(&quot;click&quot;, () =&gt; {
        index = (index - 1 + slides.length) % slides.length;
        render();
      });
      next.addEventListener(&quot;click&quot;, () =&gt; {
        index = (index + 1) % slides.length;
        render();
      });
      carousel.tabIndex = 0;
      carousel.addEventListener(&quot;keydown&quot;, (e) =&gt; {
        if (e.key === &quot;ArrowLeft&quot;) prev.click();
        if (e.key === &quot;ArrowRight&quot;) next.click();
      });

      &#x2F;&#x2F; Touch &#x2F; swipe support
      let startX = 0,
        startY = 0,
        deltaX = 0,
        isSwiping = false;
      const threshold = 30;
      const maxVertical = 75;

      carousel.addEventListener(
        &quot;touchstart&quot;,
        (e) =&gt; {
          if (!e.touches || e.touches.length &gt; 1) return;
          startX = e.touches[0].clientX;
          startY = e.touches[0].clientY;
          deltaX = 0;
          isSwiping = true;
        },
        { passive: true },
      );

      carousel.addEventListener(
        &quot;touchmove&quot;,
        (e) =&gt; {
          if (!isSwiping || !e.touches || e.touches.length &gt; 1) return;
          deltaX = e.touches[0].clientX - startX;
          const deltaY = Math.abs(e.touches[0].clientY - startY);
          if (deltaY &gt; maxVertical) {
            isSwiping = false;
            return;
          }
          if (Math.abs(deltaX) &gt; 10) e.preventDefault();
        },
        { passive: false },
      );

      carousel.addEventListener(&quot;touchend&quot;, (e) =&gt; {
        if (!isSwiping) return;
        isSwiping = false;
        if (Math.abs(deltaX) &lt; threshold) return;
        if (deltaX &gt; 0) {
          prev.click();
        } else {
          next.click();
        }
      });

      window.addEventListener(&quot;resize&quot;, () =&gt; render());
      render();
    });
  });
&lt;&#x2F;script&gt;
&lt;p&gt;I had $250 of credit from OpenAI Dev Day in London that I wanted to use up anyway so this was the perfect excuse to do so. However, after trying to one&#x2F;two-shot it, I wasn’t satisfied with how much finetuning and correction I had to do when prompting Codex.&lt;&#x2F;p&gt;
&lt;p&gt;I then figured I might as well switch to Claude Opus 4.5 which I have access to through my GitHub Copilot Pro pack included in the GitHub Student Developers Pack (if you don’t have the pack already, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;settings&#x2F;education&#x2F;benefits&quot;&gt;sign up here&lt;&#x2F;a&gt; and if you already have it but don’t have the pro version of Copilot, you have to sign up for it through this sneaky hidden link &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;github-copilot&#x2F;free_signup&quot;&gt;here&lt;&#x2F;a&gt;.)&lt;&#x2F;p&gt;
&lt;p&gt;After I switched to using Claude Opus 4.5, my mind was blown. I was able to produce the first working prototype of the app after about 8 hours of vibing with Opus 4.5 and steadily but happily adjusting its’ course as it vibed along.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-went-well-and-what-didn-t&quot;&gt;What went well and what didn’t&lt;a class=&quot;post-anchor&quot; href=&quot;#what-went-well-and-what-didn-t&quot; aria-label=&quot;Anchor link for: what-went-well-and-what-didn-t&quot;&gt;&lt;span aria-hidden=&quot;true&quot;&gt;#&lt;&#x2F;span&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;One thing I’ve noticed is that AI is very good at generating and writing code, but often leaves behind a trail of mess and redundant code that you have to selectively clean up yourself. However, despite this, what would have taken me a week or two to do by myself only took total 3-4 days.&lt;&#x2F;p&gt;
&lt;p&gt;After my first implementation which used FFMPEG (and subsequently me bundling it ballooned the binary size to 134MB), a friend of mine suggested to bundle &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;wiki.gpac.io&#x2F;Build&#x2F;build&#x2F;GPAC-Build-Guide-for-Linux&#x2F;#mp4box-gpac-only-minimal-static-build&quot;&gt;MP4Box&lt;&#x2F;a&gt;, which is a much slimmer alternative to ffmpeg, and supports embedding vtt subtitle files into mp4 files (which was genuinely the only functionaity I needed from ffmpeg). That alone reduced the binary size to 16MB!&lt;&#x2F;p&gt;
&lt;p&gt;I did consider just using the system’s bundled version of ffmpeg, but honestly, I wanted the app to work in a self-contained way without any dependencies; and frankly, I’m happy with how small the binary is now.&lt;&#x2F;p&gt;
&lt;section class=&quot;alert info&quot; role=&quot;note&quot; aria-labelledby=&quot;kpdMCLuF&quot;&gt;
    &lt;div class=&quot;alert-icon alert-icon-info&quot;&gt;&lt;&#x2F;div&gt;
    &lt;div class=&quot;alert-content&quot; role=&quot;presentation&quot;&gt;
        &lt;strong id=&quot;kpdMCLuF&quot; class=&quot;alert-title&quot; aria-hidden=&quot;true&quot;&gt;INFO&lt;&#x2F;strong&gt;
        &lt;p&gt;Switching from bundling ffmpeg to MP4Box reduced the binary size from &lt;strong&gt;134MB&lt;&#x2F;strong&gt; down to just &lt;strong&gt;16MB&lt;&#x2F;strong&gt; — an 88% reduction!&lt;&#x2F;p&gt;

    &lt;&#x2F;div&gt;
&lt;&#x2F;section&gt;
      

&lt;div class=&quot;simple-carousel&quot; aria-label=&quot;Post screenshots carousel&quot;&gt;
  &lt;div class=&quot;sc-viewport&quot;&gt;
    &lt;button class=&quot;sc-btn sc-prev&quot; aria-label=&quot;Previous&quot;&gt;&amp;#8249;&lt;&#x2F;button&gt;
    &lt;div
      class=&quot;sc-track&quot;
      data-images=&quot;before.png,after.png&quot;
      data-captions=&quot;Before,After&quot;
    &gt;
      
    &lt;&#x2F;div&gt;
    &lt;button class=&quot;sc-btn sc-next&quot; aria-label=&quot;Next&quot;&gt;&amp;#8250;&lt;&#x2F;button&gt;
  &lt;&#x2F;div&gt;
  &lt;div class=&quot;sc-caption-outer&quot;&gt;&lt;&#x2F;div&gt;
  &lt;div class=&quot;sc-dots&quot; aria-hidden=&quot;false&quot;&gt;&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;

&lt;style&gt;
  .simple-carousel {
    max-width: 960px;
    margin: 1.5rem auto;
  }
  .sc-viewport {
    position: relative;
    overflow: hidden;
    transition: height 0.35s ease;
  }
  .sc-track {
    display: flex;
    transition: transform 0.35s ease;
    align-items: flex-start;
  }
  .sc-slide-wrapper {
    min-width: 100%;
    max-width: 100%;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    align-items: center;
  }
  .sc-slide {
    max-width: 100%;
    height: auto;
    object-fit: contain;
    border-radius: 6px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
  }
  .sc-caption-outer {
    text-align: center;
    font-size: 0.9rem;
    color: #888;
    font-style: italic;
    min-height: 1.4em;
    margin-top: 0.5rem;
  }
  .sc-caption-outer:empty {
    display: none;
  }
  .sc-btn {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    background: rgba(0, 0, 0, 0.6);
    color: #fff;
    border: 0;
    padding: 0.5rem 0.8rem;
    border-radius: 4px;
    cursor: pointer;
    z-index: 10;
    font-size: 1.5rem;
    line-height: 1;
  }
  .sc-btn:hover {
    background: rgba(0, 0, 0, 0.8);
  }
  .sc-prev {
    left: 8px;
  }
  .sc-next {
    right: 8px;
  }
  .sc-dots {
    display: flex;
    gap: 0.4rem;
    justify-content: center;
    margin-top: 0.6rem;
  }
  .sc-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    border: none;
    background: rgba(150, 150, 150, 0.4);
    display: inline-block;
    cursor: pointer;
    padding: 0;
  }
  .sc-dot.active {
    background: rgba(200, 200, 200, 0.9);
  }
&lt;&#x2F;style&gt;

&lt;script&gt;
  document.addEventListener(&quot;DOMContentLoaded&quot;, function () {
    document.querySelectorAll(&quot;.simple-carousel&quot;).forEach(function (carousel) {
      const viewport = carousel.querySelector(&quot;.sc-viewport&quot;);
      const track = carousel.querySelector(&quot;.sc-track&quot;);
      let slides = Array.from(track.querySelectorAll(&quot;.sc-slide-wrapper&quot;));
      const prev = carousel.querySelector(&quot;.sc-prev&quot;);
      const next = carousel.querySelector(&quot;.sc-next&quot;);
      const dotsWrap = carousel.querySelector(&quot;.sc-dots&quot;);
      const captionOuter = carousel.querySelector(&quot;.sc-caption-outer&quot;);

      &#x2F;&#x2F; Store captions separately — always parse from data attribute
      let captionsArr = [];
      const captionsData = track.dataset.captions || &quot;&quot;;
      if (captionsData) {
        const trimmedCaptions = captionsData.trim();
        if (
          trimmedCaptions.startsWith(&quot;[&quot;) ||
          trimmedCaptions.startsWith(&quot;{&quot;)
        ) {
          try {
            captionsArr = JSON.parse(trimmedCaptions);
          } catch (e) {
            captionsArr = [];
          }
        } else {
          captionsArr = trimmedCaptions
            .split(&quot;,&quot;)
            .map((s) =&gt; s.trim())
            .filter(Boolean);
        }
      }

      if (!slides.length) {
        const data = track.dataset.images || &quot;&quot;;
        let imgsArr = [];
        if (data) {
          const trimmed = data.trim();
          if (trimmed.startsWith(&quot;[&quot;) || trimmed.startsWith(&quot;{&quot;)) {
            try {
              imgsArr = JSON.parse(trimmed);
            } catch (e) {
              imgsArr = [];
            }
          } else {
            imgsArr = trimmed
              .split(&quot;,&quot;)
              .map((s) =&gt; s.trim())
              .filter(Boolean);
          }
        }
        imgsArr.forEach((src, i) =&gt; {
          const wrapper = document.createElement(&quot;div&quot;);
          wrapper.className = &quot;sc-slide-wrapper&quot;;

          const img = document.createElement(&quot;img&quot;);
          img.className = &quot;sc-slide&quot;;
          img.src = src;
          img.alt = &quot;Screenshot &quot; + (i + 1);
          img.loading = &quot;lazy&quot;;
          wrapper.appendChild(img);

          track.appendChild(wrapper);
        });
        slides = Array.from(track.querySelectorAll(&quot;.sc-slide-wrapper&quot;));
      }

      if (!slides.length) return;

      let index = 0;

      const getSlideHeight = (slide) =&gt; {
        const img = slide.querySelector(&quot;.sc-slide&quot;);
        return img ? img.offsetHeight : 0;
      };

      const updateHeight = () =&gt; {
        const h = getSlideHeight(slides[index]);
        if (h &gt; 0) {
          viewport.style.height = h + &quot;px&quot;;
        }
      };

      const updateCaption = () =&gt; {
        if (captionOuter) {
          captionOuter.textContent = captionsArr[index] || &quot;&quot;;
        }
      };

      const render = () =&gt; {
        track.style.transform = `translateX(-${index * 100}%)`;
        Array.from(dotsWrap.children).forEach((d, i) =&gt;
          d.classList.toggle(&quot;active&quot;, i === index),
        );
        updateHeight();
        updateCaption();
      };

      &#x2F;&#x2F; Update height when any image loads
      slides.forEach((wrapper) =&gt; {
        const img = wrapper.querySelector(&quot;.sc-slide&quot;);
        if (img) {
          img.addEventListener(&quot;load&quot;, () =&gt; updateHeight());
        }
      });

      &#x2F;&#x2F; Build dots
      dotsWrap.innerHTML = &quot;&quot;;
      slides.forEach((s, i) =&gt; {
        const d = document.createElement(&quot;button&quot;);
        d.className = &quot;sc-dot&quot;;
        d.type = &quot;button&quot;;
        d.setAttribute(&quot;aria-label&quot;, &quot;Go to slide &quot; + (i + 1));
        d.addEventListener(&quot;click&quot;, () =&gt; {
          index = i;
          render();
        });
        dotsWrap.appendChild(d);
      });

      prev.addEventListener(&quot;click&quot;, () =&gt; {
        index = (index - 1 + slides.length) % slides.length;
        render();
      });
      next.addEventListener(&quot;click&quot;, () =&gt; {
        index = (index + 1) % slides.length;
        render();
      });
      carousel.tabIndex = 0;
      carousel.addEventListener(&quot;keydown&quot;, (e) =&gt; {
        if (e.key === &quot;ArrowLeft&quot;) prev.click();
        if (e.key === &quot;ArrowRight&quot;) next.click();
      });

      &#x2F;&#x2F; Touch &#x2F; swipe support
      let startX = 0,
        startY = 0,
        deltaX = 0,
        isSwiping = false;
      const threshold = 30;
      const maxVertical = 75;

      carousel.addEventListener(
        &quot;touchstart&quot;,
        (e) =&gt; {
          if (!e.touches || e.touches.length &gt; 1) return;
          startX = e.touches[0].clientX;
          startY = e.touches[0].clientY;
          deltaX = 0;
          isSwiping = true;
        },
        { passive: true },
      );

      carousel.addEventListener(
        &quot;touchmove&quot;,
        (e) =&gt; {
          if (!isSwiping || !e.touches || e.touches.length &gt; 1) return;
          deltaX = e.touches[0].clientX - startX;
          const deltaY = Math.abs(e.touches[0].clientY - startY);
          if (deltaY &gt; maxVertical) {
            isSwiping = false;
            return;
          }
          if (Math.abs(deltaX) &gt; 10) e.preventDefault();
        },
        { passive: false },
      );

      carousel.addEventListener(&quot;touchend&quot;, (e) =&gt; {
        if (!isSwiping) return;
        isSwiping = false;
        if (Math.abs(deltaX) &lt; threshold) return;
        if (deltaX &gt; 0) {
          prev.click();
        } else {
          next.click();
        }
      });

      window.addEventListener(&quot;resize&quot;, () =&gt; render());
      render();
    });
  });
&lt;&#x2F;script&gt;
&lt;p&gt;Go and grab my Adobe Connect lecture downloader for yourself here: &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;keanucz&#x2F;AdobeConnectDL&#x2F;releases&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;keanucz&#x2F;AdobeConnectDL&#x2F;releases&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
    </channel>
</rss>
