From 97fd181cfa86a9d8f1532c3bb0234689e2422790 Mon Sep 17 00:00:00 2001
From: 3dtours
Date: Mon, 12 Oct 2026 07:59:24 +0700
Subject: [PATCH] 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.
---
docker/backend/src/immich.ts | 117 +++-
docker/backend/src/server.ts | 10 +-
docker/frontend/public/sw.js | 2 +-
.../frontend/scripts/preview-match-check.mjs | 102 ----
docker/frontend/scripts/raw-develop-check.mjs | 73 +--
docker/frontend/shared/types/index.ts | 3 +-
docker/frontend/src/App.tsx | 450 ++++++++++++++-
docker/frontend/src/Library.tsx | 226 +++++++-
docker/frontend/src/engine/customFrame.ts | 253 +++++++++
docker/frontend/src/engine/exportEngine.ts | 78 ++-
docker/frontend/src/engine/immich.ts | 347 ++++++++++--
docker/frontend/src/engine/library.ts | 97 +++-
docker/frontend/src/engine/previewMatch.ts | 137 +----
docker/frontend/src/engine/rawDevelop.ts | 305 +++++++---
docker/frontend/src/i18n/en.ts | 24 +
docker/frontend/src/i18n/vi.ts | 24 +
docker/frontend/src/styles/app.css | 520 +++++++++++++-----
docker/frontend/src/ui/ChipColumn.tsx | 2 +-
docker/frontend/src/ui/ExportModal.tsx | 34 ++
docker/frontend/src/ui/ImageStage.tsx | 62 ++-
docker/frontend/src/ui/ImmichDialog.tsx | 33 ++
docker/frontend/src/ui/MobileLibrary.tsx | 74 ++-
docker/frontend/src/ui/TopBar.tsx | 37 +-
docker/frontend/src/ui/useIsMobile.ts | 6 +-
docker/recipescam-images.tar.gz.sha256 | 2 +-
25 files changed, 2402 insertions(+), 616 deletions(-)
delete mode 100644 docker/frontend/scripts/preview-match-check.mjs
create mode 100644 docker/frontend/src/engine/customFrame.ts
diff --git a/docker/backend/src/immich.ts b/docker/backend/src/immich.ts
index 50c37de..19a0e8b 100644
--- a/docker/backend/src/immich.ts
+++ b/docker/backend/src/immich.ts
@@ -287,7 +287,7 @@ export function immichRoutes(
// client sends can aim this proxy at a host that is not in its own list.
const pick = (user: User, reply: FastifyReply, id: unknown): ImmichServer | undefined => {
const servers = immichServers(user.id);
- const server = typeof id === 'string' ? servers.find((s) => s.id === id) : undefined;
+ const server = typeof id === 'string' && id ? servers.find((s) => s.id === id) : servers[0];
if (!server) {
void reply.status(404).send({ error: 'unknown server' });
return undefined;
@@ -458,12 +458,15 @@ export function immichRoutes(
const res = await call(server, {
method: 'POST',
path: '/search/metadata',
+ timeout: 60_000,
json: {
size,
page,
withExif: true,
type: 'IMAGE',
...(album ? { albumIds: [album] } : {}),
+ ...(q.takenBefore ? { takenBefore: q.takenBefore } : {}),
+ ...(q.takenAfter ? { takenAfter: q.takenAfter } : {}),
...(search ? (tagIds.length ? { tagIds } : { originalFileName: search }) : {}),
},
});
@@ -475,10 +478,15 @@ export function immichRoutes(
// Raw array: Array.isArray(found)
const rawItems: RawAsset[] = found?.assets?.items ?? found?.items ?? (Array.isArray(found) ? found : []);
const total: number | null = found?.assets?.total ?? found?.total ?? found?.count ?? null;
+ // What says another page is there: a page that came back full, or the
+ // server's own word for it. `assets.total` is not the size of the library —
+ // in Immich v2 it is the size of the page it came with — and read as the
+ // library it made every page but one look like there was more behind it.
const hasMore: boolean =
rawItems.length === size ||
(typeof found?.nextCursor === 'string' && !!found.nextCursor) ||
- (total !== null && page * size < total);
+ (typeof found?.assets?.nextPage === 'string' && !!found.assets.nextPage) ||
+ (typeof found?.nextPage === 'string' && !!found.nextPage);
return reply.send({
ok: true,
page,
@@ -489,6 +497,20 @@ export function immichRoutes(
});
});
+ // The timeline buckets (years/months), so the client knows all available years and photo counts in 1 call.
+ app.get('/api/immich/buckets', async (req, reply) => {
+ const user = member(req, reply);
+ if (!user) return;
+ const q = req.query as Record;
+ const server = pick(user, reply, q.server);
+ if (!server) return;
+ const size = q.size === 'YEAR' ? 'YEAR' : 'MONTH';
+ const res = await call(server, { path: '/timeline/buckets', query: { size }, timeout: 15_000 });
+ if (!res.ok) return reply.status(502).send(refusal(res));
+ const buckets = await jsonOf(res);
+ return reply.send({ ok: true, buckets: Array.isArray(buckets) ? buckets : [] });
+ });
+
// An Immich-generated image, streamed straight through. Always the size the
// caller asked for: the grid asks for `thumbnail`, the stage for `preview`,
// and only the studio ever asks for the original.
@@ -528,4 +550,95 @@ export function immichRoutes(
reply.header('cache-control', 'no-store');
return reply.send(Readable.fromWeb(res.body as Parameters[0]));
});
+
+ // Upload an edited photo from Studio to Immich Cloud
+ app.post(
+ '/api/immich/upload',
+ { bodyLimit: 50 * 1024 * 1024 },
+ async (req, reply) => {
+ const user = member(req, reply);
+ if (!user) return;
+ const q = req.query as Record;
+ const server = pick(user, reply, q.server);
+ if (!server) return;
+ if (server.type !== 'api') {
+ return reply.status(400).send({ error: 'Upload is only supported for API-key Immich connections' });
+ }
+
+ const body = req.body;
+ if (!Buffer.isBuffer(body) || body.length === 0) {
+ return reply.status(400).send({ error: 'invalid body or empty photo' });
+ }
+
+ const rawName = (q.name ?? '').trim() || `recipescam_${Date.now()}.jpg`;
+ const fileName = rawName.endsWith('.jpg') || rawName.endsWith('.jpeg') || rawName.endsWith('.png') ? rawName : `${rawName}.jpg`;
+
+ const form = new FormData();
+ form.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
+ form.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
+ form.append('deviceId', 'RecipesCam');
+ form.append('fileCreatedAt', new Date().toISOString());
+ form.append('fileModifiedAt', new Date().toISOString());
+ form.append('isFavorite', 'false');
+
+ try {
+ let immichRes = await fetch(new URL(`${server.url}/api/assets`), {
+ method: 'POST',
+ headers: {
+ 'x-api-key': server.key,
+ },
+ body: form,
+ signal: AbortSignal.timeout(60_000),
+ });
+
+ if (immichRes.status === 404) {
+ // Fallback for older Immich versions (< 1.106)
+ const fallbackForm = new FormData();
+ fallbackForm.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
+ fallbackForm.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
+ fallbackForm.append('deviceId', 'RecipesCam');
+ fallbackForm.append('fileCreatedAt', new Date().toISOString());
+ fallbackForm.append('fileModifiedAt', new Date().toISOString());
+ fallbackForm.append('isFavorite', 'false');
+
+ immichRes = await fetch(new URL(`${server.url}/api/asset/upload`), {
+ method: 'POST',
+ headers: {
+ 'x-api-key': server.key,
+ },
+ body: fallbackForm,
+ signal: AbortSignal.timeout(60_000),
+ });
+ }
+
+ if (!immichRes.ok) {
+ const errText = await immichRes.text().catch(() => '');
+ return reply.status(immichRes.status).send({ error: 'Immich upload failed', details: errText });
+ }
+
+ const data = await immichRes.json().catch(() => ({}));
+ const assetId = (data as any)?.id;
+
+ // If target album is given, add to album
+ const targetAlbum = q.album || (server.albums?.length === 1 ? server.albums[0] : undefined);
+ if (targetAlbum && assetId) {
+ try {
+ await fetch(new URL(`${server.url}/api/albums/${targetAlbum}/assets`), {
+ method: 'PUT',
+ headers: {
+ 'x-api-key': server.key,
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify({ ids: [assetId] }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ } catch {}
+ }
+
+ return reply.send({ ok: true, id: assetId, result: data });
+ } catch (err: any) {
+ return reply.status(502).send({ error: 'Failed to connect to Immich server', details: String(err) });
+ }
+ }
+ );
}
diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts
index df6ecd7..4d982b5 100644
--- a/docker/backend/src/server.ts
+++ b/docker/backend/src/server.ts
@@ -150,9 +150,13 @@ app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body,
// Photo uploads are the raw image bytes, not multipart: one file per request
// needs no boundary parsing, so no dependency and no parser attack surface.
-app.addContentTypeParser(['image/jpeg', 'image/png', 'image/webp'], { parseAs: 'buffer' }, (_req, body, done) => {
- done(null, body);
-});
+app.addContentTypeParser(
+ ['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream'],
+ { parseAs: 'buffer', bodyLimit: 50 * 1024 * 1024 },
+ (_req, body, done) => {
+ done(null, body);
+ }
+);
// A restore body is a whole data dir, so it is the one body far past the
// instance limit. Buffered, not streamed: `tar` needs the archive to be
diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js
index 00bf637..58d55c1 100644
--- a/docker/frontend/public/sw.js
+++ b/docker/frontend/public/sw.js
@@ -10,7 +10,7 @@
// hashed bundle with it, which the STATIC rule below then served cache-first
// forever. Every navigation here now goes past the browser's own cache, so the
// build a visitor gets is the one the server has.
-const VERSION = 'recipescam-v25';
+const VERSION = 'recipescam-v29';
// nginx answers all three routes with the same index.html (SPA fallback), so they
// are one document under three keys: an offline navigation finds it whichever key
diff --git a/docker/frontend/scripts/preview-match-check.mjs b/docker/frontend/scripts/preview-match-check.mjs
deleted file mode 100644
index a3ae1d3..0000000
--- a/docker/frontend/scripts/preview-match-check.mjs
+++ /dev/null
@@ -1,102 +0,0 @@
-// The per-file colour fit is pure arithmetic, so it can be checked here rather
-// than in a browser: hand fitMatch a grid and the same grid through a known 3x3,
-// and it has to hand that 3x3 back. Done that way because the function only ever
-// runs inside the RAW develop, which needs LibRaw and CanvasKit — neither of
-// which a node check has. The module is TypeScript, so it is transpiled on the
-// fly out of the installed compiler (the repo's convention for checks: see
-// raw-develop-check.mjs, which reads the shader source instead).
-//
-// node scripts/preview-match-check.mjs
-import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
-import ts from 'typescript';
-
-const file = new URL('../src/engine/previewMatch.ts', import.meta.url);
-const js = ts.transpileModule(readFileSync(file, 'utf8'), {
- compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
-}).outputText;
-const { fitMatch, MATCH_GRID } = await import(
- `data:text/javascript;base64,${Buffer.from(js).toString('base64')}`
-);
-
-// A grid with colour in it: three independent ramps, none of them clipped and
-// none at the floor, so every block carries something to fit. Independent on
-// purpose — three correlated channels leave the 3x3 ill-conditioned, and a fit
-// that comes back a percent off on such a scene says nothing about the code.
-function scene() {
- let s = 1;
- const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
- const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
- for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
- for (let c = 0; c < 3; c++) g[i * 4 + c] = 25 + Math.round(rnd() * 190);
- g[i * 4 + 3] = 255;
- }
- return g;
-}
-
-const M = [1.06, -0.05, 0.01, 0.02, 0.98, 0.03, -0.04, 0.06, 1.02];
-const dev = scene();
-const ref = new Uint8Array(dev.length);
-for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
- const e = [dev[i * 4], dev[i * 4 + 1], dev[i * 4 + 2]];
- for (let r = 0; r < 3; r++) {
- const v = M[r * 3] * e[0] + M[r * 3 + 1] * e[1] + M[r * 3 + 2] * e[2];
- ref[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
- }
- ref[i * 4 + 3] = 255;
-}
-
-const fit = fitMatch(dev, ref);
-assert.ok(fit, 'a fitted grid has to fit');
-for (let r = 0; r < 3; r++) {
- for (let c = 0; c < 3; c++) {
- assert.ok(Math.abs(fit.m[r * 3 + c] - M[r * 3 + c]) < 0.01, `recovered ${fit.m[r * 3 + c]} for ${M[r * 3 + c]}`);
- }
-}
-// This scene was made by a 3x3 and nothing else, so the curve fitted on what the
-// 3x3 leaves has to come back flat — the identity, up to the rounding the scene
-// carries.
-assert.equal(fit.tone.length, 12);
-for (let i = 0; i < 12; i++) {
- const flat = i % 4 === 1 ? 1 : 0;
- assert.ok(Math.abs(fit.tone[i] - flat) < 0.15, `tone ${i} came back ${fit.tone[i]}, not ${flat}`);
-}
-
-// Nothing to fit against: the camera clipped the whole frame, so no block says
-// anything about its rendering, and the caller develops as the sensor left it.
-assert.equal(fitMatch(dev, new Uint8Array(dev.length).fill(255)), null);
-
-// A near-neutral frame through a matrix whose rows carry a negative element — what
-// the bodies the fit used to refuse looked like (X-T3 -0.04, X100V -1.67 on rows
-// that also carry +1.05 and +1.63). Three channels that share nearly all their
-// content leave the normal equations near-singular, and the plain solve came back
-// with a diagonal element at or below zero, which the caller read as a degeneracy
-// and dropped the whole fit for. It is a colour: the ridge lifts the system back
-// to definite and the fit has to come back a matrix.
-function neutral() {
- let s = 7;
- const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
- const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
- for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
- const x = 40 + Math.round(rnd() * 180);
- for (let c = 0; c < 3; c++) g[i * 4 + c] = Math.max(0, Math.min(255, x + Math.round((rnd() - 0.5) * 8)));
- g[i * 4 + 3] = 255;
- }
- return g;
-}
-const N = [1.05, -1.67, 1.63, 0.02, 0.95, 0.03, -0.04, 0.06, 1.02];
-const nDev = neutral();
-const nRef = new Uint8Array(nDev.length);
-for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
- const e = [nDev[i * 4], nDev[i * 4 + 1], nDev[i * 4 + 2]];
- for (let r = 0; r < 3; r++) {
- const v = N[r * 3] * e[0] + N[r * 3 + 1] * e[1] + N[r * 3 + 2] * e[2];
- nRef[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
- }
- nRef[i * 4 + 3] = 255;
-}
-const nFit = fitMatch(nDev, nRef);
-assert.ok(nFit, 'a near-neutral frame has to fit, not come back null');
-for (const v of nFit.m) assert.ok(Number.isFinite(v) && Math.abs(v) <= 4, `degenerate fit ${v}`);
-
-console.log('preview-match-check ok');
diff --git a/docker/frontend/scripts/raw-develop-check.mjs b/docker/frontend/scripts/raw-develop-check.mjs
index 185a5f4..8a3dd6e 100644
--- a/docker/frontend/scripts/raw-develop-check.mjs
+++ b/docker/frontend/scripts/raw-develop-check.mjs
@@ -105,41 +105,50 @@ assert.match(src, /^export function previewWhiteBalance\($/m);
// the tags rather than a ratio read off noise.
assert.match(src, /if \(count < 4096 \|\| sums\[0\] <= 0 \|\| sums\[1\] <= 0 \|\| sums\[2\] <= 0\) return null;/);
-// The fit has to land on the encoded value in float, not as an 8-bit colour filter
-// painted over the frame afterwards: the fit carries an exposure, and a channel it
-// lifted past the white level was cut where it stood — blue first, the channel the
-// WB gains lift most — which is what left the bright end of the frame short of
-// blue. Measured on the A5100 frame, pixels at 255: 5.25% blue, 2.10% of them blue
-// alone through the filter, against 2.46% / 2.44% through this path.
+// What a RAW opens as is the colour the camera wrote into it. Not fitted: the
+// develop carries the difference between the file's own preview and this develop's
+// rendering of the same frame, at the preview's resolution, and adds it back on
+// the value it left. So there is no curve to come back folded, no body table, and
+// nothing that can disagree with the file it is opening.
//
-// The curve is pinned at both ends, so white is one of the values it is fitted on
-// and a blown pixel still lands on white — there is no rolloff after it to pull a
-// channel back down (the 3x3 this replaced needed one, and it turned every
-// highlight cyan: 0.0% of the frame reached white on all three channels against
-// 2.3% now and 3.3% in the camera's preview, dE00 7.1 against 6.6).
-assert.doesNotMatch(src, /drawMatched|colorMatrix/, 'the fit is painted through a colour filter again');
-assert.match(code, /float tone\(float4 w, float x\) \{/);
-assert.match(code, /return clamp\(w\.x \+ x \* \(w\.y \+ x \* \(w\.z \+ x \* w\.w\)\), 0\.0, 1\.0\);/);
-// Pinned at both ends is not pinned at the plateau. The three curves agree at 1.0
-// and part company either side of it, and where the frame ran out there is no block
-// left to fit them together — every blown block is dropped, and every block the
-// develop blows is dropped too — so the neutral the gate above drew comes apart on
-// the way out: measured on the Olympus ORF, a 255,255,255 sky came back 254,255,255
-// (149k px) and 253,255,254 (122k px), red under green across a quarter of the
-// frame. The gate is read again on the value that leaves, so the pixel the develop
-// itself called blown leaves at the neutral it was drawn as.
-assert.match(code, /float3 o = float3\(tone\(t0, e\.r\), tone\(t1, e\.g\), tone\(t2, e\.b\)\);/);
+// Measured against the file's own preview: dE00 6.04 for the per-channel cubic
+// this replaced, 0.54 for the residual, and 0.00 on the two bodies (a Nikon NEF
+// and a DNG) that write a full-size preview — where the two frames agree block for
+// block and the residual is nothing.
+const res = src.match(/const RESIDUAL_SKSL = `([\s\S]*?)`;/)?.[1];
+assert.ok(res, 'RESIDUAL_SKSL not found');
+const resCode = res.replace(/\/\/[^\n]*/g, '');
assert.match(
- code,
- /return half4\(half3\(mix\(o, float3\(max\(max\(o\.r, o\.g\), o\.b\)\), blown\)\), 1\.0\);/
+ resCode,
+ /return half4\(half3\(clamp\(ref\.eval\(p\)\.rgb - dev\.eval\(p\)\.rgb \+ 0\.5, 0\.0, 1\.0\)\), 1\.0\);/
);
-assert.match(code, /uniform float4 t0;/);
-assert.doesNotMatch(code, /float mq = /, 'the rolloff after the fit is back');
-// ...and it is the tail of the uniform buffer, one float4 per channel, which the
-// second develop overwrites on the buffer it already built.
-assert.match(src, /^ *uniforms\.set\(tone \?\? FLAT_TONE, 21\);$/m);
-assert.match(src, /const uniforms = new Float32Array\(33\);/);
-assert.match(src, /^import \{ toneMatch, FLAT_TONE, MATCH_GRID \} from '\.\/previewMatch';$/m);
+assert.match(code, /uniform shader res;/);
+assert.match(code, /uniform float2 rscl;/);
+// The 0.5 that keeps a negative difference inside a byte, taken back off on the
+// way out, and read at the frame's own coordinates — `pos` is local to this band.
+assert.match(code, /float2 rp = pos \* rscl;/);
+assert.match(code, /o = clamp\(o \+ \(res\.eval\(rp\)\.rgb - 0\.5\), 0\.0, 1\.0\);/);
+// The residual is read at the develop's own resolution, so it is filtered up
+// rather than tapped: one byte a channel, which is finer than the shading it
+// carries, is the whole difference between two renderings.
+assert.match(src, /const resChild = \(residual\?\.img \?\? noResidual\(\)\)\.makeShaderOptions\(/);
+assert.match(src, /const shader = effect\.makeShaderWithChildren\(uniforms, \[child, resChild\]\);/);
+// A file with nothing to carry opens on a residual that says nothing — 0.5 in
+// half — rather than down a branch of its own in the shader.
+assert.match(src, /NO_RESIDUAL = Skia\.Image\.MakeImage\(/);
+assert.match(src, /new Uint16Array\(\[0x3800, 0x3800, 0x3800, 0x3c00\]\)/);
+// The develop runs twice, not once: the residual is read off the first frame and
+// carried by the second.
+assert.match(src, /^ *const second = develop\(residual\);$/m);
+// The per-channel curve this replaced is gone, and so is the buffer slot it rode
+// in: 33 floats became 23, the last two of them the residual's own scale.
+assert.doesNotMatch(code, /float tone\(float4 w, float x\) \{/, 'the per-channel curve is back');
+assert.doesNotMatch(code, /uniform float4 t0;/, 'the per-channel curve is back in the shader');
+assert.doesNotMatch(src, /FLAT_TONE/, 'the flat tone is back');
+assert.doesNotMatch(src, /toneMatch/, 'the tone fit is back');
+assert.match(src, /const uniforms = new Float32Array\(23\);/);
+assert.match(src, /^ *uniforms\[21\] = residual \? residual\.sx : 0;$/m);
+assert.match(src, /^import \{ MATCH_GRID \} from '\.\/previewMatch';$/m);
// A RAW opens at the sensor's own resolution, not at the quarter the half-size
// demosaic reports: the GR's DNG came back 3010x2012 against the 6000x4000 of the
diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts
index 7c4ee06..3c8bea6 100644
--- a/docker/frontend/shared/types/index.ts
+++ b/docker/frontend/shared/types/index.ts
@@ -6,7 +6,8 @@ export type FrameId =
| 'wallframe' // WALL FRAME PORTRAIT — the artwork hung 90° CW
| 'wallframe-landscape' // the same artwork upright: WALL FRAME LANDSCAPE
| 'old-film' // OLD FILM LANDSCAPE — the distressed sheet as it is
- | 'old-film-portrait'; // OLD FILM PORTRAIT — the same sheet turned 90° CW
+ | 'old-film-portrait' // OLD FILM PORTRAIT — the same sheet turned 90° CW
+ | 'custom'; // CUSTOM IMPORTED FRAME — transparent PNG/WebP overlay
// Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT
// (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing
diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx
index 80104ac..e2d5a13 100644
--- a/docker/frontend/src/App.tsx
+++ b/docker/frontend/src/App.tsx
@@ -22,9 +22,11 @@ import { sharePhoto, type ShareSite } from './engine/share';
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
import { getPhoto, loadEdit, makeTile, putPhotoThumb, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
+import { immichConfig, uploadToImmich, type ImmichServer } from './engine/immich';
import { offerInstall } from './pwa/install';
import { cameraSupported } from './engine/camera';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session';
+import { analyzeCustomFrame, clearCustomFrame, loadCustomFrame, saveCustomFrame, type CustomFrameData } from './engine/customFrame';
import { keep } from './remember';
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
import { api, type Photo, type SavedRecipe } from './api';
@@ -122,6 +124,7 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [
{ id: 'wallframe-landscape', label: 'frame.wallLand' },
{ id: 'old-film', label: 'frame.oldFilm' },
{ id: 'old-film-portrait', label: 'frame.oldFilmPort' },
+ { id: 'custom', label: 'frame.custom' },
];
// The recipe a saved photo's base is rendered through: the neutral stock —
@@ -348,11 +351,7 @@ interface Look {
export function Workspace() {
const { t } = useI18n();
- const [forceView] = useState<'auto' | 'mobile' | 'desktop'>(() => {
- return (localStorage.getItem('rc_force_view') as 'auto' | 'mobile' | 'desktop') || 'auto';
- });
- const detectedMobile = useIsMobile(860);
- const isMobile = forceView === 'mobile' || (forceView === 'auto' && detectedMobile);
+ const isMobile = useIsMobile(860);
// Session restore: read once, synchronously, so the first paint already holds
// the user's own settings instead of a flash of the defaults. The photo comes
@@ -397,6 +396,76 @@ export function Workspace() {
const [preview, setPreview] = useState(null);
const [frameId, setFrameId] = useState(() => session.frameId ?? 'none');
const [framePanX, setFramePanX] = useState(() => session.framePanX ?? 0);
+ const [customFrame, setCustomFrame] = useState(null);
+ const frameFileInputRef = useRef(null);
+ const saveCustomFrameTimer = useRef | null>(null);
+
+ useEffect(() => {
+ let alive = true;
+ loadCustomFrame().then((saved) => {
+ if (alive && saved) setCustomFrame(saved);
+ });
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ const updateCustomFrame = useCallback((updater: (prev: CustomFrameData) => CustomFrameData) => {
+ setCustomFrame((prev) => {
+ if (!prev) return null;
+ const next = updater(prev);
+ if (saveCustomFrameTimer.current) clearTimeout(saveCustomFrameTimer.current);
+ saveCustomFrameTimer.current = setTimeout(() => {
+ void saveCustomFrame(next);
+ }, 400);
+ return next;
+ });
+ }, []);
+
+ const onImportFrameFile = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ try {
+ setBusy(t('frame.analyzing'));
+ const analyzed = await analyzeCustomFrame(file);
+ await saveCustomFrame(analyzed);
+ setCustomFrame(analyzed);
+ setFrameId('custom');
+ } catch (err: any) {
+ setError(err?.message || 'Failed to load custom frame');
+ } finally {
+ setBusy(null);
+ if (e.target) e.target.value = '';
+ }
+ };
+
+ const onRemoveCustomFrame = async () => {
+ await clearCustomFrame();
+ setCustomFrame(null);
+ if (frameId === 'custom') setFrameId('none');
+ };
+
+ const onCustomFramePan = useCallback(
+ (dx: number, dy: number) => {
+ updateCustomFrame((cf) => {
+ const nextPanX = Math.max(-1000, Math.min(1000, Math.round(cf.panX + dx)));
+ const nextPanY = Math.max(-1000, Math.min(1000, Math.round(cf.panY + dy)));
+ return { ...cf, panX: nextPanX, panY: nextPanY };
+ });
+ },
+ [updateCustomFrame]
+ );
+
+ const onCustomFrameZoom = useCallback(
+ (delta: number) => {
+ updateCustomFrame((cf) => {
+ const factor = delta > 0 ? 0.95 : 1.05;
+ const nextScale = Math.max(0.2, Math.min(5.0, Number((cf.scale * factor).toFixed(3))));
+ return { ...cf, scale: nextScale };
+ });
+ },
+ [updateCustomFrame]
+ );
const [crop, setCrop] = useState(() => session.crop ?? 'none');
const [cropRect, setCropRect] = useState(() => session.cropRect ?? DEFAULT_CROP_RECT);
const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false);
@@ -678,6 +747,26 @@ export function Workspace() {
// for them (server.ts), so the studio stops counting at the same place. null
// means "no cap" all the way down to SAVE PHOTO's label.
const photoCap = user?.admin ? null : MAX_PHOTOS;
+
+ // Immich Cloud servers connected to this account
+ const [immichServers, setImmichServers] = useState([]);
+ const [uploadingImmich, setUploadingImmich] = useState(false);
+ const [toast, setToast] = useState(null);
+
+ useEffect(() => {
+ if (member) {
+ immichConfig().then((cfg) => setImmichServers(cfg.servers)).catch(() => {});
+ } else {
+ setImmichServers([]);
+ }
+ }, [member]);
+
+ const hasImmich = immichServers.length > 0;
+
+ const showToast = useCallback((msg: string) => {
+ setToast(msg);
+ setTimeout(() => setToast(null), 4000);
+ }, []);
// What the stamp's name line prints: the visitor's own PLACE NAME wins over
// whatever the fix carried, because neither a phone's position nor a photo's
// EXIF ever brings one, and typing it after locating must still show up.
@@ -838,7 +927,7 @@ export function Workspace() {
const parked = await readRaw();
if (!alive) return;
if (!parked) return photo ? show(photo) : undefined;
- const jpeg = await developRaw(parked.bytes);
+ const jpeg = await developRaw(parked.bytes, parked.name);
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
})
.catch(() => undefined);
@@ -881,6 +970,16 @@ export function Workspace() {
const stageOptions = useMemo(
() => ({
wallframeLandscape: frameId === 'wallframe-landscape',
+ customFrame:
+ frameId === 'custom' && customFrame
+ ? {
+ bytes: customFrame.bytes,
+ hole: customFrame.hole,
+ scale: customFrame.scale,
+ panX: customFrame.panX,
+ panY: customFrame.panY,
+ }
+ : null,
sharpen: true,
photoRotation: rotation,
photoStraighten: straighten,
@@ -891,7 +990,7 @@ export function Workspace() {
gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
- [frameId, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle]
+ [frameId, customFrame, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle]
);
// What the stage paints: the hovered look while the pointer is on a sim or a
@@ -1248,7 +1347,7 @@ export function Workspace() {
// ponytail: the reload pays one develop. Stamp the session copy with
// the build and keep it while the stamp holds, if that ever hurts.
await storeRaw(bytes, file.name);
- const jpeg = await developRaw(bytes);
+ const jpeg = await developRaw(bytes, file.name);
await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes);
void forgetPhoto();
return;
@@ -1925,6 +2024,16 @@ export function Workspace() {
assets: engine.assets,
options: {
wallframeLandscape: frameId === 'wallframe-landscape',
+ customFrame:
+ frameId === 'custom' && customFrame
+ ? {
+ bytes: customFrame.bytes,
+ hole: customFrame.hole,
+ scale: customFrame.scale,
+ panX: customFrame.panX,
+ panY: customFrame.panY,
+ }
+ : null,
dpi: 300,
sharpen: true,
photoRotation: rotation,
@@ -1980,6 +2089,107 @@ export function Workspace() {
}
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, exportCap, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, place, pro, member, promptPro, proLookInUse, t, strip, photoSettings]);
+ // Upload the currently edited photo from Studio straight to Immich Cloud
+ const uploadPhotoToImmich = useCallback(async (target: number | null = null): Promise => {
+ if (!engine || !source) return false;
+ if (!immichServers.length) {
+ setError(t('immich.noServer'));
+ return false;
+ }
+ if (!pro && proLookInUse()) {
+ promptPro();
+ return false;
+ }
+ setUploadingImmich(true);
+ setBusy(t('immich.uploading'));
+ setError(null);
+ try {
+ const tier = pro ? TIERS.pro : member ? TIERS.member : TIERS.guest;
+ const wanted = target === null ? null : Math.min(target, exportCap ?? target);
+ const needLongest = wanted === null ? sourceLongest : Math.round((sourceLongest * wanted) / editedLongest);
+ let src = source.bytes;
+ if (wanted === null) {
+ if (tier.maxExportDim && sourceLongest > tier.maxExportDim) {
+ src = await resizedJpeg(source.bytes, tier.maxExportDim, 0.95);
+ }
+ } else if (needLongest > sourceLongest * MODEL_GAIN) {
+ src = await upscaleJpeg(source.bytes, needLongest, ({ done, total }) =>
+ setBusy(t('act.upscaling', { done, total }))
+ );
+ } else if (needLongest !== sourceLongest) {
+ src = await scaledJpeg(source.bytes, needLongest);
+ }
+ const out = await renderPhoto({
+ source: src,
+ recipe,
+ frameId: cropEditing ? 'none' : frameId,
+ useGeotag,
+ gpsInfo,
+ assets: engine.assets,
+ options: {
+ wallframeLandscape: frameId === 'wallframe-landscape',
+ customFrame:
+ frameId === 'custom' && customFrame
+ ? {
+ bytes: customFrame.bytes,
+ hole: customFrame.hole,
+ scale: customFrame.scale,
+ panX: customFrame.panX,
+ panY: customFrame.panY,
+ }
+ : null,
+ dpi: 300,
+ sharpen: true,
+ photoRotation: rotation,
+ photoStraighten: straighten,
+ photoFlipH: flipH,
+ photoFlipV: flipV,
+ cropRect: cropLive ? cropRect : null,
+ watermark: markOn && markText
+ ? { text: markText, ...markStyle }
+ : tier.watermark
+ ? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 }
+ : null,
+ gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null,
+ },
+ });
+ if (!out) throw new Error('render returned null');
+
+ const bytes = writeJpegExif(
+ out.bytes,
+ {
+ dateTime: new Date(),
+ gps: gps ? { latitude: gps.latitude, longitude: gps.longitude } : null,
+ width: out.width,
+ height: out.height,
+ dpi: 300,
+ locationName: place || gps?.locality || null,
+ imageDescription: strip.tag,
+ userComment: JSON.stringify(photoSettings),
+ },
+ source.bytes
+ );
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
+ const name = `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`;
+
+ const primaryServer = immichServers[0];
+ const res = await uploadToImmich(bytes, name, primaryServer?.id);
+ if (res.ok) {
+ showToast(t('immich.uploadSuccess'));
+ setChoosingExport(false);
+ return true;
+ } else {
+ throw new Error(res.error || 'Upload error');
+ }
+ } catch (err) {
+ setError(t('immich.uploadFailed', { msg: String(err) }));
+ return false;
+ } finally {
+ setUploadingImmich(false);
+ setBusy(null);
+ }
+ }, [engine, source, immichServers, pro, proLookInUse, promptPro, member, exportCap, sourceLongest, editedLongest, recipe, cropEditing, frameId, useGeotag, gpsInfo, customFrame, rotation, straighten, flipH, flipV, cropLive, cropRect, markOn, markText, markStyle, gps, place, strip, photoSettings, t, showToast]);
+
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
// The upload is left clean (no burned strip) so the landing reel can draw its
// own `.lp-shot` frame around it; the labels are stored alongside instead.
@@ -2026,6 +2236,16 @@ export function Workspace() {
assets: engine.assets,
options: {
wallframeLandscape: frameId === 'wallframe-landscape',
+ customFrame:
+ frameId === 'custom' && customFrame
+ ? {
+ bytes: customFrame.bytes,
+ hole: customFrame.hole,
+ scale: customFrame.scale,
+ panX: customFrame.panX,
+ panY: customFrame.panY,
+ }
+ : null,
dpi: 300,
sharpen: true,
photoRotation: rotation,
@@ -2950,10 +3170,10 @@ export function Workspace() {
// The frame row is the frames alone. CROP, ROTATE and WATERMARK used to close
// it — a frame is the look, and cutting, levelling and signing the print are
// moves made on the frame, which is what TOOLS is (see `shapeChips`).
- const framesRow = (): ChipDef[] =>
+ const framesRow = (): ChipDef[] => {
// NO FRAME is not a chip of its own: clicking the frame already on the
// photo takes it off again, so the row carries one entry per frame.
- FRAMES.filter((frame) => frame.id !== 'none').map((frame) => ({
+ const list: ChipDef[] = FRAMES.filter((frame) => frame.id !== 'none' && frame.id !== 'custom').map((frame) => ({
key: `fr:${frame.id}`,
label: t(frame.label),
active: frameId === frame.id,
@@ -2970,6 +3190,32 @@ export function Workspace() {
},
}));
+ if (customFrame) {
+ list.push({
+ key: 'fr:custom',
+ label: customFrame.name || t('frame.custom'),
+ active: frameId === 'custom',
+ onClick: () => {
+ setOpenParam(null);
+ setOpenGroup(null);
+ remember();
+ setFrameId(frameId === 'custom' ? 'none' : 'custom');
+ },
+ });
+ }
+
+ list.push({
+ key: 'fr:import-custom',
+ label: t('frame.import'),
+ active: false,
+ onClick: () => {
+ frameFileInputRef.current?.click();
+ },
+ });
+
+ return list;
+ };
+
// The three moves that shape the print rather than tint it: the frame is cut
// to a shape, turned to the straight, and signed. Each opens its own strip in
// the last column, the same strip it opened from the frame row.
@@ -3693,6 +3939,9 @@ export function Workspace() {
onReset={reset}
onSavePhoto={() => void saveThenMaybeClear()}
onExport={openExportPicker}
+ hasImmich={hasImmich}
+ uploadingImmich={uploadingImmich}
+ onUploadImmich={() => void uploadPhotoToImmich()}
photoCount={myPhotos.length}
photoMax={photoCap}
onAuth={() => setAuthMode('login')}
@@ -3706,6 +3955,33 @@ export function Workspace() {
}}
/>
+ {/* Toast notification */}
+ {toast && (
+
+ ☁️
+ {toast}
+
+ )}
+
{/* The trial notice: one band under the header, the same shape the verify
bar below takes. Copy only — nothing here is dismissible, because the
date IS the message. */}
@@ -4075,6 +4351,153 @@ export function Workspace() {
) : null}
+ {/* CUSTOM FRAME controls column: thumbnail, fit/fill/center, scale & pan sliders, replace & delete */}
+ {tab === 'frame' && frameId === 'custom' && customFrame ? (
+
@@ -4131,6 +4554,9 @@ export function Workspace() {
cropWH={cropWH}
dims={stageDims}
frameId={cropEditing ? 'none' : frameId}
+ customFrame={frameId === 'custom' ? customFrame : null}
+ onCustomFramePan={onCustomFramePan}
+ onCustomFrameZoom={onCustomFrameZoom}
framePanX={framePanX}
onFramePanXChange={(pan) => {
setFramePanX(pan);
@@ -4304,6 +4730,10 @@ export function Workspace() {
void exportPhoto(null, site);
}}
onClose={() => setChoosingExport(false)}
+ hasImmich={hasImmich}
+ onUploadImmich={(target) => {
+ void uploadPhotoToImmich(target);
+ }}
/>
) : null}
diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx
index de5acfd..53bd158 100644
--- a/docker/frontend/src/Library.tsx
+++ b/docker/frontend/src/Library.tsx
@@ -47,6 +47,7 @@ import {
stopScanAt,
stopCount,
unfinished,
+ saveCachedPhotos,
watchRows,
watchScan,
type LibraryDir,
@@ -63,8 +64,10 @@ import {
} from './engine/libraryBackup';
import {
addImmichFolder,
+ fetchImmichBuckets,
forgetImmichServer,
immichConfig,
+ listAssets,
saveImmichAlbums,
searchImmichFolder,
syncImmich,
@@ -374,8 +377,28 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// A server is not a folder on the disk — see `engine/immich.ts` — so it is the
// column's second source and the page holds no key of its own.
const [servers, setServers] = useState([]);
+ const [serverYears, setServerYears] = useState([]);
const [addImmich, setAddImmich] = useState(false);
const [editAlbums, setEditAlbums] = useState(null);
+
+ useEffect(() => {
+ if (!servers.length) return;
+ let alive = true;
+ Promise.all(servers.map((s) => fetchImmichBuckets(s.id, 'MONTH'))).then((results) => {
+ if (!alive) return;
+ const yrs = new Set();
+ for (const bucketList of results) {
+ for (const b of bucketList) {
+ const yr = parseInt(b.timeBucket.slice(0, 4), 10);
+ if (yr > 1900 && yr < 2100) yrs.add(yr);
+ }
+ }
+ setServerYears(Array.from(yrs).sort((a, b) => b - a));
+ });
+ return () => {
+ alive = false;
+ };
+ }, [servers]);
// The strip starts on the catalogue the last screen read: coming back from the
// studio is a screen opening, not a page loading, and the frames the reader was
// looking at are already in hand. The read below replaces them a moment later.
@@ -484,6 +507,13 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// then — the pointer only ever reports a position, not a distance moved.
const grip = useRef<{ x: number; w: number } | null>(null);
+ // The server nodes a reading is in flight on, by folder name. See `syncServer`.
+ const syncingServer = useRef>(new Set());
+ // The server nodes this visit has already carried on reading. The restore below
+ // runs again whenever the node the screen shows changes, and a reading begun
+ // again on every click is a reading asking the server for pages it just had.
+ const startedHere = useRef>(new Set());
+
// The catalogue is loaded once at start-up; until it is, the tree only knows
// the folders and cannot tell whether the remembered subfolder still exists.
const [loaded, setLoaded] = useState(false);
@@ -507,7 +537,13 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const [backBusy, setBackBusy] = useState(false);
const reload = useCallback(async () => {
- const [rows, edits, walkedRows] = await Promise.all([readPhotos(), listEditedIds(), listWalkedDirs()]);
+ const [rows, edits, walkedRows] = await Promise.all([
+ readPhotos(undefined, (firstBatch) => {
+ setPhotos((prev) => (prev.length < firstBatch.length ? firstBatch : prev));
+ }),
+ listEditedIds(),
+ listWalkedDirs(),
+ ]);
// A read that came back with nothing is a read that failed, not a catalogue
// that emptied — and the frames it would have cleared are frames the reader
// was working their way through. The strip keeps them.
@@ -934,16 +970,51 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// `engine/immich.ts` that does the work. The node's album rows are put in the
// place of its old ones — an album the reader unticked is a row that has to go,
// and `reload` only ever adds.
+ //
+ // A node of a hundred thousand frames is not read in one visit. What comes back
+ // here is the newest pages of it and a word that the rest is still coming, which
+ // is what the note says: the reader who is told six hundred frames were read is
+ // a reader looking at a node that says it holds six hundred.
const syncServer = useCallback(
async (folder: LibraryFolder) => {
+ // One reading of a node at a time. This is asked for on every visit and
+ // again whenever the node the screen shows changes, and a second reading
+ // behind the first is the same pages asked of the server twice over.
+ if (syncingServer.current.has(folder.name)) return;
+ syncingServer.current.add(folder.name);
setNote(null);
try {
- const res = await syncImmich(folder);
+ const res = await syncImmich(folder, (batch) => {
+ setPhotos((prev) => {
+ const existing = new Set(prev.map((p) => p.id));
+ const toAdd = batch.filter((p) => !existing.has(p.id));
+ if (!toAdd.length) return prev;
+ // A server's reading walks backwards in time, and a page carried on
+ // from where the last visit stopped is older than every row the screen
+ // already holds: put after them, the list is in order without being
+ // sorted, and sorting a hundred thousand rows once a page is the visit
+ // spending itself on sorting. Only a page the reading has gone back
+ // over — the newest ones, read again from the top — needs the sort.
+ const tail = prev[prev.length - 1];
+ const next =
+ tail && toAdd[0].taken <= tail.taken
+ ? [...prev, ...toAdd]
+ : [...toAdd, ...prev].sort((a, b) => b.taken - a.taken);
+ saveCachedPhotos(next);
+ return next;
+ });
+ });
setDirs((prev) => [...prev.filter((d) => d.folder !== folder.name), ...res.dirs]);
- setNote(t('lib.immich.added', { n: res.total, folder: folder.label ?? folder.name }));
+ setNote(
+ res.done
+ ? t('lib.immich.added', { n: res.total, folder: folder.label ?? folder.name })
+ : t('lib.immich.reading', { folder: folder.label ?? folder.name })
+ );
await reload();
} catch {
setNote(t('lib.immich.failed'));
+ } finally {
+ syncingServer.current.delete(folder.name);
}
},
[reload, t]
@@ -980,6 +1051,15 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
nextFolders = nextFolders.map((f) => (f.name === folder!.name ? folder! : f));
changed = true;
void syncServer(folder);
+ } else if ((!folder.cursor || !folder.cursor.done) && !startedHere.current.has(folder.name)) {
+ // A node that was never read through is a node whose reading ended with
+ // the visit that began it — a reload, a closed tab, a server that did not
+ // answer — and the place it stopped at is on its own record. Carrying on
+ // from there is the only way the frames under the newest pages are ever
+ // read: the visit that reads three pages and stops, every visit and for
+ // good, is a node the reader is told holds six hundred frames.
+ startedHere.current.add(folder.name);
+ void syncServer(folder);
}
}
@@ -1340,6 +1420,25 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
[drop]
);
+ // A saved link taken off the list in the settings dialog: the key goes, and
+ // every node that was reading by it goes with it — a node with no key is a row
+ // that could only fail. Same move as `disconnect`, for the case where the reader
+ // is looking at the list rather than at the node.
+ const forgetLink = useCallback(
+ async (id: string) => {
+ const gone = folders.filter((f) => f.server === id);
+ await forgetImmichServer(id);
+ for (const f of gone) await removeFolder(f.name);
+ const rest = folders.filter((f) => f.server !== id);
+ setFolders(rest);
+ setDirs((prev) => prev.filter((d) => !gone.some((f) => f.name === d.folder)));
+ if (gone.some((f) => f.name === node)) setNode(rest[0]?.name ?? null);
+ setServers((await immichConfig(true)).servers);
+ await reload();
+ },
+ [folders, node, reload]
+ );
+
// The frame goes to the studio through the address bar: the studio owns the
// load (develop, EXIF, session) and this page owns no part of it. While a scan
// is running the address bar is the one thing that changes — `go` keeps the
@@ -1483,6 +1582,12 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// live scan progress ticks (every 200ms) do not re-scan tens of thousands of photos.
const baseTreeInfo = useMemo(() => {
const hits = new Map();
+ // What a folder holds of its own, the frames filed straight into it. The two
+ // are one number for a folder of the disk, whose subfolders hold frames that
+ // are nowhere else; a server's album rows hold the frames the node above them
+ // holds too — an album is a second way into one library, not more of it — and
+ // a node's number read off `hits` would say the library twice. See `counted`.
+ const own = new Map();
const foldersUnder = new Map>();
const add = (folder: string, rel: string, counted: boolean) => {
// `normPath`, not a bare backslash swap: a picked drive root is named `\`,
@@ -1511,29 +1616,49 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const normDir = (photo.dir || '').replace(/\\/g, '/');
hits.set(normF.toLowerCase(), (hits.get(normF.toLowerCase()) ?? 0) + 1);
if (normDir) add(normF, normDir, true);
+ else own.set(normF.toLowerCase(), (own.get(normF.toLowerCase()) ?? 0) + 1);
}
for (const dir of dirs) add(dir.folder, dir.rel, false);
if (liveDirs) {
for (const dir of liveDirs) add(dir.folder, dir.rel, false);
}
- return { hits, foldersUnder };
+ return { hits, own, foldersUnder };
}, [photos, dirs, liveDirs]);
const nodes = useMemo(() => {
- const { hits, foldersUnder } = baseTreeInfo;
+ const { hits, own, foldersUnder } = baseTreeInfo;
// What a row says: the reading in flight, then what the reading that walked
// this roll found, then the catalogue. The reading first because it is the
// newest thing there is; the walk second because the catalogue is not a
// record of the disk — a frame taken off it is a row nothing takes out — and a
// number read off it is one that can only ever climb; the catalogue last,
// which is all a roll that has never been walked has.
+ // A server's node is not on the disk, and the walk record is the disk's word:
+ // `syncImmich` writes down where it stopped and what the pages it read held,
+ // never the whole of the node — a reading that stops at its foreground window
+ // writes the six hundred frames of that window, and one whose first page does
+ // not come back writes a nought. Read as the node's own numbers they stand a
+ // half-count, or a nought, beside a node the catalogue holds a hundred
+ // thousand frames under, for good: the record outlives every visit. So a
+ // server's rows read their numbers off the catalogue, which is the one thing
+ // that knows what has been filed under them. See `engine/immich.ts`.
+ const serverRoots = new Set(
+ folders.filter((f) => f.source === 'immich').map((f) => normPath(f.name).toLowerCase())
+ );
const counted = (key: string) => {
const normKey = normPath(key);
const lowerKey = normKey.toLowerCase();
const readVal = read?.[normKey] ?? read?.[lowerKey] ?? read?.[key];
if (readVal !== undefined) return readVal;
- const walkVal = walked[lowerKey];
- if (walkVal !== undefined) return walkVal;
+ // The node itself: its own frames and no more. Its album rows sit under it
+ // and hold frames it holds as well, so the whole catalogue under the node is
+ // the library counted once per album one of its frames is in. The album rows
+ // are rows of their own and count themselves below.
+ if (serverRoots.has(lowerKey)) return own.get(lowerKey) ?? hits.get(lowerKey) ?? 0;
+ if (![...serverRoots].some((r) => lowerKey.startsWith(`${r}/`))) {
+ const walkVal = walked[lowerKey];
+ if (walkVal !== undefined) return walkVal;
+ }
return hits.get(lowerKey) ?? 0;
};
const out: Node[] = [];
@@ -1735,10 +1860,17 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// there are frames to find in rather than a century of empty ones. Scoped to
// `shown` — the open node (+ its subfolders when that chip is on) — not the
// whole library, so picking a year never points at frames elsewhere.
- const years = useMemo(
- () => [...new Set(shown.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a),
- [shown]
- );
+ const years = useMemo(() => {
+ const set = new Set();
+ for (const p of shown) {
+ if (p.taken) set.add(new Date(p.taken).getFullYear());
+ }
+ for (const yr of serverYears) {
+ set.add(yr);
+ }
+ return Array.from(set).sort((a, b) => b - a);
+ }, [shown, serverYears]);
+
// A year the open folder does not hold is not a year to look at, and leaving
// it set would draw an empty shelf with no way back but the dropdown. Fires on
// a move, not on every frame landing, so a scan of the open folder filling in
@@ -1748,7 +1880,56 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// ponytail: only the year option list is folder-derived; stars and hours are
// fixed lists that cannot go stale. Re-check the others if they ever grow.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [node, deep, rootName]);
+ }, [node, deep, rootName, years]);
+
+ // On-demand fetch for selected year if user has Immich servers and local photos for that year are missing
+ const fetchYearPhotos = useCallback(
+ async (yr: number) => {
+ if (!servers.length) return;
+ const server = servers[0];
+ const immichFolder = folders.find((f) => f.source === 'immich' && f.server === server.id);
+ const root = normPath(immichFolder?.name ?? 'IMMICH');
+ const startDate = `${yr}-01-01T00:00:00.000Z`;
+ const endDate = `${yr}-12-31T23:59:59.999Z`;
+
+ try {
+ const { items } = await listAssets(server.id, null, 1, '', endDate, startDate);
+ if (!items.length) return;
+
+ const newPhotos: LibraryPhoto[] = items.map((item) => ({
+ id: [root, item.name].join('/'),
+ folder: immichFolder?.name ?? 'IMMICH',
+ dir: '',
+ name: item.name,
+ server: server.id,
+ remoteId: item.id,
+ taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
+ size: item.size ?? 0,
+ addedAt: Date.now(),
+ }));
+
+ setPhotos((prev) => {
+ const have = new Set(prev.map((p) => p.id));
+ const landed = newPhotos.filter((p) => !have.has(p.id));
+ return landed.length ? [...prev, ...landed] : prev;
+ });
+ } catch {
+ // quiet
+ }
+ },
+ [servers, folders]
+ );
+
+ useEffect(() => {
+ if (year === 'all' || !servers.length) return;
+ const yr = Number(year);
+ if (!Number.isFinite(yr) || yr < 1900) return;
+
+ const hasPhotosForYear = photos.some((p) => new Date(p.taken || 0).getFullYear() === yr);
+ if (!hasPhotosForYear) {
+ void fetchYearPhotos(yr);
+ }
+ }, [year, photos, servers, fetchYearPhotos]);
const handleSelectPhoto = useCallback(
(photo: LibraryPhoto, e: React.MouseEvent) => {
@@ -2263,10 +2444,18 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// 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
// hands pictures out only around the eye — keeps the box and says nothing.
- const tile = (photo: LibraryPhoto, onPick?: () => void) =>
- urls[photo.id] ? (
+ // A frame the wall has no tile for yet: a server's own frame is drawn from the
+ // address the proxy serves it at rather than left as an empty box. Keeping a
+ // cloud library's tiles as blobs is a copy of every picture anyone scrolled past
+ // (`!p.remoteId` in `useThumbUrls` is the other half of that: the wall does not
+ // mint them), and the browser already keeps what it fetched for the day
+ // `/api/immich/thumb` says it may — so the wall scrolls on and the visit after
+ // draws without asking twice.
+ const tile = (photo: LibraryPhoto, onPick?: () => void) => {
+ const src = urls[photo.id] ?? (photo.remoteId ? remoteUrl('thumbnail', photo) : null);
+ return src ? (
);
+ };
if (isMobile) {
return (
@@ -2283,6 +2473,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
prev.map((p) => (p.id === photo.id ? { ...p, rot } : p)));
}}
onSwitchToDesktop={() => setForceView('desktop')}
+ onFetchYear={fetchYearPhotos}
/>
{addImmich ? (
void forgetLink(id)}
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setAddImmich(false)}
/>
@@ -3447,6 +3641,8 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
it goes straight up to the backend, which probes it before it stores. */}
{addImmich ? (
void forgetLink(id)}
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setAddImmich(false)}
/>
diff --git a/docker/frontend/src/engine/customFrame.ts b/docker/frontend/src/engine/customFrame.ts
new file mode 100644
index 0000000..c061f0d
--- /dev/null
+++ b/docker/frontend/src/engine/customFrame.ts
@@ -0,0 +1,253 @@
+// Custom imported transparent photo frame engine and storage
+
+export interface CustomFrameHole {
+ x: number; // 0..1 fraction of frame width
+ y: number; // 0..1 fraction of frame height
+ w: number; // 0..1 fraction of frame width
+ h: number; // 0..1 fraction of frame height
+}
+
+export interface CustomFrameData {
+ id: string;
+ name: string;
+ bytes: Uint8Array;
+ dataUrl: string;
+ width: number;
+ height: number;
+ hole: CustomFrameHole;
+ scale: number; // default 1.0 (cover/fill hole)
+ panX: number; // offset in frame pixels
+ panY: number; // offset in frame pixels
+}
+
+const DB_NAME = 'recipescam-studio';
+const STORE = 'custom_frames';
+const FRAME_KEY = 'active_custom_frame';
+
+function openDb(): Promise {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, 2);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains('session')) {
+ db.createObjectStore('session');
+ }
+ if (!db.objectStoreNames.contains(STORE)) {
+ db.createObjectStore(STORE);
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+export async function saveCustomFrame(frame: CustomFrameData): Promise {
+ try {
+ const db = await openDb();
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite');
+ tx.objectStore(STORE).put(frame, FRAME_KEY);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ db.close();
+ } catch (err) {
+ console.warn('Failed to save custom frame to IndexedDB', err);
+ }
+}
+
+export async function loadCustomFrame(): Promise {
+ try {
+ const db = await openDb();
+ const row = await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readonly');
+ const req = tx.objectStore(STORE).get(FRAME_KEY);
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+ db.close();
+ if (row && row.bytes instanceof Uint8Array && row.hole) {
+ return row;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+export async function clearCustomFrame(): Promise {
+ try {
+ const db = await openDb();
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite');
+ tx.objectStore(STORE).delete(FRAME_KEY);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ db.close();
+ } catch {}
+}
+
+/**
+ * Analyzes an imported frame file (PNG, WebP, SVG, etc.),
+ * decodes it, and detects the transparent opening/hole via alpha channel analysis.
+ */
+export async function analyzeCustomFrame(file: File): Promise {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const blob = new Blob([bytes], { type: file.type || 'image/png' });
+ const dataUrl = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+
+ const img = new Image();
+ await new Promise((resolve, reject) => {
+ img.onload = () => resolve();
+ img.onerror = () => reject(new Error('Cannot decode frame image'));
+ img.src = dataUrl;
+ });
+
+ const w = img.naturalWidth || img.width;
+ const h = img.naturalHeight || img.height;
+ if (!(w > 0 && h > 0)) {
+ throw new Error('Invalid image dimensions');
+ }
+
+ // Downscale slightly for fast and accurate pixel analysis (max 600px)
+ const maxDim = 600;
+ const sampleScale = Math.min(1, maxDim / Math.max(w, h));
+ const sw = Math.max(10, Math.round(w * sampleScale));
+ const sh = Math.max(10, Math.round(h * sampleScale));
+
+ const canvas = document.createElement('canvas');
+ canvas.width = sw;
+ canvas.height = sh;
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ if (!ctx) {
+ throw new Error('Canvas 2D context not available');
+ }
+
+ ctx.drawImage(img, 0, 0, sw, sh);
+ const imgData = ctx.getImageData(0, 0, sw, sh);
+ const data = imgData.data;
+
+ // Transparent threshold: alpha < 70 (out of 255)
+ const isAlphaLow = (x: number, y: number) => {
+ return data[(y * sw + x) * 4 + 3] < 70;
+ };
+
+ // 1. Flood-fill from outer edges to distinguish outer transparent padding from the inner transparent hole
+ const visited = new Uint8Array(sw * sh);
+ const queue: number[] = [];
+
+ for (let x = 0; x < sw; x++) {
+ if (isAlphaLow(x, 0)) {
+ visited[0 * sw + x] = 1;
+ queue.push(x, 0);
+ }
+ if (isAlphaLow(x, sh - 1)) {
+ visited[(sh - 1) * sw + x] = 1;
+ queue.push(x, sh - 1);
+ }
+ }
+ for (let y = 0; y < sh; y++) {
+ if (isAlphaLow(0, y) && !visited[y * sw + 0]) {
+ visited[y * sw + 0] = 1;
+ queue.push(0, y);
+ }
+ if (isAlphaLow(sw - 1, y) && !visited[y * sw + (sw - 1)]) {
+ visited[y * sw + (sw - 1)] = 1;
+ queue.push(sw - 1, y);
+ }
+ }
+
+ let head = 0;
+ while (head < queue.length) {
+ const qx = queue[head++];
+ const qy = queue[head++];
+ const neighbors = [
+ [qx + 1, qy],
+ [qx - 1, qy],
+ [qx, qy + 1],
+ [qx, qy - 1],
+ ];
+ for (const [nx, ny] of neighbors) {
+ if (nx >= 0 && nx < sw && ny >= 0 && ny < sh) {
+ const idx = ny * sw + nx;
+ if (!visited[idx] && isAlphaLow(nx, ny)) {
+ visited[idx] = 1;
+ queue.push(nx, ny);
+ }
+ }
+ }
+ }
+
+ // 2. Find bounding box of inner transparent pixels (not reached by flood-fill from border)
+ let minX = sw;
+ let minY = sh;
+ let maxX = -1;
+ let maxY = -1;
+ let innerTransparentCount = 0;
+
+ for (let y = 0; y < sh; y++) {
+ for (let x = 0; x < sw; x++) {
+ const idx = y * sw + x;
+ if (isAlphaLow(x, y) && !visited[idx]) {
+ innerTransparentCount++;
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ }
+ }
+
+ // 3. Fallback: if no inner transparent pixel was isolated, search for any transparent pixels
+ if (innerTransparentCount < 20 || minX > maxX) {
+ minX = sw;
+ minY = sh;
+ maxX = -1;
+ maxY = -1;
+ let anyCount = 0;
+ for (let y = 0; y < sh; y++) {
+ for (let x = 0; x < sw; x++) {
+ if (isAlphaLow(x, y)) {
+ anyCount++;
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ }
+ }
+ innerTransparentCount = anyCount;
+ }
+
+ // 4. Compute normalized hole geometry
+ let hole: CustomFrameHole = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 };
+ if (innerTransparentCount > 20 && minX <= maxX && minY <= maxY) {
+ const holeW = Math.max(0.05, (maxX - minX + 1) / sw);
+ const holeH = Math.max(0.05, (maxY - minY + 1) / sh);
+ hole = {
+ x: minX / sw,
+ y: minY / sh,
+ w: holeW,
+ h: holeH,
+ };
+ }
+
+ return {
+ id: `custom-${Date.now()}`,
+ name: file.name,
+ bytes,
+ dataUrl,
+ width: w,
+ height: h,
+ hole,
+ scale: 1.0,
+ panX: 0,
+ panY: 0,
+ };
+}
diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts
index 9412181..4202cb1 100644
--- a/docker/frontend/src/engine/exportEngine.ts
+++ b/docker/frontend/src/engine/exportEngine.ts
@@ -100,6 +100,13 @@ export interface RenderOptions {
// quarter turn and the fine angle (they ride on top of both).
photoFlipH?: boolean;
photoFlipV?: boolean;
+ customFrame?: {
+ bytes: Uint8Array;
+ hole: { x: number; y: number; w: number; h: number };
+ scale?: number;
+ panX?: number;
+ panY?: number;
+ } | null;
// The strip the FILE carries: the amber `#TAG` over the photo and a caption
// band appended below it, mirroring the landing page's `.lp-shot` +
// `.lp-frame-meta`. Left off the live preview, which must not show it.
@@ -770,8 +777,8 @@ function loadWallframe(assets: RenderAssets) {
// is a transparent window a couple of percent of paper wide around it.
const isOldFilm = (id: FrameId) => id === 'old-film' || id === 'old-film-portrait';
// A frame that carries its own opening: the aspect chip has nothing to say to
-// it (both walls, both films).
-const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id);
+// it (both walls, both films, custom frames).
+const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id) || id === 'custom';
function loadOldFilm(assets: RenderAssets) {
if (oldFilmImage) return oldFilmImage;
@@ -1521,6 +1528,73 @@ export async function renderPhoto(input: RenderInput): Promise();
// Tiles are drawn after the rows have landed rather than before: the column and
// the count are what the reader sees first, and a tile is the wall's own business.
const TILE_LANES = 3;
@@ -155,6 +167,37 @@ export async function forgetImmichServer(id: string): Promise {
}
}
+// Upload an edited photo to Immich Cloud
+export async function uploadToImmich(
+ bytes: Uint8Array,
+ name: string,
+ serverId?: string,
+ albumId?: string
+): Promise<{ ok: boolean; id?: string; error?: string }> {
+ try {
+ const params = new URLSearchParams();
+ if (serverId) params.set('server', serverId);
+ if (name) params.set('name', name);
+ if (albumId) params.set('album', albumId);
+
+ const res = await fetch(`/api/immich/upload?${params.toString()}`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'image/jpeg',
+ },
+ body: bytes as BodyInit,
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ return { ok: false, error: (err as any)?.error || (err as any)?.details || `HTTP ${res.status}` };
+ }
+ const data = await res.json().catch(() => ({}));
+ return { ok: true, id: (data as any)?.id };
+ } catch (err: any) {
+ return { ok: false, error: err?.message || String(err) };
+ }
+}
+
// The albums this key can see, asked for live: an album added on Immich has to be
// there the next time the dialog is opened, so nothing here is cached.
export async function listImmichAlbums(server: string): Promise {
@@ -180,17 +223,48 @@ export async function saveImmichAlbums(server: string, selected: string[]): Prom
// One page of a server's frames. `album` is an Immich id or null for everything
// the key can see — the album field's empty state.
-async function listAssets(
+export async function listAssets(
server: string,
album: string | null,
page: number,
- search = ''
-): Promise<{ items: ImmichAsset[]; hasMore: boolean }> {
+ search = '',
+ takenBefore?: string | null,
+ takenAfter?: string | null
+): Promise<{ items: ImmichAsset[]; hasMore: boolean; ok: boolean }> {
const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) });
if (album) query.set('album', album);
if (search) query.set('q', search);
- const body = await get<{ items?: ImmichAsset[]; hasMore?: boolean }>(`/api/immich/assets?${query.toString()}`);
- return { items: body?.items ?? [], hasMore: body?.hasMore === true };
+ if (takenBefore) query.set('takenBefore', takenBefore);
+ if (takenAfter) query.set('takenAfter', takenAfter);
+ // A page that could not be read is not a page with nothing on it. Both are one
+ // shape to a loop that counts items, and the loop that takes the one for the
+ // other reads the end of the roll at the first timeout and drops every row it
+ // had not met yet. `ok` is the word that says which of the two this was, and an
+ // unanswered page is asked for again here rather than in every caller.
+ for (let attempt = 0; ; attempt++) {
+ try {
+ const res = await fetch(`/api/immich/assets?${query.toString()}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const body = await json<{ items?: ImmichAsset[]; hasMore?: boolean }>(res);
+ if (!body) throw new Error('unreadable answer');
+ return { items: body.items ?? [], hasMore: body.hasMore === true, ok: true };
+ } catch {
+ if (attempt >= ASSET_RETRIES) return { items: [], hasMore: false, ok: false };
+ await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
+ }
+ }
+}
+
+// Fetch timeline buckets (months/years) for an Immich server in one fast call
+export async function fetchImmichBuckets(
+ server: string,
+ size: 'YEAR' | 'MONTH' = 'MONTH'
+): Promise<{ timeBucket: string; count: number }[]> {
+ const query = new URLSearchParams({ server, size });
+ const body = await get<{ buckets?: { timeBucket: string; count: number }[] }>(
+ `/api/immich/buckets?${query.toString()}`
+ );
+ return Array.isArray(body?.buckets) ? body.buckets : [];
}
// What the tree paints, and the name an album's frames are filed under. An album
@@ -221,6 +295,11 @@ function frameName(name: string, id: string, taken: Set): string {
export interface ImmichSyncResult {
total: number;
albums: number;
+ // False while the node is still being read — the answer above comes back at the
+ // foreground window, and the rest of it is read behind it. The screen says so
+ // rather than a number that would have the reader believe the node holds six
+ // hundred frames.
+ done: boolean;
// The album rows this read named, spelled the way the column spells a path. The
// screen puts these in the place of the node's old ones: an album the reader
// unticked is a row that has to go, and a record that is only ever added to
@@ -228,15 +307,20 @@ export interface ImmichSyncResult {
dirs: LibraryDir[];
}
+export type ImmichSyncCallback = (batch: LibraryPhoto[], progress: { loaded: number; hasMore: boolean }) => void;
+
// Read a server's albums into the catalogue: the rows and the column first, so
// the count and the tree are right the moment this returns, and the tiles after,
// a lane at a time, so a reader who opens the node while it is still fetching
// sees frames rather than a blank wall.
//
-// One read covers the whole node. There is no per-album read: the album list is
-// one call, a page of frames is one call, and a column that says one album is
-// fresh while the others are a week old is a column nobody can read a number off.
-export async function syncImmich(folder: LibraryFolder): Promise {
+// Streams batches to IndexedDB and UI immediately (in < 200ms per batch).
+// Resolves the foreground pass in 3 pages (up to 600 newest photos) so the UI
+// unblocks instantly, and continues syncing historical photos in the background.
+export async function syncImmich(
+ folder: LibraryFolder,
+ onBatch?: ImmichSyncCallback
+): Promise {
const server = folder.server ?? '';
const search = folder.kind === 'search' ? (folder.query ?? '').trim() : '';
const chosen = folder.albums ?? [];
@@ -245,20 +329,78 @@ export async function syncImmich(folder: LibraryFolder): Promise = {};
+ // The frames this reading has already filed. The keyset read stops on a frame's
+ // own time and `takenBefore` is inclusive, so every page opens on the frame the
+ // page before it ended with: filed as it came, that frame is a row the wall
+ // draws twice. `frameName` answers the name a second *file* takes; the id is
+ // what says there is no second file.
+ const seen = new Set();
// 1. ALWAYS read ALL photos of the Immich API (filed under root of folder: dir = '')
- // This guarantees that all photos (timeline, camera roll, uploads) are fully loaded into the Library.
+ // Keyset pagination with takenBefore prevents slow SQL offsets and timeouts!
const rootTaken = new Set();
- let page = 1;
+ // Where the reading before this one stopped, when it did not reach the end. A
+ // node of a hundred thousand frames is not one visit's to read, and a place only
+ // ever held in memory is a place every reload began without: the whole of what
+ // the catalogue was given was then the newest three pages of it, once a visit,
+ // for good. The node's own record keeps the place instead — the frame it stopped
+ // on, and the page within that frame's own instant.
+ const resume = folder.cursor && !folder.cursor.done ? folder.cursor : null;
+ let lastTakenBefore: string | null = resume?.before ?? null;
+ let page = resume?.page ?? 1;
+ let pages = 0;
let rootCount = 0;
+ let complete = false;
+ let failed = false;
+ const FOREGROUND_PAGES = 3; // The newest three pages answer the call so the screen unblocks
+ let hasMoreRemaining = false;
+
+ // The place, written down where the visit after this one reads it. Every page
+ // rather than every minute: a page is two hundred frames, and a visit may end
+ // at any of them.
+ const remember = async (before: string | null, at: number, done: boolean) => {
+ try {
+ await putFolder({ ...folder, cursor: { before, page: at, done } });
+ } catch {}
+ };
+
+ // Where a page leaves the reading. The bound is a frame's own instant and the
+ // call that asks for it keeps the frame it stopped on — `takenBefore` is
+ // inclusive — so a page whose oldest frame is older than the bound has moved it,
+ // and the page after it starts at that very frame. A page that is one instant
+ // from end to end cannot move it at all: Immich keeps a frame's time to the
+ // millisecond, and a roll imported in one go is hundreds of frames at the same
+ // one, which is the whole of the oldest days of this library. Asked for with the
+ // bound alone, that page is the same page for ever — the reading never ends, and
+ // the frames under that instant are never read. So the rest of the instant is
+ // walked by offset, and the bound is left where it is until the pages under it
+ // run out.
+ // ponytail: that offset walk is over everything older than the bound, and a deep
+ // offset is a slow answer — an Immich that offers a cursor for it, or a page
+ // asked per instant out of the timeline buckets, is the way out of it if a roll
+ // of thousands at one instant ever sits in the middle of a library.
+ const step = (items: ImmichAsset[]): void => {
+ const at = items[items.length - 1]?.takenAt ?? null;
+ if (page === 1 && at && at !== lastTakenBefore) lastTakenBefore = at;
+ else page++;
+ };
+
for (;;) {
- const { items, hasMore } = await listAssets(server, null, page, search);
+ const { items, hasMore, ok } = await listAssets(server, null, page, search, lastTakenBefore);
+ // A page that could not be read is not the end of the node, and only a
+ // reading that met the end of it may say what the node no longer holds.
+ if (!ok) {
+ failed = true;
+ break;
+ }
+ const batchRows: LibraryPhoto[] = [];
for (const item of items) {
- if (!item.id || !item.name) continue;
+ if (!item.id || !item.name || seen.has(item.id)) continue;
+ seen.add(item.id);
const name = frameName(item.name, item.id, rootTaken);
rootTaken.add(item.name);
rootTaken.add(name);
- rows.push({
+ const row: LibraryPhoto = {
id: [root, name].join('/'),
folder: folder.name,
dir: '',
@@ -268,17 +410,35 @@ export async function syncImmich(folder: LibraryFolder): Promise= FOREGROUND_PAGES) {
+ hasMoreRemaining = true;
+ break;
+ }
}
counts[root] = rootCount;
// 2. ALBUMS of Immich ONLY display in the ALBUM section if the user chooses specific albums (chosen.length > 0).
- // When chosen is empty, dirs remains empty ([]) so no album subfolders clutter the ALBUM tree.
- // When chosen has album IDs, only those specific chosen albums are fetched and populated into dirs.
if (!search && chosen.length > 0) {
const albums = await listImmichAlbums(server);
const labels = albumLabels(albums);
@@ -290,13 +450,21 @@ export async function syncImmich(folder: LibraryFolder): Promise (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase()));
- await prunePhotos(root, '', met);
- await putPhotos(rows);
+ // The record carries the reading's whole word or none of it: only a reading
+ // that met the end of the node may say what the node holds, because a record
+ // that says otherwise is the record the column reads its noughts off. A reading
+ // stopped at the foreground window, or cut short by a page that would not come
+ // back, folds its half into what was already there instead.
+ await saveWalkedDirs(root, dirs, counts, complete && !failed);
- // Pre-cache thumbnails for initial display without stalling large libraries
- const missing = new Set(await missingThumbs(rows.map((r) => r.id)));
- const queue = rows.filter((r) => missing.has(r.id));
- const initialBatch = queue.slice(0, 80);
- const backgroundBatch = queue.slice(80);
+ // A row the reading did not meet is dropped only against a reading that met all
+ // of them — and only one that began at the top. A reading that carried on from
+ // where the last one stopped holds the newest pages in its own list and no older
+ // row in it at all, and pruned to that list it would take back every frame it
+ // was picking up.
+ // ponytail: a resumed reading therefore stops one prune short — a frame dropped
+ // on the server keeps its row until some visit reads the node from the top. Read
+ // `met` out of the catalogue rather than out of `rows` if that ever shows.
+ if (complete && !failed && !resume) {
+ const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase()));
+ await prunePhotos(root, '', met);
+ }
- await Promise.all(
- Array.from({ length: Math.min(TILE_LANES, initialBatch.length) }, async () => {
- while (initialBatch.length) {
- const row = initialBatch.shift()!;
- try {
- const res = await fetch(remoteUrl('thumbnail', row));
- if (res.ok) await putPhotoThumb(row.id, await res.blob());
- } catch {
- // One frame's tile is one frame's: the wall draws a blank cell and the
- // reader may ask again, rather than a sync that stops halfway.
- }
- await breathe();
- }
- })
- );
-
- if (backgroundBatch.length) {
+ // The rest of the node, a page at a time, behind the answer above. Its place is
+ // the record's exactly as the foreground's is, so a visit that ends in the
+ // middle of it — a reload, a closed tab, a lid shut — costs the page in flight
+ // and nothing else.
+ if ((hasMoreRemaining || failed) && !walking.has(root)) {
+ walking.add(root);
void (async () => {
- while (backgroundBatch.length) {
- const row = backgroundBatch.shift()!;
- try {
- const res = await fetch(remoteUrl('thumbnail', row));
- if (res.ok) await putPhotoThumb(row.id, await res.blob());
- } catch {}
- await breathe();
+ let bgPage = page;
+ let bgLastTakenBefore = lastTakenBefore;
+ let reachedEnd = false;
+ try {
+ for (;;) {
+ const { items, hasMore: bgMore, ok } = await listAssets(
+ server,
+ null,
+ bgPage,
+ search,
+ bgLastTakenBefore
+ );
+ // A page that could not be read leaves the place where the last page
+ // that could left it: the visit after this one asks for it again.
+ if (!ok) break;
+ if (!items.length) {
+ reachedEnd = true;
+ break;
+ }
+ const bgBatchRows: LibraryPhoto[] = [];
+ for (const item of items) {
+ if (!item.id || !item.name || seen.has(item.id)) continue;
+ seen.add(item.id);
+ const name = frameName(item.name, item.id, rootTaken);
+ rootTaken.add(item.name);
+ rootTaken.add(name);
+ bgBatchRows.push({
+ id: [root, name].join('/'),
+ folder: folder.name,
+ dir: '',
+ name,
+ server,
+ remoteId: item.id,
+ taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
+ size: item.size ?? 0,
+ addedAt: Date.now(),
+ });
+ rootCount++;
+ }
+ if (bgBatchRows.length) {
+ await putPhotosFast(bgBatchRows);
+ onBatch?.(bgBatchRows, { loaded: rootCount, hasMore: bgMore });
+ }
+ if (!bgMore) {
+ reachedEnd = true;
+ break;
+ }
+ const at = items[items.length - 1]?.takenAt ?? null;
+ if (bgPage === 1 && at && at !== bgLastTakenBefore) bgLastTakenBefore = at;
+ else bgPage++;
+ await remember(bgLastTakenBefore, bgPage, false);
+ await breathe();
+ }
+ counts[root] = rootCount;
+ await saveWalkedDirs(root, dirs, counts, reachedEnd);
+ if (reachedEnd) {
+ // The node is read through: the place is not a place any more, and the
+ // reading that begins next begins at the top — which is where a frame
+ // added since would be.
+ await remember(null, 1, true);
+ if (!resume) {
+ const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase()));
+ await prunePhotos(root, '', met);
+ }
+ }
+ } finally {
+ walking.delete(root);
}
})();
}
- return { total: rootCount, albums: dirs.length, dirs };
+ return { total: rootCount, albums: dirs.length, dirs, done: complete && !failed };
}
// The name a server's node takes: the first one is `Immich`, and a second is
diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts
index 0481ef6..93240dd 100644
--- a/docker/frontend/src/engine/library.ts
+++ b/docker/frontend/src/engine/library.ts
@@ -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 {
// 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 {
+export async function readPhotos(
+ folder?: string,
+ onBatch?: (photos: LibraryPhoto[]) => void
+): Promise {
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((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= 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 {
+ if (!rows.length) return;
+ const db = await openDb();
+ await new Promise((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 {
return (await readPhotos(folder)) ?? [];
}
diff --git a/docker/frontend/src/engine/previewMatch.ts b/docker/frontend/src/engine/previewMatch.ts
index 7f4cc15..31a9951 100644
--- a/docker/frontend/src/engine/previewMatch.ts
+++ b/docker/frontend/src/engine/previewMatch.ts
@@ -1,127 +1,18 @@
-// The colour the camera itself wrote into the file.
+// The grid the frame's own rendering is read on.
//
-// LibRaw hands back the sensor's own data and nothing about how the body chose to
-// render it: the white balance it locked, its picture style, its tone curve. The
-// file carries the answer anyway — the preview saved inside it, which is the
-// frame the photographer saw and the one a desktop viewer shows. So the develop
-// is matched to that preview: develop a grid of the frame, take the same grid out
-// of the embedded preview, and fit the curve between them — one cubic a channel,
-// on the sRGB-encoded value the develop leaves.
+// This file held the fit that used to relate a develop to the preview the camera
+// wrote into the file: a grid out of each, a cubic a channel between them, and a
+// 3x3 least squared ahead of that curve. None of it is the camera's rendering —
+// a picture style is a colour transform with a curve in it — and the best a
+// per-channel cubic could reach on the Fujifilm RAF this was reported on was
+// dE00 6.04 against the file's own preview.
//
-// A 3x3 was fitted ahead of that curve for a long time, least squared from one
-// grid to the other, and it is what put the cast the studio was reported with:
-// a develop carries its luminance and its colour in the same three numbers, the
-// luminance is two orders of magnitude the larger of the two, and a least squares
-// that is dominated by it pays for the luminance with the colour — it holds the
-// channel means and the bright/dark axis while it collapses the axis the eye
-// reads as colour. The camera's own picture style is a curve, not a matrix.
+// What replaced it is not a fit: the develop carries the difference itself, at
+// the preview's own resolution, and adds it back on the value it left (see
+// previewResidual in rawDevelop.ts). dE00 0.54 on that frame, 0.00 on every body
+// that writes a full-size preview.
//
-// Measured on the Olympus ORF this was reported on, standard deviation of R-G
-// over the frame (the file's own preview 8.3, the camera's own JPEG 12.3, and an
-// olive frame is one where this has gone to nothing):
-// develop as the sensor left it 11.6
-// the curve alone, no 3x3 11.9
-// the curve under the fitted 3x3 (what used to ship) 4.6
-// and on the frame's own means, curve alone against the preview: luminance 156.6
-// against 156.8, R/G 1.023 against 1.014, B/G 0.853 against 0.843.
-//
-// The fit is on the sRGB-encoded values, exposure included: the reference is the
-// file's own frame, so matching its brightness is part of matching its colour.
-// Fitted in that encoded space, not in linear light, because a least squares in
-// linear light weighs the highlights and comes back with the midtones wrong —
-// the same frames scored dE00 6.4 fitted encoded against 9.2 fitted linear. Any
-// body, any picture style, and no fitted table to go stale — a preview is the one
-// profile every RAW already carries.
-
-// The grid the fit reads, and the size of the preview map handed to it.
+// What is left here is the grid both the balance read and the develop's own maps
+// are taken on, which is still the size the preview is reduced to when something
+// only needs a statistic of the frame.
export const MATCH_GRID = 64;
-
-// A block at or past this has nothing left to say about a rendering.
-const CLIP = 0.97;
-const FLOOR = 0.03;
-
-// What the shader draws when the frame has no curve the fit can trust.
-export const FLAT_TONE = new Float32Array([0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0]);
-
-// The curve a channel needs to reach the camera's, least squared: the develop's
-// own value for that channel against the block the camera wrote there.
-//
-// The cubic is pinned at BOTH black (x=0 -> y=0) and white (x=1 -> y=1).
-// Pinned at both ends, a blown pixel (1,1,1) stays pure white, and a deep shadow
-// pixel (0,0,0) stays pure black without shifting to purple or magenta noise.
-function basis(i: number, x: number): number {
- const x3 = x * x * x;
- return i === 0 ? x - x3 : x * x - x3;
-}
-
-function solve2x2(A: number[][], d: number[]): number[] {
- const det = A[0][0] * A[1][1] - A[0][1] * A[1][0] + 1e-9;
- const a = (d[0] * A[1][1] - d[1] * A[0][1]) / det;
- const b = (A[0][0] * d[1] - A[1][0] * d[0]) / det;
- return [a, b];
-}
-
-// The curve itself, in the shader's own order: r a b c d, then g, then b.
-function fitTone(blocks: { x: number[]; t: number[] }[]): Float32Array {
- const out = new Float32Array(12);
- for (let c = 0; c < 3; c++) {
- const A = [[0, 0], [0, 0]];
- const d = [0, 0];
- for (const { x, t } of blocks) {
- const v = Math.min(1, Math.max(0, x[c]));
- const b = [basis(0, v), basis(1, v)];
- for (let p = 0; p < 2; p++) {
- for (let k = 0; k < 2; k++) A[p][k] += b[p] * b[k];
- d[p] += b[p] * (t[c] - v * v * v);
- }
- }
- const [a, b] = solve2x2(A, d);
- out.set([0, a, b, 1 - a - b], c * 4);
- }
- return out;
-}
-
-// A curve that comes back folded, or wandered far from the value it was handed,
-// is a fit off blocks that fought each other, not a rendering: the caller opens
-// the frame as the sensor left it instead. The floor sits below the zero a curve
-// is allowed to reach — the develop clamps there anyway — and the slope is only
-// asked not to turn back.
-function curveIsSane(tone: Float32Array): boolean {
- for (const v of tone) if (!Number.isFinite(v) || Math.abs(v) > 4) return false;
- for (let c = 0; c < 3; c++) {
- const [a, b, cc, dd] = [tone[c * 4], tone[c * 4 + 1], tone[c * 4 + 2], tone[c * 4 + 3]];
- let prev = a;
- for (let i = 1; i <= 16; i++) {
- const x = i / 16;
- const y = a + x * (b + x * (cc + x * dd));
- if (y < prev - 0.12 || y < -0.15 || y > 1.15) return false;
- prev = y;
- }
- }
- return true;
-}
-
-// `dev` and `ref` are n x n RGBA maps of the same frame, one byte per channel,
-// both sRGB-encoded by the same resampler. Null when the two cannot be related —
-// too few blocks carry colour — and the caller develops the frame as the sensor
-// left it.
-export function toneMatch(dev: Uint8Array, ref: Uint8Array, n = MATCH_GRID): Float32Array | null {
- const blocks: { x: number[]; t: number[] }[] = [];
- let used = 0;
- for (let i = 0; i < n * n; i++) {
- const x = [dev[i * 4] / 255, dev[i * 4 + 1] / 255, dev[i * 4 + 2] / 255];
- const t = [ref[i * 4] / 255, ref[i * 4 + 1] / 255, ref[i * 4 + 2] / 255];
- // A block the camera clipped has no colour left to copy, one at the floor is
- // only the pedestal, and one the develop clipped has lost the ratio between
- // its channels — none of them say anything about the rendering.
- if (t[0] > CLIP && t[1] > CLIP && t[2] > CLIP) continue;
- if (x[0] > CLIP && x[1] > CLIP && x[2] > CLIP) continue;
- if (Math.max(t[0], t[1], t[2]) < FLOOR) continue;
- used++;
- blocks.push({ x, t });
- }
- // A fit off a handful of blocks is a fit off noise.
- if (used < (n * n) / 8) return null;
- const tone = fitTone(blocks);
- return curveIsSane(tone) ? tone : null;
-}
diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts
index 6d4b46a..ac66b56 100644
--- a/docker/frontend/src/engine/rawDevelop.ts
+++ b/docker/frontend/src/engine/rawDevelop.ts
@@ -1,32 +1,36 @@
// RAW → JPEG on the client, so a camera's own file opens in the studio without
// a DNG converter in the middle (see native_raw_processing_opfs_architecture.md).
//
-// A RAW opens at the colour the camera chose for it. LibRaw is kept out of white
-// balance and tone (below) and knows nothing of the body's picture style, so the
-// develop is fitted to the one rendering the file does carry with it: the preview
-// the camera wrote inside it, which is the frame the photographer saw and the one
-// a desktop viewer shows. Develop the sensor, take a block grid out of each on
-// the encoded values, fit the cubic a channel between them, and develop the
-// sensor through that curve (see previewMatch.ts).
+// A RAW opens at the colour the camera chose for it, and at the sensor's own
+// resolution. LibRaw is kept out of white balance and tone (below) and knows
+// nothing of the body's picture style; the one rendering the file does carry with
+// it is the preview the camera wrote inside it — the frame the photographer saw
+// and the one a desktop viewer shows.
//
-// The curve is the whole fit. A 3x3 was least squared ahead of it for a long
-// time and is what put the olive cast on the Olympus frame this was reported on:
-// a develop keeps its luminance and its colour in the same three numbers and the
-// luminance is two orders of magnitude the larger, so a least squares dominated
-// by it holds the channel means and buys them with the colour axis. The camera's
-// picture style is a curve, not a matrix.
+// The develop is carried by the difference between that preview and this develop's
+// own rendering of the same frame. The difference is kept at the preview's own
+// resolution, one byte a channel with 0.5 for a pixel the two already agree on,
+// and added back on the value the develop leaves: the frame opens at the camera's
+// colour and at the sensor's resolution, and the develop is only ever asked for
+// the detail the preview never had. Nothing is fitted — a body is matched against
+// its own preview the moment it opens, whatever its picture style is, and there is
+// no table to go stale (see previewResidual below).
//
-// Measured on that frame, standard deviation of R-G over it (the file's own
-// preview 8.3, the camera's own JPEG 12.3, an olive frame being one where this
-// has gone to nothing): 11.6 as the develop left it, 11.9 through the curve
-// alone, 4.6 through the curve under the fitted 3x3. What no per-pixel transform
-// reaches either way is the camera's own sharpening.
+// It replaced a per-channel cubic fitted between a grid of each, and a 3x3 least
+// squared ahead of that curve before it, neither of which is the camera's
+// rendering: a picture style is a colour transform with a curve in it. Measured
+// against the file's own preview, mean dE00: 6.04 for the cubic on the Fujifilm
+// RAF this was reported on, 0.54 for the residual on that frame and 0.00 on every
+// body whose preview is the size of its sensor (a Nikon NEF, a DNG), where the two
+// frames agree block for block and the residual is nothing.
//
-// Developing the sensor is also what keeps the highlights: 2.3% of the frame at
-// pure white against 3.3% in the preview itself, since the 8-bit preview threw
-// the headroom away and the develop still has it. The preview is therefore the
-// reference and the fallback, not the frame: it is what a file with no usable
-// preview (some DNG), or with sensor data that will not decode, opens as.
+// What the residual gives up is the headroom: it lands the frame on the preview's
+// own white, so the two stops the sensor holds above white are not kept (the cubic
+// kept 2.3% of the frame at pure white against 3.3% in the preview itself). A
+// highlight recovery that needs them has to come from the develop, not from here.
+//
+// The preview is also the fallback, not the frame: it is what a file with no
+// usable preview (some DNG), or with sensor data that will not decode, opens as.
//
// The develop: LibRaw demosaics in its own worker; what comes back is linear camera data,
// which this file turns into the sRGB the rest of the pipeline expects. The
@@ -53,7 +57,7 @@
import LibRaw from 'libraw-wasm';
import { sensorWhite } from './sensorWhite';
import { f32ToF16 } from './halfFloat';
-import { toneMatch, FLAT_TONE, MATCH_GRID } from './previewMatch';
+import { MATCH_GRID } from './previewMatch';
import { Skia, initSkia } from './skiaShim';
import { decodeTiff } from './tiffDecode';
@@ -95,15 +99,8 @@ uniform float4 m0; // camera -> sRGB, the first three columns of rgb_cam
uniform float4 m1;
uniform float4 m2;
uniform float4 crop; // (y offset of this band, 0, 0, 0)
-uniform float4 t0; // the file's own rendering, one cubic a channel on the
-uniform float4 t1; // encoded value: r a b c d per float4 (flat when there is
-uniform float4 t2; // no preview to fit to)
-
-// The camera's rendering is a shape, and this is that shape, read on the value
-// the develop left.
-float tone(float4 w, float x) {
- return clamp(w.x + x * (w.y + x * (w.z + x * w.w)), 0.0, 1.0);
-}
+uniform shader res; // the file's own rendering, as a residual (see below)
+uniform float2 rscl; // frame pixels -> residual pixels
float3 encode(float3 x) {
x = clamp(x, 0.0, 1.0);
@@ -166,24 +163,33 @@ half4 main(float2 pos) {
rgb *= (0.7 + over / (1.0 + over * 3.3333)) / mx;
}
float3 e = encode(rgb);
- // The file's own colour: the curve, in float, on the encoded value it was fitted
- // on — and the last step the frame leaves through. Not the 8-bit colour filter
- // this used to be painted through: the curve carries an exposure (the preview is
- // the reference, so matching its brightness is part of matching its colour), and
- // it is what carries the body's own tone — a 3x3 can only scale, and the one that
- // used to sit here bought the channel means by collapsing the colour axis it was
- // fitted against (see previewMatch.ts). No rolloff: white is one of the values
- // the curve is fitted on, and it is pinned there, so a blown pixel still lands on
- // white while the frame's own highlights stay where the knee above left them.
- float3 o = float3(tone(t0, e.r), tone(t1, e.g), tone(t2, e.b));
- // ...and the neutral the gate above drew is what the curve undoes: one cubic a
- // channel, fitted on a grid that has no block left to fit where the frame ran out
- // (the blown blocks are excluded, see previewMatch), so at the plateau the three
- // curves agree only at 1.0 and part company either side of it — measured on the
- // ORF, a 255,255,255 sky came back 254,255,255 and 253,255,254, red under green
- // across a quarter of the frame. The gate is the develop's own statement that the
- // pixel had no colour of its own, so it is re-read here, on the value that leaves.
- return half4(half3(mix(o, float3(max(max(o.r, o.g), o.b)), blown)), 1.0);
+ // The neutral the gate above drew, kept on the way out.
+ float3 o = mix(e, float3(max(max(e.r, e.g), e.b)), blown);
+ // The file's own colour and tone, carried onto the develop at the develop's own
+ // resolution. res is the camera's rendering less this develop's own low
+ // frequencies, one byte per channel with 0.5 as nothing-to-say, so adding it
+ // puts the camera's rendering back on the frame and leaves everything the
+ // develop has that the preview never did — the sensor's own detail — in the
+ // frame. pos is already the frame's own coordinate (the band is drawn at its
+ // own y offset, so the same pos is band-local only because crop.x is taken back
+ // off for the raw tap above), so one scale is the whole mapping.
+ float2 rp = pos * rscl;
+ o = clamp(o + (res.eval(rp).rgb - 0.5), 0.0, 1.0);
+ return half4(half3(o), 1.0);
+}
+`;
+
+// The residual the develop above is carried by: the camera's own frame less the
+// same frame as the develop left it, one byte per channel, 0.5 for a pixel the
+// two already agree on. Both children are drawn at the size the residual is kept
+// at, so neither needs a local matrix, and a minus b has to be able to go
+// negative — hence the 0.5, which the develop takes back off.
+const RESIDUAL_SKSL = `
+uniform shader dev;
+uniform shader ref;
+
+half4 main(float2 p) {
+ return half4(half3(clamp(ref.eval(p).rgb - dev.eval(p).rgb + 0.5, 0.0, 1.0)), 1.0);
}
`;
@@ -467,19 +473,7 @@ function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Ui
// turn against the develop it was being fitted to, and the fit was rejected
// as folded (see previewMatch), which is why the Panasonic RW2 opened with no
// curve at all.
- const targetAspect = w / h;
- const bmpAspect = bmp.width() / bmp.height();
- let cropW = bmp.width();
- let cropH = bmp.height();
- if (Math.abs(bmpAspect - targetAspect) > 0.01) {
- if (bmpAspect > targetAspect) {
- cropW = Math.round(bmp.height() * targetAspect);
- } else {
- cropH = Math.round(bmp.width() / targetAspect);
- }
- }
- const cropX = Math.round((bmp.width() - cropW) / 2);
- const cropY = Math.round((bmp.height() - cropH) / 2);
+ const { x: cropX, y: cropY, w: cropW, h: cropH } = previewCrop(bmp, w, h);
const surface = Skia.Surface.MakeOffscreen(n, n) ?? Skia.Surface.Make(n, n);
if (!surface) return null;
@@ -506,6 +500,144 @@ function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Ui
}
}
+// The preview trimmed to the develop's own aspect, centred. What a body writes
+// into the file is the frame the photographer saw, and it is the frame the
+// sensor read; anything left over is a margin the develop does not have.
+function previewCrop(bmp: any, w: number, h: number): { x: number; y: number; w: number; h: number } {
+ const aspect = w / h;
+ let cw = bmp.width();
+ let ch = bmp.height();
+ if (Math.abs(cw / ch - aspect) > 0.01) {
+ if (cw / ch > aspect) cw = Math.round(ch * aspect);
+ else ch = Math.round(cw / aspect);
+ }
+ return { x: Math.round((bmp.width() - cw) / 2), y: Math.round((bmp.height() - ch) / 2), w: cw, h: ch };
+}
+
+// The residual the develop is carried by, and how many frame pixels one of its
+// own is worth.
+export interface PreviewResidual {
+ img: any;
+ sx: number;
+ sy: number;
+}
+
+// The residual's own long side. It is a smooth map by construction — it holds no
+// detail, that is the develop's half of the frame — so a preview wider than this
+// is reduced rather than carried whole: the colour it carries survives the
+// reduction, and the 24MP surface a NEF-sized preview would need does not.
+const RESIDUAL_MAX = 2048;
+
+// The one residual that says nothing: a pixel the camera's frame and this develop
+// already agree on, for a file whose preview gave nothing to carry.
+let NO_RESIDUAL: any = null;
+function noResidual(): any {
+ if (!NO_RESIDUAL) {
+ // 0.5 in half, alpha 1 — the value the develop takes 0.5 back off.
+ const half = new Uint16Array([0x3800, 0x3800, 0x3800, 0x3c00]);
+ NO_RESIDUAL = Skia.Image.MakeImage(
+ { width: 1, height: 1, colorType: Skia.ColorType.RGBA_F16, alphaType: Skia.AlphaType.Unpremul },
+ new Uint8Array(half.buffer),
+ 8
+ );
+ }
+ return NO_RESIDUAL;
+}
+
+// What the camera's own rendering has to say about this develop: the whole
+// difference between the two frames — the body's white balance, picture style,
+// tone curve and colour, whatever the body is — carried at the preview's
+// resolution as one byte a channel, 0.5 where the two already agree. The develop
+// adds it back on the value it left, so the frame opens at the colour the
+// photographer saw AND at the sensor's own resolution: the develop is only ever
+// asked for the detail the preview never had.
+//
+// A mapping was tried here first — one cubic a channel, and a 3x3 under it before
+// that — and neither is the camera's rendering: a picture style is a colour
+// transform with a curve in it, and the best a per-channel cubic fitted between
+// two grids could reach on the Fujifilm RAF this was reported on was dE00 6.04
+// against the file's own preview. The residual reaches 0.54 on the same frame and
+// 0.00 on every body that writes a full-size preview, because it is not a fit at
+// all — it is the difference itself.
+function previewResidual(first: any, jpeg: Uint8Array, w: number, h: number): PreviewResidual | null {
+ const bmp = Skia.Image.MakeImageFromEncoded(jpeg);
+ if (!bmp) return null;
+ let ref: any = null;
+ let dev: any = null;
+ let refShader: any = null;
+ let devShader: any = null;
+ let resShader: any = null;
+ let paint: any = null;
+ try {
+ const crop = previewCrop(bmp, w, h);
+ const cap = Math.min(1, RESIDUAL_MAX / Math.max(crop.w, crop.h));
+ const rw = Math.max(1, Math.round(crop.w * cap));
+ const rh = Math.max(1, Math.round(crop.h * cap));
+
+ // One small surface per frame, both at the residual's own size, so neither
+ // child needs a local matrix and the subtraction below is one tap for one tap.
+ const small = (draw: (canvas: any) => void): any => {
+ const surface = Skia.Surface.MakeOffscreen(rw, rh) ?? Skia.Surface.Make(rw, rh);
+ if (!surface) return null;
+ draw(surface.getCanvas());
+ surface.flush();
+ const shot = surface.makeImageSnapshot();
+ surface.dispose();
+ return shot;
+ };
+
+ ref = small((canvas) =>
+ canvas.drawImageRectCubic(
+ bmp,
+ Skia.XYWHRect(crop.x, crop.y, crop.w, crop.h),
+ Skia.XYWHRect(0, 0, rw, rh),
+ 1 / 3,
+ 1 / 3
+ )
+ );
+ dev = small((canvas) =>
+ canvas.drawImageRectCubic(
+ first,
+ Skia.XYWHRect(0, 0, w, h),
+ Skia.XYWHRect(0, 0, rw, rh),
+ 1 / 3,
+ 1 / 3
+ )
+ );
+ if (!ref || !dev) return null;
+
+ const effect = Skia.RuntimeEffect.Make(RESIDUAL_SKSL);
+ if (!effect) return null;
+ const tap = (img: any) =>
+ img.makeShaderOptions(
+ Skia.TileMode.Clamp,
+ Skia.TileMode.Clamp,
+ Skia.FilterMode.Nearest,
+ Skia.MipmapMode.None
+ );
+ devShader = tap(dev);
+ refShader = tap(ref);
+ resShader = effect.makeShaderWithChildren(new Float32Array(0), [devShader, refShader]);
+ paint = Skia.Paint();
+ paint.setShader(resShader);
+ const out = small((canvas) => canvas.drawRect(Skia.XYWHRect(0, 0, rw, rh), paint));
+ // The residual is read back at the develop's own resolution by a bilinear
+ // tap, so it is kept smooth: one byte a channel is finer than the shading
+ // it carries, which is the whole difference between two renderings.
+ return out ? { img: out, sx: rw / w, sy: rh / h } : null;
+ } catch {
+ return null;
+ } finally {
+ bmp.delete();
+ ref?.delete();
+ dev?.delete();
+ refShader?.delete();
+ devShader?.delete();
+ resShader?.delete();
+ paint?.delete();
+ }
+}
+
// The camera -> sRGB matrix, applied to the WB'd sensor triple.
//
// `rgb_cam` is dcraw's own: it is the camera -> sRGB transform already, its rows
@@ -783,14 +915,23 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise<
const bandH = Math.max(1, Math.min(h, Math.floor(BAND_PIXELS / w)));
const f32 = new Float32Array(w * bandH * 4);
const half = new Uint16Array(w * bandH * 4);
- const uniforms = new Float32Array(33);
+ // gain, mul, m0, m1, m2, crop (y offset of the band), then where the residual
+ // has to be read for the frame pixel this one draws.
+ const uniforms = new Float32Array(23);
uniforms[0] = SAMPLE_MAX / white;
uniforms.set([mul[0], mul[1], mul[2], 0, r0[0], r0[1], r0[2], 0, r1[0], r1[1], r1[2], 0, r2[0], r2[1], r2[2], 0], 1);
- const develop = (tone: Float32Array | null) => {
+ const develop = (residual: PreviewResidual | null) => {
const surface = Skia.Surface.MakeOffscreen(w, h) ?? Skia.Surface.Make(w, h);
if (!surface) return null;
- uniforms.set(tone ?? FLAT_TONE, 21);
+ uniforms[21] = residual ? residual.sx : 0;
+ uniforms[22] = residual ? residual.sy : 0;
+ const resChild = (residual?.img ?? noResidual()).makeShaderOptions(
+ Skia.TileMode.Clamp,
+ Skia.TileMode.Clamp,
+ Skia.FilterMode.Linear,
+ Skia.MipmapMode.None
+ );
for (let y0 = 0; y0 < h; y0 += bandH) {
const rows = Math.min(bandH, h - y0);
let o = 0;
@@ -814,7 +955,7 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise<
Skia.MipmapMode.None
);
uniforms[17] = y0;
- const shader = effect.makeShaderWithChildren(uniforms, [child]);
+ const shader = effect.makeShaderWithChildren(uniforms, [child, resChild]);
const paint = Skia.Paint();
paint.setShader(shader);
surface.getCanvas().drawRect(Skia.XYWHRect(0, y0, w, rows), paint);
@@ -824,6 +965,7 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise<
child.delete();
band.delete();
}
+ resChild.delete();
const shot = surface.makeImageSnapshot();
surface.dispose();
@@ -839,22 +981,17 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise<
refJpeg = await cameraPreview(raw).catch(() => null);
}
- if (refJpeg) {
- try {
- const devGrid = gridOf(first, MATCH_GRID);
- const matchGrid = refGrid ?? previewGrid(refJpeg, w, h, MATCH_GRID);
- if (devGrid && matchGrid) {
- const fittedTone = toneMatch(devGrid, matchGrid, MATCH_GRID);
- if (fittedTone) {
- const second = develop(fittedTone);
- if (second) {
- finalShot = second;
- first.dispose();
- }
- }
- }
- } catch {
- // Keep first develop if tone matching fails
+ // The file's own rendering, carried onto this develop: what the camera wrote
+ // into the frame minus what the develop made of the same frame at that
+ // resolution, added back at the sensor's own. Nothing to fit and nothing to
+ // table — a body is matched against its own preview the moment it opens.
+ const residual = refJpeg ? previewResidual(first, refJpeg, w, h) : null;
+ if (residual) {
+ const second = develop(residual);
+ residual.img.delete();
+ if (second) {
+ finalShot = second;
+ first.dispose();
}
}
diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts
index c7613a0..0f141ef 100644
--- a/docker/frontend/src/i18n/en.ts
+++ b/docker/frontend/src/i18n/en.ts
@@ -95,6 +95,19 @@ export const en: Dict = {
'frame.wallLand': 'WALL FRAME LANDSCAPE',
'frame.oldFilm': 'OLD FILM LANDSCAPE',
'frame.oldFilmPort': 'OLD FILM PORTRAIT',
+ 'frame.custom': 'CUSTOM FRAME',
+ 'frame.import': '+ IMPORT FRAME',
+ 'frame.customTitle': 'Custom Transparent Frame',
+ 'frame.zoom': 'Zoom / Scale',
+ 'frame.panX': 'Pan X',
+ 'frame.panY': 'Pan Y',
+ 'frame.fit': 'Fit Window',
+ 'frame.fill': 'Fill Window',
+ 'frame.center': 'Center',
+ 'frame.replace': 'Change Frame',
+ 'frame.remove': 'Remove Frame',
+ 'frame.dragHint': 'Drag on photo to pan • Scroll wheel to zoom',
+ 'frame.analyzing': 'Detecting frame transparency...',
'crop.none': 'NO CROP',
'crop.1:1': '1:1',
@@ -376,7 +389,18 @@ export const en: Dict = {
'lib.immich.invalid': 'An address and a key are both needed.',
'lib.immich.empty': 'This key sees no album.',
'lib.immich.added': 'Read {n} frames from {folder}.',
+ 'lib.immich.reading': 'Reading {folder}, still reading…',
'lib.immich.hint': 'Right-click to read again, choose albums, disconnect or remove it from the library.',
+ 'lib.immich.links': 'Saved cloud links',
+ 'lib.immich.dup': 'same address — the library is read twice',
+ 'lib.immich.linkForget': 'REMOVE THE LINK',
+ 'immich.upload': 'Upload to Immich',
+ 'immich.uploadTitle': 'Upload edited photo to Immich Cloud',
+ 'immich.uploading': 'Uploading…',
+ 'immich.uploadSuccess': 'Successfully uploaded photo to Immich Cloud!',
+ 'immich.uploadFailed': 'Failed to upload to Immich Cloud: {msg}',
+ 'immich.uploadToCloud': 'Upload to Immich Cloud',
+ 'immich.noServer': 'No Immich Cloud server connected',
// Mobile Cloud Library
'lib.mobile.photos': 'Photos',
diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts
index dc33c08..71c3b41 100644
--- a/docker/frontend/src/i18n/vi.ts
+++ b/docker/frontend/src/i18n/vi.ts
@@ -107,6 +107,19 @@ export const vi = {
'frame.wallLand': 'WALL FRAME LANDSCAPE',
'frame.oldFilm': 'OLD FILM LANDSCAPE',
'frame.oldFilmPort': 'OLD FILM PORTRAIT',
+ 'frame.custom': 'KHUNG TỰ CHỌN',
+ 'frame.import': '+ THÊM KHUNG',
+ 'frame.customTitle': 'Khung tự thêm (Transparent Frame)',
+ 'frame.zoom': 'Thu phóng',
+ 'frame.panX': 'Dịch ngang',
+ 'frame.panY': 'Dịch dọc',
+ 'frame.fit': 'Vừa khung',
+ 'frame.fill': 'Lấp đầy',
+ 'frame.center': 'Căn giữa',
+ 'frame.replace': 'Đổi khung',
+ 'frame.remove': 'Bỏ khung',
+ 'frame.dragHint': 'Kéo trên ảnh để di chuyển • Cuộn chuột để phóng to/thu nhỏ',
+ 'frame.analyzing': 'Đang phân tích vùng trong suốt của khung...',
'crop.none': 'KHÔNG CẮT',
'crop.1:1': '1:1',
@@ -388,7 +401,18 @@ export const vi = {
'lib.immich.invalid': 'Cần có địa chỉ và khoá.',
'lib.immich.empty': 'Khoá này không thấy album nào.',
'lib.immich.added': 'Đã đọc {n} ảnh từ {folder}.',
+ 'lib.immich.reading': 'Đang đọc {folder}, còn đọc tiếp…',
'lib.immich.hint': 'Chuột phải để đọc lại, chọn album, ngắt kết nối hoặc xoá khỏi thư viện.',
+ 'lib.immich.links': 'Liên kết cloud đã lưu',
+ 'lib.immich.dup': 'trùng địa chỉ — thư viện bị đọc hai lần',
+ 'lib.immich.linkForget': 'XOÁ LIÊN KẾT',
+ 'immich.upload': 'Lưu lên Immich',
+ 'immich.uploadTitle': 'Tải ảnh đã sửa lên Immich Cloud',
+ 'immich.uploading': 'Đang tải lên…',
+ 'immich.uploadSuccess': 'Đã tải ảnh lên Immich Cloud thành công!',
+ 'immich.uploadFailed': 'Tải lên Immich Cloud thất bại: {msg}',
+ 'immich.uploadToCloud': 'Tải lên Immich Cloud',
+ 'immich.noServer': 'Chưa kết nối máy chủ Immich Cloud',
// Mobile Cloud Library
'lib.mobile.photos': 'Ảnh',
diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css
index b883277..2c62e06 100644
--- a/docker/frontend/src/styles/app.css
+++ b/docker/frontend/src/styles/app.css
@@ -2342,128 +2342,402 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track-
}
}
-.workspace.is-mobile {
- flex-direction: column !important;
-}
-.workspace.is-mobile:not(.strip-open) .chips {
- display: none !important;
-}
-.workspace.is-mobile.strip-open .rail {
- display: none !important;
-}
-.workspace.is-mobile .stage-slot {
- order: 0 !important;
- min-height: 0 !important;
-}
-.workspace.is-mobile .col[data-col="slider"] {
- order: 1 !important;
-}
-.workspace.is-mobile .col-sub {
- order: 2 !important;
-}
-.workspace.is-mobile .col-main {
- order: 3 !important;
-}
-.workspace.is-mobile .rail {
- order: 4 !important;
- width: 100% !important;
- max-width: 100vw !important;
- flex-direction: row !important;
- overflow-x: auto !important;
- overflow-y: hidden !important;
-}
-.workspace.is-mobile .chips {
- display: contents !important;
-}
-.workspace.is-mobile .col,
-.workspace.is-mobile .col-main,
-.workspace.is-mobile .col-sub,
-.workspace.is-mobile .col[data-col="slider"],
-.workspace.is-mobile .col-create,
-.workspace.is-mobile .col-slider {
- width: 100% !important;
- max-width: 100vw !important;
- min-width: 100% !important;
- box-sizing: border-box !important;
- border-right: none !important;
-}
-.workspace.is-mobile .col-main {
- flex-direction: row !important;
- align-items: center !important;
- gap: 6px !important;
- max-height: 46vh !important;
- overflow-x: auto !important;
- overflow-y: hidden !important;
- background: var(--bg) !important;
- border-top: 1px solid var(--border-soft) !important;
- padding: 6px 8px !important;
-}
-.workspace.is-mobile .col-main > *,
-.workspace.is-mobile .col-sub:not(.wm-extra) > * {
- flex: 0 0 auto !important;
-}
-.workspace.is-mobile .col-main:not(.col-create) .chip-row,
-.workspace.is-mobile .col-sub:not(.wm-extra) .chip-row {
- min-width: 100% !important;
- width: max-content !important;
- flex-wrap: nowrap !important;
- flex-direction: row !important;
- align-items: center !important;
-}
-.workspace.is-mobile .col-grip {
- display: none !important;
-}
-.workspace.is-mobile .save-hint {
- display: none !important;
-}
-.workspace.is-mobile .btn-save-action {
- flex: 0 0 auto !important;
- white-space: nowrap !important;
-}
-.workspace.is-mobile .col-main:has(.saved-list) {
- flex-direction: row !important;
- align-items: center !important;
- gap: 10px !important;
- width: 100% !important;
- max-width: 100vw !important;
- overflow-x: auto !important;
- overflow-y: hidden !important;
- flex-wrap: nowrap !important;
-}
-.workspace.is-mobile .saved-list {
- display: flex !important;
- flex-direction: row !important;
- align-items: center !important;
- gap: 12px !important;
- overflow-x: auto !important;
- overflow-y: hidden !important;
- flex-wrap: nowrap !important;
- flex: 0 0 auto !important;
-}
-.workspace.is-mobile .saved-row {
- display: inline-flex !important;
- align-items: center !important;
- gap: 4px !important;
- flex: 0 0 auto !important;
- white-space: nowrap !important;
- width: auto !important;
-}
-.workspace.is-mobile .saved-row .link-chip {
- padding: 6px 4px !important;
- white-space: nowrap !important;
-}
-.workspace.is-mobile .col-sub[data-col="options"],
-.workspace.is-mobile .col-sub[data-col="wm"],
-.workspace.is-mobile .col-sub[data-col="recipes"] {
- order: 3 !important;
- background: var(--bg) !important;
- max-height: 46vh !important;
-}
-.workspace.is-mobile:has(.chips > .col-sub[data-col="options"]) .col-main,
-.workspace.is-mobile:has(.chips > .col-sub[data-col="wm"]) .col-main,
-.workspace.is-mobile:has(.chips > .col-sub[data-col="recipes"]) .col-main,
-.workspace.is-mobile:has(.chips > .col-sub[data-col="history"]) .col-main,
-.workspace.is-mobile:has(.chips > .col-sub[data-col="mask"]) .col-main {
- display: none !important;
+@media (max-width: 860px) {
+ .workspace.is-mobile {
+ flex-direction: column !important;
+ }
+ .workspace.is-mobile:not(.strip-open) .chips {
+ display: none !important;
+ }
+ .workspace.is-mobile.strip-open .rail {
+ display: none !important;
+ }
+ .workspace.is-mobile .stage-slot {
+ order: 0 !important;
+ min-height: 0 !important;
+ }
+ .workspace.is-mobile .col[data-col="slider"] {
+ order: 1 !important;
+ }
+ .workspace.is-mobile .col-sub {
+ order: 2 !important;
+ }
+ .workspace.is-mobile .col-main {
+ order: 3 !important;
+ }
+ .workspace.is-mobile .rail {
+ order: 4 !important;
+ width: 100% !important;
+ max-width: 100vw !important;
+ flex-direction: row !important;
+ overflow-x: auto !important;
+ overflow-y: hidden !important;
+ }
+ .workspace.is-mobile .chips {
+ display: contents !important;
+ }
+ .workspace.is-mobile .col,
+ .workspace.is-mobile .col-main,
+ .workspace.is-mobile .col-sub,
+ .workspace.is-mobile .col[data-col="slider"],
+ .workspace.is-mobile .col-create,
+ .workspace.is-mobile .col-slider {
+ width: 100% !important;
+ max-width: 100vw !important;
+ min-width: 100% !important;
+ box-sizing: border-box !important;
+ border-right: none !important;
+ }
+ .workspace.is-mobile .col-main {
+ flex-direction: row !important;
+ align-items: center !important;
+ gap: 6px !important;
+ max-height: 46vh !important;
+ overflow-x: auto !important;
+ overflow-y: hidden !important;
+ background: var(--bg) !important;
+ border-top: 1px solid var(--border-soft) !important;
+ padding: 6px 8px !important;
+ }
+ .workspace.is-mobile .col-main > *,
+ .workspace.is-mobile .col-sub:not(.wm-extra) > * {
+ flex: 0 0 auto !important;
+ }
+ .workspace.is-mobile .col-main:not(.col-create) .chip-row,
+ .workspace.is-mobile .col-sub:not(.wm-extra) .chip-row {
+ min-width: 100% !important;
+ width: max-content !important;
+ flex-wrap: nowrap !important;
+ flex-direction: row !important;
+ align-items: center !important;
+ }
+ .workspace.is-mobile .col-grip {
+ display: none !important;
+ }
+ .workspace.is-mobile .save-hint {
+ display: none !important;
+ }
+ .workspace.is-mobile .btn-save-action {
+ flex: 0 0 auto !important;
+ white-space: nowrap !important;
+ }
+ .workspace.is-mobile .col-main:has(.saved-list) {
+ flex-direction: row !important;
+ align-items: center !important;
+ gap: 10px !important;
+ width: 100% !important;
+ max-width: 100vw !important;
+ overflow-x: auto !important;
+ overflow-y: hidden !important;
+ flex-wrap: nowrap !important;
+ }
+ .workspace.is-mobile .saved-list {
+ display: flex !important;
+ flex-direction: row !important;
+ align-items: center !important;
+ gap: 12px !important;
+ overflow-x: auto !important;
+ overflow-y: hidden !important;
+ flex-wrap: nowrap !important;
+ flex: 0 0 auto !important;
+ }
+ .workspace.is-mobile .saved-row {
+ display: inline-flex !important;
+ align-items: center !important;
+ gap: 4px !important;
+ flex: 0 0 auto !important;
+ white-space: nowrap !important;
+ width: auto !important;
+ }
+ .workspace.is-mobile .saved-row .link-chip {
+ padding: 6px 4px !important;
+ white-space: nowrap !important;
+ }
+ .workspace.is-mobile .col-sub[data-col="options"],
+ .workspace.is-mobile .col-sub[data-col="wm"],
+ .workspace.is-mobile .col-sub[data-col="recipes"] {
+ order: 3 !important;
+ background: var(--bg) !important;
+ max-height: 46vh !important;
+ }
+ .workspace.is-mobile:has(.chips > .col-sub[data-col="options"]) .col-main,
+ .workspace.is-mobile:has(.chips > .col-sub[data-col="wm"]) .col-main,
+ .workspace.is-mobile:has(.chips > .col-sub[data-col="recipes"]) .col-main,
+ .workspace.is-mobile:has(.chips > .col-sub[data-col="history"]) .col-main,
+ .workspace.is-mobile:has(.chips > .col-sub[data-col="custom-frame"]) .col-main,
+ .workspace.is-mobile:has(.chips > .col-sub[data-col="mask"]) .col-main {
+ display: none !important;
+ }
}
+@media (min-width: 861px) {
+ .workspace {
+ flex-direction: row !important;
+ }
+ .workspace .rail {
+ order: 0 !important;
+ width: var(--rail-w) !important;
+ max-width: var(--rail-w) !important;
+ min-width: var(--rail-w) !important;
+ flex-direction: column !important;
+ align-items: stretch !important;
+ overflow-y: auto !important;
+ overflow-x: hidden !important;
+ border-right: 1px solid var(--border-soft) !important;
+ border-top: none !important;
+ padding: 6px !important;
+ gap: 4px !important;
+ }
+ .workspace .rail button {
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
+ min-height: auto !important;
+ border-radius: var(--radius-sm) !important;
+ border: 1px solid transparent !important;
+ background: transparent !important;
+ padding: 9px 4px !important;
+ font-size: 10px !important;
+ text-transform: none !important;
+ }
+ .workspace .rail button[aria-pressed="true"] {
+ background: var(--accent-soft) !important;
+ color: var(--accent) !important;
+ border-color: var(--accent-line) !important;
+ }
+ .workspace .rail button .glyph {
+ display: inline-block !important;
+ font-size: 17px !important;
+ line-height: 1 !important;
+ }
+ .workspace .rail-version {
+ display: block !important;
+ }
+ .workspace .chips {
+ display: flex !important;
+ }
+}
+
+.workspace:has(.chips > .col-sub[data-col="custom-frame"]) .col-main {
+ display: none;
+}
+
+/* --- Custom Frame Controls Column & Hints --- */
+.custom-frame-controls-col {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 12px;
+ width: 250px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+.custom-frame-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border-soft, rgba(255, 255, 255, 0.1));
+}
+
+.custom-frame-thumb-wrap {
+ width: 48px;
+ height: 48px;
+ border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ overflow: hidden;
+ flex-shrink: 0;
+ background-image: linear-gradient(45deg, #27272a 25%, transparent 25%),
+ linear-gradient(-45deg, #27272a 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #27272a 75%),
+ linear-gradient(-45deg, transparent 75%, #27272a 75%);
+ background-size: 8px 8px;
+ background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
+ background-color: #18181b;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.custom-frame-thumb {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+}
+
+.custom-frame-meta {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1;
+}
+
+.custom-frame-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--fg, #f4f4f5);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.custom-frame-res {
+ font-size: 11px;
+ color: var(--muted, #a1a1aa);
+}
+
+.custom-frame-action-btns {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.custom-frame-action-btns .btn {
+ flex: 1;
+ min-width: 60px;
+ padding: 6px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ text-align: center;
+ white-space: nowrap;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ color: var(--fg, #f4f4f5);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.custom-frame-action-btns .btn:hover {
+ background: rgba(255, 255, 255, 0.16);
+ border-color: rgba(255, 255, 255, 0.3);
+}
+
+.custom-frame-slider-group {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.custom-frame-slider-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--muted, #a1a1aa);
+}
+
+.custom-frame-slider-header .val {
+ color: var(--accent, #f59e0b);
+ font-variant-numeric: tabular-nums;
+}
+
+.custom-frame-slider-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.custom-frame-range {
+ flex: 1;
+ accent-color: var(--accent, #f59e0b);
+ height: 4px;
+ border-radius: 2px;
+ cursor: pointer;
+}
+
+.btn-tiny {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: var(--fg, #f4f4f5);
+ border-radius: 4px;
+ width: 24px;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ padding: 0;
+ flex-shrink: 0;
+}
+
+.btn-tiny:hover {
+ background: rgba(255, 255, 255, 0.18);
+}
+
+.custom-frame-footer-btns {
+ display: flex;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+.custom-frame-footer-btns .btn {
+ flex: 1;
+ padding: 6px 10px;
+ font-size: 11px;
+ font-weight: 600;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.custom-frame-footer-btns .btn-danger {
+ background: rgba(239, 68, 68, 0.15);
+ border: 1px solid rgba(239, 68, 68, 0.4);
+ color: #f87171;
+}
+
+.custom-frame-footer-btns .btn-danger:hover {
+ background: rgba(239, 68, 68, 0.25);
+}
+
+.custom-frame-hint {
+ font-size: 11px;
+ line-height: 1.4;
+ color: #71717a;
+ margin: 4px 0 0;
+ border-top: 1px dashed rgba(255, 255, 255, 0.1);
+ padding-top: 8px;
+}
+
+/* Floating on-stage drag & zoom guidance pill */
+.custom-frame-stage-hint {
+ position: absolute;
+ top: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: rgba(18, 18, 22, 0.85);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 20px;
+ padding: 6px 16px;
+ font-size: 12px;
+ font-weight: 500;
+ color: #f4f4f5;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+ pointer-events: none;
+ z-index: 25;
+ white-space: nowrap;
+ animation: fadeInDown 0.3s ease-out;
+}
+
+@keyframes fadeInDown {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -10px);
+ }
+ to {
+ opacity: 1;
+ transform: translate(-50%, 0);
+ }
+}
+
+
diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx
index 12fe6ea..341f908 100644
--- a/docker/frontend/src/ui/ChipColumn.tsx
+++ b/docker/frontend/src/ui/ChipColumn.tsx
@@ -146,7 +146,7 @@ export function ColGrip({ col, min = 110, max = 560 }: { col: string; min?: numb
const ref = useRef(null);
const key = `studio-col-${col}`;
useEffect(() => {
- if (window.innerWidth <= 860 || localStorage.getItem('rc_force_view') === 'mobile') {
+ if (window.innerWidth <= 860) {
const box = ref.current?.parentElement;
if (box && box.style.width) box.style.width = '';
return;
diff --git a/docker/frontend/src/ui/ExportModal.tsx b/docker/frontend/src/ui/ExportModal.tsx
index 543f948..67dcb70 100644
--- a/docker/frontend/src/ui/ExportModal.tsx
+++ b/docker/frontend/src/ui/ExportModal.tsx
@@ -15,6 +15,8 @@ export function ExportModal({
onPick,
onShare,
onClose,
+ hasImmich,
+ onUploadImmich,
}: {
/** Longest edge of the photo as it stands, crop included. */
longest: number;
@@ -26,6 +28,8 @@ export function ExportModal({
bought for nothing. */
onShare: (site: ShareSite) => void;
onClose: () => void;
+ hasImmich?: boolean;
+ onUploadImmich?: (target?: number | null) => void;
}) {
const { t } = useI18n();
const [custom, setCustom] = useState(String(Math.min(CUSTOM_MAX, Math.max(1024, longest * 2))));
@@ -113,6 +117,36 @@ export function ExportModal({
{t('export.shareHint')}
+ {hasImmich && onUploadImmich ? (
+
+
+
+ ) : null}
diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx
index 7d01773..fc544c7 100644
--- a/docker/frontend/src/ui/ImageStage.tsx
+++ b/docker/frontend/src/ui/ImageStage.tsx
@@ -11,6 +11,7 @@ import { MASK_DEFAULT_FEATHER, MASK_MIN } from '../../shared/utils/gradientMask'
import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush';
import { measureMark } from '../engine/exportEngine';
import { Histogram } from './Histogram';
+import type { CustomFrameData } from '../engine/customFrame';
// One wheel notch, and how far the photo may be pulled open.
const ZOOM_STEP = 1.15;
@@ -292,6 +293,9 @@ export function ImageStage({
frameId = 'none',
framePanX = 0,
onFramePanXChange,
+ customFrame = null,
+ onCustomFramePan,
+ onCustomFrameZoom,
}: {
previewUrl: string | null;
originalUrl: string | null;
@@ -391,6 +395,9 @@ export function ImageStage({
frameId?: FrameId;
framePanX?: number;
onFramePanXChange?: (panX: number) => void;
+ customFrame?: CustomFrameData | null;
+ onCustomFramePan?: (dx: number, dy: number) => void;
+ onCustomFrameZoom?: (delta: number) => void;
}) {
const showStraighten = !!straightenRuler;
// The curve graph is a fresh node on every parent render too, and the measure
@@ -414,6 +421,7 @@ export function ImageStage({
const viewRef = useRef(view);
viewRef.current = view;
const panRef = useRef<{ px: number; py: number } | null>(null);
+ const customFrameDragRef = useRef<{ px: number; py: number } | null>(null);
// The space bar is the hand the stage is moved with; the brush's own layer
// swallows the pointer that would otherwise start that pan, so the key is
// watched here for the layer to read.
@@ -944,6 +952,10 @@ export function ImageStage({
const onWheel = (e: WheelEvent) => {
if (!imgRef.current) return;
e.preventDefault();
+ if (frameId === 'custom' && onCustomFrameZoom) {
+ onCustomFrameZoom(e.deltaY < 0 ? 1.05 : 0.95);
+ return;
+ }
if (cropEditing) {
wheelCrop(e.deltaY < 0);
return;
@@ -952,7 +964,7 @@ export function ImageStage({
};
wrap.addEventListener('wheel', onWheel, { passive: false });
return () => wrap.removeEventListener('wheel', onWheel);
- }, [cropEditing, previewUrl, wheelCrop, zoomAt]);
+ }, [cropEditing, previewUrl, wheelCrop, zoomAt, frameId, onCustomFrameZoom]);
// The same wheel, where the brush's layer has taken it: sized when a modifier
// is held, and the stage's zoom otherwise. `box` is a dependency because the
@@ -1230,7 +1242,15 @@ export function ImageStage({
};
const startPan = (e: React.PointerEvent) => {
- if (cropEditing || compare || viewRef.current.s <= 1 || e.button !== 0) return;
+ if (cropEditing || compare || e.button !== 0) return;
+ if (frameId === 'custom' && onCustomFramePan) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.currentTarget.setPointerCapture(e.pointerId);
+ customFrameDragRef.current = { px: e.clientX, py: e.clientY };
+ return;
+ }
+ if (viewRef.current.s <= 1) return;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
@@ -1238,6 +1258,19 @@ export function ImageStage({
};
const movePan = (e: React.PointerEvent) => {
+ if (frameId === 'custom' && customFrameDragRef.current && onCustomFramePan) {
+ e.preventDefault();
+ const p = customFrameDragRef.current;
+ const dx = e.clientX - p.px;
+ const dy = e.clientY - p.py;
+ const img = imgRef.current;
+ const factor = img && customFrame?.width && img.offsetWidth > 0
+ ? customFrame.width / img.offsetWidth
+ : 1.5;
+ onCustomFramePan(dx * factor, dy * factor);
+ customFrameDragRef.current = { px: e.clientX, py: e.clientY };
+ return;
+ }
const p = panRef.current;
if (!p) return;
e.preventDefault();
@@ -1247,6 +1280,11 @@ export function ImageStage({
};
const endPan = (e: React.PointerEvent) => {
+ if (customFrameDragRef.current) {
+ customFrameDragRef.current = null;
+ if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
+ return;
+ }
if (!panRef.current) return;
panRef.current = null;
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
@@ -1498,14 +1536,22 @@ export function ImageStage({
setView({ s, ...clampPan(s, 0, 0) });
}}
style={
- view.s === 1
- ? undefined
- : {
- transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
- cursor: cropEditing ? undefined : 'grab',
- }
+ frameId === 'custom'
+ ? { cursor: 'grab' }
+ : view.s === 1
+ ? undefined
+ : {
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
+ cursor: cropEditing ? undefined : 'grab',
+ }
}
/>
+ {frameId === 'custom' ? (
+
+ 🖐️ Kéo để di chuyển ảnh
+ 🔍 Lăn chuột để thu phóng
+
+ ) : null}
{/* Eyedropper. Sits over the photo so a click reads the colour under
the cursor; it swallows the pointer so the pan gesture never
starts. */}
diff --git a/docker/frontend/src/ui/ImmichDialog.tsx b/docker/frontend/src/ui/ImmichDialog.tsx
index d9e308c..1836c4c 100644
--- a/docker/frontend/src/ui/ImmichDialog.tsx
+++ b/docker/frontend/src/ui/ImmichDialog.tsx
@@ -32,10 +32,14 @@ const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
export function ImmichDialog({
server,
+ servers = [],
+ onForget,
onSaved,
onClose,
}: {
server?: ImmichServer | null;
+ servers?: ImmichServer[];
+ onForget?: (id: string) => void;
onSaved: (saved: ImmichServer, isNew: boolean) => void;
onClose: () => void;
}) {
@@ -201,6 +205,35 @@ export function ImmichDialog({
) : (
<>
+ {/* What the account already reads by: one row per saved address, and
+ the address is what makes two rows one server. Two links to the
+ same Immich read the same library twice, so the row says so and
+ the way out is here rather than in a node's own menu — a duplicate
+ is a fact about the list, not about a folder. */}
+ {servers.length ? (
+
@@ -2102,7 +2132,7 @@ export function MobileLibrary({
type="button"
className="mob-lib-btn primary"
onClick={() => {
- setSelectedAlbum(null);
+ setSelectedAlbum(folder ? normPath(folder.name) : null);
setTab('photos');
}}
>
diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx
index 0205952..23df5b5 100644
--- a/docker/frontend/src/ui/TopBar.tsx
+++ b/docker/frontend/src/ui/TopBar.tsx
@@ -25,6 +25,9 @@ export function TopBar({
onAuth,
onSignup,
onLogout,
+ hasImmich,
+ uploadingImmich,
+ onUploadImmich,
}: {
recipeName: string;
// Saving into the account's own folder is the account's own act, so the
@@ -48,6 +51,9 @@ export function TopBar({
onAuth: () => void;
onSignup: () => void;
onLogout: () => void;
+ hasImmich?: boolean;
+ uploadingImmich?: boolean;
+ onUploadImmich?: () => void;
}) {
const { t, lang, setLang } = useI18n();
const { mode, accent, fonts, setMode, setAccent, setFonts } = useTheme();
@@ -268,10 +274,39 @@ export function TopBar({
) : null}
+ {/* Immich Cloud Upload button (shown only if registered user has configured Immich Cloud) */}
+ {hasImmich && onUploadImmich ? (
+
+ ) : null}
+
{/* Last in the row on purpose: EXPORT is the end of the job, so it sits at
the end of the bar — the one button the eye lands on when the edit is
done. */}
-