<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Photo2Reel Engineering]]></title><description><![CDATA[Photo2Reel Engineering]]></description><link>https://photo2reel.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Photo2Reel Engineering</title><link>https://photo2reel.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 11:39:14 GMT</lastBuildDate><atom:link href="https://photo2reel.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a Browser-Local Slideshow Exporter: MediaRecorder MIME Negotiation, Canvas Timing, and Audio Mixing]]></title><description><![CDATA[A browser-local slideshow exporter is less about drawing pictures and more about making three independent clocks agree: the canvas, the audio graph, and MediaRecorder. The browser can do the entire jo]]></description><link>https://photo2reel.hashnode.dev/building-a-browser-local-slideshow-exporter-mediarecorder-mime-negotiation-canvas-timing-and-audio-mixing</link><guid isPermaLink="true">https://photo2reel.hashnode.dev/building-a-browser-local-slideshow-exporter-mediarecorder-mime-negotiation-canvas-timing-and-audio-mixing</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[HTML Canvas]]></category><category><![CDATA[Web Audio API]]></category><category><![CDATA[MediaRecorder]]></category><dc:creator><![CDATA[Flower Rain Studio]]></dc:creator><pubDate>Fri, 11 Sep 2026 05:05:53 GMT</pubDate><content:encoded><![CDATA[<p>A browser-local slideshow exporter is less about drawing pictures and more about making three independent clocks agree: the canvas, the audio graph, and <code>MediaRecorder</code>. The browser can do the entire job without a rendering server, but only if the application treats format selection, scene deadlines, and recorder shutdown as explicit parts of the design.</p>
<p>This article walks through the implementation used in the small, dependency-free <a href="https://photo2reel.com/">Photo2Reel</a> exporter. The complete MIT-licensed source is also available in the <a href="https://github.com/wuyuwuyan3013/photo2reel-browser-slideshow-exporter">public GitHub repository</a>.</p>
<h2>The pipeline in one sentence</h2>
<p>Local image files become decoded <code>Image</code> objects, those images are drawn onto a fixed-size canvas, <code>canvas.captureStream()</code> supplies the video track, Web Audio supplies an optional audio track, and <code>MediaRecorder</code> turns the combined stream into chunks that become a downloadable <code>Blob</code>.</p>
<p>Nothing in that pipeline requires <code>fetch()</code>, an upload form, a database, or a remote encoder. An object URL such as <code>blob:https://...</code> is a browser-managed reference to local data; creating one does not send the file anywhere.</p>
<p>The implementation loads photos this way:</p>
<pre><code class="language-js">async function addFiles(fileList) {
  for (const file of [...fileList].filter(f =&gt; f.type.startsWith('image/'))) {
    const url = URL.createObjectURL(file);
    const image = new Image();
    image.src = url;

    try {
      await image.decode();
      photos.push({ file, url, image, duration: 3 });
    } catch {
      URL.revokeObjectURL(url);
    }
  }
}
</code></pre>
<p>Waiting for <code>image.decode()</code> matters. It moves a possible decoding failure to the import step instead of discovering it halfway through a recording. Keeping the object URL also avoids converting a large binary file into a longer base64 string. When a photo is removed, its URL is revoked.</p>
<h2>Make the canvas the output contract</h2>
<p>The exporter uses a <code>1280 × 720</code> canvas. That is not merely a preview size: it defines the actual video frame. CSS may scale the visible canvas down for a phone, but its drawing buffer remains 1280 by 720.</p>
<p>Photos rarely share the canvas aspect ratio. Stretching them damages proportions, while filling the entire frame would crop portraits. This implementation chooses containment over cropping and paints a dark matte behind unused space:</p>
<pre><code class="language-js">function drawContained(image) {
  const iw = image.naturalWidth || image.width;
  const ih = image.naturalHeight || image.height;
  const cw = canvas.width;
  const ch = canvas.height;

  const scale = Math.min(cw / iw, ch / ih);
  const width = iw * scale;
  const height = ih * scale;

  ctx.fillStyle = '#060c19';
  ctx.fillRect(0, 0, cw, ch);
  ctx.drawImage(image, (cw - width) / 2, (ch - height) / 2, width, height);
}
</code></pre>
<p>Using <code>Math.min()</code> is the key. Replacing it with <code>Math.max()</code> produces a cover-style frame: no matte, but some source pixels are cropped. Neither choice is universally correct. A slideshow that preserves family photos benefits from containment; a full-bleed social clip may deliberately prefer cover.</p>
<h2>Negotiate the recording format at runtime</h2>
<p>There is no single MIME string that can be assumed across every browser. File extensions do not choose encoders either. The application has to ask the current browser which recorder configurations it exposes, in preference order.</p>
<pre><code class="language-js">function bestMime() {
  if (!window.MediaRecorder) return '';

  const candidates = [
    'video/webm;codecs=vp9,opus',
    'video/webm;codecs=vp8,opus',
    'video/webm',
    'video/mp4;codecs=avc1.42E01E,mp4a.40.2',
    'video/mp4'
  ];

  return candidates.find(type =&gt; MediaRecorder.isTypeSupported(type)) || '';
}
</code></pre>
<p>The order expresses policy, not a claim that the first entry is always best. Here, explicit WebM combinations are preferred, generic WebM is a fallback, and MP4 is used only when the runtime reports a matching recorder.</p>
<p>The selected MIME type must travel through the rest of the pipeline. The recorder receives it, the final blob uses its container portion, and the download extension follows the same decision:</p>
<pre><code class="language-js">const recorder = new MediaRecorder(stream, {
  mimeType,
  videoBitsPerSecond: 5_000_000
});

