feat(library): mint a server frame's tile once and keep it, always show the whole branch under the open node, and draw the year tick as a grey rule

This commit is contained in:
2026-10-13 11:50:01 +07:00
parent 150a55f7a3
commit 6d3ee728b4
5 changed files with 86 additions and 59 deletions
+52 -1
View File
@@ -83,6 +83,13 @@ const PNG = Buffer.from(
// the key live on the backend, and the page only ever calls its own origin.
let direct = 0;
let thumbs = 0;
// What was asked of the server, a frame at a time: a tile is fetched once and kept
// (`useThumbUrls` mints it and the store holds it), so a frame this map counts
// twice is a frame the wall went back to Immich for.
const thumbIds = new Map();
// The mint itself: the fetch `tileFrom` makes for a server's frame, as against the
// picture the wall paints straight off the address while the mint is on its way.
const mints = new Map();
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
@@ -236,7 +243,11 @@ await context.route('**/api/**', (route) => {
const box = host(url.searchParams.get('server') ?? '');
if (!box) return send(502, { ok: false, error: 'unreachable' });
if (path.endsWith('original') && !box.canDownload) return send(403, { ok: false, error: 'auth' });
if (path.endsWith('thumb')) thumbs++;
if (path.endsWith('thumb')) {
thumbs++;
thumbIds.set(id, (thumbIds.get(id) ?? 0) + 1);
if (req.resourceType() === 'fetch') mints.set(id, (mints.get(id) ?? 0) + 1);
}
return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
}
@@ -350,6 +361,46 @@ step(
);
step('and its frames were tiled off the backend, not off Immich', thumbs > 0, `${thumbs} tiles`);
// --- the wall of a server's frames, and the tiles it keeps -------------------
// A server's frames are read up and down the roll and the reader jumps the ruler
// to a year and back, so the frames of one visit are the frames of the next. What
// a wall drawn again must not do is go to Immich for a picture this machine
// already drew: a tile is minted once off the backend (`useThumbUrls`), the store
// keeps it, and the visit after reads it back out of the store — a wall that asks
// again is a wall that stands grey on every jump until the server answers.
await page.click('[data-key="lib-node-Immich"]');
await page.click('[data-key="lib-view-grid"]');
const tiles = () => page.$$eval('.lib-tile', (els) => els.length);
const stored = () =>
page.$$eval('.lib-tile img', (els) => els.filter((e) => (e.getAttribute('src') ?? '').startsWith('blob:')).length);
await page.waitForSelector('.lib-tile', { timeout: 30_000 });
await page.waitForFunction(() => document.querySelector('.lib-tile img[src^="blob:"]') !== null, null, { timeout: 30_000 }).catch(() => {});
const firstLook = new Map(mints);
step(
'the wall draws a server’s frames, and their tiles are minted off the backend',
firstLook.size > 0 && (await stored()) > 0,
`${firstLook.size} frames minted, ${await stored()} of ${await tiles()} tiles kept`
);
// 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.
await page.reload();
await page.waitForFunction(
() => {
const imgs = [...document.querySelectorAll('.lib-tile img')];
return imgs.length > 0 && imgs.every((i) => (i.getAttribute('src') ?? '').startsWith('blob:'));
},
null,
{ timeout: 30_000 }
).catch(() => {});
const again = [...mints].filter(([id, n]) => n > (firstLook.get(id) ?? 0));
step(
'and a frame drawn once is drawn out of this machine, not minted off Immich again',
(await tiles()) > 0 && (await stored()) === (await tiles()) && again.length === 0,
`${await stored()} of ${await tiles()} tiles out of the store, ${again.length} minted twice${again.length ? `: ${again.slice(0, 3).map(([id, n]) => id.slice(0, 8) + 'x' + n).join(' ')}` : ''}`
);
// --- an album unticked -------------------------------------------------------
await editDialog('Immich');
await page.uncheck('[data-key="immich-album-' + A2 + '"]');
+22 -49
View File
@@ -110,7 +110,6 @@ const OPEN_KEY = 'recipescam.library.open';
// shut them out, and the chip wrote that first answer down on the very first
// visit — so the old key holds 'true' for every reader who never touched it, and
// reading it would keep them on a default they never chose.
const DEEP_KEY = 'recipescam.library.deep2';
const VIEW_KEY = 'recipescam.library.view';
const SORT_BY_KEY = 'recipescam.library.sortBy';
const SORT_DIR_KEY = 'recipescam.library.sortDir';
@@ -461,13 +460,6 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
for (const parent of nodeParents) set.add(parent);
return set;
});
// Whether the strip draws the whole branch under the open node or only the
// node itself. The node the reader picked is what the screen shows: the frames
// in the folders under it are asked for, not assumed. A roll whose top level
// holds date folders then reads as those folders, and the frames behind them
// arrive when the reader says so. The answer is picked up where the last visit
// left it, the same as the open node above.
const [deep, setDeep] = useState(() => recall(DEEP_KEY) === 'true');
// The frame that is up in the open node — raised by a click on its thumbnail,
// and picked up where the last visit left it, so a reader who leaves for the
// studio comes back to the negative they were looking at rather than to the
@@ -967,9 +959,6 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
useEffect(() => {
keep(OPEN_KEY, [...expanded].join(','));
}, [expanded]);
useEffect(() => {
keep(DEEP_KEY, String(deep));
}, [deep]);
useEffect(() => {
keep(VIEW_KEY, view);
}, [view]);
@@ -1613,9 +1602,9 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// not set the job off again; the rows of that same render are what goes.
const normRoot = normPath(rootFolder.name);
const rows = photos.filter((p) => normPath(p.folder ?? '') === normRoot);
void makeThumbsFor(rootFolder, rel, { below: deep, rows });
void makeThumbsFor(rootFolder, rel, { below: true, rows });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deep, loaded, makeThumbsFor, node, photos.length, relOf, rootFolder]);
}, [loaded, makeThumbsFor, node, photos.length, relOf, rootFolder]);
// The tree: one row per picked folder, then one per subfolder, indented by its
// depth — the folders the walk has named, whether or not a frame has been read
@@ -1816,8 +1805,8 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
setNode(nodes.find((n) => n.key === rootName || n.key === normPath(rootName ?? ''))?.key ?? nodes[0]?.key ?? null);
}, [loaded, node, nodes, rootName]);
// What the open node draws: its own frames, and — when the reader has asked
// for the branch — the ones in every subfolder under it.
// What the open node draws: its own frames and those of every folder under it,
// which is the whole of the branch the row above it counted.
const shown = useMemo(() => {
if (!rootName) return photos;
const normRoot = normPath(rootName);
@@ -1826,21 +1815,18 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const below = dir === ''
? (p: LibraryPhoto) => normPath(p.dir ?? '') !== ''
: (p: LibraryPhoto) => normPath(p.dir ?? '').startsWith(`${dir}/`);
const own: LibraryPhoto[] = [];
const both: LibraryPhoto[] = [];
for (const p of photos) {
if (normPath(p.folder ?? '') !== normRoot) continue;
const pDir = normPath(p.dir ?? '');
const isOwn = pDir === dir;
if (isOwn) own.push(p);
if (isOwn || below(p)) both.push(p);
if (pDir === dir || below(p)) both.push(p);
}
// A row counts what sits at or under its folder, so a folder whose frames all
// lie one level down would open on an empty screen under a number that says
// otherwise. The branch is drawn then, and only then: a folder holding frames
// of its own opens on those, and the toggle keeps the last word.
return deep || !own.length ? both : own;
}, [deep, node, photos, rootName]);
// A folder is its branch: the row counts what sits at or under the folder, so
// the wall under it holds the same frames, and a frame two levels down is not
// a frame the reader has to go and find. A sibling's frames are the one thing
// left out.
return both;
}, [node, photos, rootName]);
// What the thumbnail view draws, out of what the open node holds. A frame's
// hour is the hour the shutter fired in, on this machine's clock — the same
@@ -1933,7 +1919,7 @@ 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, years]);
}, [node, rootName, years]);
// On-demand fetch for selected year if user has Immich servers and local photos for that year are missing
const fetchYearPhotos = useCallback(
@@ -2642,13 +2628,10 @@ 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.
// 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.
// A frame the wall has no tile for yet: a server's own frame is drawn straight
// from the address the proxy serves it at rather than left as an empty box, and
// the tile is minted and kept beside it (`useThumbUrls`), so what the reader
// sees first is the picture and what the visit after reads is this machine.
const tile = (photo: LibraryPhoto, onPick?: () => void, onAr?: (ar: number) => void) => {
const src = urls[photo.id] ?? (photo.remoteId ? remoteUrl('thumbnail', photo) : null);
return src ? (
@@ -2925,19 +2908,6 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
<rect x="9" y="9" width="5" height="5" rx="1" />
</svg>
</button>
{/* Whether the strip draws the branch under the open folder or
only the folder itself. The label says what the click does,
`on` says where the strip is now. */}
<button
type="button"
className={`chip${deep ? ' on' : ''}`}
data-key="lib-subs"
aria-pressed={deep}
title={t('lib.subsHint')}
onClick={() => setDeep((prev) => !prev)}
>
<span className="chip-label">{t(deep ? 'lib.subsHide' : 'lib.subsShow')}</span>
</button>
</>
) : null}
</div>
@@ -4214,9 +4184,12 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?
setUrls((prev) => ({ ...prev, ...landed }));
}
// A frame off the disk is one no server owns: its file is asked of the
// folder's handle when the tile is made, so there is no handle to test for.
const queue = list.filter((p) => !(p.id in landed) && !p.remoteId);
// A server's own frame is minted here as well: its tile is the one Immich
// drew, asked for once and kept in the store, so the day the reader jumps
// back to that year the wall draws it out of this machine instead of
// paying the round trip again — the wall is read up and down the roll, and
// every jump is otherwise the same frames asked of the server a second time.
const queue = list.filter((p) => !(p.id in landed));
await Promise.all(
Array.from({ length: Math.min(WALL_LANES, queue.length) }, async () => {
while (alive && queue.length) {
-3
View File
@@ -282,9 +282,6 @@ export const en: Dict = {
'lib.rail': 'Timeline',
'lib.prev': 'Previous frame',
'lib.next': 'Next frame',
'lib.subsShow': 'SHOW SUBFOLDERS',
'lib.subsHide': 'THIS FOLDER ONLY',
'lib.subsHint': 'Frames in subfolders: the whole branch under the open folder, or only what that folder holds itself.',
'lib.open': 'Open in the studio',
'lib.openExplorer': 'Open in window explorer',
'lib.openLocation': 'Open at location',
-4
View File
@@ -293,10 +293,6 @@ export const vi = {
'lib.rail': 'Dòng thời gian',
'lib.prev': 'Ảnh trước',
'lib.next': 'Ảnh sau',
'lib.subsShow': 'HIỆN CẢ THƯ MỤC CON',
'lib.subsHide': 'CHỈ THƯ MỤC ĐANG CHỌN',
'lib.subsHint':
'Ảnh trong thư mục con: hiện cả nhánh dưới thư mục đang chọn, hoặc chỉ ảnh của chính thư mục đó.',
'lib.open': 'Mở trong studio',
'lib.openExplorer': 'Open in window explorer',
'lib.openLocation': 'Open at location',
+12 -2
View File
@@ -1555,8 +1555,18 @@ form[data-key='immich-modal'] {
read by the year first and the month under it after. */
.lib-ruler-band { position: absolute; left: 0; right: 0; background: var(--bg-elev); border-radius: 3px; }
.lib-ruler-tick { position: absolute; left: 0; width: 10px; height: 1px; background: color-mix(in srgb, var(--text-dim) 30%, transparent); }
.lib-ruler-year { width: 100%; height: 2px; background: var(--text-dim); }
.lib-ruler-year i { position: absolute; top: 2px; left: 14px; font-size: 9px; font-style: normal; font-weight: 700; letter-spacing: 0.06em; color: var(--text-dim); }
/* A year is the ruler's own rule: a hairline across the column with the year in
brackets at the end of it — `-----[2024]`, which is what a ruler reads like.
The line is the months' own grey, since a year is a tick like any other and not
a band; the brackets are drawn here so the tick still reads as its bare year. */
.lib-ruler-tick.lib-ruler-year { left: 0; right: 0; width: auto; height: 1px; background: none; display: flex; align-items: center; gap: 4px; }
.lib-ruler-tick.lib-ruler-year::before { content: ''; flex: 1 1 auto; height: 1px; background: color-mix(in srgb, var(--text-dim) 30%, transparent); }
.lib-ruler-tick.lib-ruler-year i { position: static; font-size: 9px; font-style: normal; font-weight: 700; letter-spacing: 0.02em; color: var(--text-dim); }
.lib-ruler-tick.lib-ruler-year i::before { content: '['; }
.lib-ruler-tick.lib-ruler-year i::after { content: ']'; }
.lib-ruler-tick.lib-ruler-year.on { width: auto; background: none; }
.lib-ruler-tick.lib-ruler-year.on::before { background: var(--accent); }
.lib-ruler-tick.lib-ruler-year.on i { color: var(--accent); }
.lib-ruler-tick.on { width: 22px; background: var(--accent); }
.lib-ruler-needle { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); pointer-events: none; }
/* What the thumbnail wall is read through: the selects and the count of what is