diff --git a/server/src/db/entities/TrekPhotoSignals.entity.ts b/server/src/db/entities/TrekPhotoSignals.entity.ts index 120f68e56..a8dfb0ec1 100644 --- a/server/src/db/entities/TrekPhotoSignals.entity.ts +++ b/server/src/db/entities/TrekPhotoSignals.entity.ts @@ -16,6 +16,10 @@ export class TrekPhotoSignals { orig_width?: number | null; orig_height?: number | null; computed_at!: string & Opt; + palette?: string | null; + busy?: string | null; + sal_x?: number | null; + sal_y?: number | null; } export const TrekPhotoSignalsSchema = defineEntity({ @@ -34,5 +38,9 @@ export const TrekPhotoSignalsSchema = defineEntity({ orig_width: p.integer().nullable(), orig_height: p.integer().nullable(), computed_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`), + palette: p.text().nullable(), + busy: p.text().nullable(), + sal_x: p.double().nullable(), + sal_y: p.double().nullable(), }, }); diff --git a/server/src/db/kysely/TrekPhotoSignals.table.ts b/server/src/db/kysely/TrekPhotoSignals.table.ts index 47c53c680..350fe2056 100644 --- a/server/src/db/kysely/TrekPhotoSignals.table.ts +++ b/server/src/db/kysely/TrekPhotoSignals.table.ts @@ -13,4 +13,8 @@ export interface TrekPhotoSignalsTable { orig_width: InsertOptional; orig_height: InsertOptional; computed_at: InsertOptional; + palette: InsertOptional; + busy: InsertOptional; + sal_x: InsertOptional; + sal_y: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101046270_a_photo_knows_its_colours_and_calm_areas.ts b/server/src/db/migrations/Migration20200101046270_a_photo_knows_its_colours_and_calm_areas.ts new file mode 100644 index 000000000..54b209bad --- /dev/null +++ b/server/src/db/migrations/Migration20200101046270_a_photo_knows_its_colours_and_calm_areas.ts @@ -0,0 +1,20 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * What else a photo's pixels say, for the assistant's book design: its main + * colours (`palette`, JSON of up to five `"#rrggbb:share"`), where it is busy + * and where calm (`busy`, an 8 x 8 grid of edge density, one hex digit per + * cell, row by row) and where its detail gathers (`sal_x`, `sal_y`, 0 to 1 of + * each side; an estimate, no face detection). Measured with the rest of the + * signals; a row of an older measurement is measured again. + */ +export class Migration20200101046270_a_photo_knows_its_colours_and_calm_areas extends Migration { + override name = 'Migration20200101046270_a_photo_knows_its_colours_and_calm_areas'; + + override up(): void { + this.addSql('ALTER TABLE trek_photo_signals ADD COLUMN palette TEXT'); + this.addSql('ALTER TABLE trek_photo_signals ADD COLUMN busy TEXT'); + this.addSql('ALTER TABLE trek_photo_signals ADD COLUMN sal_x REAL'); + this.addSql('ALTER TABLE trek_photo_signals ADD COLUMN sal_y REAL'); + } +} diff --git a/server/src/db/repositories/TrekPhotoSignals.repository.ts b/server/src/db/repositories/TrekPhotoSignals.repository.ts index b4a3023f7..3d65a2aa6 100644 --- a/server/src/db/repositories/TrekPhotoSignals.repository.ts +++ b/server/src/db/repositories/TrekPhotoSignals.repository.ts @@ -15,6 +15,13 @@ export interface TrekPhotoSignalWrite { clipped: number | null; orig_width: number | null; orig_height: number | null; + /** The main colours as JSON of up to five `"#rrggbb:share"`; absent or null when not measured. */ + palette?: string | null; + /** An 8 x 8 grid of edge density, one hex digit per cell, row by row. */ + busy?: string | null; + /** Where the detail gathers, 0 to 1 of each side. */ + sal_x?: number | null; + sal_y?: number | null; } /** A stored measurement, with when it was made. */ @@ -68,6 +75,10 @@ export class TrekPhotoSignalsRepository extends TrekRepository 'orig_width', 'orig_height', 'computed_at', + 'palette', + 'busy', + 'sal_x', + 'sal_y', ]) .where('photo_id', 'in', chunk) .execute(), @@ -81,7 +92,7 @@ export class TrekPhotoSignalsRepository extends TrekRepository const computedAt = dbNow(); await this.db() .insertInto('trek_photo_signals') - .values({ ...row, computed_at: computedAt }) + .values({ ...row, ...colours(row), computed_at: computedAt }) .onConflict((oc) => oc.column('photo_id').doUpdateSet({ algo: row.algo, @@ -91,6 +102,7 @@ export class TrekPhotoSignalsRepository extends TrekRepository clipped: row.clipped, orig_width: row.orig_width, orig_height: row.orig_height, + ...colours(row), computed_at: computedAt, }), ) @@ -124,3 +136,13 @@ export class TrekPhotoSignalsRepository extends TrekRepository .execute(); } } + +/** The colour signals of a measurement, null where it has none, so an update never keeps an older one's. */ +function colours(row: TrekPhotoSignalWrite) { + return { + palette: row.palette ?? null, + busy: row.busy ?? null, + sal_x: row.sal_x ?? null, + sal_y: row.sal_y ?? null, + }; +} diff --git a/server/src/nest/photos/photo-colour-signals.ts b/server/src/nest/photos/photo-colour-signals.ts new file mode 100644 index 000000000..f849cc6f1 --- /dev/null +++ b/server/src/nest/photos/photo-colour-signals.ts @@ -0,0 +1,167 @@ +/** + * What a photo's colours and edges say about where words and frames can go, + * for the assistant's book design (journey-studio-wp13.md 5.1): its main + * colours, where it is busy and where calm, and where its detail gathers. + * Pure arithmetic on the RGBA pixels the measurement already decoded and + * scaled (photo-signals-runner.ts), run in the same worker thread. + * + * None of this sees what is in a photo: the salient point is the centroid of + * the edges, not a face or a subject, and the palette is colour clustering, + * not taste. + */ + +/** Clusters the palette is made of at most. */ +const PALETTE_SIZE = 5; +/** Pixels the clustering looks at at most; a larger picture is sampled evenly. */ +const MAX_SAMPLES = 6000; +/** Rounds of k-means after the start from the histogram. */ +const ROUNDS = 6; +/** A cluster holding less of the picture than this is left out of the palette. */ +const MIN_SHARE = 0.02; +/** Two starting colours at least this far apart (RGB distance), so the palette is not five shades of one sky. */ +const MIN_START_DISTANCE = 48; +/** The busy grid's cells per side. */ +const GRID = 8; +/** Mean Laplacian magnitude per step of the busy grid's 0..15 scale: 45 and more is 15. */ +const BUSY_STEP = 3; + +export interface ColourSignals { + /** Up to five `#rrggbb:share`, most of the picture first; null for a picture with no pixels to read. */ + palette: string[] | null; + /** 8 x 8 cells, row by row, one hex digit each: 0 calm to f busy. */ + busy: string | null; + /** The edge-energy centroid, 0..1 of the width and the height; null for a picture without edges. */ + salX: number | null; + salY: number | null; +} + +type Rgb = [number, number, number]; + +const hex2 = (v: number) => + Math.max(0, Math.min(255, Math.round(v))) + .toString(16) + .padStart(2, '0'); + +const distance2 = (a: Rgb, b: Rgb) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; + +/** Every n-th pixel's colour, n chosen so at most MAX_SAMPLES are read. */ +function samples(data: Uint8Array, total: number): Rgb[] { + const stride = Math.max(1, Math.floor(total / MAX_SAMPLES)); + const out: Rgb[] = []; + for (let i = 0; i < total; i += stride) { + const o = i * 4; + out.push([data[o] ?? 0, data[o + 1] ?? 0, data[o + 2] ?? 0]); + } + return out; +} + +/** The starting colours: the fullest 4-bit histogram bins, each far enough from the ones before it. */ +function startColours(pixels: readonly Rgb[]): Rgb[] { + const counts = new Map(); + for (const [r, g, b] of pixels) { + const bin = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4); + counts.set(bin, (counts.get(bin) ?? 0) + 1); + } + const bins = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + const starts: Rgb[] = []; + for (const [bin] of bins) { + const colour: Rgb = [((bin >> 8) & 15) * 16 + 8, ((bin >> 4) & 15) * 16 + 8, (bin & 15) * 16 + 8]; + if (starts.every((s) => distance2(s, colour) >= MIN_START_DISTANCE ** 2)) starts.push(colour); + if (starts.length === PALETTE_SIZE) break; + } + return starts; +} + +/** The index of the centre nearest to a colour. */ +function nearest(centres: readonly Rgb[], colour: Rgb): number { + let best = 0; + let bestDistance = Infinity; + centres.forEach((centre, i) => { + const d = distance2(centre, colour); + if (d < bestDistance) { + best = i; + bestDistance = d; + } + }); + return best; +} + +/** A few rounds of k-means from the histogram's colours; deterministic for the same pixels. */ +export function paletteOf(data: Uint8Array, width: number, height: number): string[] | null { + const pixels = samples(data, width * height); + if (pixels.length === 0) return null; + const centres = startColours(pixels); + let members = new Array(centres.length).fill(0); + for (let round = 0; round <= ROUNDS; round++) { + const sums = centres.map((): Rgb => [0, 0, 0]); + members = new Array(centres.length).fill(0); + for (const colour of pixels) { + const at = nearest(centres, colour); + const sum = sums[at]; + if (!sum) continue; + sum[0] += colour[0]; + sum[1] += colour[1]; + sum[2] += colour[2]; + members[at] = (members[at] ?? 0) + 1; + } + // The last round only counts: the centres stay where the counting found them. + if (round === ROUNDS) break; + sums.forEach((sum, i) => { + const n = members[i] ?? 0; + if (n > 0) centres[i] = [sum[0] / n, sum[1] / n, sum[2] / n]; + }); + } + return centres + .map((centre, i) => ({ centre, share: (members[i] ?? 0) / pixels.length, i })) + .filter((c) => c.share >= MIN_SHARE) + .sort((a, b) => b.share - a.share || a.i - b.i) + .map((c) => `#${hex2(c.centre[0])}${hex2(c.centre[1])}${hex2(c.centre[2])}:${c.share.toFixed(2)}`); +} + +/** + * The busy grid and the salient point from one pass of the grey Laplacian: + * each cell's mean magnitude on a 0..15 scale, and the centroid of all of it. + * The caller hands in at least 3 x 3 pixels. + */ +export function edgeSignals(data: Uint8Array, width: number, height: number): Omit { + const grey = new Float32Array(width * height); + for (let i = 0; i < grey.length; i++) { + const o = i * 4; + grey[i] = 0.299 * (data[o] ?? 0) + 0.587 * (data[o + 1] ?? 0) + 0.114 * (data[o + 2] ?? 0); + } + const at = (k: number) => grey[k] ?? 0; + const sums = new Array(GRID * GRID).fill(0); + const counts = new Array(GRID * GRID).fill(0); + let energy = 0; + let sx = 0; + let sy = 0; + for (let row = 1; row < height - 1; row++) { + const cellRow = Math.min(GRID - 1, Math.floor((row * GRID) / height)); + for (let col = 1; col < width - 1; col++) { + const i = row * width + col; + const lap = Math.abs(at(i - 1) + at(i + 1) + at(i - width) + at(i + width) - 4 * at(i)); + const cell = cellRow * GRID + Math.min(GRID - 1, Math.floor((col * GRID) / width)); + sums[cell] = (sums[cell] ?? 0) + lap; + counts[cell] = (counts[cell] ?? 0) + 1; + energy += lap; + sx += lap * ((col + 0.5) / width); + sy += lap * ((row + 0.5) / height); + } + } + const busy = sums + .map((sum, cell) => { + const n = counts[cell] ?? 0; + const level = n > 0 ? Math.min(15, Math.round(sum / n / BUSY_STEP)) : 0; + return level.toString(16); + }) + .join(''); + if (energy <= 0) return { busy, salX: null, salY: null }; + return { busy, salX: round3(sx / energy), salY: round3(sy / energy) }; +} + +const round3 = (v: number) => Math.round(v * 1000) / 1000; + +/** Everything above, for one decoded and scaled picture. */ +export function colourSignals(data: Uint8Array, width: number, height: number): ColourSignals { + return { palette: paletteOf(data, width, height), ...edgeSignals(data, width, height) }; +} diff --git a/server/src/nest/photos/photo-signals-runner.ts b/server/src/nest/photos/photo-signals-runner.ts index dfbef8acc..de924259f 100644 --- a/server/src/nest/photos/photo-signals-runner.ts +++ b/server/src/nest/photos/photo-signals-runner.ts @@ -1,11 +1,14 @@ +import { colourSignals, type ColourSignals } from './photo-colour-signals'; + import { Jimp } from 'jimp'; /** * The version of the measurement below. A stored row of another version is * measured again, so a better measurement never mixes with an older one. - * Version 2 measures every photo at the same `SIGNAL_EDGE`, up or down. + * Version 2 measures every photo at the same `SIGNAL_EDGE`, up or down; + * version 3 adds the colours, the busy grid and the salient point. */ -export const PHOTO_SIGNALS_ALGO = 2; +export const PHOTO_SIGNALS_ALGO = 3; /** * The long edge every photo is scaled to, up or down, before it is measured. A @@ -23,7 +26,7 @@ const CLIP_LOW = 5; const CLIP_HIGH = 250; /** What a photo's pixels say. `width`/`height` are the decoded image's, before it is scaled to measure. */ -export interface PhotoPixels { +export interface PhotoPixels extends ColourSignals { /** The perceptual hash, 64 bits as 16 hex digits; null if the hash came out in another shape. */ phash: string | null; /** The variance of the grey Laplacian at `SIGNAL_EDGE`: the higher, the sharper. */ @@ -51,8 +54,9 @@ export async function measurePhoto(bytes: Uint8Array): Promise; + /** Errors of the runner that say nothing about the photo: it is measured again on a later call. */ const TRANSIENT = new Set(['busy', 'closed', 'unavailable']); @@ -107,6 +112,19 @@ export class PhotoSignalsService implements OnModuleDestroy { return { byPhoto, pending: ids.filter((id) => !byPhoto.has(id)).length }; } + /** + * What the book engine reads of these photos, from whatever measurement is + * stored, none measured or waited for: the original's size for the print + * check, and from the current measurement the colours, the busy grid and + * the salient point. A photo without a row is left out. + */ + async bookSignals(photoIds: readonly number[]): Promise> { + const ids = [...new Set(photoIds)]; + if (ids.length === 0) return new Map(); + const rows = await this.signalsRepo.listForPhotos(ids); + return new Map(rows.map((row) => [row.photo_id, toBookSignals(row)])); + } + /** The signals stored for these photos by the current measurement; older ones count as missing. */ private async stored(ids: number[]): Promise> { if (ids.length === 0) return new Map(); @@ -183,6 +201,10 @@ export class PhotoSignalsService implements OnModuleDestroy { luma: pixels?.luma ?? null, clipped: pixels?.clipped ?? null, ...originalSize(source, pixels), + palette: pixels?.palette ? encodeJson(TREK_PHOTO_SIGNAL_PALETTE, pixels.palette) : null, + busy: pixels?.busy ?? null, + sal_x: pixels?.salX ?? null, + sal_y: pixels?.salY ?? null, }); } @@ -222,6 +244,32 @@ function toSignals(row: TrekPhotoSignalRow): PhotoPickSignals { }; } +const BUSY_GRID = /^[0-9a-f]{64}$/; + +/** A stored palette as the engine reads it; anything that is not the measurement's own shape reads as none. */ +function paletteOf(row: TrekPhotoSignalRow): string[] | undefined { + const entries = decodeJson(TREK_PHOTO_SIGNAL_PALETTE, row.palette, `photo ${row.photo_id}`); + return entries.length > 0 ? entries : undefined; +} + +function toBookSignals(row: TrekPhotoSignalRow): BookPhotoSignals { + const fresh = row.algo === PHOTO_SIGNALS_ALGO; + const palette = fresh ? paletteOf(row) : undefined; + const busy = fresh && row.busy && BUSY_GRID.test(row.busy) ? row.busy : undefined; + const salient = + fresh && typeof row.sal_x === 'number' && typeof row.sal_y === 'number' + ? { x: row.sal_x, y: row.sal_y } + : undefined; + return { + width: row.orig_width, + height: row.orig_height, + ...(typeof row.luma === 'number' ? { luma: Math.round(row.luma * 255) } : {}), + ...(palette ? { palette } : {}), + ...(busy ? { busy } : {}), + ...(salient ? { salient } : {}), + }; +} + /** Resolves when `work` settles or after `ms`, whichever comes first; never rejects. */ function waitAtMost(work: Promise, ms: number): Promise { return new Promise((resolve) => { diff --git a/server/tests/unit/db/repositories/TrekPhotoSignals.repository.test.ts b/server/tests/unit/db/repositories/TrekPhotoSignals.repository.test.ts index 6a9d04127..007056ba4 100644 --- a/server/tests/unit/db/repositories/TrekPhotoSignals.repository.test.ts +++ b/server/tests/unit/db/repositories/TrekPhotoSignals.repository.test.ts @@ -2,7 +2,8 @@ * `TrekPhotoSignalsRepository` (PSIGREPO-*): one measurement per photo, * replaced by the next, gone with its photo; read back for many photos at once * past SQLite's bound-value limit; and the gallery facts the book photo picker - * starts from, in gallery order and for one journey only. + * starts from, in gallery order and for one journey only; the colour signals + * (palette, busy grid, salient point) of the newest measurement only. */ import { JourneyPhotos } from '../../../../src/db/entities/JourneyPhotos.entity'; import { TrekPhotoSignals } from '../../../../src/db/entities/TrekPhotoSignals.entity'; @@ -144,4 +145,17 @@ describe('TrekPhotoSignalsRepository', () => { expect.objectContaining({ photo_id: clip, media_type: 'video' }), ]); }); + + it('PSIGREPO-005: the colours, the busy grid and the salient point read back, and a later measurement without them clears them', async () => { + const { user } = await makeUser(t); + const photoId = await trekPhoto(user.id); + const colours = { palette: '["#336699:0.6","#f0e0d0:0.4"]', busy: 'f'.repeat(64), sal_x: 0.25, sal_y: 0.75 }; + await signals.saveSignals(measured(photoId, { algo: 3, ...colours })); + expect(await signals.listForPhotos([photoId])).toEqual([expect.objectContaining({ algo: 3, ...colours })]); + + await signals.saveSignals(measured(photoId, { algo: 2 })); + expect(await signals.listForPhotos([photoId])).toEqual([ + expect.objectContaining({ algo: 2, palette: null, busy: null, sal_x: null, sal_y: null }), + ]); + }); }); diff --git a/server/tests/unit/nest/photos/photo-colour-signals.test.ts b/server/tests/unit/nest/photos/photo-colour-signals.test.ts new file mode 100644 index 000000000..c01f3d486 --- /dev/null +++ b/server/tests/unit/nest/photos/photo-colour-signals.test.ts @@ -0,0 +1,78 @@ +/** + * The colour signals of a photo (PHSIG3-*), on pixels drawn here: a picture + * half flat and half textured has its busy cells and its salient point on + * the textured side, a two-colour picture has both colours in its palette + * with their shares, a flat one has one colour, no salient point and a calm + * grid; the measurement of a whole photo carries them, at version 3. + */ +import { colourSignals, edgeSignals, paletteOf } from '../../../../src/nest/photos/photo-colour-signals'; +import { measurePhoto, PHOTO_SIGNALS_ALGO } from '../../../../src/nest/photos/photo-signals-runner'; + +import { Jimp } from 'jimp'; +import { describe, expect, it } from 'vitest'; + +type Rgb = [number, number, number]; + +/** RGBA pixels of `width` x `height`, each drawn by `paint`. */ +function pixels(width: number, height: number, paint: (x: number, y: number) => Rgb): Uint8Array { + const data = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const [r, g, b] = paint(x, y); + data.set([r, g, b, 255], (y * width + x) * 4); + } + } + return data; +} + +/** Flat grey on the left half, a two-pixel black and white check on the right. */ +const halfBusy = (x: number, y: number): Rgb => { + if (x < 64) return [128, 128, 128]; + const light = (Math.floor(x / 2) + Math.floor(y / 2)) % 2 === 0; + return light ? [255, 255, 255] : [0, 0, 0]; +}; + +describe('colour signals', () => { + it('PHSIG3-001: the busy cells and the salient point sit on the textured half', () => { + const { busy, salX, salY } = edgeSignals(pixels(128, 96, halfBusy), 128, 96); + expect(busy).toMatch(/^[0-9a-f]{64}$/); + const rows = Array.from({ length: 8 }, (_, r) => busy?.slice(r * 8, r * 8 + 8) ?? ''); + for (const row of rows) { + expect(row.slice(0, 3)).toBe('000'); + expect(row.slice(5)).toBe('fff'); + } + expect(salX).toBeGreaterThan(0.6); + expect(salY).toBeGreaterThan(0.4); + expect(salY).toBeLessThan(0.6); + }); + + it('PHSIG3-002: a two-colour picture has both colours with their shares, the larger first', () => { + const palette = paletteOf( + pixels(100, 100, (x) => (x < 70 ? [200, 30, 30] : [20, 40, 220])), + 100, + 100, + ); + expect(palette).toEqual(['#c81e1e:0.70', '#1428dc:0.30']); + }); + + it('PHSIG3-003: a flat picture has one colour, a calm grid and no salient point', () => { + const flat = colourSignals( + pixels(40, 30, () => [10, 200, 120]), + 40, + 30, + ); + expect(flat).toEqual({ palette: ['#0ac878:1.00'], busy: '0'.repeat(64), salX: null, salY: null }); + expect(paletteOf(new Uint8Array(0), 0, 0)).toBeNull(); + }); + + it('PHSIG3-004: a measured photo carries its colours, its grid and its salient point, at version 3', async () => { + const image = new Jimp({ width: 256, height: 192, color: 0xffffffff }); + image.bitmap.data.set(pixels(256, 192, (x, y) => halfBusy(Math.floor(x / 2), Math.floor(y / 2)))); + const measured = await measurePhoto(new Uint8Array(await image.getBuffer('image/png'))); + expect(PHOTO_SIGNALS_ALGO).toBe(3); + expect(measured?.palette?.length).toBeGreaterThan(0); + expect(measured?.palette?.[0]).toMatch(/^#[0-9a-f]{6}:(0|1)\.\d{2}$/); + expect(measured?.busy).toMatch(/^[0-9a-f]{64}$/); + expect(measured?.salX).toBeGreaterThan(0.6); + }); +}); diff --git a/server/tests/unit/nest/photos/photo-signals.service.test.ts b/server/tests/unit/nest/photos/photo-signals.service.test.ts index ed3ae0b76..feaae9c8f 100644 --- a/server/tests/unit/nest/photos/photo-signals.service.test.ts +++ b/server/tests/unit/nest/photos/photo-signals.service.test.ts @@ -299,4 +299,29 @@ describe('PhotoSignalsService, the line', () => { expect(byPhoto.get(unsized)).toMatchObject({ origWidth: null, origHeight: null }); expect(byPhoto.get(unsized)?.sharpness).toEqual(expect.any(Number)); }); + + it('PHSVC-011: the book engine reads the original size, brightness, colours, busy grid and salient point of a photo', async () => { + const { ids } = await gallery(['image', 'image', 'image']); + const [measured, older, broken] = ids as [number, number, number]; + resolver.readForSignals.mockResolvedValue({ kind: 'original', bytes: photo }); + await main.signals.signalsFor([measured]); + await insertRow(orm, TrekPhotoSignals, { photo: older, algo: PHOTO_SIGNALS_ALGO - 1, orig_width: 800, luma: 0.5 }); + await insertRow(orm, TrekPhotoSignals, { + photo: broken, + algo: PHOTO_SIGNALS_ALGO, + palette: 'not json', + busy: 'abc', + }); + + const book = await main.signals.bookSignals([measured, older, broken, measured]); + + const own = book.get(measured); + expect(own).toMatchObject({ width: 300, height: 200, luma: 128, busy: '0'.repeat(64) }); + expect(own?.palette).toEqual([expect.stringMatching(/^#[0-9a-f]{6}:1\.00$/)]); + expect(own?.salient).toBeUndefined(); + // An older measurement still knows the size, never the colours. + expect(book.get(older)).toEqual({ width: 800, height: null, luma: 128 }); + expect(book.get(broken)).toEqual({ width: null, height: null }); + expect(await main.signals.bookSignals([])).toEqual(new Map()); + }); });