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

- RAW opens at the colour the camera wrote into it: the develop carries the
  difference against the file's own preview (previewResidual) instead of the
  per-channel cubic fit, dE00 6.04 -> 0.54; previewMatch.ts keeps only the grid.
- Immich reading: keyset position kept across visits, one reading per node,
  a page in flight retried, timeline buckets for the years on offer.
- The wall draws a server frame at the address the proxy serves it (no blob
  minted for it), and the 80-thumbnail pre-cache on open is gone.
- Settings lists the cloud links, flags two that answer at the same address,
  and forgets one.
- Custom frames imported from a file (engine/customFrame.ts, IndexedDB).
- Upload an edited frame from Studio to Immich Cloud.
This commit is contained in:
2026-10-12 07:59:24 +07:00
parent 807c271b6b
commit 97fd181cfa
25 changed files with 2402 additions and 616 deletions
+115 -2
View File
@@ -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<string, string | undefined>;
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<any>(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<typeof Readable.fromWeb>[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<string, string | undefined>;
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) });
}
}
);
}
+7 -3
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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');
+41 -32
View File
@@ -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
+2 -1
View File
@@ -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
+440 -10
View File
@@ -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<Recipe | null>(null);
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
const [framePanX, setFramePanX] = useState<number>(() => session.framePanX ?? 0);
const [customFrame, setCustomFrame] = useState<CustomFrameData | null>(null);
const frameFileInputRef = useRef<HTMLInputElement>(null);
const saveCustomFrameTimer = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
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<CropRatio>(() => session.crop ?? 'none');
const [cropRect, setCropRect] = useState<CropRect>(() => 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<ImmichServer[]>([]);
const [uploadingImmich, setUploadingImmich] = useState(false);
const [toast, setToast] = useState<string | null>(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<boolean> => {
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 && (
<div
style={{
position: 'fixed',
top: '56px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(24, 24, 27, 0.95)',
border: '1px solid #4285f4',
color: '#fff',
padding: '10px 20px',
borderRadius: '9999px',
fontSize: '13px',
fontWeight: 500,
zIndex: 9999,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>☁️</span>
<span>{toast}</span>
</div>
)}
{/* 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() {
</div>
) : null}
{/* CUSTOM FRAME controls column: thumbnail, fit/fill/center, scale & pan sliders, replace & delete */}
{tab === 'frame' && frameId === 'custom' && customFrame ? (
<div className="col col-sub strip custom-frame-controls-col" data-col="custom-frame">
<div className="custom-frame-header">
<div className="custom-frame-thumb-wrap">
<img src={customFrame.dataUrl} alt={customFrame.name} className="custom-frame-thumb" />
</div>
<div className="custom-frame-meta">
<div className="custom-frame-title">{customFrame.name}</div>
<div className="custom-frame-res">
{customFrame.width} × {customFrame.height}
</div>
</div>
</div>
<div className="custom-frame-action-btns">
<button
type="button"
className="btn"
onClick={() => {
if (!photoDims) {
updateCustomFrame((cf) => ({ ...cf, scale: 1, panX: 0, panY: 0 }));
return;
}
const pa = photoDims.w / photoDims.h;
const ha = (customFrame.hole.w * customFrame.width) / (customFrame.hole.h * customFrame.height);
const fitScale = Number(Math.min(1, pa > ha ? ha / pa : pa / ha).toFixed(3));
updateCustomFrame((cf) => ({ ...cf, scale: fitScale, panX: 0, panY: 0 }));
}}
>
{t('frame.fit')}
</button>
<button
type="button"
className="btn"
onClick={() => {
updateCustomFrame((cf) => ({ ...cf, scale: 1, panX: 0, panY: 0 }));
}}
>
{t('frame.fill')}
</button>
<button
type="button"
className="btn"
onClick={() => {
updateCustomFrame((cf) => ({ ...cf, panX: 0, panY: 0 }));
}}
>
{t('frame.center')}
</button>
</div>
<div className="custom-frame-slider-group">
<div className="custom-frame-slider-header">
<span className="lbl">{t('frame.zoom')}</span>
<span className="val">{Math.round(customFrame.scale * 100)}%</span>
</div>
<div className="custom-frame-slider-row">
<input
type="range"
className="custom-frame-range"
min="0.2"
max="4.0"
step="0.02"
value={customFrame.scale}
onChange={(e) => {
const sc = parseFloat(e.target.value);
updateCustomFrame((cf) => ({ ...cf, scale: sc }));
}}
/>
</div>
</div>
<div className="custom-frame-slider-group">
<div className="custom-frame-slider-header">
<span className="lbl">{t('frame.panX')}</span>
<span className="val">{Math.round(customFrame.panX)} px</span>
</div>
<div className="custom-frame-slider-row">
<input
type="range"
className="custom-frame-range"
min="-500"
max="500"
step="1"
value={customFrame.panX}
onChange={(e) => {
const px = parseFloat(e.target.value);
updateCustomFrame((cf) => ({ ...cf, panX: px }));
}}
/>
</div>
</div>
<div className="custom-frame-slider-group">
<div className="custom-frame-slider-header">
<span className="lbl">{t('frame.panY')}</span>
<span className="val">{Math.round(customFrame.panY)} px</span>
</div>
<div className="custom-frame-slider-row">
<input
type="range"
className="custom-frame-range"
min="-500"
max="500"
step="1"
value={customFrame.panY}
onChange={(e) => {
const py = parseFloat(e.target.value);
updateCustomFrame((cf) => ({ ...cf, panY: py }));
}}
/>
</div>
</div>
<p className="custom-frame-hint">{t('frame.dragHint')}</p>
<div className="custom-frame-footer-btns">
<button
type="button"
className="btn"
onClick={() => {
frameFileInputRef.current?.click();
}}
>
{t('frame.replace')}
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => void onRemoveCustomFrame()}
>
{t('frame.remove')}
</button>
</div>
</div>
) : null}
{/* Hidden input for importing custom frames */}
<input
type="file"
ref={frameFileInputRef}
accept="image/png,image/webp,image/svg+xml,image/*"
style={{ display: 'none' }}
onChange={(e) => void onImportFrameFile(e)}
/>
{/* column 4 — the open ruler, sliders stacked */}
{slider ? (
<div className="col col-slider" data-col="slider">
@@ -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}
+211 -15
View File
@@ -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<ImmichServer[]>([]);
const [serverYears, setServerYears] = useState<number[]>([]);
const [addImmich, setAddImmich] = useState(false);
const [editAlbums, setEditAlbums] = useState<LibraryFolder | null>(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<number>();
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<Set<string>>(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<Set<string>>(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<string, number>();
// 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<string, number>();
const foldersUnder = new Map<string, Set<string>>();
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<Node[]>(() => {
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<number>();
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 ? (
<TileImg
src={urls[photo.id]!}
src={src}
rot={photo.rot ?? 0}
onPick={onPick}
title={onPick ? t('lib.open') : undefined}
@@ -2276,6 +2465,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
{photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : isHeicName(photo.name) ? 'HEIC' : ''}
</div>
);
};
if (isMobile) {
return (
@@ -2283,6 +2473,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
<MobileLibrary
folders={folders}
servers={servers}
serverYears={serverYears}
photos={photos}
dirs={dirs}
walked={walked}
@@ -2305,9 +2496,12 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, rot } : p)));
}}
onSwitchToDesktop={() => setForceView('desktop')}
onFetchYear={fetchYearPhotos}
/>
{addImmich ? (
<ImmichDialog
servers={servers}
onForget={(id) => 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 ? (
<ImmichDialog
servers={servers}
onForget={(id) => void forgetLink(id)}
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setAddImmich(false)}
/>
+253
View File
@@ -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<IDBDatabase> {
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<void> {
try {
const db = await openDb();
await new Promise<void>((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<CustomFrameData | null> {
try {
const db = await openDb();
const row = await new Promise<CustomFrameData | undefined>((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<void> {
try {
const db = await openDb();
await new Promise<void>((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<CustomFrameData> {
const bytes = new Uint8Array(await file.arrayBuffer());
const blob = new Blob([bytes], { type: file.type || 'image/png' });
const dataUrl = await new Promise<string>((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<void>((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,
};
}
+76 -2
View File
@@ -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<RenderResult | nu
stampCanvas = cardCanvas;
stampW = outW;
stampH = outH;
} else if (frameId === 'custom' && options?.customFrame) {
flush(surface);
const photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const pw = photoImg.width();
const ph = photoImg.height();
const customImg = own(Skia.Image.MakeImageFromEncoded(options.customFrame.bytes));
if (!customImg) {
console.error('Failed to decode custom frame');
return null;
}
const fw = customImg.width();
const fh = customImg.height();
const cardSurface = createSurface(fw, fh);
if (!cardSurface) return null;
own(cardSurface);
const cardCanvas = cardSurface.getCanvas();
const plainPaint = own(Skia.Paint());
const hole = options.customFrame.hole;
const holeX = hole.x * fw;
const holeY = hole.y * fh;
const holeW = hole.w * fw;
const holeH = hole.h * fh;
const holeCenterX = holeX + holeW / 2;
const holeCenterY = holeY + holeH / 2;
// Base cover scale to fill the transparent hole
const baseScale = Math.max(holeW / pw, holeH / ph);
const zoom = options.customFrame.scale ?? 1.0;
const finalScale = baseScale * zoom;
const drawW = pw * finalScale;
const drawH = ph * finalScale;
const panX = options.customFrame.panX ?? 0;
const panY = options.customFrame.panY ?? 0;
const drawX = holeCenterX + panX - drawW / 2;
const drawY = holeCenterY + panY - drawH / 2;
// 1. Draw photo underneath
cardCanvas.drawImageRectOptions(
photoImg,
Skia.XYWHRect(0, 0, pw, ph),
Skia.XYWHRect(drawX, drawY, drawW, drawH),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
// 2. Draw custom frame on top so transparent area displays the photo
cardCanvas.drawImageRectOptions(
customImg,
Skia.XYWHRect(0, 0, fw, fh),
Skia.XYWHRect(0, 0, fw, fh),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
outputSurface = cardSurface;
stampCanvas = cardCanvas;
stampW = fw;
stampH = fh;
} else {
drawFrameOnCanvas(canvas, width, height, frameId);
}
+288 -59
View File
@@ -14,6 +14,7 @@ import {
putFolder,
putPhotoThumb,
putPhotos,
putPhotosFast,
remoteUrl,
saveWalkedDirs,
type LibraryDir,
@@ -25,6 +26,17 @@ import {
// there), so this is the size the sync loops at: one round trip per two hundred
// frames rather than one per frame.
const PAGE = 200;
// How many times one page of a server's frames is asked for again before the
// reading gives up on it. A page in flight is a call to a box the reader's own
// network may be between, and the second answer is usually the first answer.
const ASSET_RETRIES = 2;
// The nodes one reading is walking through. The screen asks for a sync when it
// opens and whenever the node it shows changes, and two readings of one node at
// the same time are two readings asking the server for the same pages and writing
// one place over the other: the second is turned away, and the first keeps the
// place for both.
const walking = new Set<string>();
// 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<boolean> {
}
}
// 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<ImmichAlbum[]> {
@@ -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>): 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<ImmichSyncResult> {
// 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<ImmichSyncResult> {
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<ImmichSyncResul
const rows: LibraryPhoto[] = [];
const dirs: LibraryDir[] = [];
const counts: Record<string, number> = {};
// 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<string>();
// 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<string>();
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<ImmichSyncResul
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
size: item.size ?? 0,
addedAt: Date.now(),
});
};
rows.push(row);
batchRows.push(row);
rootCount++;
}
if (!hasMore || !items.length) break;
page++;
// Save batch to IndexedDB and push to UI immediately
if (batchRows.length) {
await putPhotosFast(batchRows);
onBatch?.(batchRows, { loaded: rootCount, hasMore });
}
pages++;
if (!hasMore || !items.length) {
complete = true;
break;
}
step(items);
await remember(lastTakenBefore, page, false);
// After foreground window, resolve foreground sync promptly so UI is responsive
if (pages >= 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<ImmichSyncResul
let albPage = 1;
let albCount = 0;
for (;;) {
const { items, hasMore } = await listAssets(server, album.id, albPage, search);
const { items, hasMore, ok } = await listAssets(server, album.id, albPage, search);
// An album that could not be read is one this reading has no word on: the
// rows it holds are left where they are, and nothing below is pruned
// against a reading that did not meet them.
if (!ok) {
failed = true;
break;
}
const albBatch: LibraryPhoto[] = [];
for (const item of items) {
if (!item.id || !item.name) continue;
const name = frameName(item.name, item.id, albumTaken);
albumTaken.add(item.name);
albumTaken.add(name);
rows.push({
const row: LibraryPhoto = {
id: [root, label, name].join('/'),
folder: folder.name,
dir: label,
@@ -306,9 +474,15 @@ export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResul
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
size: item.size ?? 0,
addedAt: Date.now(),
});
};
rows.push(row);
albBatch.push(row);
albCount++;
}
if (albBatch.length) {
await putPhotosFast(albBatch);
onBatch?.(albBatch, { loaded: rootCount + albCount, hasMore });
}
if (!hasMore || !items.length) break;
albPage++;
}
@@ -317,50 +491,105 @@ export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResul
}
}
await saveWalkedDirs(root, dirs, counts, true);
// A frame the server no longer lists is not a frame to keep drawing: the row,
// its tile and its marks go, exactly as a frame taken off the disk does. This
// read saw the whole node, so it is the one allowed to say it.
const met = new Set(rows.map((r) => (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
+75 -22
View File
@@ -25,7 +25,7 @@ const PHOTOS = 'photos';
const THUMBS = 'thumbs';
const EDITS = 'edits';
const DIRS = 'dirs';
const DB_VERSION = 4;
const DB_VERSION = 5;
// The tile the catalogue keeps is the same tile the grid cell and the strip both
// paint, and there is one of them per frame of a roll that reaches six figures.
@@ -112,6 +112,17 @@ export interface LibraryFolder {
// the name is the store's key — but a label the reader may rename is no place to
// keep a query, and the sync asks with this.
query?: string;
// Where a server's reading stopped, on a node whose frames are an Immich
// server's. A node of a hundred thousand frames is not one visit's to read, and
// the keyset the reading pages by is a value the page cannot hold across a
// reload: without this, every visit read the newest three pages of the node
// again and nothing below them was ever read at all. `before` is the instant the
// next page starts from, `page` the page within that instant — Immich keeps a
// frame's time to the millisecond and a roll imported in one go is hundreds of
// frames at the same one, so one instant is several pages — and `done` says the
// whole node has been read, which is the only state in which a frame that is no
// longer there may be dropped from the catalogue.
cursor?: { before: string | null; page?: number; done: boolean };
}
export interface LibraryPhoto {
@@ -322,11 +333,14 @@ function openDb(): Promise<IDBDatabase> {
// to be made anyway.
if (db.objectStoreNames.contains(THUMBS)) db.deleteObjectStore(THUMBS);
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
let photosStore: IDBObjectStore;
if (!db.objectStoreNames.contains(PHOTOS)) {
const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' });
photos.createIndex('folder', 'folder');
photos.createIndex('taken', 'taken');
photosStore = db.createObjectStore(PHOTOS, { keyPath: 'id' });
} else {
photosStore = req.transaction!.objectStore(PHOTOS);
}
if (!photosStore.indexNames.contains('folder')) photosStore.createIndex('folder', 'folder');
if (!photosStore.indexNames.contains('taken')) photosStore.createIndex('taken', 'taken');
if (!db.objectStoreNames.contains(THUMBS)) {
db.createObjectStore(THUMBS, { keyPath: 'id' });
}
@@ -2047,9 +2061,9 @@ function pump(): void {
let cached: LibraryPhoto[] | null = null;
export function cachedPhotos(): LibraryPhoto[] {
if (cached) return cached;
if (cached && cached.length > 0) return cached;
try {
const raw = sessionStorage.getItem('rc_cached_photos');
const raw = sessionStorage.getItem('rc_cached_photos') || localStorage.getItem('rc_cached_photos');
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
@@ -2061,6 +2075,26 @@ export function cachedPhotos(): LibraryPhoto[] {
return [];
}
export function saveCachedPhotos(photos: LibraryPhoto[]): void {
try {
const slim = photos.slice(0, 300).map((p) => ({
id: p.id,
folder: p.folder,
name: p.name,
taken: p.taken,
addedAt: p.addedAt,
dir: p.dir,
star: p.star,
server: p.server,
remoteId: p.remoteId,
rot: p.rot,
}));
const json = JSON.stringify(slim);
sessionStorage.setItem('rc_cached_photos', json);
localStorage.setItem('rc_cached_photos', json);
} catch {}
}
export function clearLibraryCache(): void {
cached = null;
dirHandles.clear();
@@ -2385,6 +2419,10 @@ export async function makeThumbs(
const dir = normPath(rel);
const rows = Array.isArray(folder) ? folder : (await readPhotos(normPath(folder.name))) ?? [];
const want = rows.filter((p) => {
// A server's frame has no tile to mint here: the wall draws the address the
// proxy serves it at (see `tile` in Library.tsx), so reading the bytes into
// the store is the same picture fetched twice and a copy kept for nothing.
if (p.remoteId) return false;
const pDir = normPath(p.dir ?? '');
if (pDir === dir) return true;
// `''` is the picked folder itself, so a row asked of the whole roll takes
@@ -2413,7 +2451,10 @@ export async function makeThumbs(
return made;
}
export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
export async function readPhotos(
folder?: string,
onBatch?: (photos: LibraryPhoto[]) => void
): Promise<LibraryPhoto[] | null> {
try {
const db = await openDb();
// Every row is filed under `normPath(folder.name)`, and a drive root is the
@@ -2431,6 +2472,8 @@ export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null
: IDBKeyRange.bound(last ?? `${root}/`, `${root}/\uffff`, last !== null, false);
const rows: LibraryPhoto[] = [];
let last: string | null = null;
let emittedFirst = false;
for (;;) {
const chunk = await new Promise<LibraryPhoto[]>((resolve, reject) => {
const req = db.transaction(PHOTOS, 'readonly').objectStore(PHOTOS).getAll(at(last), READ_CHUNK);
@@ -2446,26 +2489,19 @@ export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null
delete (row as { thumb?: unknown }).thumb;
}
rows.push(...chunk);
if (onBatch && !emittedFirst && rows.length >= 400) {
emittedFirst = true;
const initial = [...rows].sort((a, b) => b.taken - a.taken);
onBatch(initial);
}
if (chunk.length < READ_CHUNK) break;
}
rows.sort((a, b) => b.taken - a.taken);
if (!folder) {
cached = rows;
try {
const slim = rows.slice(0, 300).map((p) => ({
id: p.id,
folder: p.folder,
name: p.name,
taken: p.taken,
addedAt: p.addedAt,
dir: p.dir,
star: p.star,
server: p.server,
remoteId: p.remoteId,
rot: p.rot,
}));
sessionStorage.setItem('rc_cached_photos', JSON.stringify(slim));
} catch {}
saveCachedPhotos(rows);
}
return rows;
} catch {
@@ -2473,6 +2509,23 @@ export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null
}
}
export async function putPhotosFast(rows: LibraryPhoto[]): Promise<void> {
if (!rows.length) return;
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(PHOTOS, 'readwrite');
const store = tx.objectStore(PHOTOS);
for (const row of rows) {
const r = { ...row };
delete (r as { handle?: unknown }).handle;
delete (r as { thumb?: unknown }).thumb;
store.put(r);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
return (await readPhotos(folder)) ?? [];
}
+14 -123
View File
@@ -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;
}
+221 -84
View File
@@ -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();
}
}
+24
View File
@@ -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',
+24
View File
@@ -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',
+397 -123
View File
@@ -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);
}
}
+1 -1
View File
@@ -146,7 +146,7 @@ export function ColGrip({ col, min = 110, max = 560 }: { col: string; min?: numb
const ref = useRef<HTMLDivElement>(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;
+34
View File
@@ -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')}
</p>
</div>
{hasImmich && onUploadImmich ? (
<div style={{ marginTop: '14px', borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '12px' }}>
<button
type="button"
className="btn"
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
background: 'linear-gradient(135deg, rgba(66, 133, 244, 0.2), rgba(52, 168, 83, 0.2))',
borderColor: '#4285f4',
color: '#fff',
padding: '10px 16px',
fontWeight: 600,
}}
data-key="export-immich"
onClick={() => onUploadImmich(null)}
>
<svg width="20" height="20" viewBox="0 0 100 100" fill="none">
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
<span>{t('immich.uploadToCloud')}</span>
</button>
</div>
) : null}
<button type="button" className="btn ghost" data-key="export-cancel" onClick={onClose}>
{t('clear.cancel')}
</button>
+54 -8
View File
@@ -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<HTMLImageElement>) => {
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<HTMLImageElement>) => {
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<HTMLImageElement>) => {
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' ? (
<div className="custom-frame-stage-hint" title="Hướng dẫn kéo & phóng to/thu nhỏ">
<span>🖐️ Kéo để di chuyển ảnh</span>
<span>🔍 Lăn chuột để thu phóng</span>
</div>
) : 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. */}
+33
View File
@@ -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({
</p>
) : (
<>
{/* 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 ? (
<div data-key="immich-links">
<p className="hint">{t('lib.immich.links')}</p>
<ul className="immich-albums">
{servers.map((s) => {
const twin = servers.some((o) => o.id !== s.id && o.url === s.url);
return (
<li key={s.id} data-key={`immich-link-${s.id}`}>
{s.name} · {s.url} ({s.albums?.length ?? 0})
{twin ? <span className="err" data-key="immich-dupe"> {t('lib.immich.dup')}</span> : null}
<button
type="button"
className="btn ghost"
data-key={`immich-forget-${s.id}`}
onClick={() => onForget?.(s.id)}
>
{t('lib.immich.linkForget')}
</button>
</li>
);
})}
</ul>
</div>
) : null}
<label className="hint" htmlFor="immich-name">
{t('lib.immich.name')}
</label>
+52 -22
View File
@@ -11,6 +11,7 @@ export type MobileTab = 'photos' | 'search' | 'albums' | 'library';
interface MobileLibraryProps {
folders: LibraryFolder[];
servers: ImmichServer[];
serverYears?: number[];
photos: LibraryPhoto[];
dirs: LibraryDir[];
walked: Record<string, number>;
@@ -26,6 +27,7 @@ interface MobileLibraryProps {
onSetStar?: (photo: LibraryPhoto, star: number) => Promise<void>;
onRotate?: (photo: LibraryPhoto, dir: 'left' | 'right') => Promise<void>;
onSwitchToDesktop?: () => void;
onFetchYear?: (year: number) => Promise<void>;
}
// Format timestamp into Immich style: "CN, 4 thg 10, 2026"
@@ -110,6 +112,7 @@ function dateKey(timestamp: number): string {
export function MobileLibrary({
folders,
servers,
serverYears = [],
photos,
dirs,
walked,
@@ -125,6 +128,7 @@ export function MobileLibrary({
onSetStar,
onRotate,
onSwitchToDesktop,
onFetchYear,
}: MobileLibraryProps) {
const { t, lang } = useI18n();
const [tab, setTab] = useState<MobileTab>('photos');
@@ -221,6 +225,17 @@ export function MobileLibrary({
const [showSearch, setShowSearch] = useState(false);
const [filterScope, setFilterScope] = useState<'all' | 'album'>('all');
// On-demand fetch for selected year if photos for that year are missing
useEffect(() => {
if (selectedYear === 'all' || !onFetchYear) return;
const yr = Number(selectedYear);
if (!Number.isFinite(yr) || yr < 1900) return;
const hasPhotosForYear = photos.some((p) => new Date(p.taken || 0).getFullYear() === yr);
if (!hasPhotosForYear) {
void onFetchYear(yr);
}
}, [selectedYear, photos, onFetchYear]);
const HOURS = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []);
const isDateFiltered = selectedYear !== 'all' || selectedMonth !== 'all' || selectedDay !== 'all';
const isTimeFiltered = timeFrom !== 'any' || timeTo !== 'any';
@@ -623,15 +638,18 @@ export function MobileLibrary({
sortOrder,
]);
// Available years from photos
// Available years from photos & server timeline buckets
const availableYears = useMemo(() => {
const years = new Set<number>();
const pool = cloudPhotos.length ? cloudPhotos : photos;
for (const p of pool) {
if (p.taken) years.add(new Date(p.taken).getFullYear());
}
for (const yr of serverYears) {
years.add(yr);
}
return Array.from(years).sort((a, b) => b - a);
}, [cloudPhotos, photos]);
}, [cloudPhotos, photos, serverYears]);
// Floating fast-scroll handle states replacing native scrollbar
const [scrollVisible, setScrollVisible] = useState(false);
@@ -1245,25 +1263,37 @@ export function MobileLibrary({
{displayedPhotos.length === 0 ? (
<div className="mob-lib-empty">
<svg className="mob-lib-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
<div className="mob-lib-empty-title">
{servers.length === 0
? t('lib.mobile.noServers')
: t('lib.mobile.noPhotos')}
</div>
<div className="mob-lib-empty-desc">
{t('lib.mobile.cloudDesc')}
</div>
<button
type="button"
className="mob-lib-btn primary"
onClick={onAddImmich}
style={{ marginTop: '8px', padding: '10px 20px', fontSize: '13px' }}
>
+ {t('lib.mobile.addImmich')}
</button>
{syncingServerId ? (
<>
<div className="mob-lib-spinner" style={{ margin: '20px auto', width: 32, height: 32 }} />
<div className="mob-lib-empty-title">Đang đồng bộ ảnh từ Immich Cloud...</div>
<div className="mob-lib-empty-desc">Ảnh mới nhất đang được tải về và hiển thị...</div>
</>
) : (
<>
<svg className="mob-lib-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
<div className="mob-lib-empty-title">
{servers.length === 0
? t('lib.mobile.noServers')
: t('lib.mobile.noPhotos')}
</div>
<div className="mob-lib-empty-desc">
{t('lib.mobile.cloudDesc')}
</div>
{servers.length === 0 && (
<button
type="button"
className="mob-lib-btn primary"
onClick={onAddImmich}
style={{ marginTop: '8px', padding: '10px 20px', fontSize: '13px' }}
>
+ {t('lib.mobile.addImmich')}
</button>
)}
</>
)}
</div>
) : viewMode === 'single' ? (
<div className="mob-lib-single-list">
@@ -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');
}}
>
+36 -1
View File
@@ -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({
</div>
) : null}
</div>
{/* Immich Cloud Upload button (shown only if registered user has configured Immich Cloud) */}
{hasImmich && onUploadImmich ? (
<button
type="button"
className="btn"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
background: 'linear-gradient(135deg, rgba(66, 133, 244, 0.15), rgba(52, 168, 83, 0.15))',
borderColor: '#4285f4',
color: '#e4e4e7',
}}
data-key="upload-immich"
disabled={uploadingImmich || exporting}
onClick={onUploadImmich}
title={t('immich.uploadTitle') || 'Upload lên Immich Cloud'}
>
{/* Immich Pinwheel Logo */}
<svg width="18" height="18" viewBox="0 0 100 100" fill="none" style={{ flexShrink: 0 }}>
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
<span>{uploadingImmich ? (t('immich.uploading') || 'Đang tải lên...') : 'Immich'}</span>
</button>
) : 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. */}
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting} onClick={onExport}>
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting || uploadingImmich} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}
</button>
</header>
+2 -4
View File
@@ -5,8 +5,7 @@ export function useIsMobile(breakpoint = 860): boolean {
if (typeof window === 'undefined') return false;
return (
window.innerWidth <= breakpoint ||
(typeof window.matchMedia === 'function' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches) ||
(typeof navigator !== 'undefined' && /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
(typeof window.matchMedia === 'function' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches)
);
});
@@ -16,8 +15,7 @@ export function useIsMobile(breakpoint = 860): boolean {
const check = () => {
setIsMobile(
window.innerWidth <= breakpoint ||
(mq ? mq.matches : false) ||
(typeof navigator !== 'undefined' && /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
(mq ? mq.matches : false)
);
};
+1 -1
View File
@@ -1 +1 @@
ae8bc8ad046e269bf7b81bde7fe42ec5dd4848e1380b8667fbc9c357ed91dfc9 recipescam-images.tar.gz
29b7df9c2a61fb3a50035657d91fe6d822c3677fee7a1bc24af9a225b1773fae recipescam-images.tar.gz