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:
@@ -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) });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) ?? [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 @@
|
||||
ae8bc8ad046e269bf7b81bde7fe42ec5dd4848e1380b8667fbc9c357ed91dfc9 recipescam-images.tar.gz
|
||||
29b7df9c2a61fb3a50035657d91fe6d822c3677fee7a1bc24af9a225b1773fae recipescam-images.tar.gz
|
||||
|
||||
Reference in New Issue
Block a user