// HEIC → JPEG on the client, the way rawDevelop.ts does it for a camera RAW: the // file is opened by the studio as the picture it holds, so a phone's own file // needs no converter in the middle either. // // Unlike a RAW there is nothing to fit: a HEIC is already a rendered 8-bit sRGB // image that the phone made, not sensor data with the camera's picture style to // be matched, so this decode is a decode — no curve, no matrix, no white // balance, and nothing here touches the RAW develop's colour. // // The reading itself is the worker's (see heicWorker.ts): this file is the queue // in front of it, one request at a time, so a folder being read does not start // four wasm decoders. import type { HeicAsk, HeicReply } from './heicWorker'; // What a picker is told to offer beside the images it knows: iOS file names are // the only sign of the format, and the MIME type for one is not registered // everywhere a file dialog reads. export const HEIC_ACCEPT = '.heic,.heif'; export function isHeicName(name: string): boolean { return /\.(heic|heif)$/i.test(name); } // The answer a request that never got one resolves to: no JPEG, no error worth // printing — the caller has the same nothing in hand as it has for a frame whose // own preview will not decode. const NOTHING: HeicReply = { id: 0 }; let worker: Worker | null = null; let seq = 0; const waiting = new Map void>(); function answer(reply: HeicReply): void { const done = waiting.get(reply.id); waiting.delete(reply.id); done?.(reply); } // The one worker, made when the first HEIC is asked for and kept for the visit. // A worker that dies takes its pending requests with it rather than leaving a // reader on a promise that will never answer, and the next file gets a new one. function lane(): Worker | null { if (worker) return worker; try { const made = new Worker(new URL('./heicWorker.ts', import.meta.url), { type: 'module' }); made.onmessage = (event: MessageEvent) => answer(event.data); made.onerror = () => { for (const id of [...waiting.keys()]) answer({ ...NOTHING, id }); made.terminate(); if (worker === made) worker = null; }; worker = made; } catch { return null; } return worker; } function ask(bytes: Uint8Array, thumb: boolean, max: number): Promise { const target = lane(); if (!target) return Promise.resolve(NOTHING); const id = ++seq; return new Promise((resolve) => { waiting.set(id, resolve); // A copy, because the bytes are read again by whoever asked: the catalogue // reads the frame's own date and position off the same slice it just asked // for a tile from. const ask: HeicAsk = { id, bytes: bytes.slice(), thumb, max }; target.postMessage(ask, [ask.bytes.buffer]); }); } // The picture a frame is, as a JPEG — what the studio opens and what a develop // hands it for a RAW. `max` caps the long edge; 0 keeps the file's own size. export async function heicToJpeg(bytes: Uint8Array, max = 0): Promise { const reply = await ask(bytes, false, max); if (!reply.jpeg) throw new Error(reply.error ?? 'heic'); return reply.jpeg; } // The phone's own small copy of the frame, for the catalogue's tiles: the item it // keeps for exactly this, decoded in milliseconds where the frame is a second. // Null when the file carries no such item, which is a frame with no tile rather // than a frame that failed. export async function heicThumbnail(bytes: Uint8Array): Promise { const reply = await ask(bytes, true, 0); return reply.jpeg ?? null; }