<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: larry</title>
    <description>The latest articles on DEV Community by larry (@larryxue).</description>
    <link>https://dev.to/larryxue</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1271059%2F4df28eb8-4d2c-41ef-ba11-cde6dc4091f2.png</url>
      <title>DEV Community: larry</title>
      <link>https://dev.to/larryxue</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/larryxue"/>
    <language>en</language>
    <item>
      <title>I benchmarked 3 ways to detect slide changes in video — fixed-interval screenshots, pixel diff, and block diff</title>
      <dc:creator>larry</dc:creator>
      <pubDate>Thu, 16 Jul 2026 06:19:30 +0000</pubDate>
      <link>https://dev.to/larryxue/i-benchmarked-3-ways-to-detect-slide-changes-in-video-fixed-interval-screenshots-pixel-diff-and-3o11</link>
      <guid>https://dev.to/larryxue/i-benchmarked-3-ways-to-detect-slide-changes-in-video-fixed-interval-screenshots-pixel-diff-and-3o11</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure up front: I maintain &lt;a href="https://github.com/larry-xue/video-slide-extractor" rel="noopener noreferrer"&gt;video-slide-extractor&lt;/a&gt; (MIT, zero dependencies) and &lt;a href="https://video2any.com" rel="noopener noreferrer"&gt;Video2Any&lt;/a&gt;, the product it came out of. The benchmark below is reproducible from two scripts, and it includes a case where my own defaults lose.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have ever tried to turn a recorded lecture, webinar, or Zoom call back into its slides, you have probably done one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Screenshot every N seconds&lt;/strong&gt; (what most tutorials do)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare pixels between frames&lt;/strong&gt; and keep the frame when "enough" changed&lt;/li&gt;
&lt;li&gt;Something smarter&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I wanted actual numbers instead of vibes, so I built a benchmark where the ground truth is &lt;strong&gt;exact by construction&lt;/strong&gt; — and the results changed how I document my own library's defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with benchmarking slide detection
&lt;/h2&gt;

&lt;p&gt;The honest way to evaluate a detector is against labeled transitions: precision, recall, F1. But hand-labeling a 45-minute lecture is hours of work, the labels have their own errors, and nobody can reproduce your benchmark without your labels.&lt;/p&gt;

