Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,9 @@ Many headers come in pairs: `foo.h` (C API for FFI use) and `foo.hpp` (C++ API).

## Code Style

- **Format with `./utils/format.sh` before every commit**, and check it with `./utils/format.sh verify`
(exit 0 means clean). Do not run `clang-format` yourself: the script pins **clang-format-19** and
formats the whole source list, and other versions — including whatever `clang-format` points at —
produce different output that fails CI.
- **Prefer DRY code**: when logic is duplicated across two or more call sites, extract a shared helper. Do this proactively when writing new code, not only when asked.
- **Specify the shape upfront**: when asked to implement something that overlaps with existing code, identify and extract the shared piece before writing the new code, so duplication never appears in the first place.
150 changes: 150 additions & 0 deletions include/session/image/thumbhash.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#pragma once

#include <array>
#include <cstdint>
#include <optional>
#include <span>
#include <vector>

namespace session::image::thumbhash {

/// The largest input dimension `encode` accepts. ThumbHash's own limit: the output is at most a
/// 7x7 DCT, so a larger input costs time without adding information.
inline constexpr uint32_t max_input_dimension = 100;

/// The largest hash `encode` can produce (7x7 luminance + 3x3 P + 3x3 Q + 5x5 alpha).
inline constexpr size_t max_hash_size = 25;

/// A decoded placeholder: RGBA8, row-major, *not* alpha-premultiplied.
struct image {
uint32_t width = 0;
uint32_t height = 0;
std::vector<std::byte> rgba;
};

/// API: image/thumbhash/encode
///
/// Encodes a small RGBA image into a ThumbHash: a ~20-25 byte placeholder that a receiving client
/// can render as a blurred preview before the real attachment arrives.
///
/// The caller is responsible for scaling the source image down; `width` and `height` must each be
/// between 1 and `max_input_dimension`. Note that the scaler matters for reproducibility: two
/// clients that downscale the same photo differently will produce different hashes. See the note
/// on cross-platform reproducibility in the implementation.
///
/// Inputs:
/// - `rgba` -- the pixels, row-major, 4 bytes per pixel, *not* alpha-premultiplied. Must be
/// exactly `width * height * 4` bytes.
/// - `width`, `height` -- the image dimensions, each in [1, `max_input_dimension`].
///
/// Outputs:
/// - the hash, between 5 and `max_hash_size` bytes.
///
/// Throws `std::invalid_argument` if the dimensions are out of range or `rgba` is the wrong size.
std::vector<std::byte> encode(std::span<const std::byte> rgba, uint32_t width, uint32_t height);

/// API: image/thumbhash/decode
///
/// Decodes a ThumbHash to RGBA pixels at the requested resolution. This is the function clients
/// should use: pass the attachment's real dimensions, scaled down small.
///
/// The output shape is entirely yours to choose -- the DCT basis is continuous, so any grid is
/// valid and this simply samples it at the points you ask for. A hash carries no usable record
/// of the source's shape (see `component_aspect_ratio`), so the aspect ratio must come from the
/// attachment metadata, not from here.
///
/// **Decode small -- 100x100 or less -- and scale the result up.** There is no enforced maximum,
/// but decoding evaluates the DCT basis per output pixel, so cost grows with the output area and
/// nothing is gained by it: the hash holds at most 7 cycles across the image, so a 32px decode
/// already captures essentially everything it contains. Upscaling that 10x with any linear filter
/// differs from a full-size decode by an RMSE under 1, which is imperceptible, and costs 0.085ms
/// against 4.6ms for decoding 240x180 directly. To fill a larger area, decode small and scale
/// with libvips (or the platform's own scaler), which handles a blurred image like this very well.
/// Do not scale with nearest-neighbour filtering, which will look blocky.
///
/// Inputs:
/// - `hash` -- the bytes produced by `encode`.
/// - `width`, `height` -- the output dimensions, each at least 1. There is no upper limit beyond
/// what the arithmetic can represent, but see above: these are meant to be small.
///
/// Outputs:
/// - the decoded RGBA8 image.
///
/// Throws `std::invalid_argument` if the hash is malformed, the dimensions are zero, or they are
/// large enough that the output size would not fit in a `size_t`. A merely very large request is
/// not rejected, and will throw `std::bad_alloc` or `std::length_error` if it cannot be allocated.
image decode(std::span<const std::byte> hash, uint32_t width, uint32_t height);

/// API: image/thumbhash/expected_size
///
/// The exact number of bytes a well-formed hash starting with these bytes must occupy, or nullopt
/// if the leading bytes are not a usable header.
///
/// A hash's length is not merely bounded, it is fully determined: the alpha flag and the 3-bit
/// component count fix how many coefficient nibbles follow. Only six lengths are reachable at
/// all -- 17, 19, 21, 23 or 24 bytes without alpha, and 23 or 25 with it. Reads the header only:
/// no DCT, no allocation.
///
/// Note that `decode` is more permissive than this, requiring only that enough bytes are present.
/// Use `valid` at a trust boundary, where "exactly well-formed" is what you mean.
std::optional<size_t> expected_size(std::span<const std::byte> hash);

/// API: image/thumbhash/valid
///
/// Whether `hash` is structurally well-formed: a usable header, and exactly the length that
/// header implies.
///
/// This is what a receive path should test before storing a value a remote peer supplied. It is
/// strictly stronger than a length cap, which a blob of the right size but arbitrary content
/// would pass, and it costs a handful of bit extractions.
bool valid(std::span<const std::byte> hash);

/// API: image/thumbhash/average_rgba
///
/// Returns the average colour of the original image as RGBA8 (not alpha-premultiplied), read
/// straight out of the hash's DC terms. Much cheaper than decoding, and enough for a flat-colour
/// placeholder.
///
/// Throws `std::invalid_argument` if the hash is malformed.
std::array<std::byte, 4> average_rgba(std::span<const std::byte> hash);

// ─── Diagnostics and last resorts ────────────────────────────────────────────
//
// The two functions below exist for inspecting a hash and for the case where one turns up with no
// accompanying metadata at all. Neither belongs on a normal display path.

/// API: image/thumbhash/component_aspect_ratio
///
/// Returns the ratio of the hash's DCT component counts. This is NOT the image's aspect ratio
/// and must never be used to lay one out.
///
/// A ThumbHash does not record the source's shape. It records how many luminance AC coefficients
/// are present, because a decoder cannot parse the hash without knowing where the chroma terms
/// begin. The encoder picks that count roughly in proportion to the source, so a wide image gets
/// more horizontal detail than vertical, and dividing the two out gives something that correlates
/// with the shape. Upstream exposes it as `thumbHashToApproximateAspectRatio`; this is the same
/// value under a name that does not invite misuse.
///
/// It is quantised to a handful of values, because one side is always pinned at the per-channel
/// maximum. The complete set of results is 7/n for n in 1..7 and their reciprocals without alpha
/// (13 values: 1/7 ... 1 ... 7), or 5/n for n in 1..5 and reciprocals with it (9 values). It also
/// saturates, so every image wider than 7:1 reports exactly 7.0: a 1000x100 banner reads as 7.0
/// rather than 10.0, and a 1x100 sliver is wrong by a factor of fourteen.
///
/// Throws `std::invalid_argument` if the hash is malformed.
double component_aspect_ratio(std::span<const std::byte> hash);

/// API: image/thumbhash/decode_unsized
///
/// Decodes a hash when its dimensions are genuinely unknown, guessing the output shape from
/// `component_aspect_ratio` with `size` pixels on the longer edge.
///
/// The guess is as coarse as that function is -- it can be wrong by more than a factor of ten --
/// so a placeholder sized from the result will visibly jump when the real image loads. Use
/// `decode` with the attachment's real dimensions instead; reach for this only when there are
/// none, such as when inspecting a hash in isolation.
///
/// Throws `std::invalid_argument` if the hash is malformed or `size` is zero.
image decode_unsized(std::span<const std::byte> hash, uint32_t size = 32);

} // namespace session::image::thumbhash
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ endmacro()

