library: open a phone's HEIC, and lock the shelves to accounts
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.
This commit is contained in:
Generated
+10
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"canvaskit-wasm": "^0.42.0",
|
"canvaskit-wasm": "^0.42.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
|
"libheif-js": "^1.23.5",
|
||||||
"libraw-wasm": "^1.6.0",
|
"libraw-wasm": "^1.6.0",
|
||||||
"onnxruntime-web": "^1.30.0",
|
"onnxruntime-web": "^1.30.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -1606,6 +1607,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/libraw-wasm": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/libraw-wasm/-/libraw-wasm-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/libraw-wasm/-/libraw-wasm-1.6.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"canvaskit-wasm": "^0.42.0",
|
"canvaskit-wasm": "^0.42.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
|
"libheif-js": "^1.23.5",
|
||||||
"libraw-wasm": "^1.6.0",
|
"libraw-wasm": "^1.6.0",
|
||||||
"onnxruntime-web": "^1.30.0",
|
"onnxruntime-web": "^1.30.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { loadEngine, MARK_FACES } from './engine/assets';
|
|||||||
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
||||||
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
|
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
|
||||||
import { developRaw, isRawName } from './engine/rawDevelop';
|
import { developRaw, isRawName } from './engine/rawDevelop';
|
||||||
|
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||||
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
||||||
import { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
|
import { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
|
||||||
import { offerInstall } from './pwa/install';
|
import { offerInstall } from './pwa/install';
|
||||||
@@ -1130,6 +1131,25 @@ export function Workspace() {
|
|||||||
void forgetPhoto();
|
void forgetPhoto();
|
||||||
return;
|
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 clearRaw();
|
||||||
await adoptPhoto(bytes, file.name, file);
|
await adoptPhoto(bytes, file.name, file);
|
||||||
void savePhoto(file.name, bytes);
|
void savePhoto(file.name, bytes);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api } from './api';
|
|||||||
import { keep, recall } from './remember';
|
import { keep, recall } from './remember';
|
||||||
import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
||||||
import { isRawName, rawThumbnail } from './engine/rawDevelop';
|
import { isRawName, rawThumbnail } from './engine/rawDevelop';
|
||||||
|
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||||
import {
|
import {
|
||||||
cachedPhotos,
|
cachedPhotos,
|
||||||
canBrowseFolders,
|
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() {
|
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 <p className="hint adm-note">{t('auth.busy')}</p>;
|
||||||
|
if (state === 'guest')
|
||||||
|
return (
|
||||||
|
<p className="adm-note">
|
||||||
|
{t('lib.locked')}{' '}
|
||||||
|
<a className="btn primary" href="/?auth=login">
|
||||||
|
{t('nav.login')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
return <Catalogue />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Catalogue() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [folders, setFolders] = useState<LibraryFolder[]>([]);
|
const [folders, setFolders] = useState<LibraryFolder[]>([]);
|
||||||
const [ready, setReady] = useState<Set<string>>(new Set());
|
const [ready, setReady] = useState<Set<string>>(new Set());
|
||||||
@@ -1075,7 +1116,14 @@ export function Library() {
|
|||||||
if (!live) return;
|
if (!live) return;
|
||||||
|
|
||||||
let url: string | null = null;
|
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());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
const previewBytes = await rawThumbnail(bytes);
|
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
|
// The tile: the thumbnail the catalogue kept, or a stand-in for the frame
|
||||||
// preview the camera never wrote. A tile only carries a handler where a click
|
// 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
|
// 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
|
// 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
|
// thumbnail to give: one that has one and has not been handed it yet — the wall
|
||||||
@@ -1157,7 +1207,7 @@ export function Library() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
|
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
|
||||||
{photo.thumb ? '' : 'RAW'}
|
{photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : 'HEIC'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
}
|
||||||
@@ -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<HeifModule> | null = null;
|
||||||
|
const heif = (): Promise<HeifModule> => (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<Rgba | null> {
|
||||||
|
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<Rgba | null> {
|
||||||
|
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<Rgba | null> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<ArrayBuffer>, 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<HeicAsk>) => void) | null;
|
||||||
|
postMessage: (message: HeicReply, transfer?: Transferable[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
scope.onmessage = (event) => {
|
||||||
|
const { id, bytes, thumb, max } = event.data;
|
||||||
|
void (async (): Promise<Rgba | null> => {
|
||||||
|
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) }));
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import type { Recipe } from '../../shared/types';
|
import type { Recipe } from '../../shared/types';
|
||||||
import { readCapturedAt } from './imageOps';
|
import { readCapturedAt } from './imageOps';
|
||||||
import { isRawName, rawThumbnail } from './rawDevelop';
|
import { isRawName, rawThumbnail } from './rawDevelop';
|
||||||
|
import { heicThumbnail, isHeicName } from './heicDevelop';
|
||||||
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
||||||
|
|
||||||
const DB_NAME = 'recipescam-library';
|
const DB_NAME = 'recipescam-library';
|
||||||
@@ -898,12 +899,20 @@ export async function scanFolder(
|
|||||||
}
|
}
|
||||||
const raw = isRawName(file.name);
|
const raw = isRawName(file.name);
|
||||||
const isTiff = /\.(tiff?)$/i.test(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 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 sliceBuffer = await file.slice(0, (raw || isTiff) ? Math.min(file.size, RAW_HEADER_BYTES) : HEAD_BYTES).arrayBuffer();
|
||||||
const bytes = new Uint8Array(sliceBuffer);
|
const bytes = new Uint8Array(sliceBuffer);
|
||||||
const preview = (raw || isTiff) ? await rawThumbnail(bytes, file.name) : null;
|
const preview = heic
|
||||||
const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file;
|
? await heicThumbnail(bytes)
|
||||||
const size = preview ? jpegSize(preview) : raw ? null : jpegSize(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 thumb = src ? await tile(src, size) : null;
|
||||||
const taken = (await readCapturedAt(bytes)) ?? file.lastModified;
|
const taken = (await readCapturedAt(bytes)) ?? file.lastModified;
|
||||||
const cut = rel.lastIndexOf('/');
|
const cut = rel.lastIndexOf('/');
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export const en: Dict = {
|
|||||||
'photos.noLabels': 'No strip labels yet',
|
'photos.noLabels': 'No strip labels yet',
|
||||||
|
|
||||||
'lib.title': 'LIBRARY',
|
'lib.title': 'LIBRARY',
|
||||||
|
'lib.locked': 'The library is for accounts. Log in to browse your photo folders.',
|
||||||
'lib.hint':
|
'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.',
|
'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',
|
'lib.add': 'ADD FOLDER',
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export const vi = {
|
|||||||
'photos.noLabels': 'Chưa có nhãn dải phim',
|
'photos.noLabels': 'Chưa có nhãn dải phim',
|
||||||
|
|
||||||
'lib.title': 'THƯ VIỆN',
|
'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':
|
'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.',
|
'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',
|
'lib.add': 'THÊM THƯ MỤC',
|
||||||
|
|||||||
+63
@@ -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<HeifModule>;
|
||||||
|
export default createHeif;
|
||||||
|
}
|
||||||
@@ -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 { MIN_CROP_FRAC, type CropRect, type GradientMask, type HealSpot, type MosaicSpot } from '../../shared/types';
|
||||||
import { bitmapReader, samplePixel } from '../engine/imageOps';
|
import { bitmapReader, samplePixel } from '../engine/imageOps';
|
||||||
import { RAW_ACCEPT } from '../engine/rawDevelop';
|
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 { findHealSource } from '../../shared/utils/heal';
|
||||||
import { MASK_DEFAULT_FEATHER, MASK_MIN } from '../../shared/utils/gradientMask';
|
import { MASK_DEFAULT_FEATHER, MASK_MIN } from '../../shared/utils/gradientMask';
|
||||||
import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush';
|
import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush';
|
||||||
@@ -2049,7 +2052,7 @@ export function ImageStage({
|
|||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={`image/*,${RAW_ACCEPT}`}
|
accept={`image/*,${RAW_ACCEPT},${HEIC_ACCEPT}`}
|
||||||
hidden
|
hidden
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user