&lt;p&gt;So I flipped it: &lt;strong&gt;render the videos from known slide sequences&lt;/strong&gt;. If &lt;em&gt;you&lt;/em&gt; choose when each slide appears, every transition timestamp is ground truth by construction. Two scripts, fully reproducible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/larry-xue/video-slide-extractor
node bench/generate-fixtures.mjs   &lt;span class="c"&gt;# renders the videos (ffmpeg)&lt;/span&gt;
node bench/run.mjs                 &lt;span class="c"&gt;# scores the methods, writes RESULTS.md&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixtures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two slide sources.&lt;/strong&gt; A 30-slide synthetic deck drawn by ffmpeg, and the 46 real slides that &lt;a href="https://video2any.com/decks/mit-what-is-computation" rel="noopener noreferrer"&gt;Video2Any extracted from MIT OCW 6.0001 Lecture 1&lt;/a&gt; (CC BY-NC-SA 4.0, fetched at run time, not committed).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three variants each&lt;/strong&gt;, same timeline: &lt;code&gt;clean&lt;/code&gt; (crf 23, hard cuts), &lt;code&gt;noisy&lt;/code&gt; (crf 45 + down/upscale), and &lt;code&gt;overlay&lt;/code&gt; (an animated "webcam" box in the corner — the classic meeting-recording trap).&lt;/li&gt;
&lt;li&gt;Slide durations 4–12 s from a seeded PRNG, so every machine renders identical timelines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caveat that belongs in the article and not in a footnote: this measures detection on &lt;em&gt;re-renderings with hard cuts&lt;/em&gt;, not on an original classroom recording. Crossfades, camera wobble, and in-slide builds are separate failure classes (&lt;a href="https://github.com/larry-xue/video-slide-extractor/blob/master/docs/evaluation.md" rel="noopener noreferrer"&gt;protocol here&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The three contestants
&lt;/h2&gt;

&lt;p&gt;All three see the exact same input: one 160×90 RGBA frame every 2 seconds, sampled by ffmpeg.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed interval&lt;/strong&gt; — keep a frame every 10 seconds. No content awareness at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Naive pixel diff&lt;/strong&gt; — a pixel "changed" if its mean |ΔRGB| &amp;gt; 25; keep the frame when &amp;gt; 2% of pixels changed since the last kept frame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Block diff&lt;/strong&gt; (&lt;a href="https://github.com/larry-xue/video-slide-extractor" rel="noopener noreferrer"&gt;video-slide-extractor&lt;/a&gt;) — split the frame into 8×8 blocks, compute each block's &lt;em&gt;mean&lt;/em&gt; absolute difference, count a block as changed when its mean exceeds a threshold, keep the frame when &amp;gt; &lt;code&gt;changedRatio&lt;/code&gt; of blocks changed. The averaging is the point: single-pixel codec noise dilutes inside a block, while a real slide change lights up whole blocks at once. Run twice: library defaults (&lt;code&gt;changedRatio: 0.02&lt;/code&gt;) and tuned (&lt;code&gt;0.10&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Results (MIT deck, 46 slides)
&lt;/h2&gt;

&lt;p&gt;Matching: each detection pairs with at most one ground-truth transition within ±2.5 s. Coverage = share of slides that got at least one capture.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;clean F1&lt;/th&gt;
&lt;th&gt;noisy F1&lt;/th&gt;
&lt;th&gt;overlay F1&lt;/th&gt;
&lt;th&gt;overlay precision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fixed interval (10 s)&lt;/td&gt;
&lt;td&gt;0.68&lt;/td&gt;
&lt;td&gt;0.68&lt;/td&gt;
&lt;td&gt;0.68&lt;/td&gt;
&lt;td&gt;0.76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Naive pixel diff (2%)&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block diff (defaults, 0.02)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.97&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.97&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.54&lt;/td&gt;
&lt;td&gt;0.37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block diff (ratio 0.10)&lt;/td&gt;
&lt;td&gt;0.89&lt;/td&gt;
&lt;td&gt;0.89&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.93&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Full tables (both decks, all metrics): &lt;a href="https://github.com/larry-xue/video-slide-extractor/blob/master/bench/RESULTS.md" rel="noopener noreferrer"&gt;bench/RESULTS.md&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned (including where I lose)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Fixed-interval capture has a hard ceiling nobody mentions.&lt;/strong&gt; Its coverage capped at ~0.78–0.80 across every variant: any slide shorter than the capture interval is simply &lt;em&gt;gone&lt;/em&gt;, no matter how clean the video is. Tutorials that recommend "grab a frame every 10 seconds" are silently accepting a ~20% slide loss on decks with normal pacing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Naive pixel diff is better than its reputation — until something animates.&lt;/strong&gt; At a sane per-pixel threshold (25), crf-45 compression noise stayed under the trigger. Where it degrades is the webcam overlay: a small pixel population sits permanently above threshold and precision drops to 0.85.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. My own defaults flood on webcam overlays.&lt;/strong&gt; This is the number I care most about publishing: with the default &lt;code&gt;changedRatio: 0.02&lt;/code&gt;, the animated overlay occupies more than 2% of blocks, so &lt;em&gt;every sampled frame&lt;/em&gt; re-triggers — 125 captures for 46 slides, precision 0.37. Raising the ratio to 0.10 restores precision to 1.0. Thresholds are inputs to report, not constants to trust. (The full product deals with this properly — per-video auto-calibration and masking the animated region — but the library's README now says this out loud instead of hoping.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Neither detector hits recall 1.0 on clean input.&lt;/strong&gt; A few consecutive MIT slides differ by roughly one bullet line; at 160×90 that change hovers at the trigger floor. Incremental builds are the honest hard case for &lt;em&gt;any&lt;/em&gt; frame-differencing approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you are building a video-to-slides pipeline in JavaScript:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't use fixed-interval capture; the miss rate is structural.&lt;/li&gt;
&lt;li&gt;Frame differencing needs noise absorption (blocks, or a robust threshold) &lt;em&gt;and&lt;/em&gt; a story for persistently-animated regions — masking, calibration, or a higher trigger ratio.&lt;/li&gt;
&lt;li&gt;Whatever you build, benchmark it against constructed ground truth. It costs two scripts and removes all labeling excuses: &lt;a href="https://github.com/larry-xue/video-slide-extractor/blob/master/bench/README.md" rel="noopener noreferrer"&gt;methodology&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The detector is ~80 lines, MIT-licensed, zero dependencies, runs in the browser and Node: &lt;a href="https://github.com/larry-xue/video-slide-extractor" rel="noopener noreferrer"&gt;github.com/larry-xue/video-slide-extractor&lt;/a&gt;. The finished product built on top of it (local processing, no upload) is &lt;a href="https://video2any.com" rel="noopener noreferrer"&gt;video2any.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>video</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to evaluate a slide-change detector without fooling yourself</title>
      <dc:creator>larry</dc:creator>
      <pubDate>Wed, 15 Jul 2026 04:49:31 +0000</pubDate>
      <link>https://dev.to/larryxue/how-to-evaluate-a-slide-change-detector-without-fooling-yourself-2558</link>
      <guid>https://dev.to/larryxue/how-to-evaluate-a-slide-change-detector-without-fooling-yourself-2558</guid>
      <description>&lt;p&gt;I maintain &lt;a href="https://github.com/larry-xue/video-slide-extractor" rel="noopener noreferrer"&gt;video-slide-extractor&lt;/a&gt;,&lt;br&gt;
a small JavaScript frame-differencing package used in a browser video-to-slides&lt;br&gt;
pipeline. The detector is intentionally simple. Evaluating it honestly is not.&lt;/p&gt;

&lt;p&gt;The easiest demo is to show a lecture clip, export a plausible deck, and say it&lt;br&gt;
worked. That proves almost nothing.&lt;/p&gt;

&lt;p&gt;A detector can produce a good-looking output while missing brief slides,&lt;br&gt;
capturing half-faded transitions, or keeping the same slide five times. It can&lt;br&gt;
also look inaccurate because the evaluator sampled too slowly to ever see the&lt;br&gt;
missing slide.&lt;/p&gt;

&lt;p&gt;Here is the protocol I now use.&lt;/p&gt;
&lt;h2&gt;
  
  
  First, define the task
&lt;/h2&gt;

&lt;p&gt;Three jobs are often mixed together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Slide-change detection&lt;/strong&gt;: find timestamps where the visible frame changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Original-slide recovery&lt;/strong&gt;: choose a clean representative image for every
slide shown in the recording.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deck generation&lt;/strong&gt;: use a transcript or model to create a new presentation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A frame-difference detector only addresses the first job. It should not get&lt;br&gt;
credit for OCR or export quality, and it should not be blamed for a PowerPoint&lt;br&gt;
writer stretching an image.&lt;/p&gt;
&lt;h2&gt;
  
  
  Build ground truth before tuning
&lt;/h2&gt;

&lt;p&gt;Label the source video manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slide_id,start_seconds,end_seconds,notes
1,0.0,42.4,title slide
2,42.4,88.1,incremental bullet builds
3,88.1,123.7,crossfade transition
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do this before changing thresholds. Otherwise it is too easy to redefine&lt;br&gt;
"correct" around the output you already have.&lt;/p&gt;

&lt;p&gt;Record the source, license, duration, resolution, and access date. If the video&lt;br&gt;
cannot be redistributed, publish the labels and source link instead.&lt;/p&gt;
&lt;h2&gt;
  
  
  Freeze the sampling configuration
&lt;/h2&gt;

&lt;p&gt;The sampling interval is part of the detector.&lt;/p&gt;

&lt;p&gt;If a slide is visible for 1.4 seconds and you sample every 2 seconds, the&lt;br&gt;
detector cannot recover it. That is a sampling false negative, not a threshold&lt;br&gt;
false negative.&lt;/p&gt;

&lt;p&gt;For every run, record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sample interval;&lt;/li&gt;
&lt;li&gt;comparison resolution;&lt;/li&gt;
&lt;li&gt;crop or mask;&lt;/li&gt;
&lt;li&gt;detector options;&lt;/li&gt;
&lt;li&gt;runtime and package version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For &lt;code&gt;video-slide-extractor&lt;/code&gt;, that looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSlideDetector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;blockSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;blockDelta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;changedRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The defaults are starting points, not an accuracy guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Match detections once
&lt;/h2&gt;

&lt;p&gt;Give each detected timestamp a tolerance window, such as two seconds, and match&lt;br&gt;
it to at most one labeled transition.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A matched detection is a true positive.&lt;/li&gt;
&lt;li&gt;An unmatched detection is a false positive.&lt;/li&gt;
&lt;li&gt;An unmatched label is a false negative.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then calculate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;precision = TP / (TP + FP)
recall    = TP / (TP + FN)
F1        = 2 * precision * recall / (precision + recall)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches a common bad evaluation: counting two detections around the same&lt;br&gt;
transition as two successes.&lt;/p&gt;
&lt;h2&gt;
  
  
  Score visual usability separately
&lt;/h2&gt;

&lt;p&gt;A timestamp can be correct while the captured image is unusable.&lt;/p&gt;

&lt;p&gt;Track at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transition frames;&lt;/li&gt;
&lt;li&gt;consecutive duplicates;&lt;/li&gt;
&lt;li&gt;a slide revisited later;&lt;/li&gt;
&lt;li&gt;low-resolution or blurred captures;&lt;/li&gt;
&lt;li&gt;frames dominated by a presenter or webcam overlay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I report duplicate rate and transition-frame rate separately from detection F1.&lt;br&gt;
Those failures often belong to temporal cleanup and export-frame selection, not&lt;br&gt;
the first-pass change detector.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tag the failure class
&lt;/h2&gt;

&lt;p&gt;The aggregate number tells you whether a version improved. The failure class&lt;br&gt;
tells you what to build next.&lt;/p&gt;

&lt;p&gt;Useful tags include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;incremental build;&lt;/li&gt;
&lt;li&gt;crossfade or wipe;&lt;/li&gt;
&lt;li&gt;cursor/webcam motion;&lt;/li&gt;
&lt;li&gt;camera footage around a screen;&lt;/li&gt;
&lt;li&gt;compression noise;&lt;/li&gt;
&lt;li&gt;repeated earlier slide;&lt;/li&gt;
&lt;li&gt;sample interval too wide.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, raising &lt;code&gt;changedRatio&lt;/code&gt; can reduce cursor-related false positives&lt;br&gt;
while making small text-only changes easier to miss. A single "accuracy" number&lt;br&gt;
hides that tradeoff.&lt;/p&gt;
&lt;h2&gt;
  
  
  Compare equal pipelines
&lt;/h2&gt;

&lt;p&gt;When comparing two libraries, use the same decoded frames and sampling interval.&lt;br&gt;
When comparing two finished products, report the entire pipeline instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what input each accepted;&lt;/li&gt;
&lt;li&gt;whether processing was local or remote;&lt;/li&gt;
&lt;li&gt;how much manual cleanup was needed;&lt;/li&gt;
&lt;li&gt;whether the final PPTX contained images, OCR text, or native objects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not rank a transcript-to-new-deck generator against an original-slide&lt;br&gt;
recovery tool as if they produced the same result.&lt;/p&gt;
&lt;h2&gt;
  
  
  Publish the failures
&lt;/h2&gt;

&lt;p&gt;The minimum reproducible evidence is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ground-truth labels;&lt;/li&gt;
&lt;li&gt;detector configuration;&lt;/li&gt;
&lt;li&gt;detected timestamps;&lt;/li&gt;
&lt;li&gt;metric calculation;&lt;/li&gt;
&lt;li&gt;package/runtime versions;&lt;/li&gt;
&lt;li&gt;known blockers and excluded cases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I added a reusable version of this protocol to the repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/larry-xue/video-slide-extractor/blob/master/docs/evaluation.md" rel="noopener noreferrer"&gt;Evaluate a Slide-Change Detector&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The detector itself is zero-dependency JavaScript and accepts ordered RGBA&lt;br&gt;
frames in the browser or Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;video-slide-extractor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The larger lesson is simple: show the labels, the settings, and the ugly frames.&lt;br&gt;
If a comparison publishes only the winners, it is marketing, not a benchmark.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>video</category>
    </item>
    <item>
      <title>How I Built a Browser-Based Video to PowerPoint Converter</title>
      <dc:creator>larry</dc:creator>
      <pubDate>Sat, 04 Jul 2026 14:16:50 +0000</pubDate>
      <link>https://dev.to/larryxue/how-i-built-a-browser-based-video-to-powerpoint-converter-ag0</link>
      <guid>https://dev.to/larryxue/how-i-built-a-browser-based-video-to-powerpoint-converter-ag0</guid>
      <description>&lt;p&gt;Long lecture recordings are a strange format.&lt;/p&gt;

&lt;p&gt;The useful part is often not the video itself. It is the slide deck trapped inside the video: a lecture, webinar, product walkthrough, Zoom recording, or screen share where the presenter spends most of the time on mostly-static slides.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://video2any.com" rel="noopener noreferrer"&gt;Video2Any&lt;/a&gt;, a browser-based tool that turns videos and screen recordings into PowerPoint decks, PDFs, image frames, and subtitles. The unusual part is that the video does not need to be uploaded. The browser does the decoding, slide detection, and export locally.&lt;/p&gt;

&lt;p&gt;On my tests, an hour-long lecture-style video can usually be sampled in under a minute on a normal laptop. The exact speed depends on the video, browser, codec, resolution, and device, but the important design choice is this: the tool does not need to understand every pixel of every frame at full resolution.&lt;/p&gt;

&lt;p&gt;It only needs to answer one question quickly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Did the visible slide change?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The first version was not AI
&lt;/h2&gt;

&lt;p&gt;The tempting answer today is: use AI.&lt;/p&gt;

&lt;p&gt;But for this problem, AI is not the first tool I reach for.&lt;/p&gt;

&lt;p&gt;A recorded slide deck has a useful property: it is mostly still. The same slide remains on screen for seconds or minutes, then a large part of the frame changes when the presenter advances to the next slide.&lt;/p&gt;

&lt;p&gt;That means a simple visual detector can work surprisingly well:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sample the video every few seconds.&lt;/li&gt;
&lt;li&gt;Downscale each sampled frame to a small size, such as 160x90.&lt;/li&gt;
&lt;li&gt;Compare the current frame to the last kept slide frame.&lt;/li&gt;
&lt;li&gt;Keep the frame if enough visual blocks changed.&lt;/li&gt;
&lt;li&gt;Export the kept frames into a deck.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is not semantic understanding. It does not know what the title says. It does not know whether a chart is important. It only detects meaningful visual changes.&lt;/p&gt;

&lt;p&gt;For lecture videos, webinars, and screen recordings, that is often enough to get a useful first deck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why downscale first?
&lt;/h2&gt;

&lt;p&gt;A 1080p frame has more than two million pixels. Comparing full-resolution frames repeatedly is wasteful if the goal is only to detect slide transitions.&lt;/p&gt;

&lt;p&gt;Instead, Video2Any compares a small version of the frame. At 160x90, the detector is looking at 14,400 pixels. That is small enough to be fast, but still large enough to tell whether a slide changed.&lt;/p&gt;

&lt;p&gt;The export step can still capture a higher-quality image later. Detection and export do not need to use the same resolution.&lt;/p&gt;

&lt;p&gt;This split matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Low-resolution frames for fast detection.&lt;/li&gt;
&lt;li&gt;Higher-resolution frames for final PowerPoint/PDF/image export.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Block-based frame differencing
&lt;/h2&gt;

&lt;p&gt;A naive frame diff compares individual pixels. That is too sensitive for compressed video.&lt;/p&gt;

&lt;p&gt;Video compression creates small changes even when the slide did not actually change. A cursor might move. A webcam bubble might flicker. A progress bar might animate.&lt;/p&gt;

&lt;p&gt;So the detector uses blocks.&lt;/p&gt;

&lt;p&gt;At a high level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;changedEnough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentFrame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockDelta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;changedRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;changedBlocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalBlocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blockSize&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;meanAbsoluteDifference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;blockDelta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;changedBlocks&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;totalBlocks&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;changedBlocks&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalBlocks&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;changedRatio&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact implementation has more edge cases, but the idea is simple: a block counts as changed only if its average visual difference is high enough. Then a frame becomes a new slide only if enough blocks changed.&lt;/p&gt;

&lt;p&gt;This makes the detector less jumpy than pixel-by-pixel comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the browser is enough
&lt;/h2&gt;

&lt;p&gt;Modern browsers can do a lot of media work locally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decode video from a &lt;code&gt;File&lt;/code&gt; or &lt;code&gt;Blob&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Seek through timestamps.&lt;/li&gt;
&lt;li&gt;Draw frames to a &lt;code&gt;canvas&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Read image data for diffing.&lt;/li&gt;
&lt;li&gt;Generate PowerPoint, PDF, or ZIP exports in JavaScript.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The privacy benefit is obvious: if the browser does the work, the file does not need to touch a server.&lt;/p&gt;

&lt;p&gt;The cost benefit is also important. If users are converting large private lecture recordings, uploading and processing everything server-side becomes expensive and slow. Local processing makes the free tier much more practical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The open-core detector
&lt;/h2&gt;

&lt;p&gt;I extracted the basic detector into a tiny open-source package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;video-slide-extractor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usage looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSlideDetector&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video-slide-extractor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSlideDetector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sampledFrames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new slide near&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package is here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/larry-xue/video-slide-extractor" rel="noopener noreferrer"&gt;https://github.com/larry-xue/video-slide-extractor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/video-slide-extractor" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/video-slide-extractor&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is intentionally small. It does not export PowerPoint files or run OCR. It just detects slide and scene changes from frames.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is harder than it sounds?
&lt;/h2&gt;

&lt;p&gt;A few cases make this problem more interesting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fade transitions
&lt;/h3&gt;

&lt;p&gt;If a deck uses fade animations, the detector might catch a half-transition frame instead of the final clean slide. A better pipeline should refine the timestamp after a change and capture the settled frame.&lt;/p&gt;

&lt;h3&gt;
  
  
  Webcam overlays
&lt;/h3&gt;

&lt;p&gt;If a webcam bubble moves constantly, it can create visual noise. Block-based diffing helps, but large overlays can still confuse the detector. One improvement is activity masking: identify small regions that move all the time and reduce their weight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Returning to an earlier slide
&lt;/h3&gt;

&lt;p&gt;Presenters often jump back. Local frame comparison only knows about the last kept frame. A stronger pipeline also compares against earlier kept slides and removes duplicates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Text-only changes
&lt;/h3&gt;

&lt;p&gt;The hardest useful case is a mostly identical slide where only a line of text changes. The threshold needs to be sensitive enough to catch that, without firing on video noise.&lt;/p&gt;

&lt;p&gt;This is where calibration helps. Instead of using one fixed threshold for every video, you can sample the video first and estimate the gap between same-slide noise and real slide changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI is still useful for
&lt;/h2&gt;

&lt;p&gt;I do not think AI is unnecessary here. I just think it belongs later in the pipeline.&lt;/p&gt;

&lt;p&gt;Frame differencing is good for finding slide frames. AI and OCR are useful after that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OCR to rebuild editable text.&lt;/li&gt;
&lt;li&gt;Speech-to-text for subtitles or speaker notes.&lt;/li&gt;
&lt;li&gt;Layout analysis to turn screenshots into structured slides.&lt;/li&gt;
&lt;li&gt;Summarization to produce study notes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That split keeps the first pass fast, private, and cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this works best
&lt;/h2&gt;

&lt;p&gt;Video2Any works best for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lecture recordings.&lt;/li&gt;
&lt;li&gt;Webinars.&lt;/li&gt;
&lt;li&gt;Conference talks.&lt;/li&gt;
&lt;li&gt;Zoom, Meet, and Teams recordings.&lt;/li&gt;
&lt;li&gt;Screen recordings and product walkthroughs.&lt;/li&gt;
&lt;li&gt;Mostly-static slide decks captured as video.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is less ideal for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Handheld camera footage.&lt;/li&gt;
&lt;li&gt;Highly animated videos.&lt;/li&gt;
&lt;li&gt;Videos where slides are tiny or blurred.&lt;/li&gt;
&lt;li&gt;Recordings where the presenter covers the slide content.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The browser tool is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://video2any.com" rel="noopener noreferrer"&gt;https://video2any.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The open-source detector is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/larry-xue/video-slide-extractor" rel="noopener noreferrer"&gt;https://github.com/larry-xue/video-slide-extractor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also started a small curated list of related tools and libraries:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/larry-xue/awesome-video-to-slides" rel="noopener noreferrer"&gt;https://github.com/larry-xue/awesome-video-to-slides&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I would be especially interested in feedback from people who deal with long lecture recordings, internal training videos, or webinar archives. The core question is simple: is the extracted deck good enough to replace scrubbing through the video?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>video</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