add_libsession_util_library(util
file.cpp
image/thumbhash.cpp
logging.cpp
util.cpp
)
Expand Down
113 changes: 113 additions & 0 deletions src/image/det_trig.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#pragma once

// Bit-reproducible trigonometry for the ThumbHash DCT.
//
// std::cos is not required by IEEE-754 to be correctly rounded, and implementations disagree: V8's
// fdlibm-derived cos differs from glibc's by 1 ulp on ~3.5% of the arguments this DCT uses, which
// is enough to change the emitted hash for ~7% of images. Since a thumbhash is sent to other
// people, that would make it a weak fingerprint of which platform produced it.
//
// Reproducibility here rests on two things:
//
// - Argument reduction is exact integer arithmetic, so no high-precision pi is needed and no
// rounding happens before the series. (This is also *more* accurate than the reference
// formulation, which hands libm an angle that has already been rounded three times: worst error
// against a high-precision reference is 1.5e-16 here versus 4.0e-15 there.)
//
// - Every remaining step is an IEEE-754 operation that the standard requires to be correctly
// rounded: +, -, * and fusedMultiplyAdd. The polynomials are written with explicit std::fma
// rather than `a + z * b`, because the latter is contractible: a compiler may or may not fuse
// it depending on -ffp-contract and on whether the target has an FMA instruction, and the fused
// and unfused results differ in the last bit. Spelling the fusion out makes the result
// independent of the build rather than dependent on a flag.
//
// What this still assumes: the default round-to-nearest-even rounding mode, and no x87 excess
// precision (i.e. SSE2 math on x86). Both hold on every platform we target.
// tests/test_image_thumbhash.cpp pins hash vectors so that a build which breaks either fails
// loudly rather than silently leaking.