const extension = mimeType.includes('mp4') ? 'mp4' : 'webm';
const blob = new Blob(chunks, { type: mimeType.split(';')[0] });
download.download = `photo-slideshow.${extension}`;
</code></pre>
<p>Renaming a WebM blob to <code>.mp4</code> does not convert it. It only creates a misleading file.</p>
<p><code>isTypeSupported()</code> is a capability gate, not a reason to remove error handling. Recorder construction or recording can still fail because of the selected tracks, media resources, or runtime conditions. The UI should report that failure and leave the original files untouched.</p>
<h2>Schedule scenes against absolute deadlines</h2>
<p>A tempting slideshow loop is “draw, sleep for the duration, repeat.” Its weakness is accumulated delay: drawing and UI work take time, so every scene begins a little later than the last. The implementation instead computes each scene's deadline from one <code>performance.now()</code> origin.</p>
<p>A reusable version looks like this:</p>
<pre><code class="language-js">const started = performance.now();
let cumulativeMs = 0;

for (let index = 0; index &lt; photos.length; index += 1) {
  const photo = photos[index];
  drawContained(photo.image);

  cumulativeMs += photo.duration * 1000;
  const remaining = started + cumulativeMs - performance.now();
  if (remaining &gt; 0) {
    await new Promise(resolve =&gt; setTimeout(resolve, remaining));
  }
}
</code></pre>
<p>If one iteration runs late, the next wait becomes shorter instead of carrying the entire delay forward. This does not create hard real-time behavior—background-tab throttling, device sleep, and a busy main thread still exist—but it prevents ordinary JavaScript work from automatically accumulating into timeline drift.</p>
<p>The canvas stream is requested at 30 frames per second:</p>
<pre><code class="language-js">const stream = canvas.captureStream(30);
</code></pre>
<p>That does not mean the code has to redraw an unchanged photograph thirty times every second. A still scene can remain on the canvas until its deadline. The captured stream represents that surface while the recorder handles the video cadence.</p>
<p>One consequence is easy to overlook: recording is wall-clock work. A slideshow with a long visual timeline does not finish instantly; the current implementation records while that timeline passes. An offline frame encoder would be a different architecture.</p>
<h2>Mix audio into the same <code>MediaStream</code></h2>
<p>Canvas contributes video only. To add music, the exporter builds a Web Audio graph whose destination exposes a <code>MediaStream</code> audio track:</p>
<pre><code class="language-js">const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const destination = audioContext.createMediaStreamDestination();
const gain = audioContext.createGain();

