<?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[Convertube Blog]]></title><description><![CDATA[Convertube Blog]]></description><link>https://convertube.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6acdd16e1d01651b0ddce270/d0a183a3-e251-4f65-8dbb-0a0e38daa3cd.png</url><title>Convertube Blog</title><link>https://convertube.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 10:21:25 GMT</lastBuildDate><atom:link href="https://convertube.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why your iPhone photos look washed out after converting HEIC in the browser]]></title><description><![CDATA[HEIC photos from iPhone are Display P3. Decode them into an sRGB ImageData and colours go dull. One ImageData option fixes it.]]></description><link>https://convertube.hashnode.dev/why-your-iphone-photos-look-washed-out-after-converting-heic-in-the-browser</link><guid isPermaLink="true">https://convertube.hashnode.dev/why-your-iphone-photos-look-washed-out-after-converting-heic-in-the-browser</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[canvas]]></category><category><![CDATA[images]]></category><dc:creator><![CDATA[Convertube]]></dc:creator><pubDate>Tue, 13 Oct 2026 07:03:22 GMT</pubDate><content:encoded><![CDATA[<p>If you decode an iPhone HEIC photo in JavaScript, draw it to a canvas and export a JPG, the result often looks slightly dull next to the original — skin tones flatter, skies less blue. Nothing is "wrong" with the decoder. The problem is colour space, and the fix is one option on <code>ImageData</code>.</p>
<h2>What is actually in the file</h2>
<p>Since the iPhone 7, the camera captures in <strong>Display P3</strong>, a wider gamut than sRGB. The HEIC file stores pixel values relative to P3 primaries and says so — either with an embedded ICC profile (<code>prof</code> / <code>rICC</code> colour box) or with an <code>nclx</code> box whose <code>colour_primaries</code> field is 12 (SMPTE EG 432-1: P3 primaries, D65 white).</p>
<p>A WebAssembly HEIF decoder such as libheif hands you raw RGBA bytes. Those bytes are P3 numbers. They carry no colour information of their own.</p>
<h2>Where it goes wrong</h2>
<p>The usual code looks like this:</p>
<pre><code class="language-js">const imageData = new ImageData(rgba, width, height); // colorSpace defaults to 'srgb'
ctx.putImageData(imageData, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
</code></pre>
<p><code>ImageData</code> defaults to <code>colorSpace: 'srgb'</code>. So the browser takes numbers that mean "this much of P3 red" and treats them as "this much of sRGB red". P3 red is more saturated than sRGB red, so the same number under sRGB is a weaker colour. Every pixel loses a little saturation. That is the washed-out look.</p>
<p>Exporting does not help, because a JPG or PNG produced by <code>canvas.toBlob()</code> carries no colour profile. Every viewer will read it as sRGB anyway.</p>
<h2>The fix: tell the browser what the numbers mean</h2>
<p><code>ImageData</code> accepts a colour space. Declare the pixels as P3 and let the browser convert them to the canvas's colour space when it draws:</p>
<pre><code class="language-js">const colorSpace = isP3 ? 'display-p3' : 'srgb';
const bitmap = await createImageBitmap(
  new ImageData(rgba, width, height, { colorSpace })
);

const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d'); // an sRGB canvas
ctx.drawImage(bitmap, 0, 0);         // P3 → sRGB happens here

const jpg = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
</code></pre>
<p>Now the output is genuinely sRGB: colours that fit in sRGB come out identical, and the few P3 colours outside sRGB are mapped to the nearest one sRGB can show. Because the result is real sRGB, it looks right in every viewer, profile or not.</p>
<h2>Deciding whether the image is P3</h2>
<p>You need <code>isP3</code> from the file, not from a guess. Two cases cover iPhone photos:</p>
<ul>
<li><p><strong>ICC profile present</strong> — the profile's description string contains "Display P3". A crude but effective check is to decode the bytes as Latin-1 and test for the name (it may appear in plain ASCII or as UTF-16):</p>
<pre><code class="language-js">const text = new TextDecoder('latin1').decode(iccBytes);
const isP3 = /Display P3|D\0i\0s\0p\0l\0a\0y\0 \0P\x003/.test(text);
</code></pre>
</li>
<li><p><strong>nclx box present</strong> — read <code>colour_primaries</code>; 12 means P3 with a D65 white point.</p>
</li>
</ul>
<p>If neither is present, assume sRGB.</p>
<h2>Two more things that bite</h2>
<p><strong>Take the primary image.</strong> A HEIC file often holds several images: the photo, thumbnails, burst frames, depth maps. libheif lists them all; "the first one" is not necessarily the photo. Look for the handle where <code>is_primary()</code> is true.</p>
<p><strong>Large photos are tiled.</strong> A 12-megapixel HEIC is usually stored as a grid of 512×512 tiles. A good decoder stitches the grid for you when you decode the primary image — but if you ever see a small square instead of the whole picture, you have decoded a tile.</p>
<h2>Checking the result</h2>
<p>Put the converted JPG next to the original HEIC in a viewer that respects colour profiles (Preview on macOS, or Safari). With the default <code>srgb</code> <code>ImageData</code> the difference is visible on red and orange areas. With <code>colorSpace: 'display-p3'</code> the two should be indistinguishable.</p>
<p><code>ImageData</code> colour spaces are supported in current Chrome, Edge and Safari. Check your target browsers: where the option is not supported it is ignored, the data is treated as sRGB, and you get the old washed-out behaviour rather than an error.</p>
]]></content:encoded></item></channel></rss>