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

{t('auth.busy')}

; + if (state === 'guest') + return ( +

+ {t('lib.locked')}{' '} + + {t('nav.login')} + +

+ ); + 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() { /> ) : (
- {photo.thumb ? '' : 'RAW'} + {photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : 'HEIC'}
); 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];