gain.gain.value = Number(volume.value) / 100;
gain.connect(destination);

// For a user-selected local audio file:
const audioElement = new Audio(URL.createObjectURL(audioFile));
audioElement.loop = true;
const source = audioContext.createMediaElementSource(audioElement);
source.connect(gain);

await audioContext.resume();
await audioElement.play();

for (const track of destination.stream.getAudioTracks()) {
  stream.addTrack(track);
}
</code></pre>
<p>The graph is <code>media element → gain → media-stream destination</code>. The destination track joins the canvas track before <code>MediaRecorder</code> is constructed, so the recorder sees one combined stream. The audio element loops when it is shorter than the slideshow and is paused when recording stops.</p>
<p>The demo can also synthesize a soundtrack. Oscillators connect through short gain envelopes to the same destination. Their start times are scheduled against <code>audioContext.currentTime</code>, which is preferable to firing every note from a chain of JavaScript timers. The recorder still determines the final cutoff, so a note scheduled beyond the slideshow endpoint is simply not present in the completed recording.</p>
<p>Audio contexts commonly need to be resumed from a user action. Starting export from a button click gives <code>resume()</code> and <code>play()</code> the user gesture they need, although an unsupported input codec can still make <code>play()</code> reject. That rejection belongs in the same visible error path as recorder failures.</p>
<h2>Do not build the blob before the recorder has stopped</h2>
<p><code>MediaRecorder</code> delivers data asynchronously. A timeslice asks it to emit chunks periodically, but it does not define slideshow frames or exact edit boundaries.</p>
<pre><code class="language-js">const chunks = [];
const stopped = new Promise(resolve =&gt; {
  recorder.onstop = resolve;
});

recorder.ondataavailable = event =&gt; {
  if (event.data.size) chunks.push(event.data);
};

recorder.start(250);

// Draw scenes and wait for their deadlines here.

recorder.stop();
await stopped;

const blob = new Blob(chunks, { type: mimeType.split(';')[0] });
const resultUrl = URL.createObjectURL(blob);
</code></pre>
<p>Waiting for <code>stop</code> ensures the final <code>dataavailable</code> event has been handled before the blob is assembled. The resulting object URL can be assigned directly to an <code>&lt;a download&gt;</code> element.</p>
<p>Output URLs should not live forever. Before exposing a replacement render, the implementation calls <code>URL.revokeObjectURL(previousResultUrl)</code>. The same cleanup rule applies to removed images and temporary audio URLs. The <code>AudioContext</code> is closed after the recorder finishes.</p>
<h2>Practical limits and hardening points</h2>
<p>Browser-local does not mean resource-free. Decoded images, a 1280 × 720 drawing buffer, recorder chunks, and the final blob all occupy memory in the user's tab. Long sequences and high-resolution inputs should be tested on the target device rather than inferred from desktop behavior.</p>
<p>Format support is also a runtime property. A responsible interface checks for <code>MediaRecorder</code>, <code>canvas.captureStream</code>, and at least one accepted MIME candidate, then displays the actual output container. It should never promise MP4 before negotiation has happened.</p>
<p>For a more defensive production version, I would add four things around the compact reference implementation:</p>
<ol>
<li>Listen for <code>recorder.onerror</code> and reject the pending completion promise instead of relying only on synchronous <code>try</code>/<code>catch</code>.</li>
<li>Put audio pause, temporary URL revocation, track stopping, and <code>AudioContext.close()</code> in a <code>finally</code> path so a failed <code>play()</code> cannot skip cleanup.</li>
<li>Check that the completed blob has a nonzero size, then test playback—not merely download creation—during browser QA.</li>
<li>Warn users to keep the page active during recording, because background throttling and device sleep can disturb a wall-clock export.</li>
</ol>
<p>The main architectural lesson is modest: use capability negotiation rather than browser names, calculate scene changes from one monotonic start time, and mix audio before constructing the recorder. With those boundaries in place, Canvas, Web Audio, and <code>MediaRecorder</code> form a useful local media pipeline without an upload queue or server-side renderer.</p>
]]></content:encoded></item></channel></rss>