Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
17 kB · 512 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513package spxrpc
import ( "bytes" "context" _ "embed" "errors" "fmt" _ "golang.org/x/image/webp" "hash/fnv" "image" "image/color" _ "image/jpeg" _ "image/png" "io" "math" "net/http" "strings"
imagedraw "image/draw"
"golang.org/x/image/draw"
"github.com/labstack/echo/v4" "github.com/patrickmn/go-cache" "github.com/tdewolff/canvas" "github.com/tdewolff/canvas/renderers" "stream.place/streamplace/js/app" "stream.place/streamplace/pkg/aqhttp" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/model")
const ( // Canvas dimensions ogWidth = 400.0 ogHeight = 210.0
// Card dimensions and positioning cardPadding = 10.0 cardWidth = 380.0 cardHeight = 190.0 cardRadius = 12.0
// Image dimensions and positioning imageX = 27.5 imageY = 60.0 imageWidth = 400 imageHeight = 480 imageRadius = 180.0 imageDPMM = 3.9
// Text positioning textStartX = 135.0 joinY = 147.0 subtitleY = 120.0 descY = 95.0
// Font sizes joinFontSize = 56.0 minJoinFontSize = 48.0 subtitleFontSize = 48.0 descFontSize = 28.0 placeholderFontSize = 18.0
// Available text width textAvailableWidth = 245.0
// Canvas DPI canvasDPMM = 3.0)
var ( // Colors bgColor = color.RGBA{R: 0, G: 0, B: 0, A: 255} cardColor = color.RGBA{R: 38, G: 38, B: 38, A: 255} cardBorderColor = color.RGBA{R: 64, G: 64, B: 64, A: 255} placeholderColor = color.RGBA{R: 240, G: 240, B: 240, A: 255} placeholderTextColor = color.RGBA{R: 100, G: 100, B: 100, A: 255} joinTextColor = color.RGBA{R: 248, G: 186, B: 202, A: 255} subtitleColor = color.RGBA{R: 200, G: 200, B: 200, A: 255} descColor = color.RGBA{R: 180, G: 180, B: 180, A: 255} imageBorderColor = color.RGBA{R: 248, G: 186, B: 202, A: 255})
const ( // Description settings maxDescriptionLength = 120 descriptionTruncate = 117)
// blendWithBackground creates a pseudo-transparent color by blending the given color with the background// alpha should be between 0.0 (fully background) and 1.0 (fully foreground color)func blendWithBackground(fg color.RGBA, bg color.RGBA, alpha float64) color.RGBA { return color.RGBA{ R: uint8(float64(bg.R)*(1-alpha) + float64(fg.R)*alpha), G: uint8(float64(bg.G)*(1-alpha) + float64(fg.G)*alpha), B: uint8(float64(bg.B)*(1-alpha) + float64(fg.B)*alpha), A: 255, }}
// createResponsiveJoinText creates a text line for "Join [username]" that fits within the available width// by reducing font size and truncating with ellipsis if necessary// Returns the text object and the font size usedfunc createResponsiveJoinText(fontFamily *canvas.FontFamily, text string, availableWidth float64, textColor color.RGBA) *canvas.Text { fontSize := joinFontSize minFontSize := minJoinFontSize
for fontSize >= minFontSize { // Try bold first, fall back to regular if bold fails face := fontFamily.Face(fontSize, textColor, canvas.FontBold, canvas.FontNormal) if face == nil { face = fontFamily.Face(fontSize, textColor, canvas.FontRegular, canvas.FontNormal) }
if face != nil { // Measure actual text width textWidth := face.TextWidth(text)
// Check if text fits with some margin if textWidth <= availableWidth { textObj := canvas.NewTextLine(face, text, canvas.Bottom) return textObj } }
fontSize -= 2.0 // Reduce font size by 2px each iteration }
// If we get here, even minimum size doesn't fit, so we need to truncate face := fontFamily.Face(minFontSize, textColor, canvas.FontBold, canvas.FontNormal) if face == nil { face = fontFamily.Face(minFontSize, textColor, canvas.FontRegular, canvas.FontNormal) }
// Ensure we have a valid face before truncating if face == nil { // Absolute fallback - just return "Join" fallbackFace := fontFamily.Face(minFontSize, textColor, canvas.FontRegular, canvas.FontNormal) if fallbackFace == nil { return canvas.NewTextLine(nil, "Join", canvas.Bottom) } face = fallbackFace }
// Try progressively shorter versions with ellipsis runes := []rune(text) for i := len(runes) - 1; i >= 7; i-- { // Keep at least "Join @" + one char truncatedText := string(runes[:i]) + "..." textWidth := face.TextWidth(truncatedText) if textWidth <= availableWidth { return canvas.NewTextLine(face, truncatedText, canvas.Bottom) } }
// Final fallback - just "Join ..." return canvas.NewTextLine(face, "Join ...", canvas.Bottom)}
func (s *Server) handlePlaceStreamLiveGetProfileCard(ctx context.Context, id string) (io.Reader, error) { if id == "" { return nil, errors.New("id required") }
// Get Echo context to set response headers c, ok := ctx.Value(echoContextKey).(echo.Context) if ok { // Set appropriate headers for image response c.Response().Header().Set("Content-Type", "image/jpeg") c.Response().Header().Set("Cache-Control", "public, max-age=300") // 5 minutes c.Response().Header().Set("X-Content-Type-Options", "nosniff") }
// trim ending slash if any username := strings.TrimRight(id, "/")
cacheKey := fmt.Sprintf("og_image_%s", username) if cached, found := s.OGImageCache.Get(cacheKey); found { imgData := cached.([]byte) log.Debug(ctx, "OG image cache hit", "username", username, "size_bytes", len(imgData)) return bytes.NewReader(imgData), nil }
imgData, err := s.generateOGImage(ctx, username) if err != nil { log.Error(ctx, "failed to generate OG image", "username", username, "error", err) return nil, err }
s.OGImageCache.Set(cacheKey, imgData, cache.DefaultExpiration) log.Debug(ctx, "OG image generated and cached", "username", username, "size_bytes", len(imgData))
return bytes.NewReader(imgData), nil}
func downloadImage(ctx context.Context, url string) ([]byte, error) { if url == "" { return nil, errors.New("empty URL provided") }
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) }
resp, err := aqhttp.Do(ctx, req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, resp.Status) }
imageData, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read image data: %w", err) }
return imageData, nil}
func (s *Server) generateOGImage(ctx context.Context, username string) ([]byte, error) { // Fetch user profile and avatar from Bluesky var imageURL string var handle, description string var userDID string
// Set default fallbacks handle = username description = "Live streaming platform for creators and their communities."
profileData, err := s.ATSync.FetchUserProfile(ctx, username) if err != nil { return nil, fmt.Errorf("failed to fetch profile, because %w", err) } else if profileData != nil { // Safely extract profile data with nil checks userDID = profileData.Did
if profileData.Avatar != nil && *profileData.Avatar != "" { imageURL = *profileData.Avatar }
if profileData.Handle != "" { handle = profileData.Handle }
if profileData.Description != nil && *profileData.Description != "" { desc := *profileData.Description // runes are used to properly handle multi-byte characters runes := []rune(desc) if len(runes) > maxDescriptionLength { desc = string(runes[:descriptionTruncate]) + "..." } description = desc } } else { log.Warn(ctx, "received nil profile data, using fallbacks", "username", username) }
// Fetch user's chat profile color var userColor = joinTextColor // default var borderColor = imageBorderColor // default if userDID != "" { chatProfile, err := s.ATSync.Model.GetChatProfile(ctx, userDID) if err != nil { log.Warn(ctx, "failed to fetch chat profile", "did", userDID, "error", err) clr := model.DefaultColors[hashString(userDID)%len(model.DefaultColors)] userColor = color.RGBA{ R: uint8(clr.Red), G: uint8(clr.Green), B: uint8(clr.Blue), A: 255, } } else if chatProfile != nil { streamplaceChatProfile, err := chatProfile.ToStreamplaceChatProfile() if err != nil { log.Warn(ctx, "failed to decode chat profile", "did", userDID, "error", err) } else if streamplaceChatProfile.Color != nil { userColor = color.RGBA{ R: uint8(streamplaceChatProfile.Color.Red), G: uint8(streamplaceChatProfile.Color.Green), B: uint8(streamplaceChatProfile.Color.Blue), A: 255, } borderColor = userColor log.Debug(ctx, "using user's custom color", "did", userDID, "color", userColor) } } }
// Create new canvas of dimension ogWidth x ogHeight mm for profile card c := canvas.New(ogWidth, ogHeight)
// Create a canvas context used to keep drawing state canvasCtx := canvas.NewContext(c)
fontAHN := canvas.NewFontFamily("Atkinson Hyperlegible Next")
regularData, regularDataErr := getAtkinsonRegular() if regularDataErr != nil { log.Warn(ctx, "failed to load regular Atkinson font data", "error", regularDataErr) }
boldData, boldDataErr := getAtkinsonBold() if boldDataErr != nil { log.Warn(ctx, "failed to load bold Atkinson font data", "error", boldDataErr) }
var regularErr, boldErr error if regularDataErr == nil { regularErr = fontAHN.LoadFont(regularData, 0, canvas.FontRegular) } if boldDataErr == nil { boldErr = fontAHN.LoadFont(boldData, 0, canvas.FontBold) }
// If font loading fails, the canvas library will fall back to default fonts if regularErr != nil { log.Warn(ctx, "failed to load regular Atkinson font, using fallback", "error", regularErr) } if boldErr != nil { log.Warn(ctx, "failed to load bold Atkinson font, using fallback", "error", boldErr) }
// If both custom fonts failed to load, ensure we have a working font family if (regularDataErr != nil || regularErr != nil) && (boldDataErr != nil || boldErr != nil) { log.Warn(ctx, "all custom fonts failed to load, using system default") fontAHN = canvas.NewFontFamily("sans-serif") }
// Set black background canvasCtx.SetFillColor(bgColor) canvasCtx.DrawPath(0, 0, canvas.Rectangle(ogWidth, ogHeight)) canvasCtx.Fill()
// Create neutral-800 rounded card canvasCtx.SetFillColor(blendWithBackground(borderColor, cardColor, 0.04)) canvasCtx.DrawPath(cardPadding, cardPadding, canvas.RoundedRectangle(cardWidth, cardHeight, cardRadius)) canvasCtx.Fill()
// border cardBorderTransparent := blendWithBackground(blendWithBackground(borderColor, color.RGBA{R: 180, G: 180, B: 180}, 0.3), bgColor, 0.3) canvasCtx.SetStrokeColor(cardBorderTransparent) canvasCtx.SetStrokeWidth(1) canvasCtx.DrawPath(cardPadding, cardPadding, canvas.RoundedRectangle(cardWidth, cardHeight, cardRadius)) canvasCtx.Stroke()
// Try to download and decode the image in memory var img image.Image if imageURL != "" { imageData, downloadErr := downloadImage(ctx, imageURL) if downloadErr != nil { log.Warn(ctx, "failed to download profile image", "username", username, "image_url", imageURL, "error", downloadErr) } else { // Decode image directly from memory reader := bytes.NewReader(imageData) var err error img, _, err = image.Decode(reader) if err != nil { log.Warn(ctx, "failed to decode image", "username", username, "error", err) img = nil } } }
if img == nil { // Fallback to placeholder if download or loading fails - positioned within card canvasCtx.SetFillColor(placeholderColor) canvasCtx.DrawPath(imageX, 55, canvas.RoundedRectangle(100, 120, 8)) canvasCtx.Fill()
imageFace := fontAHN.Face(placeholderFontSize, placeholderTextColor, canvas.FontBold, canvas.FontNormal) imageText := canvas.NewTextBox(imageFace, "Streamplace", 100, 30, canvas.Center, canvas.Center, &canvas.TextOptions{}) canvasCtx.DrawText(imageX, 105, imageText) } else { // High-quality avatar processing with circular masking avatarDisplaySize := imageRadius * 2 / imageDPMM avatarSize := int(avatarDisplaySize * canvasDPMM)
// High-quality scaling with center cropping bounds := img.Bounds() srcWidth, srcHeight := bounds.Dx(), bounds.Dy()
// Calculate square crop (center crop for circular fit) cropSize := srcWidth if srcHeight < cropSize { cropSize = srcHeight } cropOffsetX := (srcWidth - cropSize) / 2 cropOffsetY := (srcHeight - cropSize) / 2 cropRect := image.Rect( bounds.Min.X+cropOffsetX, bounds.Min.Y+cropOffsetY, bounds.Min.X+cropOffsetX+cropSize, bounds.Min.Y+cropOffsetY+cropSize, )
scaledAvatar := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize)) draw.CatmullRom.Scale(scaledAvatar, scaledAvatar.Bounds(), img, cropRect, draw.Over, nil)
// Create circular alpha mask mask := image.NewAlpha(image.Rect(0, 0, avatarSize, avatarSize)) center := avatarSize / 2 radius := float64(center)
// Generate anti-aliased circular mask for y := 0; y < avatarSize; y++ { for x := 0; x < avatarSize; x++ { dx := float64(x - center) dy := float64(y - center) distance := math.Sqrt(dx*dx + dy*dy)
if distance <= radius { alpha := 255.0 if distance > radius-1 { alpha = 255.0 * (radius - distance) } mask.SetAlpha(x, y, color.Alpha{uint8(alpha)}) } } }
// Apply circular mask maskedAvatar := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize)) imagedraw.DrawMask(maskedAvatar, maskedAvatar.Bounds(), scaledAvatar, image.Point{}, mask, image.Point{}, imagedraw.Over)
// Add circular border with user's color (50% opacity for subtle effect) avatarCenterX := imageX + avatarDisplaySize/2 avatarCenterY := imageY + avatarDisplaySize/2 avatarBorderTransparent := blendWithBackground(blendWithBackground(borderColor, color.RGBA{R: 180, G: 180, B: 180}, 0.5), bgColor, 0.5) canvasCtx.SetStrokeColor(avatarBorderTransparent) canvasCtx.SetStrokeWidth(1) canvasCtx.DrawPath(avatarCenterX, avatarCenterY, canvas.Circle(avatarDisplaySize/2)) canvasCtx.Stroke()
canvasCtx.DrawImage(imageX, imageY, maskedAvatar, canvas.DPMM(canvasDPMM)) }
joinUserContent := fmt.Sprintf("Join @%s", handle)
availableWidth := textAvailableWidth joinText := createResponsiveJoinText(fontAHN, joinUserContent, availableWidth, userColor) canvasCtx.DrawText(textStartX, joinY-(joinFontSize*0.5), joinText)
// Add "streaming on Stream.place" subtitle onFace := fontAHN.Face(subtitleFontSize, blendWithBackground(borderColor, subtitleColor, 0.2), canvas.FontRegular, canvas.FontNormal) onText := canvas.NewTextBox(onFace, "streaming on Stream.place", 250, 30, canvas.Left, canvas.Center, &canvas.TextOptions{}) canvasCtx.DrawText(textStartX, subtitleY, onText)
// Add user description or promotional text descFace := fontAHN.Face(descFontSize, blendWithBackground(borderColor, descColor, 0.2), canvas.FontRegular, canvas.FontNormal) descText := canvas.NewTextBox(descFace, description, 230, 30, canvas.Left, canvas.Center, &canvas.TextOptions{}) canvasCtx.DrawText(textStartX, descY, descText)
b := &bytes.Buffer{} if err := c.Write(b, renderers.JPEG(canvas.DPMM(canvasDPMM))); err != nil { return nil, fmt.Errorf("failed to render canvas to buffer: %w", err) }
return b.Bytes(), nil}
func hashString(s string) int { h := fnv.New32a() h.Write([]byte(s)) return int(h.Sum32())}
// getAtkinsonRegular returns the regular Atkinson Hyperlegible Next font data from app filesystemfunc getAtkinsonRegular() ([]byte, error) { files, err := app.Assets() if err != nil { return nil, fmt.Errorf("failed to get app assets: %w", err) }
file, err := files.Open("fonts/AtkinsonHyperlegibleNext-Regular.ttf") if err != nil { return nil, fmt.Errorf("failed to open regular font: %w", err) } defer file.Close()
data, err := io.ReadAll(file) if err != nil { return nil, fmt.Errorf("failed to read regular font: %w", err) }
return data, nil}
// getAtkinsonBold returns the bold Atkinson Hyperlegible Next font data from app filesystemfunc getAtkinsonBold() ([]byte, error) { files, err := app.Assets() if err != nil { return nil, fmt.Errorf("failed to get app assets: %w", err) }
file, err := files.Open("fonts/AtkinsonHyperlegibleNext-Bold.ttf") if err != nil { return nil, fmt.Errorf("failed to open bold font: %w", err) } defer file.Close()
data, err := io.ReadAll(file) if err != nil { return nil, fmt.Errorf("failed to read bold font: %w", err) }
return data, nil}