diff --git a/docker/backend/src/immich.ts b/docker/backend/src/immich.ts index 50c37de..19a0e8b 100644 --- a/docker/backend/src/immich.ts +++ b/docker/backend/src/immich.ts @@ -287,7 +287,7 @@ export function immichRoutes( // client sends can aim this proxy at a host that is not in its own list. const pick = (user: User, reply: FastifyReply, id: unknown): ImmichServer | undefined => { const servers = immichServers(user.id); - const server = typeof id === 'string' ? servers.find((s) => s.id === id) : undefined; + const server = typeof id === 'string' && id ? servers.find((s) => s.id === id) : servers[0]; if (!server) { void reply.status(404).send({ error: 'unknown server' }); return undefined; @@ -458,12 +458,15 @@ export function immichRoutes( const res = await call(server, { method: 'POST', path: '/search/metadata', + timeout: 60_000, json: { size, page, withExif: true, type: 'IMAGE', ...(album ? { albumIds: [album] } : {}), + ...(q.takenBefore ? { takenBefore: q.takenBefore } : {}), + ...(q.takenAfter ? { takenAfter: q.takenAfter } : {}), ...(search ? (tagIds.length ? { tagIds } : { originalFileName: search }) : {}), }, }); @@ -475,10 +478,15 @@ export function immichRoutes( // Raw array: Array.isArray(found) const rawItems: RawAsset[] = found?.assets?.items ?? found?.items ?? (Array.isArray(found) ? found : []); const total: number | null = found?.assets?.total ?? found?.total ?? found?.count ?? null; + // What says another page is there: a page that came back full, or the + // server's own word for it. `assets.total` is not the size of the library — + // in Immich v2 it is the size of the page it came with — and read as the + // library it made every page but one look like there was more behind it. const hasMore: boolean = rawItems.length === size || (typeof found?.nextCursor === 'string' && !!found.nextCursor) || - (total !== null && page * size < total); + (typeof found?.assets?.nextPage === 'string' && !!found.assets.nextPage) || + (typeof found?.nextPage === 'string' && !!found.nextPage); return reply.send({ ok: true, page, @@ -489,6 +497,20 @@ export function immichRoutes( }); }); + // The timeline buckets (years/months), so the client knows all available years and photo counts in 1 call. + app.get('/api/immich/buckets', async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const q = req.query as Record; + const server = pick(user, reply, q.server); + if (!server) return; + const size = q.size === 'YEAR' ? 'YEAR' : 'MONTH'; + const res = await call(server, { path: '/timeline/buckets', query: { size }, timeout: 15_000 }); + if (!res.ok) return reply.status(502).send(refusal(res)); + const buckets = await jsonOf(res); + return reply.send({ ok: true, buckets: Array.isArray(buckets) ? buckets : [] }); + }); + // An Immich-generated image, streamed straight through. Always the size the // caller asked for: the grid asks for `thumbnail`, the stage for `preview`, // and only the studio ever asks for the original. @@ -528,4 +550,95 @@ export function immichRoutes( reply.header('cache-control', 'no-store'); return reply.send(Readable.fromWeb(res.body as Parameters[0])); }); + + // Upload an edited photo from Studio to Immich Cloud + app.post( + '/api/immich/upload', + { bodyLimit: 50 * 1024 * 1024 }, + async (req, reply) => { + const user = member(req, reply); + if (!user) return; + const q = req.query as Record; + const server = pick(user, reply, q.server); + if (!server) return; + if (server.type !== 'api') { + return reply.status(400).send({ error: 'Upload is only supported for API-key Immich connections' }); + } + + const body = req.body; + if (!Buffer.isBuffer(body) || body.length === 0) { + return reply.status(400).send({ error: 'invalid body or empty photo' }); + } + + const rawName = (q.name ?? '').trim() || `recipescam_${Date.now()}.jpg`; + const fileName = rawName.endsWith('.jpg') || rawName.endsWith('.jpeg') || rawName.endsWith('.png') ? rawName : `${rawName}.jpg`; + + const form = new FormData(); + form.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName); + form.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`); + form.append('deviceId', 'RecipesCam'); + form.append('fileCreatedAt', new Date().toISOString()); + form.append('fileModifiedAt', new Date().toISOString()); + form.append('isFavorite', 'false'); + + try { + let immichRes = await fetch(new URL(`${server.url}/api/assets`), { + method: 'POST', + headers: { + 'x-api-key': server.key, + }, + body: form, + signal: AbortSignal.timeout(60_000), + }); + + if (immichRes.status === 404) { + // Fallback for older Immich versions (< 1.106) + const fallbackForm = new FormData(); + fallbackForm.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName); + fallbackForm.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`); + fallbackForm.append('deviceId', 'RecipesCam'); + fallbackForm.append('fileCreatedAt', new Date().toISOString()); + fallbackForm.append('fileModifiedAt', new Date().toISOString()); + fallbackForm.append('isFavorite', 'false'); + + immichRes = await fetch(new URL(`${server.url}/api/asset/upload`), { + method: 'POST', + headers: { + 'x-api-key': server.key, + }, + body: fallbackForm, + signal: AbortSignal.timeout(60_000), + }); + } + + if (!immichRes.ok) { + const errText = await immichRes.text().catch(() => ''); + return reply.status(immichRes.status).send({ error: 'Immich upload failed', details: errText }); + } + + const data = await immichRes.json().catch(() => ({})); + const assetId = (data as any)?.id; + + // If target album is given, add to album + const targetAlbum = q.album || (server.albums?.length === 1 ? server.albums[0] : undefined); + if (targetAlbum && assetId) { + try { + await fetch(new URL(`${server.url}/api/albums/${targetAlbum}/assets`), { + method: 'PUT', + headers: { + 'x-api-key': server.key, + 'content-type': 'application/json', + }, + body: JSON.stringify({ ids: [assetId] }), + signal: AbortSignal.timeout(10_000), + }); + } catch {} + } + + return reply.send({ ok: true, id: assetId, result: data }); + } catch (err: any) { + return reply.status(502).send({ error: 'Failed to connect to Immich server', details: String(err) }); + } + } + ); } diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index df6ecd7..4d982b5 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -150,9 +150,13 @@ app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, // Photo uploads are the raw image bytes, not multipart: one file per request // needs no boundary parsing, so no dependency and no parser attack surface. -app.addContentTypeParser(['image/jpeg', 'image/png', 'image/webp'], { parseAs: 'buffer' }, (_req, body, done) => { - done(null, body); -}); +app.addContentTypeParser( + ['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream'], + { parseAs: 'buffer', bodyLimit: 50 * 1024 * 1024 }, + (_req, body, done) => { + done(null, body); + } +); // A restore body is a whole data dir, so it is the one body far past the // instance limit. Buffered, not streamed: `tar` needs the archive to be diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js index 00bf637..58d55c1 100644 --- a/docker/frontend/public/sw.js +++ b/docker/frontend/public/sw.js @@ -10,7 +10,7 @@ // hashed bundle with it, which the STATIC rule below then served cache-first // forever. Every navigation here now goes past the browser's own cache, so the // build a visitor gets is the one the server has. -const VERSION = 'recipescam-v25'; +const VERSION = 'recipescam-v29'; // nginx answers all three routes with the same index.html (SPA fallback), so they // are one document under three keys: an offline navigation finds it whichever key diff --git a/docker/frontend/scripts/preview-match-check.mjs b/docker/frontend/scripts/preview-match-check.mjs deleted file mode 100644 index a3ae1d3..0000000 --- a/docker/frontend/scripts/preview-match-check.mjs +++ /dev/null @@ -1,102 +0,0 @@ -// The per-file colour fit is pure arithmetic, so it can be checked here rather -// than in a browser: hand fitMatch a grid and the same grid through a known 3x3, -// and it has to hand that 3x3 back. Done that way because the function only ever -// runs inside the RAW develop, which needs LibRaw and CanvasKit — neither of -// which a node check has. The module is TypeScript, so it is transpiled on the -// fly out of the installed compiler (the repo's convention for checks: see -// raw-develop-check.mjs, which reads the shader source instead). -// -// node scripts/preview-match-check.mjs -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import ts from 'typescript'; - -const file = new URL('../src/engine/previewMatch.ts', import.meta.url); -const js = ts.transpileModule(readFileSync(file, 'utf8'), { - compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, -}).outputText; -const { fitMatch, MATCH_GRID } = await import( - `data:text/javascript;base64,${Buffer.from(js).toString('base64')}` -); - -// A grid with colour in it: three independent ramps, none of them clipped and -// none at the floor, so every block carries something to fit. Independent on -// purpose — three correlated channels leave the 3x3 ill-conditioned, and a fit -// that comes back a percent off on such a scene says nothing about the code. -function scene() { - let s = 1; - const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096; - const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4); - for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) { - for (let c = 0; c < 3; c++) g[i * 4 + c] = 25 + Math.round(rnd() * 190); - g[i * 4 + 3] = 255; - } - return g; -} - -const M = [1.06, -0.05, 0.01, 0.02, 0.98, 0.03, -0.04, 0.06, 1.02]; -const dev = scene(); -const ref = new Uint8Array(dev.length); -for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) { - const e = [dev[i * 4], dev[i * 4 + 1], dev[i * 4 + 2]]; - for (let r = 0; r < 3; r++) { - const v = M[r * 3] * e[0] + M[r * 3 + 1] * e[1] + M[r * 3 + 2] * e[2]; - ref[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v))); - } - ref[i * 4 + 3] = 255; -} - -const fit = fitMatch(dev, ref); -assert.ok(fit, 'a fitted grid has to fit'); -for (let r = 0; r < 3; r++) { - for (let c = 0; c < 3; c++) { - assert.ok(Math.abs(fit.m[r * 3 + c] - M[r * 3 + c]) < 0.01, `recovered ${fit.m[r * 3 + c]} for ${M[r * 3 + c]}`); - } -} -// This scene was made by a 3x3 and nothing else, so the curve fitted on what the -// 3x3 leaves has to come back flat — the identity, up to the rounding the scene -// carries. -assert.equal(fit.tone.length, 12); -for (let i = 0; i < 12; i++) { - const flat = i % 4 === 1 ? 1 : 0; - assert.ok(Math.abs(fit.tone[i] - flat) < 0.15, `tone ${i} came back ${fit.tone[i]}, not ${flat}`); -} - -// Nothing to fit against: the camera clipped the whole frame, so no block says -// anything about its rendering, and the caller develops as the sensor left it. -assert.equal(fitMatch(dev, new Uint8Array(dev.length).fill(255)), null); - -// A near-neutral frame through a matrix whose rows carry a negative element — what -// the bodies the fit used to refuse looked like (X-T3 -0.04, X100V -1.67 on rows -// that also carry +1.05 and +1.63). Three channels that share nearly all their -// content leave the normal equations near-singular, and the plain solve came back -// with a diagonal element at or below zero, which the caller read as a degeneracy -// and dropped the whole fit for. It is a colour: the ridge lifts the system back -// to definite and the fit has to come back a matrix. -function neutral() { - let s = 7; - const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096; - const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4); - for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) { - const x = 40 + Math.round(rnd() * 180); - for (let c = 0; c < 3; c++) g[i * 4 + c] = Math.max(0, Math.min(255, x + Math.round((rnd() - 0.5) * 8))); - g[i * 4 + 3] = 255; - } - return g; -} -const N = [1.05, -1.67, 1.63, 0.02, 0.95, 0.03, -0.04, 0.06, 1.02]; -const nDev = neutral(); -const nRef = new Uint8Array(nDev.length); -for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) { - const e = [nDev[i * 4], nDev[i * 4 + 1], nDev[i * 4 + 2]]; - for (let r = 0; r < 3; r++) { - const v = N[r * 3] * e[0] + N[r * 3 + 1] * e[1] + N[r * 3 + 2] * e[2]; - nRef[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v))); - } - nRef[i * 4 + 3] = 255; -} -const nFit = fitMatch(nDev, nRef); -assert.ok(nFit, 'a near-neutral frame has to fit, not come back null'); -for (const v of nFit.m) assert.ok(Number.isFinite(v) && Math.abs(v) <= 4, `degenerate fit ${v}`); - -console.log('preview-match-check ok'); diff --git a/docker/frontend/scripts/raw-develop-check.mjs b/docker/frontend/scripts/raw-develop-check.mjs index 185a5f4..8a3dd6e 100644 --- a/docker/frontend/scripts/raw-develop-check.mjs +++ b/docker/frontend/scripts/raw-develop-check.mjs @@ -105,41 +105,50 @@ assert.match(src, /^export function previewWhiteBalance\($/m); // the tags rather than a ratio read off noise. assert.match(src, /if \(count < 4096 \|\| sums\[0\] <= 0 \|\| sums\[1\] <= 0 \|\| sums\[2\] <= 0\) return null;/); -// The fit has to land on the encoded value in float, not as an 8-bit colour filter -// painted over the frame afterwards: the fit carries an exposure, and a channel it -// lifted past the white level was cut where it stood — blue first, the channel the -// WB gains lift most — which is what left the bright end of the frame short of -// blue. Measured on the A5100 frame, pixels at 255: 5.25% blue, 2.10% of them blue -// alone through the filter, against 2.46% / 2.44% through this path. +// What a RAW opens as is the colour the camera wrote into it. Not fitted: the +// develop carries the difference between the file's own preview and this develop's +// rendering of the same frame, at the preview's resolution, and adds it back on +// the value it left. So there is no curve to come back folded, no body table, and +// nothing that can disagree with the file it is opening. // -// The curve is pinned at both ends, so white is one of the values it is fitted on -// and a blown pixel still lands on white — there is no rolloff after it to pull a -// channel back down (the 3x3 this replaced needed one, and it turned every -// highlight cyan: 0.0% of the frame reached white on all three channels against -// 2.3% now and 3.3% in the camera's preview, dE00 7.1 against 6.6). -assert.doesNotMatch(src, /drawMatched|colorMatrix/, 'the fit is painted through a colour filter again'); -assert.match(code, /float tone\(float4 w, float x\) \{/); -assert.match(code, /return clamp\(w\.x \+ x \* \(w\.y \+ x \* \(w\.z \+ x \* w\.w\)\), 0\.0, 1\.0\);/); -// Pinned at both ends is not pinned at the plateau. The three curves agree at 1.0 -// and part company either side of it, and where the frame ran out there is no block -// left to fit them together — every blown block is dropped, and every block the -// develop blows is dropped too — so the neutral the gate above drew comes apart on -// the way out: measured on the Olympus ORF, a 255,255,255 sky came back 254,255,255 -// (149k px) and 253,255,254 (122k px), red under green across a quarter of the -// frame. The gate is read again on the value that leaves, so the pixel the develop -// itself called blown leaves at the neutral it was drawn as. -assert.match(code, /float3 o = float3\(tone\(t0, e\.r\), tone\(t1, e\.g\), tone\(t2, e\.b\)\);/); +// Measured against the file's own preview: dE00 6.04 for the per-channel cubic +// this replaced, 0.54 for the residual, and 0.00 on the two bodies (a Nikon NEF +// and a DNG) that write a full-size preview — where the two frames agree block for +// block and the residual is nothing. +const res = src.match(/const RESIDUAL_SKSL = `([\s\S]*?)`;/)?.[1]; +assert.ok(res, 'RESIDUAL_SKSL not found'); +const resCode = res.replace(/\/\/[^\n]*/g, ''); assert.match( - code, - /return half4\(half3\(mix\(o, float3\(max\(max\(o\.r, o\.g\), o\.b\)\), blown\)\), 1\.0\);/ + resCode, + /return half4\(half3\(clamp\(ref\.eval\(p\)\.rgb - dev\.eval\(p\)\.rgb \+ 0\.5, 0\.0, 1\.0\)\), 1\.0\);/ ); -assert.match(code, /uniform float4 t0;/); -assert.doesNotMatch(code, /float mq = /, 'the rolloff after the fit is back'); -// ...and it is the tail of the uniform buffer, one float4 per channel, which the -// second develop overwrites on the buffer it already built. -assert.match(src, /^ *uniforms\.set\(tone \?\? FLAT_TONE, 21\);$/m); -assert.match(src, /const uniforms = new Float32Array\(33\);/); -assert.match(src, /^import \{ toneMatch, FLAT_TONE, MATCH_GRID \} from '\.\/previewMatch';$/m); +assert.match(code, /uniform shader res;/); +assert.match(code, /uniform float2 rscl;/); +// The 0.5 that keeps a negative difference inside a byte, taken back off on the +// way out, and read at the frame's own coordinates — `pos` is local to this band. +assert.match(code, /float2 rp = pos \* rscl;/); +assert.match(code, /o = clamp\(o \+ \(res\.eval\(rp\)\.rgb - 0\.5\), 0\.0, 1\.0\);/); +// The residual is read at the develop's own resolution, so it is filtered up +// rather than tapped: one byte a channel, which is finer than the shading it +// carries, is the whole difference between two renderings. +assert.match(src, /const resChild = \(residual\?\.img \?\? noResidual\(\)\)\.makeShaderOptions\(/); +assert.match(src, /const shader = effect\.makeShaderWithChildren\(uniforms, \[child, resChild\]\);/); +// A file with nothing to carry opens on a residual that says nothing — 0.5 in +// half — rather than down a branch of its own in the shader. +assert.match(src, /NO_RESIDUAL = Skia\.Image\.MakeImage\(/); +assert.match(src, /new Uint16Array\(\[0x3800, 0x3800, 0x3800, 0x3c00\]\)/); +// The develop runs twice, not once: the residual is read off the first frame and +// carried by the second. +assert.match(src, /^ *const second = develop\(residual\);$/m); +// The per-channel curve this replaced is gone, and so is the buffer slot it rode +// in: 33 floats became 23, the last two of them the residual's own scale. +assert.doesNotMatch(code, /float tone\(float4 w, float x\) \{/, 'the per-channel curve is back'); +assert.doesNotMatch(code, /uniform float4 t0;/, 'the per-channel curve is back in the shader'); +assert.doesNotMatch(src, /FLAT_TONE/, 'the flat tone is back'); +assert.doesNotMatch(src, /toneMatch/, 'the tone fit is back'); +assert.match(src, /const uniforms = new Float32Array\(23\);/); +assert.match(src, /^ *uniforms\[21\] = residual \? residual\.sx : 0;$/m); +assert.match(src, /^import \{ MATCH_GRID \} from '\.\/previewMatch';$/m); // A RAW opens at the sensor's own resolution, not at the quarter the half-size // demosaic reports: the GR's DNG came back 3010x2012 against the 6000x4000 of the diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 7c4ee06..3c8bea6 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -6,7 +6,8 @@ export type FrameId = | 'wallframe' // WALL FRAME PORTRAIT — the artwork hung 90° CW | 'wallframe-landscape' // the same artwork upright: WALL FRAME LANDSCAPE | 'old-film' // OLD FILM LANDSCAPE — the distressed sheet as it is - | 'old-film-portrait'; // OLD FILM PORTRAIT — the same sheet turned 90° CW + | 'old-film-portrait' // OLD FILM PORTRAIT — the same sheet turned 90° CW + | 'custom'; // CUSTOM IMPORTED FRAME — transparent PNG/WebP overlay // Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT // (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 80104ac..e2d5a13 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -22,9 +22,11 @@ import { sharePhoto, type ShareSite } from './engine/share'; import { heicToJpeg, isHeicName } from './engine/heicDevelop'; import { clearRaw, readRaw, storeRaw } from './engine/rawStore'; import { getPhoto, loadEdit, makeTile, putPhotoThumb, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library'; +import { immichConfig, uploadToImmich, type ImmichServer } from './engine/immich'; import { offerInstall } from './pwa/install'; import { cameraSupported } from './engine/camera'; import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session'; +import { analyzeCustomFrame, clearCustomFrame, loadCustomFrame, saveCustomFrame, type CustomFrameData } from './engine/customFrame'; import { keep } from './remember'; import { preloadSuperRes, upscaleJpeg } from './engine/superRes'; import { api, type Photo, type SavedRecipe } from './api'; @@ -122,6 +124,7 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [ { id: 'wallframe-landscape', label: 'frame.wallLand' }, { id: 'old-film', label: 'frame.oldFilm' }, { id: 'old-film-portrait', label: 'frame.oldFilmPort' }, + { id: 'custom', label: 'frame.custom' }, ]; // The recipe a saved photo's base is rendered through: the neutral stock — @@ -348,11 +351,7 @@ interface Look { export function Workspace() { const { t } = useI18n(); - const [forceView] = useState<'auto' | 'mobile' | 'desktop'>(() => { - return (localStorage.getItem('rc_force_view') as 'auto' | 'mobile' | 'desktop') || 'auto'; - }); - const detectedMobile = useIsMobile(860); - const isMobile = forceView === 'mobile' || (forceView === 'auto' && detectedMobile); + const isMobile = useIsMobile(860); // Session restore: read once, synchronously, so the first paint already holds // the user's own settings instead of a flash of the defaults. The photo comes @@ -397,6 +396,76 @@ export function Workspace() { const [preview, setPreview] = useState(null); const [frameId, setFrameId] = useState(() => session.frameId ?? 'none'); const [framePanX, setFramePanX] = useState(() => session.framePanX ?? 0); + const [customFrame, setCustomFrame] = useState(null); + const frameFileInputRef = useRef(null); + const saveCustomFrameTimer = useRef | null>(null); + + useEffect(() => { + let alive = true; + loadCustomFrame().then((saved) => { + if (alive && saved) setCustomFrame(saved); + }); + return () => { + alive = false; + }; + }, []); + + const updateCustomFrame = useCallback((updater: (prev: CustomFrameData) => CustomFrameData) => { + setCustomFrame((prev) => { + if (!prev) return null; + const next = updater(prev); + if (saveCustomFrameTimer.current) clearTimeout(saveCustomFrameTimer.current); + saveCustomFrameTimer.current = setTimeout(() => { + void saveCustomFrame(next); + }, 400); + return next; + }); + }, []); + + const onImportFrameFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + setBusy(t('frame.analyzing')); + const analyzed = await analyzeCustomFrame(file); + await saveCustomFrame(analyzed); + setCustomFrame(analyzed); + setFrameId('custom'); + } catch (err: any) { + setError(err?.message || 'Failed to load custom frame'); + } finally { + setBusy(null); + if (e.target) e.target.value = ''; + } + }; + + const onRemoveCustomFrame = async () => { + await clearCustomFrame(); + setCustomFrame(null); + if (frameId === 'custom') setFrameId('none'); + }; + + const onCustomFramePan = useCallback( + (dx: number, dy: number) => { + updateCustomFrame((cf) => { + const nextPanX = Math.max(-1000, Math.min(1000, Math.round(cf.panX + dx))); + const nextPanY = Math.max(-1000, Math.min(1000, Math.round(cf.panY + dy))); + return { ...cf, panX: nextPanX, panY: nextPanY }; + }); + }, + [updateCustomFrame] + ); + + const onCustomFrameZoom = useCallback( + (delta: number) => { + updateCustomFrame((cf) => { + const factor = delta > 0 ? 0.95 : 1.05; + const nextScale = Math.max(0.2, Math.min(5.0, Number((cf.scale * factor).toFixed(3)))); + return { ...cf, scale: nextScale }; + }); + }, + [updateCustomFrame] + ); const [crop, setCrop] = useState(() => session.crop ?? 'none'); const [cropRect, setCropRect] = useState(() => session.cropRect ?? DEFAULT_CROP_RECT); const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false); @@ -678,6 +747,26 @@ export function Workspace() { // for them (server.ts), so the studio stops counting at the same place. null // means "no cap" all the way down to SAVE PHOTO's label. const photoCap = user?.admin ? null : MAX_PHOTOS; + + // Immich Cloud servers connected to this account + const [immichServers, setImmichServers] = useState([]); + const [uploadingImmich, setUploadingImmich] = useState(false); + const [toast, setToast] = useState(null); + + useEffect(() => { + if (member) { + immichConfig().then((cfg) => setImmichServers(cfg.servers)).catch(() => {}); + } else { + setImmichServers([]); + } + }, [member]); + + const hasImmich = immichServers.length > 0; + + const showToast = useCallback((msg: string) => { + setToast(msg); + setTimeout(() => setToast(null), 4000); + }, []); // What the stamp's name line prints: the visitor's own PLACE NAME wins over // whatever the fix carried, because neither a phone's position nor a photo's // EXIF ever brings one, and typing it after locating must still show up. @@ -838,7 +927,7 @@ export function Workspace() { const parked = await readRaw(); if (!alive) return; if (!parked) return photo ? show(photo) : undefined; - const jpeg = await developRaw(parked.bytes); + const jpeg = await developRaw(parked.bytes, parked.name); await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes); }) .catch(() => undefined); @@ -881,6 +970,16 @@ export function Workspace() { const stageOptions = useMemo( () => ({ wallframeLandscape: frameId === 'wallframe-landscape', + customFrame: + frameId === 'custom' && customFrame + ? { + bytes: customFrame.bytes, + hole: customFrame.hole, + scale: customFrame.scale, + panX: customFrame.panX, + panY: customFrame.panY, + } + : null, sharpen: true, photoRotation: rotation, photoStraighten: straighten, @@ -891,7 +990,7 @@ export function Workspace() { gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [frameId, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle] + [frameId, customFrame, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle] ); // What the stage paints: the hovered look while the pointer is on a sim or a @@ -1248,7 +1347,7 @@ export function Workspace() { // ponytail: the reload pays one develop. Stamp the session copy with // the build and keep it while the stamp holds, if that ever hurts. await storeRaw(bytes, file.name); - const jpeg = await developRaw(bytes); + const jpeg = await developRaw(bytes, file.name); await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes); void forgetPhoto(); return; @@ -1925,6 +2024,16 @@ export function Workspace() { assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', + customFrame: + frameId === 'custom' && customFrame + ? { + bytes: customFrame.bytes, + hole: customFrame.hole, + scale: customFrame.scale, + panX: customFrame.panX, + panY: customFrame.panY, + } + : null, dpi: 300, sharpen: true, photoRotation: rotation, @@ -1980,6 +2089,107 @@ export function Workspace() { } }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, exportCap, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, place, pro, member, promptPro, proLookInUse, t, strip, photoSettings]); + // Upload the currently edited photo from Studio straight to Immich Cloud + const uploadPhotoToImmich = useCallback(async (target: number | null = null): Promise => { + if (!engine || !source) return false; + if (!immichServers.length) { + setError(t('immich.noServer')); + return false; + } + if (!pro && proLookInUse()) { + promptPro(); + return false; + } + setUploadingImmich(true); + setBusy(t('immich.uploading')); + setError(null); + try { + const tier = pro ? TIERS.pro : member ? TIERS.member : TIERS.guest; + const wanted = target === null ? null : Math.min(target, exportCap ?? target); + const needLongest = wanted === null ? sourceLongest : Math.round((sourceLongest * wanted) / editedLongest); + let src = source.bytes; + if (wanted === null) { + if (tier.maxExportDim && sourceLongest > tier.maxExportDim) { + src = await resizedJpeg(source.bytes, tier.maxExportDim, 0.95); + } + } else if (needLongest > sourceLongest * MODEL_GAIN) { + src = await upscaleJpeg(source.bytes, needLongest, ({ done, total }) => + setBusy(t('act.upscaling', { done, total })) + ); + } else if (needLongest !== sourceLongest) { + src = await scaledJpeg(source.bytes, needLongest); + } + const out = await renderPhoto({ + source: src, + recipe, + frameId: cropEditing ? 'none' : frameId, + useGeotag, + gpsInfo, + assets: engine.assets, + options: { + wallframeLandscape: frameId === 'wallframe-landscape', + customFrame: + frameId === 'custom' && customFrame + ? { + bytes: customFrame.bytes, + hole: customFrame.hole, + scale: customFrame.scale, + panX: customFrame.panX, + panY: customFrame.panY, + } + : null, + dpi: 300, + sharpen: true, + photoRotation: rotation, + photoStraighten: straighten, + photoFlipH: flipH, + photoFlipV: flipV, + cropRect: cropLive ? cropRect : null, + watermark: markOn && markText + ? { text: markText, ...markStyle } + : tier.watermark + ? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 } + : null, + gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null, + }, + }); + if (!out) throw new Error('render returned null'); + + const bytes = writeJpegExif( + out.bytes, + { + dateTime: new Date(), + gps: gps ? { latitude: gps.latitude, longitude: gps.longitude } : null, + width: out.width, + height: out.height, + dpi: 300, + locationName: place || gps?.locality || null, + imageDescription: strip.tag, + userComment: JSON.stringify(photoSettings), + }, + source.bytes + ); + const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14); + const name = `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`; + + const primaryServer = immichServers[0]; + const res = await uploadToImmich(bytes, name, primaryServer?.id); + if (res.ok) { + showToast(t('immich.uploadSuccess')); + setChoosingExport(false); + return true; + } else { + throw new Error(res.error || 'Upload error'); + } + } catch (err) { + setError(t('immich.uploadFailed', { msg: String(err) })); + return false; + } finally { + setUploadingImmich(false); + setBusy(null); + } + }, [engine, source, immichServers, pro, proLookInUse, promptPro, member, exportCap, sourceLongest, editedLongest, recipe, cropEditing, frameId, useGeotag, gpsInfo, customFrame, rotation, straighten, flipH, flipV, cropLive, cropRect, markOn, markText, markStyle, gps, place, strip, photoSettings, t, showToast]); + // SAVE PHOTO: the stage as it stands, uploaded to this account's own folder. // The upload is left clean (no burned strip) so the landing reel can draw its // own `.lp-shot` frame around it; the labels are stored alongside instead. @@ -2026,6 +2236,16 @@ export function Workspace() { assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', + customFrame: + frameId === 'custom' && customFrame + ? { + bytes: customFrame.bytes, + hole: customFrame.hole, + scale: customFrame.scale, + panX: customFrame.panX, + panY: customFrame.panY, + } + : null, dpi: 300, sharpen: true, photoRotation: rotation, @@ -2950,10 +3170,10 @@ export function Workspace() { // The frame row is the frames alone. CROP, ROTATE and WATERMARK used to close // it — a frame is the look, and cutting, levelling and signing the print are // moves made on the frame, which is what TOOLS is (see `shapeChips`). - const framesRow = (): ChipDef[] => + const framesRow = (): ChipDef[] => { // NO FRAME is not a chip of its own: clicking the frame already on the // photo takes it off again, so the row carries one entry per frame. - FRAMES.filter((frame) => frame.id !== 'none').map((frame) => ({ + const list: ChipDef[] = FRAMES.filter((frame) => frame.id !== 'none' && frame.id !== 'custom').map((frame) => ({ key: `fr:${frame.id}`, label: t(frame.label), active: frameId === frame.id, @@ -2970,6 +3190,32 @@ export function Workspace() { }, })); + if (customFrame) { + list.push({ + key: 'fr:custom', + label: customFrame.name || t('frame.custom'), + active: frameId === 'custom', + onClick: () => { + setOpenParam(null); + setOpenGroup(null); + remember(); + setFrameId(frameId === 'custom' ? 'none' : 'custom'); + }, + }); + } + + list.push({ + key: 'fr:import-custom', + label: t('frame.import'), + active: false, + onClick: () => { + frameFileInputRef.current?.click(); + }, + }); + + return list; + }; + // The three moves that shape the print rather than tint it: the frame is cut // to a shape, turned to the straight, and signed. Each opens its own strip in // the last column, the same strip it opened from the frame row. @@ -3693,6 +3939,9 @@ export function Workspace() { onReset={reset} onSavePhoto={() => void saveThenMaybeClear()} onExport={openExportPicker} + hasImmich={hasImmich} + uploadingImmich={uploadingImmich} + onUploadImmich={() => void uploadPhotoToImmich()} photoCount={myPhotos.length} photoMax={photoCap} onAuth={() => setAuthMode('login')} @@ -3706,6 +3955,33 @@ export function Workspace() { }} /> + {/* Toast notification */} + {toast && ( +
+ ☁️ + {toast} +
+ )} + {/* The trial notice: one band under the header, the same shape the verify bar below takes. Copy only — nothing here is dismissible, because the date IS the message. */} @@ -4075,6 +4351,153 @@ export function Workspace() { ) : null} + {/* CUSTOM FRAME controls column: thumbnail, fit/fill/center, scale & pan sliders, replace & delete */} + {tab === 'frame' && frameId === 'custom' && customFrame ? ( +
+
+
+ {customFrame.name} +
+
+
{customFrame.name}
+
+ {customFrame.width} × {customFrame.height} +
+
+
+ +
+ + + +
+ +
+
+ {t('frame.zoom')} + {Math.round(customFrame.scale * 100)}% +
+
+ { + const sc = parseFloat(e.target.value); + updateCustomFrame((cf) => ({ ...cf, scale: sc })); + }} + /> +
+
+ +
+
+ {t('frame.panX')} + {Math.round(customFrame.panX)} px +
+
+ { + const px = parseFloat(e.target.value); + updateCustomFrame((cf) => ({ ...cf, panX: px })); + }} + /> +
+
+ +
+
+ {t('frame.panY')} + {Math.round(customFrame.panY)} px +
+
+ { + const py = parseFloat(e.target.value); + updateCustomFrame((cf) => ({ ...cf, panY: py })); + }} + /> +
+
+ +

