feat(photos): a photo knows its colours and its calm areas

Photo signals move to version 3: a deterministic palette, a busy grid and
the point of most detail, stored as measured values, never as image bytes.
This commit is contained in:
Maurice
2026-10-22 21:25:46 +00:00
parent 4570ace78c
commit 2c98ae8e4a
10 changed files with 398 additions and 8 deletions
@@ -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(),
},
});
@@ -13,4 +13,8 @@ export interface TrekPhotoSignalsTable {
orig_width: InsertOptional<number | null>;
orig_height: InsertOptional<number | null>;
computed_at: InsertOptional<string>;
palette: InsertOptional<string | null>;
busy: InsertOptional<string | null>;
sal_x: InsertOptional<number | null>;
sal_y: InsertOptional<number | null>;
}
@@ -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');
}
}
@@ -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<TrekPhotoSignals>
'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<TrekPhotoSignals>
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<TrekPhotoSignals>
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<TrekPhotoSignals>
.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,
};
}
@@ -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<number, number>();
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<number>(centres.length).fill(0);
for (let round = 0; round <= ROUNDS; round++) {
const sums = centres.map((): Rgb => [0, 0, 0]);
members = new Array<number>(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<ColourSignals, 'palette'> {
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<number>(GRID * GRID).fill(0);
const counts = new Array<number>(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) };
}
@@ -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<PhotoPixels | nul
image.scaleToFit({ w: SIGNAL_EDGE, h: SIGNAL_EDGE });
// A strip far longer than it is wide ends up under 3 pixels across, which leaves no inner pixel.
if (image.bitmap.width < 3 || image.bitmap.height < 3) return null;
const grey = greyStats(image.bitmap.data, image.bitmap.width, image.bitmap.height);
return { phash: bitsToHex(image.pHash()), ...grey, width, height };
const { data, width: w, height: h } = image.bitmap;
const grey = greyStats(data, w, h);
return { phash: bitsToHex(image.pHash()), ...grey, ...colourSignals(data, w, h), width, height };
} catch {
return null;
}
@@ -1,9 +1,11 @@
import { TrekPhotoSignals } from '../../db/entities/TrekPhotoSignals.entity';
import { TREK_PHOTO_SIGNAL_PALETTE } from '../../db/json-columns';
import type {
GalleryPhotoFacts,
TrekPhotoSignalRow,
TrekPhotoSignalsRepository,
} from '../../db/repositories/TrekPhotoSignals.repository';
import { decodeJson, encodeJson } from '../../utils/json-column';
import { logWarn } from '../audit/audit-log.logger';
import { withRequestContext } from '../database/request-context';
import { PhotoResolverService, type PhotoSignalSource } from '../memories/photo-resolver.service';
@@ -16,7 +18,7 @@ import {
import { MikroORM } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
import type { PhotoPickSignals } from '@trek/shared';
import type { BookPhotoInfo, PhotoPickSignals } from '@trek/shared';
/** How long a caller waits for photos still being measured before it answers on what the rows know. */
const SIGNALS_WAIT_MS = 8000;
@@ -27,6 +29,9 @@ export const PARALLEL_FETCHES = 2;
/** A local original larger than this is not read for its signals. */
export const MAX_ORIGINAL_BYTES = 40 * 1024 * 1024;
/** What the book engine reads of a photo's measurement: the original's size, brightness, colours and calm areas. */
type BookPhotoSignals = Pick<BookPhotoInfo, 'width' | 'height' | 'luma' | 'palette' | 'busy' | 'salient'>;
/** 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<Map<number, BookPhotoSignals>> {
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<Map<number, PhotoPickSignals>> {
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<unknown>, ms: number): Promise<void> {
return new Promise((resolve) => {
@@ -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 }),
]);
});
});
@@ -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);
});
});
@@ -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());
});
});