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/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.26.0 // indirect
|
go.uber.org/zap v1.26.0 // indirect
|
||||||
golang.org/x/crypto v0.45.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/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/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/time v0.12.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // 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.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
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/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.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
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.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 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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.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 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
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 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
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=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
Reference in New Issue
Block a user