{t('frame.dragHint')}

+ +
+ + +
+
+ ) : null} + + {/* Hidden input for importing custom frames */} + void onImportFrameFile(e)} + /> + {/* column 4 — the open ruler, sliders stacked */} {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} diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index de5acfd..53bd158 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -47,6 +47,7 @@ import { stopScanAt, stopCount, unfinished, + saveCachedPhotos, watchRows, watchScan, type LibraryDir, @@ -63,8 +64,10 @@ import { } from './engine/libraryBackup'; import { addImmichFolder, + fetchImmichBuckets, forgetImmichServer, immichConfig, + listAssets, saveImmichAlbums, searchImmichFolder, syncImmich, @@ -374,8 +377,28 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // A server is not a folder on the disk — see `engine/immich.ts` — so it is the // column's second source and the page holds no key of its own. const [servers, setServers] = useState([]); + const [serverYears, setServerYears] = useState([]); const [addImmich, setAddImmich] = useState(false); const [editAlbums, setEditAlbums] = useState(null); + + useEffect(() => { + if (!servers.length) return; + let alive = true; + Promise.all(servers.map((s) => fetchImmichBuckets(s.id, 'MONTH'))).then((results) => { + if (!alive) return; + const yrs = new Set(); + for (const bucketList of results) { + for (const b of bucketList) { + const yr = parseInt(b.timeBucket.slice(0, 4), 10); + if (yr > 1900 && yr < 2100) yrs.add(yr); + } + } + setServerYears(Array.from(yrs).sort((a, b) => b - a)); + }); + return () => { + alive = false; + }; + }, [servers]); // The strip starts on the catalogue the last screen read: coming back from the // studio is a screen opening, not a page loading, and the frames the reader was // looking at are already in hand. The read below replaces them a moment later. @@ -484,6 +507,13 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // then — the pointer only ever reports a position, not a distance moved. const grip = useRef<{ x: number; w: number } | null>(null); + // The server nodes a reading is in flight on, by folder name. See `syncServer`. + const syncingServer = useRef>(new Set()); + // The server nodes this visit has already carried on reading. The restore below + // runs again whenever the node the screen shows changes, and a reading begun + // again on every click is a reading asking the server for pages it just had. + const startedHere = useRef>(new Set()); + // The catalogue is loaded once at start-up; until it is, the tree only knows // the folders and cannot tell whether the remembered subfolder still exists. const [loaded, setLoaded] = useState(false); @@ -507,7 +537,13 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { const [backBusy, setBackBusy] = useState(false); const reload = useCallback(async () => { - const [rows, edits, walkedRows] = await Promise.all([readPhotos(), listEditedIds(), listWalkedDirs()]); + const [rows, edits, walkedRows] = await Promise.all([ + readPhotos(undefined, (firstBatch) => { + setPhotos((prev) => (prev.length < firstBatch.length ? firstBatch : prev)); + }), + listEditedIds(), + listWalkedDirs(), + ]); // A read that came back with nothing is a read that failed, not a catalogue // that emptied — and the frames it would have cleared are frames the reader // was working their way through. The strip keeps them. @@ -934,16 +970,51 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // `engine/immich.ts` that does the work. The node's album rows are put in the // place of its old ones — an album the reader unticked is a row that has to go, // and `reload` only ever adds. + // + // A node of a hundred thousand frames is not read in one visit. What comes back + // here is the newest pages of it and a word that the rest is still coming, which + // is what the note says: the reader who is told six hundred frames were read is + // a reader looking at a node that says it holds six hundred. const syncServer = useCallback( async (folder: LibraryFolder) => { + // One reading of a node at a time. This is asked for on every visit and + // again whenever the node the screen shows changes, and a second reading + // behind the first is the same pages asked of the server twice over. + if (syncingServer.current.has(folder.name)) return; + syncingServer.current.add(folder.name); setNote(null); try { - const res = await syncImmich(folder); + const res = await syncImmich(folder, (batch) => { + setPhotos((prev) => { + const existing = new Set(prev.map((p) => p.id)); + const toAdd = batch.filter((p) => !existing.has(p.id)); + if (!toAdd.length) return prev; + // A server's reading walks backwards in time, and a page carried on + // from where the last visit stopped is older than every row the screen + // already holds: put after them, the list is in order without being + // sorted, and sorting a hundred thousand rows once a page is the visit + // spending itself on sorting. Only a page the reading has gone back + // over — the newest ones, read again from the top — needs the sort. + const tail = prev[prev.length - 1]; + const next = + tail && toAdd[0].taken <= tail.taken + ? [...prev, ...toAdd] + : [...toAdd, ...prev].sort((a, b) => b.taken - a.taken); + saveCachedPhotos(next); + return next; + }); + }); setDirs((prev) => [...prev.filter((d) => d.folder !== folder.name), ...res.dirs]); - setNote(t('lib.immich.added', { n: res.total, folder: folder.label ?? folder.name })); + setNote( + res.done + ? t('lib.immich.added', { n: res.total, folder: folder.label ?? folder.name }) + : t('lib.immich.reading', { folder: folder.label ?? folder.name }) + ); await reload(); } catch { setNote(t('lib.immich.failed')); + } finally { + syncingServer.current.delete(folder.name); } }, [reload, t] @@ -980,6 +1051,15 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { nextFolders = nextFolders.map((f) => (f.name === folder!.name ? folder! : f)); changed = true; void syncServer(folder); + } else if ((!folder.cursor || !folder.cursor.done) && !startedHere.current.has(folder.name)) { + // A node that was never read through is a node whose reading ended with + // the visit that began it — a reload, a closed tab, a server that did not + // answer — and the place it stopped at is on its own record. Carrying on + // from there is the only way the frames under the newest pages are ever + // read: the visit that reads three pages and stops, every visit and for + // good, is a node the reader is told holds six hundred frames. + startedHere.current.add(folder.name); + void syncServer(folder); } } @@ -1340,6 +1420,25 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { [drop] ); + // A saved link taken off the list in the settings dialog: the key goes, and + // every node that was reading by it goes with it — a node with no key is a row + // that could only fail. Same move as `disconnect`, for the case where the reader + // is looking at the list rather than at the node. + const forgetLink = useCallback( + async (id: string) => { + const gone = folders.filter((f) => f.server === id); + await forgetImmichServer(id); + for (const f of gone) await removeFolder(f.name); + const rest = folders.filter((f) => f.server !== id); + setFolders(rest); + setDirs((prev) => prev.filter((d) => !gone.some((f) => f.name === d.folder))); + if (gone.some((f) => f.name === node)) setNode(rest[0]?.name ?? null); + setServers((await immichConfig(true)).servers); + await reload(); + }, + [folders, node, reload] + ); + // The frame goes to the studio through the address bar: the studio owns the // load (develop, EXIF, session) and this page owns no part of it. While a scan // is running the address bar is the one thing that changes — `go` keeps the @@ -1483,6 +1582,12 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // live scan progress ticks (every 200ms) do not re-scan tens of thousands of photos. const baseTreeInfo = useMemo(() => { const hits = new Map(); + // What a folder holds of its own, the frames filed straight into it. The two + // are one number for a folder of the disk, whose subfolders hold frames that + // are nowhere else; a server's album rows hold the frames the node above them + // holds too — an album is a second way into one library, not more of it — and + // a node's number read off `hits` would say the library twice. See `counted`. + const own = new Map(); const foldersUnder = new Map>(); const add = (folder: string, rel: string, counted: boolean) => { // `normPath`, not a bare backslash swap: a picked drive root is named `\`, @@ -1511,29 +1616,49 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { const normDir = (photo.dir || '').replace(/\\/g, '/'); hits.set(normF.toLowerCase(), (hits.get(normF.toLowerCase()) ?? 0) + 1); if (normDir) add(normF, normDir, true); + else own.set(normF.toLowerCase(), (own.get(normF.toLowerCase()) ?? 0) + 1); } for (const dir of dirs) add(dir.folder, dir.rel, false); if (liveDirs) { for (const dir of liveDirs) add(dir.folder, dir.rel, false); } - return { hits, foldersUnder }; + return { hits, own, foldersUnder }; }, [photos, dirs, liveDirs]); const nodes = useMemo(() => { - const { hits, foldersUnder } = baseTreeInfo; + const { hits, own, foldersUnder } = baseTreeInfo; // What a row says: the reading in flight, then what the reading that walked // this roll found, then the catalogue. The reading first because it is the // newest thing there is; the walk second because the catalogue is not a // record of the disk — a frame taken off it is a row nothing takes out — and a // number read off it is one that can only ever climb; the catalogue last, // which is all a roll that has never been walked has. + // A server's node is not on the disk, and the walk record is the disk's word: + // `syncImmich` writes down where it stopped and what the pages it read held, + // never the whole of the node — a reading that stops at its foreground window + // writes the six hundred frames of that window, and one whose first page does + // not come back writes a nought. Read as the node's own numbers they stand a + // half-count, or a nought, beside a node the catalogue holds a hundred + // thousand frames under, for good: the record outlives every visit. So a + // server's rows read their numbers off the catalogue, which is the one thing + // that knows what has been filed under them. See `engine/immich.ts`. + const serverRoots = new Set( + folders.filter((f) => f.source === 'immich').map((f) => normPath(f.name).toLowerCase()) + ); const counted = (key: string) => { const normKey = normPath(key); const lowerKey = normKey.toLowerCase(); const readVal = read?.[normKey] ?? read?.[lowerKey] ?? read?.[key]; if (readVal !== undefined) return readVal; - const walkVal = walked[lowerKey]; - if (walkVal !== undefined) return walkVal; + // The node itself: its own frames and no more. Its album rows sit under it + // and hold frames it holds as well, so the whole catalogue under the node is + // the library counted once per album one of its frames is in. The album rows + // are rows of their own and count themselves below. + if (serverRoots.has(lowerKey)) return own.get(lowerKey) ?? hits.get(lowerKey) ?? 0; + if (![...serverRoots].some((r) => lowerKey.startsWith(`${r}/`))) { + const walkVal = walked[lowerKey]; + if (walkVal !== undefined) return walkVal; + } return hits.get(lowerKey) ?? 0; }; const out: Node[] = []; @@ -1735,10 +1860,17 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // there are frames to find in rather than a century of empty ones. Scoped to // `shown` — the open node (+ its subfolders when that chip is on) — not the // whole library, so picking a year never points at frames elsewhere. - const years = useMemo( - () => [...new Set(shown.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a), - [shown] - ); + const years = useMemo(() => { + const set = new Set(); + for (const p of shown) { + if (p.taken) set.add(new Date(p.taken).getFullYear()); + } + for (const yr of serverYears) { + set.add(yr); + } + return Array.from(set).sort((a, b) => b - a); + }, [shown, serverYears]); + // A year the open folder does not hold is not a year to look at, and leaving // it set would draw an empty shelf with no way back but the dropdown. Fires on // a move, not on every frame landing, so a scan of the open folder filling in @@ -1748,7 +1880,56 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // ponytail: only the year option list is folder-derived; stars and hours are // fixed lists that cannot go stale. Re-check the others if they ever grow. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [node, deep, rootName]); + }, [node, deep, rootName, years]); + + // On-demand fetch for selected year if user has Immich servers and local photos for that year are missing + const fetchYearPhotos = useCallback( + async (yr: number) => { + if (!servers.length) return; + const server = servers[0]; + const immichFolder = folders.find((f) => f.source === 'immich' && f.server === server.id); + const root = normPath(immichFolder?.name ?? 'IMMICH'); + const startDate = `${yr}-01-01T00:00:00.000Z`; + const endDate = `${yr}-12-31T23:59:59.999Z`; + + try { + const { items } = await listAssets(server.id, null, 1, '', endDate, startDate); + if (!items.length) return; + + const newPhotos: LibraryPhoto[] = items.map((item) => ({ + id: [root, item.name].join('/'), + folder: immichFolder?.name ?? 'IMMICH', + dir: '', + name: item.name, + server: server.id, + remoteId: item.id, + taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(), + size: item.size ?? 0, + addedAt: Date.now(), + })); + + setPhotos((prev) => { + const have = new Set(prev.map((p) => p.id)); + const landed = newPhotos.filter((p) => !have.has(p.id)); + return landed.length ? [...prev, ...landed] : prev; + }); + } catch { + // quiet + } + }, + [servers, folders] + ); + + useEffect(() => { + if (year === 'all' || !servers.length) return; + const yr = Number(year); + if (!Number.isFinite(yr) || yr < 1900) return; + + const hasPhotosForYear = photos.some((p) => new Date(p.taken || 0).getFullYear() === yr); + if (!hasPhotosForYear) { + void fetchYearPhotos(yr); + } + }, [year, photos, servers, fetchYearPhotos]); const handleSelectPhoto = useCallback( (photo: LibraryPhoto, e: React.MouseEvent) => { @@ -2263,10 +2444,18 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // it raises, not to the studio. The word is only for a frame that has no // thumbnail to give: one that has one and has not been handed it yet — the wall // hands pictures out only around the eye — keeps the box and says nothing. - const tile = (photo: LibraryPhoto, onPick?: () => void) => - urls[photo.id] ? ( + // A frame the wall has no tile for yet: a server's own frame is drawn from the + // address the proxy serves it at rather than left as an empty box. Keeping a + // cloud library's tiles as blobs is a copy of every picture anyone scrolled past + // (`!p.remoteId` in `useThumbUrls` is the other half of that: the wall does not + // mint them), and the browser already keeps what it fetched for the day + // `/api/immich/thumb` says it may — so the wall scrolls on and the visit after + // draws without asking twice. + const tile = (photo: LibraryPhoto, onPick?: () => void) => { + const src = urls[photo.id] ?? (photo.remoteId ? remoteUrl('thumbnail', photo) : null); + return src ? ( ); + }; if (isMobile) { return ( @@ -2283,6 +2473,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { prev.map((p) => (p.id === photo.id ? { ...p, rot } : p))); }} onSwitchToDesktop={() => setForceView('desktop')} + onFetchYear={fetchYearPhotos} /> {addImmich ? ( void forgetLink(id)} onSaved={(saved, isNew) => void serverSaved(saved, isNew)} onClose={() => setAddImmich(false)} /> @@ -3447,6 +3641,8 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { it goes straight up to the backend, which probes it before it stores. */} {addImmich ? ( void forgetLink(id)} onSaved={(saved, isNew) => void serverSaved(saved, isNew)} onClose={() => setAddImmich(false)} /> diff --git a/docker/frontend/src/engine/customFrame.ts b/docker/frontend/src/engine/customFrame.ts new file mode 100644 index 0000000..c061f0d --- /dev/null +++ b/docker/frontend/src/engine/customFrame.ts @@ -0,0 +1,253 @@ +// Custom imported transparent photo frame engine and storage + +export interface CustomFrameHole { + x: number; // 0..1 fraction of frame width + y: number; // 0..1 fraction of frame height + w: number; // 0..1 fraction of frame width + h: number; // 0..1 fraction of frame height +} + +export interface CustomFrameData { + id: string; + name: string; + bytes: Uint8Array; + dataUrl: string; + width: number; + height: number; + hole: CustomFrameHole; + scale: number; // default 1.0 (cover/fill hole) + panX: number; // offset in frame pixels + panY: number; // offset in frame pixels +} + +const DB_NAME = 'recipescam-studio'; +const STORE = 'custom_frames'; +const FRAME_KEY = 'active_custom_frame'; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 2); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains('session')) { + db.createObjectStore('session'); + } + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export async function saveCustomFrame(frame: CustomFrameData): Promise { + try { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(frame, FRAME_KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } catch (err) { + console.warn('Failed to save custom frame to IndexedDB', err); + } +} + +export async function loadCustomFrame(): Promise { + try { + const db = await openDb(); + const row = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).get(FRAME_KEY); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + db.close(); + if (row && row.bytes instanceof Uint8Array && row.hole) { + return row; + } + return null; + } catch { + return null; + } +} + +export async function clearCustomFrame(): Promise { + try { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(FRAME_KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } catch {} +} + +/** + * Analyzes an imported frame file (PNG, WebP, SVG, etc.), + * decodes it, and detects the transparent opening/hole via alpha channel analysis. + */ +export async function analyzeCustomFrame(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + const blob = new Blob([bytes], { type: file.type || 'image/png' }); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error('Cannot decode frame image')); + img.src = dataUrl; + }); + + const w = img.naturalWidth || img.width; + const h = img.naturalHeight || img.height; + if (!(w > 0 && h > 0)) { + throw new Error('Invalid image dimensions'); + } + + // Downscale slightly for fast and accurate pixel analysis (max 600px) + const maxDim = 600; + const sampleScale = Math.min(1, maxDim / Math.max(w, h)); + const sw = Math.max(10, Math.round(w * sampleScale)); + const sh = Math.max(10, Math.round(h * sampleScale)); + + const canvas = document.createElement('canvas'); + canvas.width = sw; + canvas.height = sh; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { + throw new Error('Canvas 2D context not available'); + } + + ctx.drawImage(img, 0, 0, sw, sh); + const imgData = ctx.getImageData(0, 0, sw, sh); + const data = imgData.data; + + // Transparent threshold: alpha < 70 (out of 255) + const isAlphaLow = (x: number, y: number) => { + return data[(y * sw + x) * 4 + 3] < 70; + }; + + // 1. Flood-fill from outer edges to distinguish outer transparent padding from the inner transparent hole + const visited = new Uint8Array(sw * sh); + const queue: number[] = []; + + for (let x = 0; x < sw; x++) { + if (isAlphaLow(x, 0)) { + visited[0 * sw + x] = 1; + queue.push(x, 0); + } + if (isAlphaLow(x, sh - 1)) { + visited[(sh - 1) * sw + x] = 1; + queue.push(x, sh - 1); + } + } + for (let y = 0; y < sh; y++) { + if (isAlphaLow(0, y) && !visited[y * sw + 0]) { + visited[y * sw + 0] = 1; + queue.push(0, y); + } + if (isAlphaLow(sw - 1, y) && !visited[y * sw + (sw - 1)]) { + visited[y * sw + (sw - 1)] = 1; + queue.push(sw - 1, y); + } + } + + let head = 0; + while (head < queue.length) { + const qx = queue[head++]; + const qy = queue[head++]; + const neighbors = [ + [qx + 1, qy], + [qx - 1, qy], + [qx, qy + 1], + [qx, qy - 1], + ]; + for (const [nx, ny] of neighbors) { + if (nx >= 0 && nx < sw && ny >= 0 && ny < sh) { + const idx = ny * sw + nx; + if (!visited[idx] && isAlphaLow(nx, ny)) { + visited[idx] = 1; + queue.push(nx, ny); + } + } + } + } + + // 2. Find bounding box of inner transparent pixels (not reached by flood-fill from border) + let minX = sw; + let minY = sh; + let maxX = -1; + let maxY = -1; + let innerTransparentCount = 0; + + for (let y = 0; y < sh; y++) { + for (let x = 0; x < sw; x++) { + const idx = y * sw + x; + if (isAlphaLow(x, y) && !visited[idx]) { + innerTransparentCount++; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + + // 3. Fallback: if no inner transparent pixel was isolated, search for any transparent pixels + if (innerTransparentCount < 20 || minX > maxX) { + minX = sw; + minY = sh; + maxX = -1; + maxY = -1; + let anyCount = 0; + for (let y = 0; y < sh; y++) { + for (let x = 0; x < sw; x++) { + if (isAlphaLow(x, y)) { + anyCount++; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + innerTransparentCount = anyCount; + } + + // 4. Compute normalized hole geometry + let hole: CustomFrameHole = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }; + if (innerTransparentCount > 20 && minX <= maxX && minY <= maxY) { + const holeW = Math.max(0.05, (maxX - minX + 1) / sw); + const holeH = Math.max(0.05, (maxY - minY + 1) / sh); + hole = { + x: minX / sw, + y: minY / sh, + w: holeW, + h: holeH, + }; + } + + return { + id: `custom-${Date.now()}`, + name: file.name, + bytes, + dataUrl, + width: w, + height: h, + hole, + scale: 1.0, + panX: 0, + panY: 0, + }; +} diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 9412181..4202cb1 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -100,6 +100,13 @@ export interface RenderOptions { // quarter turn and the fine angle (they ride on top of both). photoFlipH?: boolean; photoFlipV?: boolean; + customFrame?: { + bytes: Uint8Array; + hole: { x: number; y: number; w: number; h: number }; + scale?: number; + panX?: number; + panY?: number; + } | null; // The strip the FILE carries: the amber `#TAG` over the photo and a caption // band appended below it, mirroring the landing page's `.lp-shot` + // `.lp-frame-meta`. Left off the live preview, which must not show it. @@ -770,8 +777,8 @@ function loadWallframe(assets: RenderAssets) { // is a transparent window a couple of percent of paper wide around it. const isOldFilm = (id: FrameId) => id === 'old-film' || id === 'old-film-portrait'; // A frame that carries its own opening: the aspect chip has nothing to say to -// it (both walls, both films). -const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id); +// it (both walls, both films, custom frames). +const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id) || id === 'custom'; function loadOldFilm(assets: RenderAssets) { if (oldFilmImage) return oldFilmImage; @@ -1521,6 +1528,73 @@ export async function renderPhoto(input: RenderInput): Promise(); // Tiles are drawn after the rows have landed rather than before: the column and // the count are what the reader sees first, and a tile is the wall's own business. const TILE_LANES = 3; @@ -155,6 +167,37 @@ export async function forgetImmichServer(id: string): Promise { } } +// Upload an edited photo to Immich Cloud +export async function uploadToImmich( + bytes: Uint8Array, + name: string, + serverId?: string, + albumId?: string +): Promise<{ ok: boolean; id?: string; error?: string }> { + try { + const params = new URLSearchParams(); + if (serverId) params.set('server', serverId); + if (name) params.set('name', name); + if (albumId) params.set('album', albumId); + + const res = await fetch(`/api/immich/upload?${params.toString()}`, { + method: 'POST', + headers: { + 'content-type': 'image/jpeg', + }, + body: bytes as BodyInit, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { ok: false, error: (err as any)?.error || (err as any)?.details || `HTTP ${res.status}` }; + } + const data = await res.json().catch(() => ({})); + return { ok: true, id: (data as any)?.id }; + } catch (err: any) { + return { ok: false, error: err?.message || String(err) }; + } +} + // The albums this key can see, asked for live: an album added on Immich has to be // there the next time the dialog is opened, so nothing here is cached. export async function listImmichAlbums(server: string): Promise { @@ -180,17 +223,48 @@ export async function saveImmichAlbums(server: string, selected: string[]): Prom // One page of a server's frames. `album` is an Immich id or null for everything // the key can see — the album field's empty state. -async function listAssets( +export async function listAssets( server: string, album: string | null, page: number, - search = '' -): Promise<{ items: ImmichAsset[]; hasMore: boolean }> { + search = '', + takenBefore?: string | null, + takenAfter?: string | null +): Promise<{ items: ImmichAsset[]; hasMore: boolean; ok: boolean }> { const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) }); if (album) query.set('album', album); if (search) query.set('q', search); - const body = await get<{ items?: ImmichAsset[]; hasMore?: boolean }>(`/api/immich/assets?${query.toString()}`); - return { items: body?.items ?? [], hasMore: body?.hasMore === true }; + if (takenBefore) query.set('takenBefore', takenBefore); + if (takenAfter) query.set('takenAfter', takenAfter); + // A page that could not be read is not a page with nothing on it. Both are one + // shape to a loop that counts items, and the loop that takes the one for the + // other reads the end of the roll at the first timeout and drops every row it + // had not met yet. `ok` is the word that says which of the two this was, and an + // unanswered page is asked for again here rather than in every caller. + for (let attempt = 0; ; attempt++) { + try { + const res = await fetch(`/api/immich/assets?${query.toString()}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await json<{ items?: ImmichAsset[]; hasMore?: boolean }>(res); + if (!body) throw new Error('unreadable answer'); + return { items: body.items ?? [], hasMore: body.hasMore === true, ok: true }; + } catch { + if (attempt >= ASSET_RETRIES) return { items: [], hasMore: false, ok: false }; + await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))); + } + } +} + +// Fetch timeline buckets (months/years) for an Immich server in one fast call +export async function fetchImmichBuckets( + server: string, + size: 'YEAR' | 'MONTH' = 'MONTH' +): Promise<{ timeBucket: string; count: number }[]> { + const query = new URLSearchParams({ server, size }); + const body = await get<{ buckets?: { timeBucket: string; count: number }[] }>( + `/api/immich/buckets?${query.toString()}` + ); + return Array.isArray(body?.buckets) ? body.buckets : []; } // What the tree paints, and the name an album's frames are filed under. An album @@ -221,6 +295,11 @@ function frameName(name: string, id: string, taken: Set): string { export interface ImmichSyncResult { total: number; albums: number; + // False while the node is still being read — the answer above comes back at the + // foreground window, and the rest of it is read behind it. The screen says so + // rather than a number that would have the reader believe the node holds six + // hundred frames. + done: boolean; // The album rows this read named, spelled the way the column spells a path. The // screen puts these in the place of the node's old ones: an album the reader // unticked is a row that has to go, and a record that is only ever added to @@ -228,15 +307,20 @@ export interface ImmichSyncResult { dirs: LibraryDir[]; } +export type ImmichSyncCallback = (batch: LibraryPhoto[], progress: { loaded: number; hasMore: boolean }) => void; + // Read a server's albums into the catalogue: the rows and the column first, so // the count and the tree are right the moment this returns, and the tiles after, // a lane at a time, so a reader who opens the node while it is still fetching // sees frames rather than a blank wall. // -// One read covers the whole node. There is no per-album read: the album list is -// one call, a page of frames is one call, and a column that says one album is -// fresh while the others are a week old is a column nobody can read a number off. -export async function syncImmich(folder: LibraryFolder): Promise { +// Streams batches to IndexedDB and UI immediately (in < 200ms per batch). +// Resolves the foreground pass in 3 pages (up to 600 newest photos) so the UI +// unblocks instantly, and continues syncing historical photos in the background. +export async function syncImmich( + folder: LibraryFolder, + onBatch?: ImmichSyncCallback +): Promise { const server = folder.server ?? ''; const search = folder.kind === 'search' ? (folder.query ?? '').trim() : ''; const chosen = folder.albums ?? []; @@ -245,20 +329,78 @@ export async function syncImmich(folder: LibraryFolder): Promise = {}; + // The frames this reading has already filed. The keyset read stops on a frame's + // own time and `takenBefore` is inclusive, so every page opens on the frame the + // page before it ended with: filed as it came, that frame is a row the wall + // draws twice. `frameName` answers the name a second *file* takes; the id is + // what says there is no second file. + const seen = new Set(); // 1. ALWAYS read ALL photos of the Immich API (filed under root of folder: dir = '') - // This guarantees that all photos (timeline, camera roll, uploads) are fully loaded into the Library. + // Keyset pagination with takenBefore prevents slow SQL offsets and timeouts! const rootTaken = new Set(); - let page = 1; + // Where the reading before this one stopped, when it did not reach the end. A + // node of a hundred thousand frames is not one visit's to read, and a place only + // ever held in memory is a place every reload began without: the whole of what + // the catalogue was given was then the newest three pages of it, once a visit, + // for good. The node's own record keeps the place instead — the frame it stopped + // on, and the page within that frame's own instant. + const resume = folder.cursor && !folder.cursor.done ? folder.cursor : null; + let lastTakenBefore: string | null = resume?.before ?? null; + let page = resume?.page ?? 1; + let pages = 0; let rootCount = 0; + let complete = false; + let failed = false; + const FOREGROUND_PAGES = 3; // The newest three pages answer the call so the screen unblocks + let hasMoreRemaining = false; + + // The place, written down where the visit after this one reads it. Every page + // rather than every minute: a page is two hundred frames, and a visit may end + // at any of them. + const remember = async (before: string | null, at: number, done: boolean) => { + try { + await putFolder({ ...folder, cursor: { before, page: at, done } }); + } catch {} + }; + + // Where a page leaves the reading. The bound is a frame's own instant and the + // call that asks for it keeps the frame it stopped on — `takenBefore` is + // inclusive — so a page whose oldest frame is older than the bound has moved it, + // and the page after it starts at that very frame. A page that is one instant + // from end to end cannot move it at all: Immich keeps a frame's time to the + // millisecond, and a roll imported in one go is hundreds of frames at the same + // one, which is the whole of the oldest days of this library. Asked for with the + // bound alone, that page is the same page for ever — the reading never ends, and + // the frames under that instant are never read. So the rest of the instant is + // walked by offset, and the bound is left where it is until the pages under it + // run out. + // ponytail: that offset walk is over everything older than the bound, and a deep + // offset is a slow answer — an Immich that offers a cursor for it, or a page + // asked per instant out of the timeline buckets, is the way out of it if a roll + // of thousands at one instant ever sits in the middle of a library. + const step = (items: ImmichAsset[]): void => { + const at = items[items.length - 1]?.takenAt ?? null; + if (page === 1 && at && at !== lastTakenBefore) lastTakenBefore = at; + else page++; + }; + for (;;) { - const { items, hasMore } = await listAssets(server, null, page, search); + const { items, hasMore, ok } = await listAssets(server, null, page, search, lastTakenBefore); + // A page that could not be read is not the end of the node, and only a + // reading that met the end of it may say what the node no longer holds. + if (!ok) { + failed = true; + break; + } + const batchRows: LibraryPhoto[] = []; for (const item of items) { - if (!item.id || !item.name) continue; + if (!item.id || !item.name || seen.has(item.id)) continue; + seen.add(item.id); const name = frameName(item.name, item.id, rootTaken); rootTaken.add(item.name); rootTaken.add(name); - rows.push({ + const row: LibraryPhoto = { id: [root, name].join('/'), folder: folder.name, dir: '', @@ -268,17 +410,35 @@ export async function syncImmich(folder: LibraryFolder): Promise= FOREGROUND_PAGES) { + hasMoreRemaining = true; + break; + } } counts[root] = rootCount; // 2. ALBUMS of Immich ONLY display in the ALBUM section if the user chooses specific albums (chosen.length > 0). - // When chosen is empty, dirs remains empty ([]) so no album subfolders clutter the ALBUM tree. - // When chosen has album IDs, only those specific chosen albums are fetched and populated into dirs. if (!search && chosen.length > 0) { const albums = await listImmichAlbums(server); const labels = albumLabels(albums); @@ -290,13 +450,21 @@ export async function syncImmich(folder: LibraryFolder): Promise (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase())); - await prunePhotos(root, '', met); - await putPhotos(rows); + // The record carries the reading's whole word or none of it: only a reading + // that met the end of the node may say what the node holds, because a record + // that says otherwise is the record the column reads its noughts off. A reading + // stopped at the foreground window, or cut short by a page that would not come + // back, folds its half into what was already there instead. + await saveWalkedDirs(root, dirs, counts, complete && !failed); - // Pre-cache thumbnails for initial display without stalling large libraries - const missing = new Set(await missingThumbs(rows.map((r) => r.id))); - const queue = rows.filter((r) => missing.has(r.id)); - const initialBatch = queue.slice(0, 80); - const backgroundBatch = queue.slice(80); + // A row the reading did not meet is dropped only against a reading that met all + // of them — and only one that began at the top. A reading that carried on from + // where the last one stopped holds the newest pages in its own list and no older + // row in it at all, and pruned to that list it would take back every frame it + // was picking up. + // ponytail: a resumed reading therefore stops one prune short — a frame dropped + // on the server keeps its row until some visit reads the node from the top. Read + // `met` out of the catalogue rather than out of `rows` if that ever shows. + if (complete && !failed && !resume) { + const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase())); + await prunePhotos(root, '', met); + } - await Promise.all( - Array.from({ length: Math.min(TILE_LANES, initialBatch.length) }, async () => { - while (initialBatch.length) { - const row = initialBatch.shift()!; - try { - const res = await fetch(remoteUrl('thumbnail', row)); - if (res.ok) await putPhotoThumb(row.id, await res.blob()); - } catch { - // One frame's tile is one frame's: the wall draws a blank cell and the - // reader may ask again, rather than a sync that stops halfway. - } - await breathe(); - } - }) - ); - - if (backgroundBatch.length) { + // The rest of the node, a page at a time, behind the answer above. Its place is + // the record's exactly as the foreground's is, so a visit that ends in the + // middle of it — a reload, a closed tab, a lid shut — costs the page in flight + // and nothing else. + if ((hasMoreRemaining || failed) && !walking.has(root)) { + walking.add(root); void (async () => { - while (backgroundBatch.length) { - const row = backgroundBatch.shift()!; - try { - const res = await fetch(remoteUrl('thumbnail', row)); - if (res.ok) await putPhotoThumb(row.id, await res.blob()); - } catch {} - await breathe(); + let bgPage = page; + let bgLastTakenBefore = lastTakenBefore; + let reachedEnd = false; + try { + for (;;) { + const { items, hasMore: bgMore, ok } = await listAssets( + server, + null, + bgPage, + search, + bgLastTakenBefore + ); + // A page that could not be read leaves the place where the last page + // that could left it: the visit after this one asks for it again. + if (!ok) break; + if (!items.length) { + reachedEnd = true; + break; + } + const bgBatchRows: LibraryPhoto[] = []; + for (const item of items) { + if (!item.id || !item.name || seen.has(item.id)) continue; + seen.add(item.id); + const name = frameName(item.name, item.id, rootTaken); + rootTaken.add(item.name); + rootTaken.add(name); + bgBatchRows.push({ + id: [root, name].join('/'), + folder: folder.name, + dir: '', + name, + server, + remoteId: item.id, + taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(), + size: item.size ?? 0, + addedAt: Date.now(), + }); + rootCount++; + } + if (bgBatchRows.length) { + await putPhotosFast(bgBatchRows); + onBatch?.(bgBatchRows, { loaded: rootCount, hasMore: bgMore }); + } + if (!bgMore) { + reachedEnd = true; + break; + } + const at = items[items.length - 1]?.takenAt ?? null; + if (bgPage === 1 && at && at !== bgLastTakenBefore) bgLastTakenBefore = at; + else bgPage++; + await remember(bgLastTakenBefore, bgPage, false); + await breathe(); + } + counts[root] = rootCount; + await saveWalkedDirs(root, dirs, counts, reachedEnd); + if (reachedEnd) { + // The node is read through: the place is not a place any more, and the + // reading that begins next begins at the top — which is where a frame + // added since would be. + await remember(null, 1, true); + if (!resume) { + const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase())); + await prunePhotos(root, '', met); + } + } + } finally { + walking.delete(root); } })(); } - return { total: rootCount, albums: dirs.length, dirs }; + return { total: rootCount, albums: dirs.length, dirs, done: complete && !failed }; } // The name a server's node takes: the first one is `Immich`, and a second is diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 0481ef6..93240dd 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -25,7 +25,7 @@ const PHOTOS = 'photos'; const THUMBS = 'thumbs'; const EDITS = 'edits'; const DIRS = 'dirs'; -const DB_VERSION = 4; +const DB_VERSION = 5; // The tile the catalogue keeps is the same tile the grid cell and the strip both // paint, and there is one of them per frame of a roll that reaches six figures. @@ -112,6 +112,17 @@ export interface LibraryFolder { // the name is the store's key — but a label the reader may rename is no place to // keep a query, and the sync asks with this. query?: string; + // Where a server's reading stopped, on a node whose frames are an Immich + // server's. A node of a hundred thousand frames is not one visit's to read, and + // the keyset the reading pages by is a value the page cannot hold across a + // reload: without this, every visit read the newest three pages of the node + // again and nothing below them was ever read at all. `before` is the instant the + // next page starts from, `page` the page within that instant — Immich keeps a + // frame's time to the millisecond and a roll imported in one go is hundreds of + // frames at the same one, so one instant is several pages — and `done` says the + // whole node has been read, which is the only state in which a frame that is no + // longer there may be dropped from the catalogue. + cursor?: { before: string | null; page?: number; done: boolean }; } export interface LibraryPhoto { @@ -322,11 +333,14 @@ function openDb(): Promise { // to be made anyway. if (db.objectStoreNames.contains(THUMBS)) db.deleteObjectStore(THUMBS); if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' }); + let photosStore: IDBObjectStore; if (!db.objectStoreNames.contains(PHOTOS)) { - const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' }); - photos.createIndex('folder', 'folder'); - photos.createIndex('taken', 'taken'); + photosStore = db.createObjectStore(PHOTOS, { keyPath: 'id' }); + } else { + photosStore = req.transaction!.objectStore(PHOTOS); } + if (!photosStore.indexNames.contains('folder')) photosStore.createIndex('folder', 'folder'); + if (!photosStore.indexNames.contains('taken')) photosStore.createIndex('taken', 'taken'); if (!db.objectStoreNames.contains(THUMBS)) { db.createObjectStore(THUMBS, { keyPath: 'id' }); } @@ -2047,9 +2061,9 @@ function pump(): void { let cached: LibraryPhoto[] | null = null; export function cachedPhotos(): LibraryPhoto[] { - if (cached) return cached; + if (cached && cached.length > 0) return cached; try { - const raw = sessionStorage.getItem('rc_cached_photos'); + const raw = sessionStorage.getItem('rc_cached_photos') || localStorage.getItem('rc_cached_photos'); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.length > 0) { @@ -2061,6 +2075,26 @@ export function cachedPhotos(): LibraryPhoto[] { return []; } +export function saveCachedPhotos(photos: LibraryPhoto[]): void { + try { + const slim = photos.slice(0, 300).map((p) => ({ + id: p.id, + folder: p.folder, + name: p.name, + taken: p.taken, + addedAt: p.addedAt, + dir: p.dir, + star: p.star, + server: p.server, + remoteId: p.remoteId, + rot: p.rot, + })); + const json = JSON.stringify(slim); + sessionStorage.setItem('rc_cached_photos', json); + localStorage.setItem('rc_cached_photos', json); + } catch {} +} + export function clearLibraryCache(): void { cached = null; dirHandles.clear(); @@ -2385,6 +2419,10 @@ export async function makeThumbs( const dir = normPath(rel); const rows = Array.isArray(folder) ? folder : (await readPhotos(normPath(folder.name))) ?? []; const want = rows.filter((p) => { + // A server's frame has no tile to mint here: the wall draws the address the + // proxy serves it at (see `tile` in Library.tsx), so reading the bytes into + // the store is the same picture fetched twice and a copy kept for nothing. + if (p.remoteId) return false; const pDir = normPath(p.dir ?? ''); if (pDir === dir) return true; // `''` is the picked folder itself, so a row asked of the whole roll takes @@ -2413,7 +2451,10 @@ export async function makeThumbs( return made; } -export async function readPhotos(folder?: string): Promise { +export async function readPhotos( + folder?: string, + onBatch?: (photos: LibraryPhoto[]) => void +): Promise { try { const db = await openDb(); // Every row is filed under `normPath(folder.name)`, and a drive root is the @@ -2431,6 +2472,8 @@ export async function readPhotos(folder?: string): Promise((resolve, reject) => { const req = db.transaction(PHOTOS, 'readonly').objectStore(PHOTOS).getAll(at(last), READ_CHUNK); @@ -2446,26 +2489,19 @@ export async function readPhotos(folder?: string): Promise= 400) { + emittedFirst = true; + const initial = [...rows].sort((a, b) => b.taken - a.taken); + onBatch(initial); + } + if (chunk.length < READ_CHUNK) break; } rows.sort((a, b) => b.taken - a.taken); if (!folder) { cached = rows; - try { - const slim = rows.slice(0, 300).map((p) => ({ - id: p.id, - folder: p.folder, - name: p.name, - taken: p.taken, - addedAt: p.addedAt, - dir: p.dir, - star: p.star, - server: p.server, - remoteId: p.remoteId, - rot: p.rot, - })); - sessionStorage.setItem('rc_cached_photos', JSON.stringify(slim)); - } catch {} + saveCachedPhotos(rows); } return rows; } catch { @@ -2473,6 +2509,23 @@ export async function readPhotos(folder?: string): Promise { + if (!rows.length) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(PHOTOS, 'readwrite'); + const store = tx.objectStore(PHOTOS); + for (const row of rows) { + const r = { ...row }; + delete (r as { handle?: unknown }).handle; + delete (r as { thumb?: unknown }).thumb; + store.put(r); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + export async function listPhotos(folder?: string): Promise { return (await readPhotos(folder)) ?? []; } diff --git a/docker/frontend/src/engine/previewMatch.ts b/docker/frontend/src/engine/previewMatch.ts index 7f4cc15..31a9951 100644 --- a/docker/frontend/src/engine/previewMatch.ts +++ b/docker/frontend/src/engine/previewMatch.ts @@ -1,127 +1,18 @@ -// The colour the camera itself wrote into the file. +// The grid the frame's own rendering is read on. // -// LibRaw hands back the sensor's own data and nothing about how the body chose to -// render it: the white balance it locked, its picture style, its tone curve. The -// file carries the answer anyway — the preview saved inside it, which is the -// frame the photographer saw and the one a desktop viewer shows. So the develop -// is matched to that preview: develop a grid of the frame, take the same grid out -// of the embedded preview, and fit the curve between them — one cubic a channel, -// on the sRGB-encoded value the develop leaves. +// This file held the fit that used to relate a develop to the preview the camera +// wrote into the file: a grid out of each, a cubic a channel between them, and a +// 3x3 least squared ahead of that curve. None of it is the camera's rendering — +// a picture style is a colour transform with a curve in it — and the best a +// per-channel cubic could reach on the Fujifilm RAF this was reported on was +// dE00 6.04 against the file's own preview. // -// A 3x3 was fitted ahead of that curve for a long time, least squared from one -// grid to the other, and it is what put the cast the studio was reported with: -// a develop carries its luminance and its colour in the same three numbers, the -// luminance is two orders of magnitude the larger of the two, and a least squares -// that is dominated by it pays for the luminance with the colour — it holds the -// channel means and the bright/dark axis while it collapses the axis the eye -// reads as colour. The camera's own picture style is a curve, not a matrix. +// What replaced it is not a fit: the develop carries the difference itself, at +// the preview's own resolution, and adds it back on the value it left (see +// previewResidual in rawDevelop.ts). dE00 0.54 on that frame, 0.00 on every body +// that writes a full-size preview. // -// Measured on the Olympus ORF this was reported on, standard deviation of R-G -// over the frame (the file's own preview 8.3, the camera's own JPEG 12.3, and an -// olive frame is one where this has gone to nothing): -// develop as the sensor left it 11.6 -// the curve alone, no 3x3 11.9 -// the curve under the fitted 3x3 (what used to ship) 4.6 -// and on the frame's own means, curve alone against the preview: luminance 156.6 -// against 156.8, R/G 1.023 against 1.014, B/G 0.853 against 0.843. -// -// The fit is on the sRGB-encoded values, exposure included: the reference is the -// file's own frame, so matching its brightness is part of matching its colour. -// Fitted in that encoded space, not in linear light, because a least squares in -// linear light weighs the highlights and comes back with the midtones wrong — -// the same frames scored dE00 6.4 fitted encoded against 9.2 fitted linear. Any -// body, any picture style, and no fitted table to go stale — a preview is the one -// profile every RAW already carries. - -// The grid the fit reads, and the size of the preview map handed to it. +// What is left here is the grid both the balance read and the develop's own maps +// are taken on, which is still the size the preview is reduced to when something +// only needs a statistic of the frame. export const MATCH_GRID = 64; - -// A block at or past this has nothing left to say about a rendering. -const CLIP = 0.97; -const FLOOR = 0.03; - -// What the shader draws when the frame has no curve the fit can trust. -export const FLAT_TONE = new Float32Array([0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0]); - -// The curve a channel needs to reach the camera's, least squared: the develop's -// own value for that channel against the block the camera wrote there. -// -// The cubic is pinned at BOTH black (x=0 -> y=0) and white (x=1 -> y=1). -// Pinned at both ends, a blown pixel (1,1,1) stays pure white, and a deep shadow -// pixel (0,0,0) stays pure black without shifting to purple or magenta noise. -function basis(i: number, x: number): number { - const x3 = x * x * x; - return i === 0 ? x - x3 : x * x - x3; -} - -function solve2x2(A: number[][], d: number[]): number[] { - const det = A[0][0] * A[1][1] - A[0][1] * A[1][0] + 1e-9; - const a = (d[0] * A[1][1] - d[1] * A[0][1]) / det; - const b = (A[0][0] * d[1] - A[1][0] * d[0]) / det; - return [a, b]; -} - -// The curve itself, in the shader's own order: r a b c d, then g, then b. -function fitTone(blocks: { x: number[]; t: number[] }[]): Float32Array { - const out = new Float32Array(12); - for (let c = 0; c < 3; c++) { - const A = [[0, 0], [0, 0]]; - const d = [0, 0]; - for (const { x, t } of blocks) { - const v = Math.min(1, Math.max(0, x[c])); - const b = [basis(0, v), basis(1, v)]; - for (let p = 0; p < 2; p++) { - for (let k = 0; k < 2; k++) A[p][k] += b[p] * b[k]; - d[p] += b[p] * (t[c] - v * v * v); - } - } - const [a, b] = solve2x2(A, d); - out.set([0, a, b, 1 - a - b], c * 4); - } - return out; -} - -// A curve that comes back folded, or wandered far from the value it was handed, -// is a fit off blocks that fought each other, not a rendering: the caller opens -// the frame as the sensor left it instead. The floor sits below the zero a curve -// is allowed to reach — the develop clamps there anyway — and the slope is only -// asked not to turn back. -function curveIsSane(tone: Float32Array): boolean { - for (const v of tone) if (!Number.isFinite(v) || Math.abs(v) > 4) return false; - for (let c = 0; c < 3; c++) { - const [a, b, cc, dd] = [tone[c * 4], tone[c * 4 + 1], tone[c * 4 + 2], tone[c * 4 + 3]]; - let prev = a; - for (let i = 1; i <= 16; i++) { - const x = i / 16; - const y = a + x * (b + x * (cc + x * dd)); - if (y < prev - 0.12 || y < -0.15 || y > 1.15) return false; - prev = y; - } - } - return true; -} - -// `dev` and `ref` are n x n RGBA maps of the same frame, one byte per channel, -// both sRGB-encoded by the same resampler. Null when the two cannot be related — -// too few blocks carry colour — and the caller develops the frame as the sensor -// left it. -export function toneMatch(dev: Uint8Array, ref: Uint8Array, n = MATCH_GRID): Float32Array | null { - const blocks: { x: number[]; t: number[] }[] = []; - let used = 0; - for (let i = 0; i < n * n; i++) { - const x = [dev[i * 4] / 255, dev[i * 4 + 1] / 255, dev[i * 4 + 2] / 255]; - const t = [ref[i * 4] / 255, ref[i * 4 + 1] / 255, ref[i * 4 + 2] / 255]; - // A block the camera clipped has no colour left to copy, one at the floor is - // only the pedestal, and one the develop clipped has lost the ratio between - // its channels — none of them say anything about the rendering. - if (t[0] > CLIP && t[1] > CLIP && t[2] > CLIP) continue; - if (x[0] > CLIP && x[1] > CLIP && x[2] > CLIP) continue; - if (Math.max(t[0], t[1], t[2]) < FLOOR) continue; - used++; - blocks.push({ x, t }); - } - // A fit off a handful of blocks is a fit off noise. - if (used < (n * n) / 8) return null; - const tone = fitTone(blocks); - return curveIsSane(tone) ? tone : null; -} diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index 6d4b46a..ac66b56 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -1,32 +1,36 @@ // RAW → JPEG on the client, so a camera's own file opens in the studio without // a DNG converter in the middle (see native_raw_processing_opfs_architecture.md). // -// A RAW opens at the colour the camera chose for it. LibRaw is kept out of white -// balance and tone (below) and knows nothing of the body's picture style, so the -// develop is fitted to the one rendering the file does carry with it: the preview -// the camera wrote inside it, which is the frame the photographer saw and the one -// a desktop viewer shows. Develop the sensor, take a block grid out of each on -// the encoded values, fit the cubic a channel between them, and develop the -// sensor through that curve (see previewMatch.ts). +// A RAW opens at the colour the camera chose for it, and at the sensor's own +// resolution. LibRaw is kept out of white balance and tone (below) and knows +// nothing of the body's picture style; the one rendering the file does carry with +// it is the preview the camera wrote inside it — the frame the photographer saw +// and the one a desktop viewer shows. // -// The curve is the whole fit. A 3x3 was least squared ahead of it for a long -// time and is what put the olive cast on the Olympus frame this was reported on: -// a develop keeps its luminance and its colour in the same three numbers and the -// luminance is two orders of magnitude the larger, so a least squares dominated -// by it holds the channel means and buys them with the colour axis. The camera's -// picture style is a curve, not a matrix. +// The develop is carried by the difference between that preview and this develop's +// own rendering of the same frame. The difference is kept at the preview's own +// resolution, one byte a channel with 0.5 for a pixel the two already agree on, +// and added back on the value the develop leaves: the frame opens at the camera's +// colour and at the sensor's resolution, and the develop is only ever asked for +// the detail the preview never had. Nothing is fitted — a body is matched against +// its own preview the moment it opens, whatever its picture style is, and there is +// no table to go stale (see previewResidual below). // -// Measured on that frame, standard deviation of R-G over it (the file's own -// preview 8.3, the camera's own JPEG 12.3, an olive frame being one where this -// has gone to nothing): 11.6 as the develop left it, 11.9 through the curve -// alone, 4.6 through the curve under the fitted 3x3. What no per-pixel transform -// reaches either way is the camera's own sharpening. +// It replaced a per-channel cubic fitted between a grid of each, and a 3x3 least +// squared ahead of that curve before it, neither of which is the camera's +// rendering: a picture style is a colour transform with a curve in it. Measured +// against the file's own preview, mean dE00: 6.04 for the cubic on the Fujifilm +// RAF this was reported on, 0.54 for the residual on that frame and 0.00 on every +// body whose preview is the size of its sensor (a Nikon NEF, a DNG), where the two +// frames agree block for block and the residual is nothing. // -// Developing the sensor is also what keeps the highlights: 2.3% of the frame at -// pure white against 3.3% in the preview itself, since the 8-bit preview threw -// the headroom away and the develop still has it. The preview is therefore the -// reference and the fallback, not the frame: it is what a file with no usable -// preview (some DNG), or with sensor data that will not decode, opens as. +// What the residual gives up is the headroom: it lands the frame on the preview's +// own white, so the two stops the sensor holds above white are not kept (the cubic +// kept 2.3% of the frame at pure white against 3.3% in the preview itself). A +// highlight recovery that needs them has to come from the develop, not from here. +// +// The preview is also the fallback, not the frame: it is what a file with no +// usable preview (some DNG), or with sensor data that will not decode, opens as. // // The develop: LibRaw demosaics in its own worker; what comes back is linear camera data, // which this file turns into the sRGB the rest of the pipeline expects. The @@ -53,7 +57,7 @@ import LibRaw from 'libraw-wasm'; import { sensorWhite } from './sensorWhite'; import { f32ToF16 } from './halfFloat'; -import { toneMatch, FLAT_TONE, MATCH_GRID } from './previewMatch'; +import { MATCH_GRID } from './previewMatch'; import { Skia, initSkia } from './skiaShim'; import { decodeTiff } from './tiffDecode'; @@ -95,15 +99,8 @@ uniform float4 m0; // camera -> sRGB, the first three columns of rgb_cam uniform float4 m1; uniform float4 m2; uniform float4 crop; // (y offset of this band, 0, 0, 0) -uniform float4 t0; // the file's own rendering, one cubic a channel on the -uniform float4 t1; // encoded value: r a b c d per float4 (flat when there is -uniform float4 t2; // no preview to fit to) - -// The camera's rendering is a shape, and this is that shape, read on the value -// the develop left. -float tone(float4 w, float x) { - return clamp(w.x + x * (w.y + x * (w.z + x * w.w)), 0.0, 1.0); -} +uniform shader res; // the file's own rendering, as a residual (see below) +uniform float2 rscl; // frame pixels -> residual pixels float3 encode(float3 x) { x = clamp(x, 0.0, 1.0); @@ -166,24 +163,33 @@ half4 main(float2 pos) { rgb *= (0.7 + over / (1.0 + over * 3.3333)) / mx; } float3 e = encode(rgb); - // The file's own colour: the curve, in float, on the encoded value it was fitted - // on — and the last step the frame leaves through. Not the 8-bit colour filter - // this used to be painted through: the curve carries an exposure (the preview is - // the reference, so matching its brightness is part of matching its colour), and - // it is what carries the body's own tone — a 3x3 can only scale, and the one that - // used to sit here bought the channel means by collapsing the colour axis it was - // fitted against (see previewMatch.ts). No rolloff: white is one of the values - // the curve is fitted on, and it is pinned there, so a blown pixel still lands on - // white while the frame's own highlights stay where the knee above left them. - float3 o = float3(tone(t0, e.r), tone(t1, e.g), tone(t2, e.b)); - // ...and the neutral the gate above drew is what the curve undoes: one cubic a - // channel, fitted on a grid that has no block left to fit where the frame ran out - // (the blown blocks are excluded, see previewMatch), so at the plateau the three - // curves agree only at 1.0 and part company either side of it — measured on the - // ORF, a 255,255,255 sky came back 254,255,255 and 253,255,254, red under green - // across a quarter of the frame. The gate is the develop's own statement that the - // pixel had no colour of its own, so it is re-read here, on the value that leaves. - return half4(half3(mix(o, float3(max(max(o.r, o.g), o.b)), blown)), 1.0); + // The neutral the gate above drew, kept on the way out. + float3 o = mix(e, float3(max(max(e.r, e.g), e.b)), blown); + // The file's own colour and tone, carried onto the develop at the develop's own + // resolution. res is the camera's rendering less this develop's own low + // frequencies, one byte per channel with 0.5 as nothing-to-say, so adding it + // puts the camera's rendering back on the frame and leaves everything the + // develop has that the preview never did — the sensor's own detail — in the + // frame. pos is already the frame's own coordinate (the band is drawn at its + // own y offset, so the same pos is band-local only because crop.x is taken back + // off for the raw tap above), so one scale is the whole mapping. + float2 rp = pos * rscl; + o = clamp(o + (res.eval(rp).rgb - 0.5), 0.0, 1.0); + return half4(half3(o), 1.0); +} +`; + +// The residual the develop above is carried by: the camera's own frame less the +// same frame as the develop left it, one byte per channel, 0.5 for a pixel the +// two already agree on. Both children are drawn at the size the residual is kept +// at, so neither needs a local matrix, and a minus b has to be able to go +// negative — hence the 0.5, which the develop takes back off. +const RESIDUAL_SKSL = ` +uniform shader dev; +uniform shader ref; + +half4 main(float2 p) { + return half4(half3(clamp(ref.eval(p).rgb - dev.eval(p).rgb + 0.5, 0.0, 1.0)), 1.0); } `; @@ -467,19 +473,7 @@ function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Ui // turn against the develop it was being fitted to, and the fit was rejected // as folded (see previewMatch), which is why the Panasonic RW2 opened with no // curve at all. - const targetAspect = w / h; - const bmpAspect = bmp.width() / bmp.height(); - let cropW = bmp.width(); - let cropH = bmp.height(); - if (Math.abs(bmpAspect - targetAspect) > 0.01) { - if (bmpAspect > targetAspect) { - cropW = Math.round(bmp.height() * targetAspect); - } else { - cropH = Math.round(bmp.width() / targetAspect); - } - } - const cropX = Math.round((bmp.width() - cropW) / 2); - const cropY = Math.round((bmp.height() - cropH) / 2); + const { x: cropX, y: cropY, w: cropW, h: cropH } = previewCrop(bmp, w, h); const surface = Skia.Surface.MakeOffscreen(n, n) ?? Skia.Surface.Make(n, n); if (!surface) return null; @@ -506,6 +500,144 @@ function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Ui } } +// The preview trimmed to the develop's own aspect, centred. What a body writes +// into the file is the frame the photographer saw, and it is the frame the +// sensor read; anything left over is a margin the develop does not have. +function previewCrop(bmp: any, w: number, h: number): { x: number; y: number; w: number; h: number } { + const aspect = w / h; + let cw = bmp.width(); + let ch = bmp.height(); + if (Math.abs(cw / ch - aspect) > 0.01) { + if (cw / ch > aspect) cw = Math.round(ch * aspect); + else ch = Math.round(cw / aspect); + } + return { x: Math.round((bmp.width() - cw) / 2), y: Math.round((bmp.height() - ch) / 2), w: cw, h: ch }; +} + +// The residual the develop is carried by, and how many frame pixels one of its +// own is worth. +export interface PreviewResidual { + img: any; + sx: number; + sy: number; +} + +// The residual's own long side. It is a smooth map by construction — it holds no +// detail, that is the develop's half of the frame — so a preview wider than this +// is reduced rather than carried whole: the colour it carries survives the +// reduction, and the 24MP surface a NEF-sized preview would need does not. +const RESIDUAL_MAX = 2048; + +// The one residual that says nothing: a pixel the camera's frame and this develop +// already agree on, for a file whose preview gave nothing to carry. +let NO_RESIDUAL: any = null; +function noResidual(): any { + if (!NO_RESIDUAL) { + // 0.5 in half, alpha 1 — the value the develop takes 0.5 back off. + const half = new Uint16Array([0x3800, 0x3800, 0x3800, 0x3c00]); + NO_RESIDUAL = Skia.Image.MakeImage( + { width: 1, height: 1, colorType: Skia.ColorType.RGBA_F16, alphaType: Skia.AlphaType.Unpremul }, + new Uint8Array(half.buffer), + 8 + ); + } + return NO_RESIDUAL; +} + +// What the camera's own rendering has to say about this develop: the whole +// difference between the two frames — the body's white balance, picture style, +// tone curve and colour, whatever the body is — carried at the preview's +// resolution as one byte a channel, 0.5 where the two already agree. The develop +// adds it back on the value it left, so the frame opens at the colour the +// photographer saw AND at the sensor's own resolution: the develop is only ever +// asked for the detail the preview never had. +// +// A mapping was tried here first — one cubic a channel, and a 3x3 under it before +// that — and neither is the camera's rendering: a picture style is a colour +// transform with a curve in it, and the best a per-channel cubic fitted between +// two grids could reach on the Fujifilm RAF this was reported on was dE00 6.04 +// against the file's own preview. The residual reaches 0.54 on the same frame and +// 0.00 on every body that writes a full-size preview, because it is not a fit at +// all — it is the difference itself. +function previewResidual(first: any, jpeg: Uint8Array, w: number, h: number): PreviewResidual | null { + const bmp = Skia.Image.MakeImageFromEncoded(jpeg); + if (!bmp) return null; + let ref: any = null; + let dev: any = null; + let refShader: any = null; + let devShader: any = null; + let resShader: any = null; + let paint: any = null; + try { + const crop = previewCrop(bmp, w, h); + const cap = Math.min(1, RESIDUAL_MAX / Math.max(crop.w, crop.h)); + const rw = Math.max(1, Math.round(crop.w * cap)); + const rh = Math.max(1, Math.round(crop.h * cap)); + + // One small surface per frame, both at the residual's own size, so neither + // child needs a local matrix and the subtraction below is one tap for one tap. + const small = (draw: (canvas: any) => void): any => { + const surface = Skia.Surface.MakeOffscreen(rw, rh) ?? Skia.Surface.Make(rw, rh); + if (!surface) return null; + draw(surface.getCanvas()); + surface.flush(); + const shot = surface.makeImageSnapshot(); + surface.dispose(); + return shot; + }; + + ref = small((canvas) => + canvas.drawImageRectCubic( + bmp, + Skia.XYWHRect(crop.x, crop.y, crop.w, crop.h), + Skia.XYWHRect(0, 0, rw, rh), + 1 / 3, + 1 / 3 + ) + ); + dev = small((canvas) => + canvas.drawImageRectCubic( + first, + Skia.XYWHRect(0, 0, w, h), + Skia.XYWHRect(0, 0, rw, rh), + 1 / 3, + 1 / 3 + ) + ); + if (!ref || !dev) return null; + + const effect = Skia.RuntimeEffect.Make(RESIDUAL_SKSL); + if (!effect) return null; + const tap = (img: any) => + img.makeShaderOptions( + Skia.TileMode.Clamp, + Skia.TileMode.Clamp, + Skia.FilterMode.Nearest, + Skia.MipmapMode.None + ); + devShader = tap(dev); + refShader = tap(ref); + resShader = effect.makeShaderWithChildren(new Float32Array(0), [devShader, refShader]); + paint = Skia.Paint(); + paint.setShader(resShader); + const out = small((canvas) => canvas.drawRect(Skia.XYWHRect(0, 0, rw, rh), paint)); + // The residual is read back at the develop's own resolution by a bilinear + // tap, so it is kept smooth: one byte a channel is finer than the shading + // it carries, which is the whole difference between two renderings. + return out ? { img: out, sx: rw / w, sy: rh / h } : null; + } catch { + return null; + } finally { + bmp.delete(); + ref?.delete(); + dev?.delete(); + refShader?.delete(); + devShader?.delete(); + resShader?.delete(); + paint?.delete(); + } +} + // The camera -> sRGB matrix, applied to the WB'd sensor triple. // // `rgb_cam` is dcraw's own: it is the camera -> sRGB transform already, its rows @@ -783,14 +915,23 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise< const bandH = Math.max(1, Math.min(h, Math.floor(BAND_PIXELS / w))); const f32 = new Float32Array(w * bandH * 4); const half = new Uint16Array(w * bandH * 4); - const uniforms = new Float32Array(33); + // gain, mul, m0, m1, m2, crop (y offset of the band), then where the residual + // has to be read for the frame pixel this one draws. + const uniforms = new Float32Array(23); uniforms[0] = SAMPLE_MAX / white; uniforms.set([mul[0], mul[1], mul[2], 0, r0[0], r0[1], r0[2], 0, r1[0], r1[1], r1[2], 0, r2[0], r2[1], r2[2], 0], 1); - const develop = (tone: Float32Array | null) => { + const develop = (residual: PreviewResidual | null) => { const surface = Skia.Surface.MakeOffscreen(w, h) ?? Skia.Surface.Make(w, h); if (!surface) return null; - uniforms.set(tone ?? FLAT_TONE, 21); + uniforms[21] = residual ? residual.sx : 0; + uniforms[22] = residual ? residual.sy : 0; + const resChild = (residual?.img ?? noResidual()).makeShaderOptions( + Skia.TileMode.Clamp, + Skia.TileMode.Clamp, + Skia.FilterMode.Linear, + Skia.MipmapMode.None + ); for (let y0 = 0; y0 < h; y0 += bandH) { const rows = Math.min(bandH, h - y0); let o = 0; @@ -814,7 +955,7 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise< Skia.MipmapMode.None ); uniforms[17] = y0; - const shader = effect.makeShaderWithChildren(uniforms, [child]); + const shader = effect.makeShaderWithChildren(uniforms, [child, resChild]); const paint = Skia.Paint(); paint.setShader(shader); surface.getCanvas().drawRect(Skia.XYWHRect(0, y0, w, rows), paint); @@ -824,6 +965,7 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise< child.delete(); band.delete(); } + resChild.delete(); const shot = surface.makeImageSnapshot(); surface.dispose(); @@ -839,22 +981,17 @@ export async function developRaw(bytes: Uint8Array, fileName?: string): Promise< refJpeg = await cameraPreview(raw).catch(() => null); } - if (refJpeg) { - try { - const devGrid = gridOf(first, MATCH_GRID); - const matchGrid = refGrid ?? previewGrid(refJpeg, w, h, MATCH_GRID); - if (devGrid && matchGrid) { - const fittedTone = toneMatch(devGrid, matchGrid, MATCH_GRID); - if (fittedTone) { - const second = develop(fittedTone); - if (second) { - finalShot = second; - first.dispose(); - } - } - } - } catch { - // Keep first develop if tone matching fails + // The file's own rendering, carried onto this develop: what the camera wrote + // into the frame minus what the develop made of the same frame at that + // resolution, added back at the sensor's own. Nothing to fit and nothing to + // table — a body is matched against its own preview the moment it opens. + const residual = refJpeg ? previewResidual(first, refJpeg, w, h) : null; + if (residual) { + const second = develop(residual); + residual.img.delete(); + if (second) { + finalShot = second; + first.dispose(); } } diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index c7613a0..0f141ef 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -95,6 +95,19 @@ export const en: Dict = { 'frame.wallLand': 'WALL FRAME LANDSCAPE', 'frame.oldFilm': 'OLD FILM LANDSCAPE', 'frame.oldFilmPort': 'OLD FILM PORTRAIT', + 'frame.custom': 'CUSTOM FRAME', + 'frame.import': '+ IMPORT FRAME', + 'frame.customTitle': 'Custom Transparent Frame', + 'frame.zoom': 'Zoom / Scale', + 'frame.panX': 'Pan X', + 'frame.panY': 'Pan Y', + 'frame.fit': 'Fit Window', + 'frame.fill': 'Fill Window', + 'frame.center': 'Center', + 'frame.replace': 'Change Frame', + 'frame.remove': 'Remove Frame', + 'frame.dragHint': 'Drag on photo to pan • Scroll wheel to zoom', + 'frame.analyzing': 'Detecting frame transparency...', 'crop.none': 'NO CROP', 'crop.1:1': '1:1', @@ -376,7 +389,18 @@ export const en: Dict = { 'lib.immich.invalid': 'An address and a key are both needed.', 'lib.immich.empty': 'This key sees no album.', 'lib.immich.added': 'Read {n} frames from {folder}.', + 'lib.immich.reading': 'Reading {folder}, still reading…', 'lib.immich.hint': 'Right-click to read again, choose albums, disconnect or remove it from the library.', + 'lib.immich.links': 'Saved cloud links', + 'lib.immich.dup': 'same address — the library is read twice', + 'lib.immich.linkForget': 'REMOVE THE LINK', + 'immich.upload': 'Upload to Immich', + 'immich.uploadTitle': 'Upload edited photo to Immich Cloud', + 'immich.uploading': 'Uploading…', + 'immich.uploadSuccess': 'Successfully uploaded photo to Immich Cloud!', + 'immich.uploadFailed': 'Failed to upload to Immich Cloud: {msg}', + 'immich.uploadToCloud': 'Upload to Immich Cloud', + 'immich.noServer': 'No Immich Cloud server connected', // Mobile Cloud Library 'lib.mobile.photos': 'Photos', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index dc33c08..71c3b41 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -107,6 +107,19 @@ export const vi = { 'frame.wallLand': 'WALL FRAME LANDSCAPE', 'frame.oldFilm': 'OLD FILM LANDSCAPE', 'frame.oldFilmPort': 'OLD FILM PORTRAIT', + 'frame.custom': 'KHUNG TỰ CHỌN', + 'frame.import': '+ THÊM KHUNG', + 'frame.customTitle': 'Khung tự thêm (Transparent Frame)', + 'frame.zoom': 'Thu phóng', + 'frame.panX': 'Dịch ngang', + 'frame.panY': 'Dịch dọc', + 'frame.fit': 'Vừa khung', + 'frame.fill': 'Lấp đầy', + 'frame.center': 'Căn giữa', + 'frame.replace': 'Đổi khung', + 'frame.remove': 'Bỏ khung', + 'frame.dragHint': 'Kéo trên ảnh để di chuyển • Cuộn chuột để phóng to/thu nhỏ', + 'frame.analyzing': 'Đang phân tích vùng trong suốt của khung...', 'crop.none': 'KHÔNG CẮT', 'crop.1:1': '1:1', @@ -388,7 +401,18 @@ export const vi = { 'lib.immich.invalid': 'Cần có địa chỉ và khoá.', 'lib.immich.empty': 'Khoá này không thấy album nào.', 'lib.immich.added': 'Đã đọc {n} ảnh từ {folder}.', + 'lib.immich.reading': 'Đang đọc {folder}, còn đọc tiếp…', 'lib.immich.hint': 'Chuột phải để đọc lại, chọn album, ngắt kết nối hoặc xoá khỏi thư viện.', + 'lib.immich.links': 'Liên kết cloud đã lưu', + 'lib.immich.dup': 'trùng địa chỉ — thư viện bị đọc hai lần', + 'lib.immich.linkForget': 'XOÁ LIÊN KẾT', + 'immich.upload': 'Lưu lên Immich', + 'immich.uploadTitle': 'Tải ảnh đã sửa lên Immich Cloud', + 'immich.uploading': 'Đang tải lên…', + 'immich.uploadSuccess': 'Đã tải ảnh lên Immich Cloud thành công!', + 'immich.uploadFailed': 'Tải lên Immich Cloud thất bại: {msg}', + 'immich.uploadToCloud': 'Tải lên Immich Cloud', + 'immich.noServer': 'Chưa kết nối máy chủ Immich Cloud', // Mobile Cloud Library 'lib.mobile.photos': 'Ảnh', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index b883277..2c62e06 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -2342,128 +2342,402 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track- } } -.workspace.is-mobile { - flex-direction: column !important; -} -.workspace.is-mobile:not(.strip-open) .chips { - display: none !important; -} -.workspace.is-mobile.strip-open .rail { - display: none !important; -} -.workspace.is-mobile .stage-slot { - order: 0 !important; - min-height: 0 !important; -} -.workspace.is-mobile .col[data-col="slider"] { - order: 1 !important; -} -.workspace.is-mobile .col-sub { - order: 2 !important; -} -.workspace.is-mobile .col-main { - order: 3 !important; -} -.workspace.is-mobile .rail { - order: 4 !important; - width: 100% !important; - max-width: 100vw !important; - flex-direction: row !important; - overflow-x: auto !important; - overflow-y: hidden !important; -} -.workspace.is-mobile .chips { - display: contents !important; -} -.workspace.is-mobile .col, -.workspace.is-mobile .col-main, -.workspace.is-mobile .col-sub, -.workspace.is-mobile .col[data-col="slider"], -.workspace.is-mobile .col-create, -.workspace.is-mobile .col-slider { - width: 100% !important; - max-width: 100vw !important; - min-width: 100% !important; - box-sizing: border-box !important; - border-right: none !important; -} -.workspace.is-mobile .col-main { - flex-direction: row !important; - align-items: center !important; - gap: 6px !important; - max-height: 46vh !important; - overflow-x: auto !important; - overflow-y: hidden !important; - background: var(--bg) !important; - border-top: 1px solid var(--border-soft) !important; - padding: 6px 8px !important; -} -.workspace.is-mobile .col-main > *, -.workspace.is-mobile .col-sub:not(.wm-extra) > * { - flex: 0 0 auto !important; -} -.workspace.is-mobile .col-main:not(.col-create) .chip-row, -.workspace.is-mobile .col-sub:not(.wm-extra) .chip-row { - min-width: 100% !important; - width: max-content !important; - flex-wrap: nowrap !important; - flex-direction: row !important; - align-items: center !important; -} -.workspace.is-mobile .col-grip { - display: none !important; -} -.workspace.is-mobile .save-hint { - display: none !important; -} -.workspace.is-mobile .btn-save-action { - flex: 0 0 auto !important; - white-space: nowrap !important; -} -.workspace.is-mobile .col-main:has(.saved-list) { - flex-direction: row !important; - align-items: center !important; - gap: 10px !important; - width: 100% !important; - max-width: 100vw !important; - overflow-x: auto !important; - overflow-y: hidden !important; - flex-wrap: nowrap !important; -} -.workspace.is-mobile .saved-list { - display: flex !important; - flex-direction: row !important; - align-items: center !important; - gap: 12px !important; - overflow-x: auto !important; - overflow-y: hidden !important; - flex-wrap: nowrap !important; - flex: 0 0 auto !important; -} -.workspace.is-mobile .saved-row { - display: inline-flex !important; - align-items: center !important; - gap: 4px !important; - flex: 0 0 auto !important; - white-space: nowrap !important; - width: auto !important; -} -.workspace.is-mobile .saved-row .link-chip { - padding: 6px 4px !important; - white-space: nowrap !important; -} -.workspace.is-mobile .col-sub[data-col="options"], -.workspace.is-mobile .col-sub[data-col="wm"], -.workspace.is-mobile .col-sub[data-col="recipes"] { - order: 3 !important; - background: var(--bg) !important; - max-height: 46vh !important; -} -.workspace.is-mobile:has(.chips > .col-sub[data-col="options"]) .col-main, -.workspace.is-mobile:has(.chips > .col-sub[data-col="wm"]) .col-main, -.workspace.is-mobile:has(.chips > .col-sub[data-col="recipes"]) .col-main, -.workspace.is-mobile:has(.chips > .col-sub[data-col="history"]) .col-main, -.workspace.is-mobile:has(.chips > .col-sub[data-col="mask"]) .col-main { - display: none !important; +@media (max-width: 860px) { + .workspace.is-mobile { + flex-direction: column !important; + } + .workspace.is-mobile:not(.strip-open) .chips { + display: none !important; + } + .workspace.is-mobile.strip-open .rail { + display: none !important; + } + .workspace.is-mobile .stage-slot { + order: 0 !important; + min-height: 0 !important; + } + .workspace.is-mobile .col[data-col="slider"] { + order: 1 !important; + } + .workspace.is-mobile .col-sub { + order: 2 !important; + } + .workspace.is-mobile .col-main { + order: 3 !important; + } + .workspace.is-mobile .rail { + order: 4 !important; + width: 100% !important; + max-width: 100vw !important; + flex-direction: row !important; + overflow-x: auto !important; + overflow-y: hidden !important; + } + .workspace.is-mobile .chips { + display: contents !important; + } + .workspace.is-mobile .col, + .workspace.is-mobile .col-main, + .workspace.is-mobile .col-sub, + .workspace.is-mobile .col[data-col="slider"], + .workspace.is-mobile .col-create, + .workspace.is-mobile .col-slider { + width: 100% !important; + max-width: 100vw !important; + min-width: 100% !important; + box-sizing: border-box !important; + border-right: none !important; + } + .workspace.is-mobile .col-main { + flex-direction: row !important; + align-items: center !important; + gap: 6px !important; + max-height: 46vh !important; + overflow-x: auto !important; + overflow-y: hidden !important; + background: var(--bg) !important; + border-top: 1px solid var(--border-soft) !important; + padding: 6px 8px !important; + } + .workspace.is-mobile .col-main > *, + .workspace.is-mobile .col-sub:not(.wm-extra) > * { + flex: 0 0 auto !important; + } + .workspace.is-mobile .col-main:not(.col-create) .chip-row, + .workspace.is-mobile .col-sub:not(.wm-extra) .chip-row { + min-width: 100% !important; + width: max-content !important; + flex-wrap: nowrap !important; + flex-direction: row !important; + align-items: center !important; + } + .workspace.is-mobile .col-grip { + display: none !important; + } + .workspace.is-mobile .save-hint { + display: none !important; + } + .workspace.is-mobile .btn-save-action { + flex: 0 0 auto !important; + white-space: nowrap !important; + } + .workspace.is-mobile .col-main:has(.saved-list) { + flex-direction: row !important; + align-items: center !important; + gap: 10px !important; + width: 100% !important; + max-width: 100vw !important; + overflow-x: auto !important; + overflow-y: hidden !important; + flex-wrap: nowrap !important; + } + .workspace.is-mobile .saved-list { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 12px !important; + overflow-x: auto !important; + overflow-y: hidden !important; + flex-wrap: nowrap !important; + flex: 0 0 auto !important; + } + .workspace.is-mobile .saved-row { + display: inline-flex !important; + align-items: center !important; + gap: 4px !important; + flex: 0 0 auto !important; + white-space: nowrap !important; + width: auto !important; + } + .workspace.is-mobile .saved-row .link-chip { + padding: 6px 4px !important; + white-space: nowrap !important; + } + .workspace.is-mobile .col-sub[data-col="options"], + .workspace.is-mobile .col-sub[data-col="wm"], + .workspace.is-mobile .col-sub[data-col="recipes"] { + order: 3 !important; + background: var(--bg) !important; + max-height: 46vh !important; + } + .workspace.is-mobile:has(.chips > .col-sub[data-col="options"]) .col-main, + .workspace.is-mobile:has(.chips > .col-sub[data-col="wm"]) .col-main, + .workspace.is-mobile:has(.chips > .col-sub[data-col="recipes"]) .col-main, + .workspace.is-mobile:has(.chips > .col-sub[data-col="history"]) .col-main, + .workspace.is-mobile:has(.chips > .col-sub[data-col="custom-frame"]) .col-main, + .workspace.is-mobile:has(.chips > .col-sub[data-col="mask"]) .col-main { + display: none !important; + } } +@media (min-width: 861px) { + .workspace { + flex-direction: row !important; + } + .workspace .rail { + order: 0 !important; + width: var(--rail-w) !important; + max-width: var(--rail-w) !important; + min-width: var(--rail-w) !important; + flex-direction: column !important; + align-items: stretch !important; + overflow-y: auto !important; + overflow-x: hidden !important; + border-right: 1px solid var(--border-soft) !important; + border-top: none !important; + padding: 6px !important; + gap: 4px !important; + } + .workspace .rail button { + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + min-height: auto !important; + border-radius: var(--radius-sm) !important; + border: 1px solid transparent !important; + background: transparent !important; + padding: 9px 4px !important; + font-size: 10px !important; + text-transform: none !important; + } + .workspace .rail button[aria-pressed="true"] { + background: var(--accent-soft) !important; + color: var(--accent) !important; + border-color: var(--accent-line) !important; + } + .workspace .rail button .glyph { + display: inline-block !important; + font-size: 17px !important; + line-height: 1 !important; + } + .workspace .rail-version { + display: block !important; + } + .workspace .chips { + display: flex !important; + } +} + +.workspace:has(.chips > .col-sub[data-col="custom-frame"]) .col-main { + display: none; +} + +/* --- Custom Frame Controls Column & Hints --- */ +.custom-frame-controls-col { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + width: 250px; + overflow-y: auto; + scrollbar-width: thin; +} + +.custom-frame-header { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-soft, rgba(255, 255, 255, 0.1)); +} + +.custom-frame-thumb-wrap { + width: 48px; + height: 48px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.15); + overflow: hidden; + flex-shrink: 0; + background-image: linear-gradient(45deg, #27272a 25%, transparent 25%), + linear-gradient(-45deg, #27272a 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #27272a 75%), + linear-gradient(-45deg, transparent 75%, #27272a 75%); + background-size: 8px 8px; + background-position: 0 0, 0 4px, 4px -4px, -4px 0px; + background-color: #18181b; + display: flex; + align-items: center; + justify-content: center; +} + +.custom-frame-thumb { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.custom-frame-meta { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.custom-frame-title { + font-size: 13px; + font-weight: 600; + color: var(--fg, #f4f4f5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.custom-frame-res { + font-size: 11px; + color: var(--muted, #a1a1aa); +} + +.custom-frame-action-btns { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.custom-frame-action-btns .btn { + flex: 1; + min-width: 60px; + padding: 6px 8px; + font-size: 11px; + font-weight: 600; + text-align: center; + white-space: nowrap; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: var(--fg, #f4f4f5); + cursor: pointer; + transition: all 0.15s ease; +} + +.custom-frame-action-btns .btn:hover { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.3); +} + +.custom-frame-slider-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.custom-frame-slider-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--muted, #a1a1aa); +} + +.custom-frame-slider-header .val { + color: var(--accent, #f59e0b); + font-variant-numeric: tabular-nums; +} + +.custom-frame-slider-row { + display: flex; + align-items: center; + gap: 6px; +} + +.custom-frame-range { + flex: 1; + accent-color: var(--accent, #f59e0b); + height: 4px; + border-radius: 2px; + cursor: pointer; +} + +.btn-tiny { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: var(--fg, #f4f4f5); + border-radius: 4px; + width: 24px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + cursor: pointer; + padding: 0; + flex-shrink: 0; +} + +.btn-tiny:hover { + background: rgba(255, 255, 255, 0.18); +} + +.custom-frame-footer-btns { + display: flex; + gap: 6px; + margin-top: 4px; +} + +.custom-frame-footer-btns .btn { + flex: 1; + padding: 6px 10px; + font-size: 11px; + font-weight: 600; + border-radius: 6px; + cursor: pointer; +} + +.custom-frame-footer-btns .btn-danger { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #f87171; +} + +.custom-frame-footer-btns .btn-danger:hover { + background: rgba(239, 68, 68, 0.25); +} + +.custom-frame-hint { + font-size: 11px; + line-height: 1.4; + color: #71717a; + margin: 4px 0 0; + border-top: 1px dashed rgba(255, 255, 255, 0.1); + padding-top: 8px; +} + +/* Floating on-stage drag & zoom guidance pill */ +.custom-frame-stage-hint { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 12px; + background: rgba(18, 18, 22, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 20px; + padding: 6px 16px; + font-size: 12px; + font-weight: 500; + color: #f4f4f5; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + pointer-events: none; + z-index: 25; + white-space: nowrap; + animation: fadeInDown 0.3s ease-out; +} + +@keyframes fadeInDown { + from { + opacity: 0; + transform: translate(-50%, -10px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + + diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 12fe6ea..341f908 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -146,7 +146,7 @@ export function ColGrip({ col, min = 110, max = 560 }: { col: string; min?: numb const ref = useRef(null); const key = `studio-col-${col}`; useEffect(() => { - if (window.innerWidth <= 860 || localStorage.getItem('rc_force_view') === 'mobile') { + if (window.innerWidth <= 860) { const box = ref.current?.parentElement; if (box && box.style.width) box.style.width = ''; return; diff --git a/docker/frontend/src/ui/ExportModal.tsx b/docker/frontend/src/ui/ExportModal.tsx index 543f948..67dcb70 100644 --- a/docker/frontend/src/ui/ExportModal.tsx +++ b/docker/frontend/src/ui/ExportModal.tsx @@ -15,6 +15,8 @@ export function ExportModal({ onPick, onShare, onClose, + hasImmich, + onUploadImmich, }: { /** Longest edge of the photo as it stands, crop included. */ longest: number; @@ -26,6 +28,8 @@ export function ExportModal({ bought for nothing. */ onShare: (site: ShareSite) => void; onClose: () => void; + hasImmich?: boolean; + onUploadImmich?: (target?: number | null) => void; }) { const { t } = useI18n(); const [custom, setCustom] = useState(String(Math.min(CUSTOM_MAX, Math.max(1024, longest * 2)))); @@ -113,6 +117,36 @@ export function ExportModal({ {t('export.shareHint')}

+ {hasImmich && onUploadImmich ? ( +
+ +
+ ) : null} diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 7d01773..fc544c7 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -11,6 +11,7 @@ import { MASK_DEFAULT_FEATHER, MASK_MIN } from '../../shared/utils/gradientMask' import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush'; import { measureMark } from '../engine/exportEngine'; import { Histogram } from './Histogram'; +import type { CustomFrameData } from '../engine/customFrame'; // One wheel notch, and how far the photo may be pulled open. const ZOOM_STEP = 1.15; @@ -292,6 +293,9 @@ export function ImageStage({ frameId = 'none', framePanX = 0, onFramePanXChange, + customFrame = null, + onCustomFramePan, + onCustomFrameZoom, }: { previewUrl: string | null; originalUrl: string | null; @@ -391,6 +395,9 @@ export function ImageStage({ frameId?: FrameId; framePanX?: number; onFramePanXChange?: (panX: number) => void; + customFrame?: CustomFrameData | null; + onCustomFramePan?: (dx: number, dy: number) => void; + onCustomFrameZoom?: (delta: number) => void; }) { const showStraighten = !!straightenRuler; // The curve graph is a fresh node on every parent render too, and the measure @@ -414,6 +421,7 @@ export function ImageStage({ const viewRef = useRef(view); viewRef.current = view; const panRef = useRef<{ px: number; py: number } | null>(null); + const customFrameDragRef = useRef<{ px: number; py: number } | null>(null); // The space bar is the hand the stage is moved with; the brush's own layer // swallows the pointer that would otherwise start that pan, so the key is // watched here for the layer to read. @@ -944,6 +952,10 @@ export function ImageStage({ const onWheel = (e: WheelEvent) => { if (!imgRef.current) return; e.preventDefault(); + if (frameId === 'custom' && onCustomFrameZoom) { + onCustomFrameZoom(e.deltaY < 0 ? 1.05 : 0.95); + return; + } if (cropEditing) { wheelCrop(e.deltaY < 0); return; @@ -952,7 +964,7 @@ export function ImageStage({ }; wrap.addEventListener('wheel', onWheel, { passive: false }); return () => wrap.removeEventListener('wheel', onWheel); - }, [cropEditing, previewUrl, wheelCrop, zoomAt]); + }, [cropEditing, previewUrl, wheelCrop, zoomAt, frameId, onCustomFrameZoom]); // The same wheel, where the brush's layer has taken it: sized when a modifier // is held, and the stage's zoom otherwise. `box` is a dependency because the @@ -1230,7 +1242,15 @@ export function ImageStage({ }; const startPan = (e: React.PointerEvent) => { - if (cropEditing || compare || viewRef.current.s <= 1 || e.button !== 0) return; + if (cropEditing || compare || e.button !== 0) return; + if (frameId === 'custom' && onCustomFramePan) { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + customFrameDragRef.current = { px: e.clientX, py: e.clientY }; + return; + } + if (viewRef.current.s <= 1) return; e.preventDefault(); e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); @@ -1238,6 +1258,19 @@ export function ImageStage({ }; const movePan = (e: React.PointerEvent) => { + if (frameId === 'custom' && customFrameDragRef.current && onCustomFramePan) { + e.preventDefault(); + const p = customFrameDragRef.current; + const dx = e.clientX - p.px; + const dy = e.clientY - p.py; + const img = imgRef.current; + const factor = img && customFrame?.width && img.offsetWidth > 0 + ? customFrame.width / img.offsetWidth + : 1.5; + onCustomFramePan(dx * factor, dy * factor); + customFrameDragRef.current = { px: e.clientX, py: e.clientY }; + return; + } const p = panRef.current; if (!p) return; e.preventDefault(); @@ -1247,6 +1280,11 @@ export function ImageStage({ }; const endPan = (e: React.PointerEvent) => { + if (customFrameDragRef.current) { + customFrameDragRef.current = null; + if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + return; + } if (!panRef.current) return; panRef.current = null; if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); @@ -1498,14 +1536,22 @@ export function ImageStage({ setView({ s, ...clampPan(s, 0, 0) }); }} style={ - view.s === 1 - ? undefined - : { - transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`, - cursor: cropEditing ? undefined : 'grab', - } + frameId === 'custom' + ? { cursor: 'grab' } + : view.s === 1 + ? undefined + : { + transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`, + cursor: cropEditing ? undefined : 'grab', + } } /> + {frameId === 'custom' ? ( +
+ 🖐️ Kéo để di chuyển ảnh + 🔍 Lăn chuột để thu phóng +
+ ) : null} {/* Eyedropper. Sits over the photo so a click reads the colour under the cursor; it swallows the pointer so the pan gesture never starts. */} diff --git a/docker/frontend/src/ui/ImmichDialog.tsx b/docker/frontend/src/ui/ImmichDialog.tsx index d9e308c..1836c4c 100644 --- a/docker/frontend/src/ui/ImmichDialog.tsx +++ b/docker/frontend/src/ui/ImmichDialog.tsx @@ -32,10 +32,14 @@ const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; export function ImmichDialog({ server, + servers = [], + onForget, onSaved, onClose, }: { server?: ImmichServer | null; + servers?: ImmichServer[]; + onForget?: (id: string) => void; onSaved: (saved: ImmichServer, isNew: boolean) => void; onClose: () => void; }) { @@ -201,6 +205,35 @@ export function ImmichDialog({

) : ( <> + {/* What the account already reads by: one row per saved address, and + the address is what makes two rows one server. Two links to the + same Immich read the same library twice, so the row says so and + the way out is here rather than in a node's own menu — a duplicate + is a fact about the list, not about a folder. */} + {servers.length ? ( +
+

{t('lib.immich.links')}

+
    + {servers.map((s) => { + const twin = servers.some((o) => o.id !== s.id && o.url === s.url); + return ( +
  • + {s.name} · {s.url} ({s.albums?.length ?? 0}) + {twin ? {t('lib.immich.dup')} : null} + +
  • + ); + })} +
+
+ ) : null} diff --git a/docker/frontend/src/ui/MobileLibrary.tsx b/docker/frontend/src/ui/MobileLibrary.tsx index 37c7d9b..082fb47 100644 --- a/docker/frontend/src/ui/MobileLibrary.tsx +++ b/docker/frontend/src/ui/MobileLibrary.tsx @@ -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; @@ -26,6 +27,7 @@ interface MobileLibraryProps { onSetStar?: (photo: LibraryPhoto, star: number) => Promise; onRotate?: (photo: LibraryPhoto, dir: 'left' | 'right') => Promise; onSwitchToDesktop?: () => void; + onFetchYear?: (year: number) => Promise; } // 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('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(); 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 ? (
- - - -
- {servers.length === 0 - ? t('lib.mobile.noServers') - : t('lib.mobile.noPhotos')} -
-
- {t('lib.mobile.cloudDesc')} -
- + {syncingServerId ? ( + <> +
+
Đang đồng bộ ảnh từ Immich Cloud...
+
Ảnh mới nhất đang được tải về và hiển thị...
+ + ) : ( + <> + + + +
+ {servers.length === 0 + ? t('lib.mobile.noServers') + : t('lib.mobile.noPhotos')} +
+
+ {t('lib.mobile.cloudDesc')} +
+ {servers.length === 0 && ( + + )} + + )}
) : viewMode === 'single' ? (
@@ -2102,7 +2132,7 @@ export function MobileLibrary({ type="button" className="mob-lib-btn primary" onClick={() => { - setSelectedAlbum(null); + setSelectedAlbum(folder ? normPath(folder.name) : null); setTab('photos'); }} > diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx index 0205952..23df5b5 100644 --- a/docker/frontend/src/ui/TopBar.tsx +++ b/docker/frontend/src/ui/TopBar.tsx @@ -25,6 +25,9 @@ export function TopBar({ onAuth, onSignup, onLogout, + hasImmich, + uploadingImmich, + onUploadImmich, }: { recipeName: string; // Saving into the account's own folder is the account's own act, so the @@ -48,6 +51,9 @@ export function TopBar({ onAuth: () => void; onSignup: () => void; onLogout: () => void; + hasImmich?: boolean; + uploadingImmich?: boolean; + onUploadImmich?: () => void; }) { const { t, lang, setLang } = useI18n(); const { mode, accent, fonts, setMode, setAccent, setFonts } = useTheme(); @@ -268,10 +274,39 @@ export function TopBar({
) : null}
+ {/* Immich Cloud Upload button (shown only if registered user has configured Immich Cloud) */} + {hasImmich && onUploadImmich ? ( + + ) : null} + {/* Last in the row on purpose: EXPORT is the end of the job, so it sits at the end of the bar — the one button the eye lands on when the edit is done. */} - diff --git a/docker/frontend/src/ui/useIsMobile.ts b/docker/frontend/src/ui/useIsMobile.ts index 349a9b2..a573844 100644 --- a/docker/frontend/src/ui/useIsMobile.ts +++ b/docker/frontend/src/ui/useIsMobile.ts @@ -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) ); }; diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 4851cbd..b70dc86 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -ae8bc8ad046e269bf7b81bde7fe42ec5dd4848e1380b8667fbc9c357ed91dfc9 recipescam-images.tar.gz +29b7df9c2a61fb3a50035657d91fe6d822c3677fee7a1bc24af9a225b1773fae recipescam-images.tar.gz