diff --git a/docker/frontend/package-lock.json b/docker/frontend/package-lock.json
index 04d0642..939674e 100644
--- a/docker/frontend/package-lock.json
+++ b/docker/frontend/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"canvaskit-wasm": "^0.42.0",
"exifr": "^7.1.3",
+ "libheif-js": "^1.23.5",
"libraw-wasm": "^1.6.0",
"onnxruntime-web": "^1.30.0",
"react": "^18.3.1",
@@ -1606,6 +1607,15 @@
"node": ">=6"
}
},
+ "node_modules/libheif-js": {
+ "version": "1.23.5",
+ "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz",
+ "integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==",
+ "license": "LGPL-3.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/libraw-wasm": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/libraw-wasm/-/libraw-wasm-1.6.0.tgz",
diff --git a/docker/frontend/package.json b/docker/frontend/package.json
index 4ad2225..c78af03 100644
--- a/docker/frontend/package.json
+++ b/docker/frontend/package.json
@@ -15,6 +15,7 @@
"dependencies": {
"canvaskit-wasm": "^0.42.0",
"exifr": "^7.1.3",
+ "libheif-js": "^1.23.5",
"libraw-wasm": "^1.6.0",
"onnxruntime-web": "^1.30.0",
"react": "^18.3.1",
diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx
index d108296..b5874d2 100644
--- a/docker/frontend/src/App.tsx
+++ b/docker/frontend/src/App.tsx
@@ -17,6 +17,7 @@ import { loadEngine, MARK_FACES } from './engine/assets';
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
import { developRaw, isRawName } from './engine/rawDevelop';
+import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
import { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
import { offerInstall } from './pwa/install';
@@ -1130,6 +1131,25 @@ export function Workspace() {
void forgetPhoto();
return;
}
+ if (isHeicName(file.name)) {
+ // A phone's HEIC: no engine on this platform paints one (see
+ // heicWorker.ts), so it is decoded to the JPEG the studio works on —
+ // by the same worker the catalogue's tiles come from. No tier: unlike
+ // a RAW there is nothing here for the engine to develop, only a format
+ // the browser will not read, and the decode is a plain reading of the
+ // pixels the file already holds.
+ //
+ // The session keeps the decode, not the file, so a reload opens the
+ // frame at once instead of paying the second again. The name stays the
+ // file's own, and it is no RAW name, so the reload path above shows the
+ // saved copy as the picture it is. The HEIC goes along as the bytes the
+ // stamps read: the JPEG the worker made carries no EXIF of its own.
+ await clearRaw();
+ const jpeg = await heicToJpeg(bytes);
+ await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes);
+ void savePhoto(file.name, jpeg);
+ return;
+ }
await clearRaw();
await adoptPhoto(bytes, file.name, file);
void savePhoto(file.name, bytes);
diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx
index 696fb13..a6e61f1 100644
--- a/docker/frontend/src/Library.tsx
+++ b/docker/frontend/src/Library.tsx
@@ -5,6 +5,7 @@ import { api } from './api';
import { keep, recall } from './remember';
import { readGps, readSpecs, specsLine } from './engine/imageOps';
import { isRawName, rawThumbnail } from './engine/rawDevelop';
+import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import {
cachedPhotos,
canBrowseFolders,
@@ -154,7 +155,47 @@ const why = (err: unknown) => {
+// The catalogue is for people with an account. The shelves hold nothing the app
+// did not read off the visitor's own disk, but browsing them is still the one
+// screen that hands a folder over to be walked, and a guest one file picker away
+// from a folder that would be read for nothing is not the offer. The gate is
+// here rather than in the router so the scan never starts for a guest — the
+// catalogue component below is not mounted at all until the account is known.
export function Library() {
+ const { t } = useI18n();
+ const [state, setState] = useState<'loading' | 'guest' | 'ready'>('loading');
+
+ useEffect(() => {
+ let alive = true;
+ api
+ .me()
+ .then((me) => {
+ if (alive) setState(me?.user ? 'ready' : 'guest');
+ })
+ // No answer is treated like no account: the API being away is not a
+ // reason to open the shelves to whoever is on the page.
+ .catch(() => {
+ if (alive) setState('guest');
+ });
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ if (state === 'loading') return
+ );
+ return ;
+}
+
+function Catalogue() {
const { t } = useI18n();
const [folders, setFolders] = useState([]);
const [ready, setReady] = useState>(new Set());
@@ -1075,7 +1116,14 @@ export function Library() {
if (!live) return;
let url: string | null = null;
- if (isRawName(file.name)) {
+ if (isHeicName(file.name)) {
+ // The tile is the phone's own 320×240 copy; the stage wants more, so
+ // the frame itself is decoded and capped at what a screen shows (see
+ // heicWorker.ts — the decode is about a second for a 12MP frame, and
+ // the tile stands in until it lands).
+ const jpeg = await heicToJpeg(new Uint8Array(await file.arrayBuffer()), 2048);
+ if (live) url = URL.createObjectURL(new Blob([jpeg as BlobPart], { type: 'image/jpeg' }));
+ } else if (isRawName(file.name)) {
const bytes = new Uint8Array(await file.arrayBuffer());
if (!live) return;
const previewBytes = await rawThumbnail(bytes);
@@ -1141,8 +1189,10 @@ export function Library() {
);
}, []);
- // The tile: the thumbnail the catalogue kept, or a stand-in for the RAW whose
- // preview the camera never wrote. A tile only carries a handler where a click
+ // The tile: the thumbnail the catalogue kept, or a stand-in for the frame
+ // whose own small copy the file never carried — a RAW whose preview the camera
+ // never wrote, a HEIC with no thumbnail item (see engine/heicDevelop.ts). A tile
+ // only carries a handler where a click
// on the picture means "open it" — in the strip the click belongs to the frame
// it raises, not to the studio. The word is only for a frame that has no
// thumbnail to give: one that has one and has not been handed it yet — the wall
@@ -1157,7 +1207,7 @@ export function Library() {
/>
) : (
);
diff --git a/docker/frontend/src/engine/heicDevelop.ts b/docker/frontend/src/engine/heicDevelop.ts
new file mode 100644
index 0000000..1f2086e
--- /dev/null
+++ b/docker/frontend/src/engine/heicDevelop.ts
@@ -0,0 +1,88 @@
+// 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;
+}
diff --git a/docker/frontend/src/engine/heicWorker.ts b/docker/frontend/src/engine/heicWorker.ts
new file mode 100644
index 0000000..d9dde20
--- /dev/null
+++ b/docker/frontend/src/engine/heicWorker.ts
@@ -0,0 +1,169 @@
+// The HEIC decode, off the main thread.
+//
+// This is the one reading of a phone's file the app has to do in software: no
+// browser engine on this platform decodes HEVC stills for `createImageBitmap`
+// (Chromium answers `InvalidStateError`, and its WebCodecs will not take an hvc1
+// configuration either), and a HEIC carries no JPEG preview to lift the way a
+// camera RAW does — the frame inside it is one HEVC image, in grid tiles. So the
+// file is decoded here, by libheif compiled to wasm, and what leaves this worker
+// is a JPEG — the same thing `developRaw` and `rawThumbnail` hand their callers.
+// Nothing downstream of this file knows which of the two it has.
+//
+// It runs in a worker for the reason the RAW develop runs in its own: this is a
+// second of wasm for a 12MP frame (measured 1065ms on an iPhone 8 Plus file,
+// 4032x3024), and a tab that stops painting for a second while it opens a folder
+// is not an app. One worker is also one decoder — libheif is single-threaded and
+// this heap is not shared — so the requests queue here instead of racing.
+//
+// A tile does not pay that second: a phone writes a small HEVC thumbnail of the
+// frame as an item of its own (`thmb` on an iPhone), 320x240 on that same file,
+// and it decodes in 11ms. It is not a top-level image, so libheif's own reader
+// never returns it and the container is walked by hand below. A file with no
+// such item gets no tile — the row still lists the frame by name, which is what
+// a DNG with no preview gets too.
+import createHeif, { type HeifImage, type HeifImageHandle, type HeifModule } from 'libheif-js/libheif-wasm/libheif-bundle.mjs';
+
+// The develop's JPEG: the studio works on what a RAW develop gives it, so this
+// is full size and encoded to be worked on rather than to be looked at.
+const QUALITY = 0.92;
+
+export interface HeicAsk {
+ id: number;
+ // The file — whole for a develop, the head of it for a tile: the thumbnail is
+ // in the first kilobytes (measured: 64KB is enough on that same frame, so the
+ // 256KB the catalogue already reads for a header covers it four times over).
+ bytes: Uint8Array;
+ // The file's own thumbnail item when set, the frame itself otherwise.
+ thumb: boolean;
+ // The long edge to encode at; 0 leaves the decoded size alone.
+ max: number;
+}
+
+export interface HeicReply {
+ id: number;
+ jpeg?: Uint8Array;
+ error?: string;
+}
+
+// One module per worker, made once and kept: it carries a wasm heap, and the
+// second HEIC of a roll must not pay for the first one's instantiation.
+let loading: Promise | null = null;
+const heif = (): Promise => (loading ??= createHeif());
+
+interface Rgba {
+ data: Uint8ClampedArray;
+ width: number;
+ height: number;
+}
+
+// libheif's own display call, wrapped: it answers on a later turn of the event
+// loop, so the promise is what makes the buffer readable, and the handle has to
+// outlive the call that reads it — every caller here frees the container after
+// this has resolved, never before.
+function pixels(image: HeifImage): Promise {
+ const width = image.get_width();
+ const height = image.get_height();
+ if (!width || !height) return Promise.resolve(null);
+ const data = new Uint8ClampedArray(width * height * 4);
+ return new Promise((resolve) =>
+ image.display({ data, width, height }, (decoded) => {
+ image.free();
+ resolve(decoded ? { data, width, height } : null);
+ })
+ );
+}
+
+// Whether a call came back with an error. The status these calls carry is
+// emscripten's enum object, not a number — a successful one is `{value: 0}`,
+// which is truthy — so the value inside it is what says yes or no. A handle that
+// came back well has no `code` at all.
+function failed(result: { code?: { value: number } }): boolean {
+ return !!result.code?.value;
+}
+
+// The small HEVC image a phone keeps of its own frame. Its item id is reached
+// through the primary image's references, then the container is asked for a
+// handle to that id — the same high-level call the top-level reader uses, so the
+// colour conversion and the frame's own transforms come out of libheif either way.
+async function thumbnailItem(m: HeifModule, bytes: Uint8Array): Promise {
+ const ctx = m.heif_context_alloc();
+ try {
+ if (failed(m.heif_context_read_from_memory(ctx, bytes))) return null;
+ const primary = m.heif_context_get_primary_image_handle(ctx);
+ if (failed(primary)) return null;
+
+ let id: number;
+ try {
+ const count = m._heif_image_handle_get_number_of_thumbnails(primary.$$.ptr);
+ if (!count) return null;
+ const list = m._malloc(4 * count);
+ try {
+ m._heif_image_handle_get_list_of_thumbnail_IDs(primary.$$.ptr, list, count);
+ id = m.HEAPU32[list >> 2];
+ } finally {
+ m._free(list);
+ }
+ } finally {
+ m.heif_image_handle_release(primary);
+ }
+
+ const handle: HeifImageHandle = m.heif_js_context_get_image_handle(ctx, id);
+ if (failed(handle)) return null;
+ return await pixels(new m.HeifImage(handle));
+ } catch {
+ return null;
+ } finally {
+ m.heif_context_free(ctx);
+ }
+}
+
+// The frame itself, which is the primary image of the container.
+async function primaryImage(m: HeifModule, bytes: Uint8Array): Promise {
+ const images = new m.HeifDecoder().decode(bytes);
+ const image = images.find((i) => i.is_primary()) ?? images[0];
+ return image ? pixels(image) : null;
+}
+
+// The pixels as a JPEG, at most `max` on the long edge. The encode happens here
+// rather than on the page for the usual reason: a 12MP frame is a 48MB readback
+// and a canvas large enough to hold it, and neither belongs on the thread that
+// paints.
+async function encode(rgba: Rgba, max: number): Promise {
+ const scale = max > 0 ? Math.min(1, max / Math.max(rgba.width, rgba.height)) : 1;
+ const width = Math.max(1, Math.round(rgba.width * scale));
+ const height = Math.max(1, Math.round(rgba.height * scale));
+ const canvas = new OffscreenCanvas(width, height);
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('no 2d context');
+ // The cast is the buffer's own kind: a typed array's default type is one that
+ // may be shared, and `ImageData` takes only one that is not.
+ const bitmap = await createImageBitmap(
+ new ImageData(rgba.data as Uint8ClampedArray, rgba.width, rgba.height)
+ );
+ ctx.drawImage(bitmap, 0, 0, width, height);
+ bitmap.close();
+ const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: QUALITY });
+ return new Uint8Array(await blob.arrayBuffer());
+}
+
+// The worker's own surface. `self` is typed as the page's window by the DOM lib
+// the rest of the app compiles against, so the two calls this file makes are
+// named here instead of pulling a second, conflicting lib in.
+const scope = self as unknown as {
+ onmessage: ((event: MessageEvent) => void) | null;
+ postMessage: (message: HeicReply, transfer?: Transferable[]) => void;
+};
+
+scope.onmessage = (event) => {
+ const { id, bytes, thumb, max } = event.data;
+ void (async (): Promise => {
+ const m = await heif();
+ return thumb ? thumbnailItem(m, bytes) : primaryImage(m, bytes);
+ })()
+ .then(async (rgba) => {
+ if (!rgba) throw new Error('no image');
+ const jpeg = await encode(rgba, max);
+ scope.postMessage({ id, jpeg }, [jpeg.buffer]);
+ })
+ .catch((err: unknown) => scope.postMessage({ id, error: err instanceof Error ? err.message : String(err) }));
+};
diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts
index 8c6dbc8..ecb23b0 100644
--- a/docker/frontend/src/engine/library.ts
+++ b/docker/frontend/src/engine/library.ts
@@ -15,6 +15,7 @@
import type { Recipe } from '../../shared/types';
import { readCapturedAt } from './imageOps';
import { isRawName, rawThumbnail } from './rawDevelop';
+import { heicThumbnail, isHeicName } from './heicDevelop';
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
const DB_NAME = 'recipescam-library';
@@ -898,12 +899,20 @@ export async function scanFolder(
}
const raw = isRawName(file.name);
const isTiff = /\.(tiff?)$/i.test(file.name);
+ // A HEIC is read for its tile the way a RAW is, and reads less: the head of
+ // the file is the whole of what its own thumbnail item needs (see
+ // heicDevelop.ts), so it takes the same slice an ordinary photo does.
+ const heic = isHeicName(file.name);
const RAW_HEADER_BYTES = 16 * 1024 * 1024;
const sliceBuffer = await file.slice(0, (raw || isTiff) ? Math.min(file.size, RAW_HEADER_BYTES) : HEAD_BYTES).arrayBuffer();
const bytes = new Uint8Array(sliceBuffer);
- const preview = (raw || isTiff) ? await rawThumbnail(bytes, file.name) : null;
- const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file;
- const size = preview ? jpegSize(preview) : raw ? null : jpegSize(bytes);
+ const preview = heic
+ ? await heicThumbnail(bytes)
+ : raw || isTiff
+ ? await rawThumbnail(bytes, file.name)
+ : null;
+ const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw || heic ? null : file;
+ const size = preview ? jpegSize(preview) : raw || heic ? null : jpegSize(bytes);
const thumb = src ? await tile(src, size) : null;
const taken = (await readCapturedAt(bytes)) ?? file.lastModified;
const cut = rel.lastIndexOf('/');
diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts
index 2598e84..d407de9 100644
--- a/docker/frontend/src/i18n/en.ts
+++ b/docker/frontend/src/i18n/en.ts
@@ -237,6 +237,7 @@ export const en: Dict = {
'photos.noLabels': 'No strip labels yet',
'lib.title': 'LIBRARY',
+ 'lib.locked': 'The library is for accounts. Log in to browse your photo folders.',
'lib.hint':
'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.',
'lib.add': 'ADD FOLDER',
diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts
index b10d292..9c2536a 100644
--- a/docker/frontend/src/i18n/vi.ts
+++ b/docker/frontend/src/i18n/vi.ts
@@ -249,6 +249,7 @@ export const vi = {
'photos.noLabels': 'Chưa có nhãn dải phim',
'lib.title': 'THƯ VIỆN',
+ 'lib.locked': 'Thư viện dành cho tài khoản. Đăng nhập để duyệt các thư mục ảnh của bạn.',
'lib.hint':
'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.',
'lib.add': 'THÊM THƯ MỤC',
diff --git a/docker/frontend/src/types/libheif.d.ts b/docker/frontend/src/types/libheif.d.ts
new file mode 100644
index 0000000..fbeb930
--- /dev/null
+++ b/docker/frontend/src/types/libheif.d.ts
@@ -0,0 +1,63 @@
+// libheif, the wasm build of the HEIF decoder, as the package ships it.
+//
+// `libheif-wasm/libheif-bundle.mjs` is the one entry point that carries the
+// binary inside it (the wasm is inlined), so nothing is copied into public/wasm
+// and nothing is fetched from a path the bundler would have to be told about —
+// the module is handed to the worker and is a decoder when it arrives.
+//
+// The package's own libheif.d.ts describes emscripten's export list as flat
+// functions, which is not how this module is reached: the C calls it does not
+// wrap are named here, and only the ones the HEIC reader uses.
+declare module 'libheif-js/libheif-wasm/libheif-bundle.mjs' {
+ export interface HeifImageHandle {
+ // Emscripten's own pointer carrier: the C calls below take the number.
+ $$: { ptr: number };
+ // Set instead of `$$` when the call failed, with the reason in `message`.
+ code?: { value: number };
+ message?: string;
+ }
+
+ export interface HeifImage {
+ get_width(): number;
+ get_height(): number;
+ is_primary(): boolean;
+ // Fills `data` and answers on a later turn of the event loop — the buffer
+ // reads as noughts until the callback has fired.
+ display(
+ target: { data: Uint8ClampedArray; width: number; height: number },
+ done: (decoded: unknown) => void
+ ): void;
+ free(): void;
+ }
+
+ export interface HeifError {
+ // The status as emscripten hands it over: an enum object, `{value: 0}` on
+ // success — truthy either way, which is why it is read by its value.
+ code?: { value: number };
+ message?: string;
+ }
+
+ export interface HeifModule {
+ // The file the way it reads itself: every top-level image, decoded on demand.
+ HeifDecoder: new () => { decode(bytes: Uint8Array): HeifImage[] };
+ HeifImage: new (handle: HeifImageHandle) => HeifImage;
+ // The container walked by hand, which is the only way to reach an item that
+ // is not a top-level image — and a phone's thumbnail is one of those.
+ heif_context_alloc(): unknown;
+ heif_context_free(ctx: unknown): void;
+ heif_context_read_from_memory(ctx: unknown, bytes: Uint8Array): HeifError;
+ heif_context_get_primary_image_handle(ctx: unknown): HeifImageHandle;
+ heif_image_handle_release(handle: HeifImageHandle): void;
+ heif_js_context_get_image_handle(ctx: unknown, id: number): HeifImageHandle;
+ _heif_image_handle_get_width(ptr: number): number;
+ _heif_image_handle_get_height(ptr: number): number;
+ _heif_image_handle_get_number_of_thumbnails(ptr: number): number;
+ _heif_image_handle_get_list_of_thumbnail_IDs(ptr: number, out: number, count: number): number;
+ _malloc(bytes: number): number;
+ _free(ptr: number): void;
+ HEAPU32: Uint32Array;
+ }
+
+ const createHeif: (options?: unknown) => Promise;
+ export default createHeif;
+}
diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx
index f46b0c2..79b0e2c 100644
--- a/docker/frontend/src/ui/ImageStage.tsx
+++ b/docker/frontend/src/ui/ImageStage.tsx
@@ -3,6 +3,9 @@ import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect, type GradientMask, type HealSpot, type MosaicSpot } from '../../shared/types';
import { bitmapReader, samplePixel } from '../engine/imageOps';
import { RAW_ACCEPT } from '../engine/rawDevelop';
+// A phone's HEIC is offered beside the camera RAWs: it is the same offer to a
+// file the browser will not open by itself (see engine/heicDevelop.ts).
+import { HEIC_ACCEPT } from '../engine/heicDevelop';
import { findHealSource } from '../../shared/utils/heal';
import { MASK_DEFAULT_FEATHER, MASK_MIN } from '../../shared/utils/gradientMask';
import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush';
@@ -2049,7 +2052,7 @@ export function ImageStage({
{
const file = e.target.files?.[0];