🏡 index : quine-global/quintodrome.git

// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash),
// parameterized to match Jellyfin so clients see equivalent hashes.
package blurhash

import (
	"errors"
	"image"
	"image/draw"
	"math"
	"strings"
	"sync"

	xdraw "golang.org/x/image/draw"
)

const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"

// maxInputSize: larger inputs are slower with no visible difference in the result.
const maxInputSize = 128

// components picks x/y component counts targeting ~16 near-square tiles.
func components(width, height int) (int, int) {
	xf := math.Sqrt(16.0 * float64(width) / float64(height))
	yf := xf * float64(height) / float64(width)
	return min(int(xf)+1, 9), min(int(yf)+1, 9)
}

// Encode returns the blurhash of img, deriving the component counts from its aspect ratio.
func Encode(img image.Image) (string, error) {
	if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
		return "", errors.New("blurhash: empty image")
	}
	// Pre-downscale: its rounding can flip a component count, and the hash is a client cache key.
	xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy())
	src := pixelsOf(downscale(img))
	w, h := src.w, src.h

	cosX := make([][]float64, xComp)
	for i := range cosX {
		cosX[i] = make([]float64, w)
		for x := range cosX[i] {
			cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w))
		}
	}
	cosY := make([][]float64, yComp)
	for j := range cosY {
		cosY[j] = make([]float64, h)
		for y := range cosY[j] {
			cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h))
		}
	}

	lin := srgbToLinearTable()
	factors := make([][3]float64, xComp*yComp)
	linR := make([]float64, w)
	linG := make([]float64, w)
	linB := make([]float64, w)
	rowR := make([]float64, xComp)
	rowG := make([]float64, xComp)
	rowB := make([]float64, xComp)
	for y := range h {
		row := src.pix[y*src.stride:]
		for x := range w {
			p := x * 4
			r, g, b := row[p], row[p+1], row[p+2]
			if src.straight {
				r, g, b = premultiply(r, g, b, row[p+3])
			}
			linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b]
		}
		// The basis is separable, so a row costs xComp dot products plus one fold over yComp,
		// rather than xComp*yComp multiply-accumulates per pixel.
		for i := range xComp {
			var sr, sg, sb float64
			for x, c := range cosX[i] {
				sr += c * linR[x]
				sg += c * linG[x]
				sb += c * linB[x]
			}
			rowR[i], rowG[i], rowB[i] = sr, sg, sb
		}
		for j := range yComp {
			cy := cosY[j][y]
			for i := range xComp {
				f := &factors[j*xComp+i]
				f[0] += cy * rowR[i]
				f[1] += cy * rowG[i]
				f[2] += cy * rowB[i]
			}
		}
	}
	for idx := range factors {
		norm := 2.0
		if idx == 0 {
			norm = 1.0
		}
		scale := norm / float64(w*h)
		factors[idx][0] *= scale
		factors[idx][1] *= scale
		factors[idx][2] *= scale
	}

	var sb strings.Builder
	sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1))

	// Derived counts are at least 1x9, so there is always at least one AC factor.
	ac := factors[1:]
	actualMax := 0.0
	for _, f := range ac {
		actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2]))
	}
	quantMax := int(max(0, min(82, math.Floor(actualMax*166-0.5))))
	maxVal := float64(quantMax+1) / 166
	sb.WriteString(encode83(quantMax, 1))

	dc := factors[0]
	sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4))
	for _, f := range ac {
		sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2))
	}
	return sb.String(), nil
}

// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At.
type pixels struct {
	pix    []uint8
	stride int
	w, h   int
	// straight marks non-premultiplied alpha, which the loop premultiplies to keep the hash
	// identical to the one an equivalent *image.RGBA produces.
	straight bool
}

// pixelsOf accepts the two types the artwork pipeline produces without copying, and converts
// anything else.
func pixelsOf(img image.Image) pixels {
	b := img.Bounds()
	switch src := img.(type) {
	case *image.RGBA:
		return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy()}
	case *image.NRGBA:
		return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy(), straight: true}
	}
	dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
	draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
	return pixels{pix: dst.Pix, stride: dst.Stride, w: b.Dx(), h: b.Dy()}
}

func premultiply(r, g, b, a uint8) (uint8, uint8, uint8) {
	if a == 255 {
		return r, g, b
	}
	return uint8(uint32(r) * uint32(a) / 255), uint8(uint32(g) * uint32(a) / 255), uint8(uint32(b) * uint32(a) / 255)
}

var srgbToLinearTable = sync.OnceValue(func() *[256]float64 {
	var t [256]float64
	for i := range t {
		t[i] = srgbToLinear(i)
	}
	return &t
})

func downscale(img image.Image) image.Image {
	b := img.Bounds()
	w, h := b.Dx(), b.Dy()
	if w <= maxInputSize && h <= maxInputSize {
		return img
	}
	scale := float64(maxInputSize) / float64(max(w, h))
	dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
	xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
	return dst
}

func quantAC(v, maxVal float64) int {
	return int(max(0, min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5))))
}

func signPow(v, exp float64) float64 {
	return math.Copysign(math.Pow(math.Abs(v), exp), v)
}

func srgbToLinear(v int) float64 {
	f := float64(v) / 255
	if f <= 0.04045 {
		return f / 12.92
	}
	return math.Pow((f+0.055)/1.055, 2.4)
}

func linearToSRGB(v float64) int {
	v = min(max(0, v), 1)
	if v <= 0.0031308 {
		return int(v*12.92*255 + 0.5)
	}
	return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
}

// encode83 encodes value as a fixed-width, big-endian base83 string of the given length.
func encode83(value, length int) string {
	b := make([]byte, length)
	for i := length - 1; i >= 0; i-- {
		b[i] = alphabet[value%83]
		value /= 83
	}
	return string(b)
}