feat(bskyweb): composite invite pass strip image

This commit is contained in:
vineyardbovines
2026-06-25 16:06:36 -04:00
parent 52382be168
commit 5433c9b8d8
6 changed files with 157 additions and 2 deletions
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"bytes"
"image"
"image/color"
"image/png"
"io/fs"
"strconv"
"golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
)
// CompositeStrip overlays a circular avatar and centered handle text onto the
// per-theme gradient base. Returns PNG bytes. If avatar is nil, a white circle
// is drawn as the fallback (the Bluesky butterfly is rendered by the client
// dialog only; the pass uses an empty circle to keep the server stateless w.r.t.
// brand vector assets).
func CompositeStrip(base image.Image, avatar image.Image, handle string, fontFace font.Face) ([]byte, error) {
bounds := base.Bounds()
dst := image.NewRGBA(bounds)
draw.Draw(dst, bounds, base, bounds.Min, draw.Src)
cx := bounds.Dx() / 2
cy := bounds.Dy() / 3
radius := bounds.Dy() / 4
mask := newCircleMask(image.Point{X: cx, Y: cy}, radius, bounds)
if avatar != nil {
fit := fitToSquare(avatar, radius*2)
draw.DrawMask(dst, image.Rect(cx-radius, cy-radius, cx+radius, cy+radius),
fit, image.Point{}, mask, image.Point{X: cx - radius, Y: cy - radius}, draw.Over)
} else {
white := &image.Uniform{C: image.White}
draw.DrawMask(dst, image.Rect(cx-radius, cy-radius, cx+radius, cy+radius),
white, image.Point{}, mask, image.Point{X: cx - radius, Y: cy - radius}, draw.Over)
}
label := "@" + handle
drawer := &font.Drawer{
Dst: dst,
Src: &image.Uniform{C: image.White},
Face: fontFace,
}
advance := drawer.MeasureString(label)
drawer.Dot = fixed.Point26_6{
X: fixed.I(cx) - advance/2,
Y: fixed.I(cy + radius + fontFace.Metrics().Ascent.Ceil() + 4),
}
drawer.DrawString(label)
var buf bytes.Buffer
if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func LoadGradientBase(staticFS fs.FS, theme string, scale int) (image.Image, error) {
theme = CoerceTheme(theme)
name := "passes/strip-" + theme + densitySuffix(scale) + ".png"
f, err := staticFS.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
return img, err
}
func densitySuffix(scale int) string {
if scale <= 1 {
return ""
}
return "@" + strconv.Itoa(scale) + "x"
}
func newCircleMask(center image.Point, radius int, area image.Rectangle) *image.Alpha {
mask := image.NewAlpha(area)
r2 := radius * radius
for y := area.Min.Y; y < area.Max.Y; y++ {
dy := y - center.Y
for x := area.Min.X; x < area.Max.X; x++ {
dx := x - center.X
if dx*dx+dy*dy <= r2 {
mask.SetAlpha(x, y, color.Alpha{255})
}
}
}
return mask
}
func fitToSquare(src image.Image, size int) image.Image {
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
return dst
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"bytes"
"image"
_ "image/png"
"os"
"testing"
"golang.org/x/image/font/basicfont"
)
func TestCompositeStrip_ProducesValidPNG(t *testing.T) {
baseF, _ := os.Open("testdata/strip-day-base.png")
defer baseF.Close()
base, _, err := image.Decode(baseF)
if err != nil {
t.Fatalf("base decode: %v", err)
}
avF, _ := os.Open("testdata/avatar-test.png")
defer avF.Close()
avatar, _, err := image.Decode(avF)
if err != nil {
t.Fatalf("avatar decode: %v", err)
}
got, err := CompositeStrip(base, avatar, "alice.bsky.social", basicfont.Face7x13)
if err != nil {
t.Fatalf("composite: %v", err)
}
if len(got) < 100 {
t.Fatalf("output too small to be valid PNG: %d bytes", len(got))
}
if _, _, err := image.Decode(bytes.NewReader(got)); err != nil {
t.Fatalf("output not a valid PNG: %v", err)
}
}
func TestCompositeStrip_NilAvatarFallsBack(t *testing.T) {
baseF, _ := os.Open("testdata/strip-day-base.png")
defer baseF.Close()
base, _, _ := image.Decode(baseF)
got, err := CompositeStrip(base, nil, "alice.bsky.social", basicfont.Face7x13)
if err != nil {
t.Fatalf("composite: %v", err)
}
if _, _, err := image.Decode(bytes.NewReader(got)); err != nil {
t.Fatalf("output not a valid PNG: %v", err)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

+3 -2
View File
@@ -91,10 +91,11 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/protobuf v1.36.6 // indirect
+6
View File
@@ -249,6 +249,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -276,6 +278,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -306,6 +310,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=