From 5cdb0d6afd7962e65ae520dc87dfbde97c8f3d55 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:30:00 -0500 Subject: [PATCH] Clamp blurhash aspect ratio Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- .../util/computeBlurHashUrl_test.dom.ts | 57 +++++++++++++++++++ ts/util/computeBlurHashUrl.std.ts | 19 +++++-- 2 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 ts/test-electron/util/computeBlurHashUrl_test.dom.ts diff --git a/ts/test-electron/util/computeBlurHashUrl_test.dom.ts b/ts/test-electron/util/computeBlurHashUrl_test.dom.ts new file mode 100644 index 0000000000..e55d0faffc --- /dev/null +++ b/ts/test-electron/util/computeBlurHashUrl_test.dom.ts @@ -0,0 +1,57 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { computeBlurHashUrl } from '../../util/computeBlurHashUrl.std.ts'; +import { defaultBlurHash } from '../../util/Attachment.std.ts'; + +describe('computeBlurHashUrl', () => { + const BLUR_HASH = defaultBlurHash(); + + async function loadImage( + desiredWidth?: number, + desiredHeight?: number + ): Promise { + const image = document.createElement('img'); + image.src = computeBlurHashUrl(BLUR_HASH, desiredWidth, desiredHeight); + await image.decode(); + return image; + } + + it('produces a square image by default', async () => { + const image = await loadImage(); + assert.strictEqual(image.naturalWidth, 32); + assert.strictEqual(image.naturalHeight, 32); + }); + + it('respects the requested aspect ratio', async () => { + const image = await loadImage(1600, 400); + assert.strictEqual(image.naturalWidth / image.naturalHeight, 4); + }); + + describe('bounds sender-provided dimensions', () => { + const DEGENERATE: ReadonlyArray<[number, number]> = [ + [1, 25_000_000], + [25_000_000, 1], + [1, 0xffffffff], + [0xffffffff, 1], + [0, 0xffffffff], + [0xffffffff, 0xffffffff], + [-1, -25_000_000], + [Number.MAX_SAFE_INTEGER, 1], + [1, Number.MAX_SAFE_INTEGER], + ]; + + for (const [desiredWidth, desiredHeight] of DEGENERATE) { + it(`stays small for ${desiredWidth}x${desiredHeight}`, async () => { + const image = await loadImage(desiredWidth, desiredHeight); + + assert.isAtLeast(image.naturalWidth, 4); + assert.isAtLeast(image.naturalHeight, 4); + assert.isAtMost(image.naturalWidth, 256); + assert.isAtMost(image.naturalHeight, 256); + }); + } + }); +}); diff --git a/ts/util/computeBlurHashUrl.std.ts b/ts/util/computeBlurHashUrl.std.ts index da6cad6346..3485dcb75f 100644 --- a/ts/util/computeBlurHashUrl.std.ts +++ b/ts/util/computeBlurHashUrl.std.ts @@ -46,6 +46,12 @@ const BITMAP_HEADER = new Uint8Array([ ]); const PIXEL_COUNT = 32 * 32; +const MIN_DIMENSION = 4; + +// width * height = PIXEL_COUNT, so aspect_ratio = PIXEL_COUNT / (height * height), which is +// maximized when height is small +const MAX_ASPECT_RATIO = PIXEL_COUNT / (MIN_DIMENSION * MIN_DIMENSION); // 64 +const MIN_ASPECT_RATIO = 1 / MAX_ASPECT_RATIO; function writeUInt32LE( bytes: Uint8Array, @@ -68,9 +74,14 @@ export function computeBlurHashUrl( desiredWidth = 1, desiredHeight = 1 ): string { - const invAspect = + const rawInvAspect = (Math.abs(desiredHeight) + 1e-23) / (Math.abs(desiredWidth) + 1e-23); + const invAspect = Math.min( + MAX_ASPECT_RATIO, + Math.max(MIN_ASPECT_RATIO, rawInvAspect) + ); + // Calculate width and height that roughly satisfy the desired PIXEL_COUNT // // height = invAspect * width @@ -85,14 +96,12 @@ export function computeBlurHashUrl( // oxlint-disable-next-line no-bitwise width <<= 2; - // Give at least two pixels of width to show gradients - width = Math.max(2, width); + width = Math.max(MIN_DIMENSION, width); let height = width * invAspect; height = Math.round(height); - // Minimum two pixels of height for gradients - height = Math.max(2, height); + height = Math.max(MIN_DIMENSION, height); const rgba = decode(blurHash, width, height); const bgrSize = (rgba.byteLength / 4) * 3;