diff --git a/docker/frontend/scripts/immich-check.mjs b/docker/frontend/scripts/immich-check.mjs index 187ca01..5838246 100644 --- a/docker/frontend/scripts/immich-check.mjs +++ b/docker/frontend/scripts/immich-check.mjs @@ -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 + '"]'); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 4f064a8..3f007ad 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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 }) { - {/* 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. */} - ) : null} @@ -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) { diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 3176faa..50daa1e 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index ae9117c..052946c 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 07ea190..1041599 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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