#include <cmath>
#include <cstdint>

namespace session::image::detail {

namespace trig {

// cos(u) and sin(u) by Taylor series on |u| <= pi/4, where the series is well conditioned.
// Terms run past u^18/18! ~= 3e-18, comfortably under a double's 2.2e-16 relative resolution.
// Evaluated by Horner from the smallest term up, each step a single fused multiply-add.
inline double cos_series(double z) { // z = u*u
double c = -1.0 / 6402373705728000; // -1/18!
c = std::fma(c, z, 1.0 / 20922789888000);
c = std::fma(c, z, -1.0 / 87178291200);
c = std::fma(c, z, 1.0 / 479001600);
c = std::fma(c, z, -1.0 / 3628800);
c = std::fma(c, z, 1.0 / 40320);
c = std::fma(c, z, -1.0 / 720);
c = std::fma(c, z, 1.0 / 24);
c = std::fma(c, z, -1.0 / 2);
return std::fma(c, z, 1.0);
}

inline double sin_series(double u, double z) { // z = u*u
double s = 1.0 / 355687428096000; // 1/17!
s = std::fma(s, z, -1.0 / 1307674368000);
s = std::fma(s, z, 1.0 / 6227020800);
s = std::fma(s, z, -1.0 / 39916800);
s = std::fma(s, z, 1.0 / 362880);
s = std::fma(s, z, -1.0 / 5040);
s = std::fma(s, z, 1.0 / 120);
s = std::fma(s, z, -1.0 / 6);
s = std::fma(s, z, 1.0);
return u * s;
}

// The double nearest pi. Only ever multiplies an already-reduced ratio in [0, 1/4], so its
// error contributes at most ~1e-16 to the angle.
inline constexpr double pi = 3.14159265358979323846;

} // namespace trig

/// cos(pi * n / d), for d > 0. Identical on every IEEE-754 platform, in any build configuration.
inline double cos_pi(int64_t n, int64_t d) {
// Reduce to one period exactly, in integers: cos has period 2 in n/d.
int64_t p = 2 * d;
n %= p;
if (n < 0)
n += p;
if (n > d)
n = p - n; // cos(2pi - t) == cos(t); now 0 <= n <= d
double sign = 1.0;
if (2 * n > d) {
n = d - n; // cos(pi - t) == -cos(t); now t <= pi/2
sign = -1.0;
}
if (4 * n > d) {
// t > pi/4: swap to the sine of the complement, pi/2 - t == pi*(d-2n)/(2d), which keeps
// the series argument inside [0, pi/4].
double u = trig::pi * (double(d - 2 * n) / double(2 * d));
return sign * trig::sin_series(u, u * u);
}
double u = trig::pi * (double(n) / double(d));
return sign * trig::cos_series(u * u);
}

/// The DCT basis value cos(pi/n * k * (i + 0.5)) that ThumbHash needs.
///
/// Upstream writes this literally as `cos(pi / n * k * (i + 0.5))`, which rounds the angle three
/// times before libm ever sees it. Restating it as an exact ratio lets cos_pi reduce it in
/// integers instead, which is both reproducible and rather more accurate.
///
/// Defining THUMBHASH_REFERENCE_COS selects that upstream formulation instead. It is the
/// definition of the algorithm, and is kept so the computation we are approximating stays legible
/// and so the deterministic path can be measured against it, but it is not reproducible -- it
/// depends on the platform's std::cos. Production builds must not define it.
inline double cos_dct(int k, int i, int n) {
#ifdef THUMBHASH_REFERENCE_COS
return std::cos(trig::pi / n * k * (i + 0.5));
#else
return cos_pi(int64_t(k) * (2 * i + 1), 2 * int64_t(n));
#endif
}

} // namespace session::image::detail
Loading