| 1 | package sky |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/base64" |
| 6 | "image" |
| 7 | "image/color" |
| 8 | "image/png" |
| 9 | "math" |
| 10 | "strconv" |
| 11 | "sync" |
| 12 | ) |
| 13 | |
| 14 | // The placeholder is the sky in a few pixels, painted into the first |
| 15 | // frame so the page never shows a blank where the sky will be. It plays |
| 16 | // blurhash's part without the script blurhash needs to decode: a tiny |
| 17 | // PNG, inline as a data: URI, that the browser's upscaling smooths into |
| 18 | // the soft nebula the haze and clouds SVGs draw — and it is now the sky's |
| 19 | // colour on its own, with only the grain and filaments over it. |
| 20 | // |
| 21 | // It is computed, not rendered: each blob is a blurred ellipse, which is |
| 22 | // close to a Gaussian, so a pixel is the blobs' Gaussians composited in |
| 23 | // the order the layers stack (haze, then clouds at full strength). |
| 24 | // Calibrated 2026-09-29 against the SVG layers, rendered in Chromium on |
| 25 | // Home's backgrounds: mean channel difference 3/255 in light, 8/255 in |
| 26 | // dark (most of the rest is the filaments, which the placeholder leaves |
| 27 | // out). SVG filters blur in linear light, so the blobs are blended in it |
| 28 | // too; that alone took dark from 12 to 8. |
| 29 | const ( |
| 30 | phLinear = true |
| 31 | phSpread = 0.65 // a blurred ellipse's Gaussian width, per unit radius |
| 32 | ) |
| 33 | |
| 34 | var phGain = map[Tone]float64{Light: 1, Dark: 1.2} |
| 35 | |
| 36 | const ( |
| 37 | phW = 50 // the canvas's 5:2, so object-fit: cover crops it exactly as |
| 38 | phH = 20 // the SVG layers' xMidYMid slice crops them |
| 39 | ) |
| 40 | |
| 41 | // placeholders memoises Placeholder: it is pure in (id, tone) and costs |
| 42 | // milliseconds, and a page renders it on every request. |
| 43 | var placeholders sync.Map // map[string]string, key id+"/"+tone |
| 44 | |
| 45 | // Placeholder is the data: URI of the sky's placeholder for one scheme. |
| 46 | func Placeholder(id string, t Tone) string { |
| 47 | key := id + "/" + string(t) |
| 48 | if uri, ok := placeholders.Load(key); ok { |
| 49 | return uri.(string) |
| 50 | } |
| 51 | uri := placeholder(id, t) |
| 52 | placeholders.Store(key, uri) |
| 53 | return uri |
| 54 | } |
| 55 | |
| 56 | func placeholder(id string, t Tone) string { |
| 57 | img := image.NewNRGBA(image.Rect(0, 0, phW, phH)) |
| 58 | haze, clouds := hazeBlobs(id, t), cloudBlobs(id, t) |
| 59 | type paint struct { |
| 60 | bl blob |
| 61 | r, g, b float64 |
| 62 | } |
| 63 | prep := func(bs []blob) []paint { |
| 64 | out := make([]paint, len(bs)) |
| 65 | for i, bl := range bs { |
| 66 | r, g, b := hexRGB(bl.fill) |
| 67 | if phLinear { |
| 68 | r, g, b = lin(r), lin(g), lin(b) |
| 69 | } |
| 70 | out[i] = paint{bl, r, g, b} |
| 71 | } |
| 72 | return out |
| 73 | } |
| 74 | hazeP, cloudsP := prep(haze), prep(clouds) |
| 75 | for y := range phH { |
| 76 | for x := range phW { |
| 77 | // the pixel's centre on the 1600x640 canvas |
| 78 | px, py := (float64(x)+.5)*width/phW, (float64(y)+.5)*height/phH |
| 79 | var r, g, b, a float64 // premultiplied, 0..1 |
| 80 | for _, layer := range []struct { |
| 81 | paints []paint |
| 82 | k float64 |
| 83 | }{{hazeP, 0.7}, {cloudsP, 1}} { |
| 84 | for _, p := range layer.paints { |
| 85 | alpha := math.Min(1, phGain[t]*layer.k*p.bl.opacity*p.bl.at(px, py)) |
| 86 | cr, cg, cb := p.r, p.g, p.b |
| 87 | r = cr*alpha + r*(1-alpha) |
| 88 | g = cg*alpha + g*(1-alpha) |
| 89 | b = cb*alpha + b*(1-alpha) |
| 90 | a = alpha + a*(1-alpha) |
| 91 | } |
| 92 | } |
| 93 | c := color.NRGBA{A: to8(a)} |
| 94 | if a > 0 { |
| 95 | r, g, b = r/a, g/a, b/a |
| 96 | if phLinear { |
| 97 | r, g, b = enc(r), enc(g), enc(b) |
| 98 | } |
| 99 | c.R, c.G, c.B = to8(r), to8(g), to8(b) |
| 100 | } |
| 101 | img.SetNRGBA(x, y, c) |
| 102 | } |
| 103 | } |
| 104 | var buf bytes.Buffer |
| 105 | enc := png.Encoder{CompressionLevel: png.BestCompression} |
| 106 | if err := enc.Encode(&buf, img); err != nil { |
| 107 | panic(err) // encoding an in-memory NRGBA cannot fail |
| 108 | } |
| 109 | return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) |
| 110 | } |
| 111 | |
| 112 | // at is the blob's coverage at a canvas point: a Gaussian as wide as the |
| 113 | // ellipse blurred by its filter, in the ellipse's own rotated frame. |
| 114 | func (bl blob) at(px, py float64) float64 { |
| 115 | dx, dy := px-bl.cx, py-bl.cy |
| 116 | th := bl.rot * math.Pi / 180 |
| 117 | u := dx*math.Cos(th) + dy*math.Sin(th) |
| 118 | v := -dx*math.Sin(th) + dy*math.Cos(th) |
| 119 | sx := math.Hypot(bl.rx*phSpread, bl.blur) |
| 120 | sy := math.Hypot(bl.ry*phSpread, bl.blur) |
| 121 | return math.Exp(-0.5 * (u*u/(sx*sx) + v*v/(sy*sy))) |
| 122 | } |
| 123 | |
| 124 | func hexRGB(h string) (r, g, b float64) { |
| 125 | n, _ := strconv.ParseUint(h[1:], 16, 32) |
| 126 | return float64(n>>16&0xff) / 255, float64(n>>8&0xff) / 255, float64(n&0xff) / 255 |
| 127 | } |
| 128 | |
| 129 | func to8(v float64) uint8 { return uint8(math.Round(math.Max(0, math.Min(1, v)) * 255)) } |
| 130 | |
| 131 | func lin(c float64) float64 { |
| 132 | if c <= 0.04045 { |
| 133 | return c / 12.92 |
| 134 | } |
| 135 | return math.Pow((c+0.055)/1.055, 2.4) |
| 136 | } |
| 137 | |
| 138 | func enc(c float64) float64 { |
| 139 | if c <= 0.0031308 { |
| 140 | return c * 12.92 |
| 141 | } |
| 142 | return 1.055*math.Pow(c, 1/2.4) - 0.055 |
| 143 | } |
| 144 | |