feat(bskyweb): composite invite pass strip image
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 107 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 115 B |
+3
-2
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user