Files
pipu-music/services/api/internal/library/palette.go
T
mrleo1nidandClaude Opus 5.5 8b76aa3964
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 34s
ci / api / api (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
feat: album covers with palettes and signed links (M2)
- covers are taken from the embedded picture of the first track or a
  cover/folder/front image in the album folder, converted by ffmpeg to
  WebP 64/300/1200 and named by content hash, so albums sharing a cover
  share the files; a full scan looks again for albums without one
- a 3-colour palette (dominant, second, most saturated accent) from a
  deterministic k-means feeds placeholders, page tints and the wave
- /covers/{id} is public but HMAC-signed and immutable; nginx serves
  the file via X-Accel-Redirect, X_ACCEL=false serves it from api
- the web client picks the size by display size and pixel density
- install.sh and update.sh generate STREAM_SIGNING_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:06:15 +03:00

101 lines
2.6 KiB
Go

package library
import (
"fmt"
"math"
"sort"
)
// Palette выбирает 3 цвета обложки по пикселям RGB (rgb24): доминирующий, второй и акцент —
// самый насыщенный из оставшихся. k-means с детерминированной инициализацией по яркости,
// чтобы одна и та же обложка всегда давала одну палитру.
func Palette(rgb []byte) []string {
n := len(rgb) / 3
if n == 0 {
return nil
}
px := make([][3]float64, n)
for i := range n {
px[i] = [3]float64{float64(rgb[3*i]), float64(rgb[3*i+1]), float64(rgb[3*i+2])}
}
const k = 4
// Начальные центры — квантили по яркости.
order := make([]int, n)
for i := range order {
order[i] = i
}
luma := func(p [3]float64) float64 { return 0.299*p[0] + 0.587*p[1] + 0.114*p[2] }
sort.SliceStable(order, func(a, b int) bool { return luma(px[order[a]]) < luma(px[order[b]]) })
centers := make([][3]float64, k)
for c := range k {
centers[c] = px[order[(2*c+1)*n/(2*k)]]
}
assign := make([]int, n)
for range 12 {
var sum [k][3]float64
var cnt [k]int
for i, p := range px {
best, bestD := 0, math.MaxFloat64
for c, ctr := range centers {
d := sq(p[0]-ctr[0]) + sq(p[1]-ctr[1]) + sq(p[2]-ctr[2])
if d < bestD {
best, bestD = c, d
}
}
assign[i] = best
cnt[best]++
for j := range 3 {
sum[best][j] += p[j]
}
}
for c := range k {
if cnt[c] > 0 {
for j := range 3 {
centers[c][j] = sum[c][j] / float64(cnt[c])
}
}
}
}
var size [k]int
for _, a := range assign {
size[a]++
}
idx := []int{}
for c := range k {
if size[c] > 0 {
idx = append(idx, c)
}
}
sort.SliceStable(idx, func(a, b int) bool { return size[idx[a]] > size[idx[b]] })
for len(idx) < 3 {
idx = append(idx, idx[len(idx)-1])
}
dominant, second, rest := idx[0], idx[1], idx[2:]
accent := rest[0]
for _, c := range rest {
if saturation(centers[c]) > saturation(centers[accent]) {
accent = c
}
}
return []string{hexColor(centers[dominant]), hexColor(centers[second]), hexColor(centers[accent])}
}
func sq(x float64) float64 { return x * x }
func saturation(p [3]float64) float64 {
hi := math.Max(p[0], math.Max(p[1], p[2]))
lo := math.Min(p[0], math.Min(p[1], p[2]))
if hi == 0 {
return 0
}
return (hi - lo) / hi
}
func hexColor(p [3]float64) string {
c := func(v float64) int { return int(math.Round(math.Max(0, math.Min(255, v)))) }
return fmt.Sprintf("#%02X%02X%02X", c(p[0]), c(p[1]), c(p[2]))
}