From 5433c9b8d8c02c13c4d35ce7237bec49772aab29 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Thu, 25 Jun 2026 16:06:36 -0400 Subject: [PATCH] feat(bskyweb): composite invite pass strip image --- bskyweb/cmd/bskyweb/passimage.go | 99 ++++++++++++++++++ bskyweb/cmd/bskyweb/passimage_test.go | 49 +++++++++ bskyweb/cmd/bskyweb/testdata/avatar-test.png | Bin 0 -> 107 bytes .../cmd/bskyweb/testdata/strip-day-base.png | Bin 0 -> 115 bytes bskyweb/go.mod | 5 +- bskyweb/go.sum | 6 ++ 6 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 bskyweb/cmd/bskyweb/passimage.go create mode 100644 bskyweb/cmd/bskyweb/passimage_test.go create mode 100644 bskyweb/cmd/bskyweb/testdata/avatar-test.png create mode 100644 bskyweb/cmd/bskyweb/testdata/strip-day-base.png diff --git a/bskyweb/cmd/bskyweb/passimage.go b/bskyweb/cmd/bskyweb/passimage.go new file mode 100644 index 0000000000..2cf5442ee0 --- /dev/null +++ b/bskyweb/cmd/bskyweb/passimage.go @@ -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 +} diff --git a/bskyweb/cmd/bskyweb/passimage_test.go b/bskyweb/cmd/bskyweb/passimage_test.go new file mode 100644 index 0000000000..bb260f4cbf --- /dev/null +++ b/bskyweb/cmd/bskyweb/passimage_test.go @@ -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) + } +} diff --git a/bskyweb/cmd/bskyweb/testdata/avatar-test.png b/bskyweb/cmd/bskyweb/testdata/avatar-test.png new file mode 100644 index 0000000000000000000000000000000000000000..c62529b3d61400020fa4209b5924e5adb14b9e39 GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={Wj67W&Ln`JZSx!tk;^2M2CHS(t ruEf@aD@}piZm0|gC#8%K1D?jf