121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { decode } from 'blurhash';
|
|
import * as Bytes from '../Bytes';
|
|
|
|
const BITMAP_HEADER = new Uint8Array([
|
|
// Header
|
|
// See https://en.wikipedia.org/wiki/BMP_file_format#Bitmap_file_header
|
|
|
|
// 0x00: BM
|
|
0x42, 0x4d,
|
|
// 0x02: Size = 3072 + 14 + 40
|
|
0x36, 0x0c, 0x00, 0x00,
|
|
// 0x06: Reserved
|
|
0x00, 0x00, 0x00, 0x00,
|
|
// 0x0a: Pixels Offset = 14 + 40
|
|
0x36, 0x00, 0x00, 0x00,
|
|
|
|
// BIP Header
|
|
// See https://en.wikipedia.org/wiki/BMP_file_format#cite_ref-bmp_2-2
|
|
|
|
// 0x0e: Size=40
|
|
0x28, 0x00, 0x00, 0x00,
|
|
|
|
// 0x12: Width=32
|
|
0x20, 0x00, 0x00, 0x00,
|
|
// 0x16: Height=-32 (top-down)
|
|
0xe0, 0xff, 0xff, 0xff,
|
|
// 0x1a: Num Color Planes
|
|
0x01, 0x00,
|
|
// 0x1c: Bits per Pixel = 24
|
|
0x18, 0x00,
|
|
// 0x1e: Compression Method
|
|
0x00, 0x00, 0x00, 0x00,
|
|
// 0x22: Image size = 3072
|
|
0x00, 0x0c, 0x00, 0x00,
|
|
// 0x26: Horizontal Resolution
|
|
0x01, 0x00, 0x00, 0x00,
|
|
// 0x2a: Vertical Resolution
|
|
0x01, 0x00, 0x00, 0x00,
|
|
// 0x2e: Number of Colors in Palette
|
|
0x00, 0x00, 0x00, 0x00,
|
|
// 0x32: Number of Important Colors
|
|
0x00, 0x00, 0x00, 0x00,
|
|
]);
|
|
|
|
const PIXEL_COUNT = 32 * 32;
|
|
|
|
/* eslint-disable no-bitwise */
|
|
function writeUInt32LE(bytes: Uint8Array, value: number, position: number) {
|
|
// eslint-disable-next-line no-param-reassign
|
|
bytes[position + 0] = (value >>> 0) & 0xff;
|
|
// eslint-disable-next-line no-param-reassign
|
|
bytes[position + 1] = (value >>> 8) & 0xff;
|
|
// eslint-disable-next-line no-param-reassign
|
|
bytes[position + 2] = (value >>> 16) & 0xff;
|
|
// eslint-disable-next-line no-param-reassign
|
|
bytes[position + 3] = (value >>> 24) & 0xff;
|
|
}
|
|
|
|
export function computeBlurHashUrl(
|
|
blurHash: string,
|
|
// Square by default
|
|
desiredWidth = 1,
|
|
desiredHeight = 1
|
|
): string {
|
|
const invAspect = Math.abs(desiredHeight) / (Math.abs(desiredWidth) + 1e-23);
|
|
|
|
// Calculate width and height that roughly satisfy the desired PIXEL_COUNT
|
|
//
|
|
// height = invAspect * width
|
|
// width * height = invAspect * width * width = PIXEL_COUNT
|
|
let width = Math.sqrt(PIXEL_COUNT / (invAspect + 1e-23));
|
|
width = Math.round(width);
|
|
|
|
// Width has to be a multiple of DWORD size (4) for BMP to render image
|
|
// correctly
|
|
width >>= 2;
|
|
width <<= 2;
|
|
|
|
// Give at least two pixels of width to show gradients
|
|
width = Math.max(2, width);
|
|
|
|
let height = width * invAspect;
|
|
height = Math.round(height);
|
|
|
|
// Minimum two pixels of height for gradients
|
|
height = Math.max(2, height);
|
|
|
|
const rgba = decode(blurHash, width, height);
|
|
const bgrSize = (rgba.byteLength / 4) * 3;
|
|
const bitmap = new Uint8Array(BITMAP_HEADER.byteLength + bgrSize);
|
|
|
|
bitmap.set(BITMAP_HEADER);
|
|
|
|
// Update size
|
|
writeUInt32LE(bitmap, bitmap.byteLength, 0x02);
|
|
|
|
// Update width and height (has to be negative for top-down drawing)
|
|
writeUInt32LE(bitmap, width, 0x12);
|
|
writeUInt32LE(bitmap, -height, 0x16);
|
|
|
|
// Update image size
|
|
writeUInt32LE(bitmap, bgrSize, 0x22);
|
|
|
|
// Copy pixels
|
|
for (
|
|
let i = 0, j = BITMAP_HEADER.byteLength;
|
|
i < rgba.byteLength;
|
|
i += 4, j += 3
|
|
) {
|
|
// BMP uses BGR ordering
|
|
bitmap[j + 2] = rgba[i];
|
|
bitmap[j + 1] = rgba[i + 1];
|
|
bitmap[j] = rgba[i + 2];
|
|
}
|
|
|
|
return `data:image/bmp;base64,${Bytes.toBase64(bitmap)}`;
|
|
}
|
|
/* eslint-enable no-bitwise */
|