feat: the camera's own colour, a reading that keeps its place, and cloud links in Settings

- RAW opens at the colour the camera wrote into it: the develop carries the
  difference against the file's own preview (previewResidual) instead of the
  per-channel cubic fit, dE00 6.04 -> 0.54; previewMatch.ts keeps only the grid.
- Immich reading: keyset position kept across visits, one reading per node,
  a page in flight retried, timeline buckets for the years on offer.
- The wall draws a server frame at the address the proxy serves it (no blob
  minted for it), and the 80-thumbnail pre-cache on open is gone.
- Settings lists the cloud links, flags two that answer at the same address,
  and forgets one.
- Custom frames imported from a file (engine/customFrame.ts, IndexedDB).
- Upload an edited frame from Studio to Immich Cloud.
This commit is contained in:
2026-10-12 07:59:24 +07:00
parent 807c271b6b
commit 97fd181cfa
25 changed files with 2402 additions and 616 deletions
+75 -22
View File
@@ -25,7 +25,7 @@ const PHOTOS = 'photos';
const THUMBS = 'thumbs';
const EDITS = 'edits';
const DIRS = 'dirs';
const DB_VERSION = 4;
const DB_VERSION = 5;
// The tile the catalogue keeps is the same tile the grid cell and the strip both
// paint, and there is one of them per frame of a roll that reaches six figures.
@@ -112,6 +112,17 @@ export interface LibraryFolder {
// the name is the store's key — but a label the reader may rename is no place to
// keep a query, and the sync asks with this.
query?: string;
// Where a server's reading stopped, on a node whose frames are an Immich
// server's. A node of a hundred thousand frames is not one visit's to read, and
// the keyset the reading pages by is a value the page cannot hold across a
// reload: without this, every visit read the newest three pages of the node
// again and nothing below them was ever read at all. `before` is the instant the
// next page starts from, `page` the page within that instant — Immich keeps a
// frame's time to the millisecond and a roll imported in one go is hundreds of
// frames at the same one, so one instant is several pages — and `done` says the
// whole node has been read, which is the only state in which a frame that is no
// longer there may be dropped from the catalogue.
cursor?: { before: string | null; page?: number; done: boolean };
}
export interface LibraryPhoto {
@@ -322,11 +333,14 @@ function openDb(): Promise<IDBDatabase> {
// to be made anyway.
if (db.objectStoreNames.contains(THUMBS)) db.deleteObjectStore(THUMBS);
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
let photosStore: IDBObjectStore;
if (!db.objectStoreNames.contains(PHOTOS)) {
const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' });
photos.createIndex('folder', 'folder');
photos.createIndex('taken', 'taken');
photosStore = db.createObjectStore(PHOTOS, { keyPath: 'id' });
} else {
photosStore = req.transaction!.objectStore(PHOTOS);
}
if (!photosStore.indexNames.contains('folder')) photosStore.createIndex('folder', 'folder');
if (!photosStore.indexNames.contains('taken')) photosStore.createIndex('taken', 'taken');
if (!db.objectStoreNames.contains(THUMBS)) {
db.createObjectStore(THUMBS, { keyPath: 'id' });
}
@@ -2047,9 +2061,9 @@ function pump(): void {
let cached: LibraryPhoto[] | null = null;
export function cachedPhotos(): LibraryPhoto[] {
if (cached) return cached;
if (cached && cached.length > 0) return cached;
try {
const raw = sessionStorage.getItem('rc_cached_photos');
const raw = sessionStorage.getItem('rc_cached_photos') || localStorage.getItem('rc_cached_photos');
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
@@ -2061,6 +2075,26 @@ export function cachedPhotos(): LibraryPhoto[] {
return [];
}
export function saveCachedPhotos(photos: LibraryPhoto[]): void {
try {
const slim = photos.slice(0, 300).map((p) => ({
id: p.id,
folder: p.folder,
name: p.name,
taken: p.taken,
addedAt: p.addedAt,
dir: p.dir,
star: p.star,
server: p.server,
remoteId: p.remoteId,
rot: p.rot,
}));
const json = JSON.stringify(slim);
sessionStorage.setItem('rc_cached_photos', json);
localStorage.setItem('rc_cached_photos', json);
} catch {}
}
export function clearLibraryCache(): void {
cached = null;
dirHandles.clear();
@@ -2385,6 +2419,10 @@ export async function makeThumbs(
const dir = normPath(rel);
const rows = Array.isArray(folder) ? folder : (await readPhotos(normPath(folder.name))) ?? [];
const want = rows.filter((p) => {
// A server's frame has no tile to mint here: the wall draws the address the
// proxy serves it at (see `tile` in Library.tsx), so reading the bytes into
// the store is the same picture fetched twice and a copy kept for nothing.
if (p.remoteId) return false;
const pDir = normPath(p.dir ?? '');
if (pDir === dir) return true;
// `''` is the picked folder itself, so a row asked of the whole roll takes
@@ -2413,7 +2451,10 @@ export async function makeThumbs(
return made;
}
export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
export async function readPhotos(
folder?: string,
onBatch?: (photos: LibraryPhoto[]) => void
): Promise<LibraryPhoto[] | null> {
try {
const db = await openDb();
// Every row is filed under `normPath(folder.name)`, and a drive root is the
@@ -2431,6 +2472,8 @@ export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null
: IDBKeyRange.bound(last ?? `${root}/`, `${root}/\uffff`, last !== null, false);
const rows: LibraryPhoto[] = [];
let last: string | null = null;
let emittedFirst = false;
for (;;) {
const chunk = await new Promise<LibraryPhoto[]>((resolve, reject) => {
const req = db.transaction(PHOTOS, 'readonly').objectStore(PHOTOS).getAll(at(last), READ_CHUNK);
@@ -2446,26 +2489,19 @@ export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null
delete (row as { thumb?: unknown }).thumb;
}
rows.push(...chunk);
if (onBatch && !emittedFirst && rows.length >= 400) {
emittedFirst = true;
const initial = [...rows].sort((a, b) => b.taken - a.taken);
onBatch(initial);
}
if (chunk.length < READ_CHUNK) break;
}
rows.sort((a, b) => b.taken - a.taken);
if (!folder) {
cached = rows;
try {
const slim = rows.slice(0, 300).map((p) => ({
id: p.id,
folder: p.folder,
name: p.name,
taken: p.taken,
addedAt: p.addedAt,
dir: p.dir,
star: p.star,
server: p.server,
remoteId: p.remoteId,
rot: p.rot,
}));
sessionStorage.setItem('rc_cached_photos', JSON.stringify(slim));
} catch {}
saveCachedPhotos(rows);
}
return rows;
} catch {
@@ -2473,6 +2509,23 @@ export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null
}
}
export async function putPhotosFast(rows: LibraryPhoto[]): Promise<void> {
if (!rows.length) return;
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(PHOTOS, 'readwrite');
const store = tx.objectStore(PHOTOS);
for (const row of rows) {
const r = { ...row };
delete (r as { handle?: unknown }).handle;
delete (r as { thumb?: unknown }).thumb;
store.put(r);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
return (await readPhotos(folder)) ?? [];
}