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
- 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>
101 lines
2.6 KiB
Go
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]))
|
|
}
|