3edafcfe1e
A HEIC is the one camera file no engine on this platform will paint: Chromium answers createImageBitmap with InvalidStateError and its WebCodecs take no hvc1 still, and unlike a RAW there is no JPEG preview inside to lift. It is decoded in software instead, by libheif built to wasm, in a worker of its own — so a folder of them reads without the tab stopping for a second, and what leaves the worker is the same JPEG developRaw hands the studio. Nothing downstream knows the difference. Into the studio it goes on the same terms as every other frame: dropped, picked, or opened from the catalogue, no tier asked and the engine's own colour untouched. The frame's date, ISO and position come off the HEIC itself, which is passed along as the bytes the stamps read. The catalogue's tile does not pay the decode: a phone writes a small HEVC copy of its frame as an item of its own (thmb, 320x240 on an iPhone), reachable only by walking the container by hand, and it decodes in milliseconds where the frame takes a second — from the same 256KB the catalogue already reads for a header. The shelves themselves now want an account: a guest gets the note and the way in, and the scan never starts.
89 lines
3.6 KiB
TypeScript
89 lines
3.6 KiB
TypeScript
// 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<number, (reply: HeicReply) => 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<HeicReply>) => 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<HeicReply> {
|
|
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<Uint8Array> {
|
|
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<Uint8Array | null> {
|
|
const reply = await ask(bytes, true, 0);
|
|
return reply.jpeg ?? null;
|
|
}
|