From 976ec2cd37052660e089977fb76d60dfcec3af6c Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 18:50:47 +0700 Subject: [PATCH] fix(library): a server's frames stand at the shape the server stated, not at a guess --- docker/frontend/scripts/immich-check.mjs | 32 +++++++++++++++++++++--- docker/frontend/src/Library.tsx | 12 +++++++-- docker/frontend/src/engine/immich.ts | 12 +++++++++ docker/frontend/src/engine/library.ts | 6 +++++ 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/docker/frontend/scripts/immich-check.mjs b/docker/frontend/scripts/immich-check.mjs index 5838246..18cc192 100644 --- a/docker/frontend/scripts/immich-check.mjs +++ b/docker/frontend/scripts/immich-check.mjs @@ -228,8 +228,11 @@ await context.route('**/api/**', (route) => { id: a.id, name: a.name, takenAt: '2026-04-01T10:00:00.000Z', - width: 6000, - height: 4000, + // Portrait, and deliberately unlike the square thumbnail above it: a wall + // laid out off the server's own numbers and a wall laid out off the pictures + // it is handed read as two different shapes here. + width: 2000, + height: 3000, size: 12345678, })); return send(200, { ok: true, page, size, total: rows.length, hasMore: items.length === size, items }); @@ -382,6 +385,27 @@ step( `${firstLook.size} frames minted, ${await stored()} of ${await tiles()} tiles kept` ); +// The shape a row is laid at. A row is justified to the wall's own width, which is +// a sum of the shapes of the frames in it, and the wall takes those shapes off the +// row Immich listed — `width` and `height` come with every frame — rather than +// standing a whole node at one guessed rectangle and laying the shelf out again +// picture by picture as the pictures arrive, which is every row below the one that +// landed moving and the ruler's needle moving with them. The fixture says 2000x3000 +// where the thumbnail it hands out is square, so a wall measured off the pictures +// reads as 1.00 here and a wall standing on the guess reads as 1.50. +const statedAr = 2000 / 3000; +const rowArs = await page.$$eval('.lib-tile', (els) => + els.map((e) => { + const r = e.getBoundingClientRect(); + return r.height ? +(r.width / r.height).toFixed(2) : 0; + }) +); +step( + 'and a frame stands at the shape the server stated, not at the guess nor at the picture', + rowArs.length > 0 && rowArs.every((ar) => Math.abs(ar - statedAr) < 0.1), + `${rowArs.slice(0, 5).join(', ')} — the server states ${statedAr.toFixed(2)}` +); + // The visit after — the page again, on the same wall. Every tile the visit before // drew is in this machine's store by now, so every tile is drawn out of it and the // backend is not asked for one of them a second time. @@ -447,7 +471,7 @@ step( // albums. It is probed before it is stored, so a key that does not answer is a // dialog still open on a node unchanged. await editDialog('Immich'); -await page.fill('[data-key="immich-newkey"]', 'not-the-key'); +await page.fill('[data-key="immich-key"]', 'not-the-key'); await page.click('[data-key="immich-save"]'); await page.waitForSelector('[data-key="immich-error"]', { timeout: 5_000 }).catch(() => {}); step( @@ -456,7 +480,7 @@ step( (await page.$('[data-key="immich-modal"]')) !== null, await page.$eval('[data-key="immich-error"]', (e) => e.textContent).catch(() => 'no word said') ); -await page.fill('[data-key="immich-newkey"]', 'good-key'); +await page.fill('[data-key="immich-key"]', 'good-key'); await page.click('[data-key="immich-save"]'); await page.waitForSelector('[data-key="immich-modal"]', { state: 'detached', timeout: 10_000 }).catch(() => {}); await settle('Immich', 5); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 53f15c9..9bf8fad 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -2101,7 +2101,10 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { let sum = 0; for (const ar of wallAr.values()) sum += ar; const fallback = wallAr.size ? sum / wallAr.size : WALL_AR; - return listed.map((photo) => wallAr.get(photo.id) ?? fallback); + // A frame Immich holds already said its own shape, so it is measured off + // nothing and stays where it was laid: `wallAr` is for the frames on the disk, + // which nobody has opened yet. + return listed.map((photo) => photo.ar ?? wallAr.get(photo.id) ?? fallback); // `arsAt` is the shape map having grown: the numbers are read off it and not // off the list, so nothing else here would make the shelf look again. }, [listed, arsAt]); @@ -3320,7 +3323,12 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { }} onContextMenu={(e) => handlePhotoContextMenu(photo, e)} > - {tile(photo, undefined, (ar) => onAr(photo.id, ar))} + {tile(photo, undefined, (ar) => { + // Only a frame whose shape is not known + // already is worth laying the shelf out + // again for. + if (photo.ar === undefined) onAr(photo.id, ar); + })} {/* The score over the picture, the same five stars the stage gives: the wall is where a roll is gone through, and going back to the diff --git a/docker/frontend/src/engine/immich.ts b/docker/frontend/src/engine/immich.ts index e0c88c1..c0e2d3d 100644 --- a/docker/frontend/src/engine/immich.ts +++ b/docker/frontend/src/engine/immich.ts @@ -312,6 +312,15 @@ function frameName(name: string, id: string, taken: Set): string { return `${name} ~${id.slice(0, 8)}`; } +// The shape a frame stands up in, off the numbers Immich holds of it rather than +// off its own pixels: the wall can be laid out from these the moment the rows +// land, where a frame whose shape is not known has to wait for its picture and +// move every row below it when it does. Immich states neither on every frame, and +// a frame of either number missing is one the wall measures itself. +function frameAr(asset: ImmichAsset): number | undefined { + return asset.width && asset.height ? asset.width / asset.height : undefined; +} + export interface ImmichSyncResult { total: number; albums: number; @@ -433,6 +442,7 @@ export async function syncImmich( name, server, remoteId: item.id, + ar: frameAr(item), taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(), size: item.size ?? 0, addedAt: Date.now(), @@ -497,6 +507,7 @@ export async function syncImmich( name, server, remoteId: item.id, + ar: frameAr(item), taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(), size: item.size ?? 0, addedAt: Date.now(), @@ -576,6 +587,7 @@ export async function syncImmich( name, server, remoteId: item.id, + ar: frameAr(item), taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(), size: item.size ?? 0, addedAt: Date.now(), diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 15292ef..eeb3ed7 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -140,6 +140,12 @@ export interface LibraryPhoto { // route above reads the frame by, and the server says which key fetches it. remoteId?: string; server?: string; + // The frame's shape — width over height — as the server stated it, on a frame + // Immich holds. A frame on the disk has none: a scan reads a date and a size off + // it and never opens it. Where it is here the wall is laid out right the first + // time, instead of standing every frame of a node at one guessed rectangle and + // moving every row below it as the pictures arrive. + ar?: number; // The tile the catalogue kept of this frame, where one has been made: it is // made when the frame is first drawn and not when it is found, so a frame just // read has none. Null is the same as absent — a tile that could not be made.