Live video on the AT Protocol
79
fork

Configure Feed

Select the types of activity you want to include in your feed.

at natb/add-problems-back 482 lines 15 kB view raw
1package spxrpc 2 3import ( 4 "bytes" 5 "context" 6 _ "embed" 7 "errors" 8 "fmt" 9 "image" 10 "image/color" 11 _ "image/jpeg" 12 _ "image/png" 13 "io" 14 "math" 15 "net/http" 16 "strings" 17 18 imagedraw "image/draw" 19 20 "golang.org/x/image/draw" 21 "golang.org/x/net/context/ctxhttp" 22 23 "github.com/bluesky-social/indigo/api/bsky" 24 "github.com/bluesky-social/indigo/xrpc" 25 "github.com/labstack/echo/v4" 26 "github.com/patrickmn/go-cache" 27 "github.com/tdewolff/canvas" 28 "github.com/tdewolff/canvas/renderers" 29 "stream.place/streamplace/js/app" 30 "stream.place/streamplace/pkg/aqhttp" 31 "stream.place/streamplace/pkg/log" 32) 33 34const ( 35 // Canvas dimensions 36 ogWidth = 400.0 37 ogHeight = 200.0 38 39 // Card dimensions and positioning 40 cardPadding = 10.0 41 cardWidth = 380.0 42 cardHeight = 180.0 43 cardRadius = 12.0 44 45 // Image dimensions and positioning 46 imageX = 25.0 47 imageY = 55.0 48 imageWidth = 400 49 imageHeight = 480 50 imageRadius = 180.0 51 imageDPMM = 3.9 52 53 // Text positioning 54 textStartX = 135.0 55 joinY = 142.0 56 subtitleY = 115.0 57 descY = 90.0 58 59 // Font sizes 60 joinFontSize = 56.0 61 minJoinFontSize = 40.0 62 subtitleFontSize = 48.0 63 descFontSize = 28.0 64 placeholderFontSize = 18.0 65 66 // Available text width 67 textAvailableWidth = 255.0 68 69 // Canvas DPI 70 canvasDPMM = 2.0 71) 72 73var ( 74 // Colors 75 bgColor = color.RGBA{R: 0, G: 0, B: 0, A: 255} 76 cardColor = color.RGBA{R: 38, G: 38, B: 38, A: 255} 77 cardBorderColor = color.RGBA{R: 64, G: 64, B: 64, A: 255} 78 placeholderColor = color.RGBA{R: 240, G: 240, B: 240, A: 255} 79 placeholderTextColor = color.RGBA{R: 100, G: 100, B: 100, A: 255} 80 joinTextColor = color.RGBA{R: 255, G: 200, B: 50, A: 255} 81 subtitleColor = color.RGBA{R: 200, G: 200, B: 200, A: 255} 82 descColor = color.RGBA{R: 180, G: 180, B: 180, A: 255} 83 imageBorderColor = color.RGBA{R: 200, G: 200, B: 200, A: 255} 84) 85 86const ( 87 // Description settings 88 maxDescriptionLength = 120 89 descriptionTruncate = 117 90) 91 92var ErrUserNotFound = errors.New("user not found") 93 94// createResponsiveJoinText creates a text box for "Join [username]" that fits within the available width 95// by reducing font size and truncating with ellipsis if necessary 96func createResponsiveJoinText(fontFamily *canvas.FontFamily, text string, availableWidth float64) (*canvas.Text, float64) { 97 fontSize := joinFontSize 98 minFontSize := minJoinFontSize 99 100 for fontSize >= minFontSize { 101 // Try bold first, fall back to regular if bold fails 102 face := fontFamily.Face(fontSize, joinTextColor, canvas.FontBold, canvas.FontNormal) 103 if face == nil { 104 face = fontFamily.Face(fontSize, joinTextColor, canvas.FontRegular, canvas.FontNormal) 105 } 106 107 if face != nil { 108 textBox := canvas.NewTextBox(face, text, availableWidth, 40, canvas.Left, canvas.Center, &canvas.TextOptions{}) 109 110 // Check if text fits 111 if textBox.Bounds().W() <= availableWidth { 112 return textBox, fontSize 113 } 114 } 115 116 fontSize -= 2.0 // Reduce font size by 2px each iteration 117 } 118 119 // If we get here, even minimum size doesn't fit, so we need to truncate 120 face := fontFamily.Face(minFontSize, joinTextColor, canvas.FontBold, canvas.FontNormal) 121 if face == nil { 122 face = fontFamily.Face(minFontSize, joinTextColor, canvas.FontRegular, canvas.FontNormal) 123 } 124 125 // Try progressively shorter versions with ellipsis 126 runes := []rune(text) 127 for i := len(runes) - 1; i > 0; i-- { 128 truncatedText := string(runes[:i]) + "..." 129 textBox := canvas.NewTextBox(face, truncatedText, availableWidth, 40, canvas.Left, canvas.Center, &canvas.TextOptions{}) 130 if textBox.Bounds().W() <= availableWidth { 131 return textBox, minFontSize 132 } 133 } 134 135 // Fallback - just ellipsis 136 return canvas.NewTextBox(face, "...", availableWidth, 40, canvas.Left, canvas.Center, &canvas.TextOptions{}), minFontSize 137} 138 139func (s *Server) handlePlaceStreamLiveGetProfileCard(ctx context.Context, id string) (io.Reader, error) { 140 if id == "" { 141 return nil, errors.New("id required") 142 } 143 144 // Get Echo context to set response headers 145 c, ok := ctx.Value(echoContextKey).(echo.Context) 146 if ok { 147 // Set appropriate headers for image response 148 c.Response().Header().Set("Content-Type", "image/jpeg") 149 c.Response().Header().Set("Cache-Control", "public, max-age=300") // 5 minutes 150 c.Response().Header().Set("X-Content-Type-Options", "nosniff") 151 } 152 153 // trim ending slash if any 154 username := strings.TrimRight(id, "/") 155 156 cacheKey := fmt.Sprintf("og_image_%s", username) 157 if cached, found := s.OGImageCache.Get(cacheKey); found { 158 imgData := cached.([]byte) 159 log.Debug(ctx, "OG image cache hit", "username", username, "size_bytes", len(imgData)) 160 return bytes.NewReader(imgData), nil 161 } 162 163 imgData, err := s.generateOGImage(ctx, username) 164 if err != nil { 165 log.Error(ctx, "failed to generate OG image", "username", username, "error", err) 166 return nil, err 167 } 168 169 s.OGImageCache.Set(cacheKey, imgData, cache.DefaultExpiration) 170 log.Debug(ctx, "OG image generated and cached", "username", username, "size_bytes", len(imgData)) 171 172 return bytes.NewReader(imgData), nil 173} 174 175func downloadImage(ctx context.Context, url string) ([]byte, error) { 176 if url == "" { 177 return nil, errors.New("empty URL provided") 178 } 179 180 req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 181 if err != nil { 182 return nil, fmt.Errorf("failed to create request: %w", err) 183 } 184 185 resp, err := ctxhttp.Do(ctx, &aqhttp.Client, req) 186 if err != nil { 187 return nil, fmt.Errorf("HTTP request failed: %w", err) 188 } 189 defer resp.Body.Close() 190 191 if resp.StatusCode != http.StatusOK { 192 return nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, resp.Status) 193 } 194 195 imageData, err := io.ReadAll(resp.Body) 196 if err != nil { 197 return nil, fmt.Errorf("failed to read image data: %w", err) 198 } 199 200 return imageData, nil 201} 202 203func (s *Server) generateOGImage(ctx context.Context, username string) ([]byte, error) { 204 // Fetch user profile and avatar from Bluesky 205 var imageURL string 206 var handle, description string 207 208 // Set default fallbacks 209 handle = username 210 description = "Live streaming platform for creators and their communities." 211 212 profileData, err := s.fetchUserProfile(ctx, username) 213 if err != nil { 214 return nil, fmt.Errorf("failed to fetch profile, because %w", err) 215 } else if profileData != nil { 216 // Safely extract profile data with nil checks 217 if profileData.Avatar != nil && *profileData.Avatar != "" { 218 imageURL = *profileData.Avatar 219 } 220 221 if profileData.Handle != "" { 222 handle = profileData.Handle 223 } 224 225 if profileData.Description != nil && *profileData.Description != "" { 226 desc := *profileData.Description 227 // runes are used to properly handle multi-byte characters 228 runes := []rune(desc) 229 if len(runes) > maxDescriptionLength { 230 desc = string(runes[:descriptionTruncate]) + "..." 231 } 232 description = desc 233 } 234 } else { 235 log.Warn(ctx, "received nil profile data, using fallbacks", "username", username) 236 } 237 238 // Create new canvas of dimension ogWidth x ogHeight mm for profile card 239 c := canvas.New(ogWidth, ogHeight) 240 241 // Create a canvas context used to keep drawing state 242 canvasCtx := canvas.NewContext(c) 243 244 fontAHN := canvas.NewFontFamily("Atkinson Hyperlegible Next") 245 246 regularData, regularDataErr := getAtkinsonRegular() 247 if regularDataErr != nil { 248 log.Warn(ctx, "failed to load regular Atkinson font data", "error", regularDataErr) 249 } 250 251 boldData, boldDataErr := getAtkinsonBold() 252 if boldDataErr != nil { 253 log.Warn(ctx, "failed to load bold Atkinson font data", "error", boldDataErr) 254 } 255 256 var regularErr, boldErr error 257 if regularDataErr == nil { 258 regularErr = fontAHN.LoadFont(regularData, 0, canvas.FontRegular) 259 } 260 if boldDataErr == nil { 261 boldErr = fontAHN.LoadFont(boldData, 0, canvas.FontBold) 262 } 263 264 // If font loading fails, the canvas library will fall back to default fonts 265 if regularErr != nil { 266 log.Warn(ctx, "failed to load regular Atkinson font, using fallback", "error", regularErr) 267 } 268 if boldErr != nil { 269 log.Warn(ctx, "failed to load bold Atkinson font, using fallback", "error", boldErr) 270 } 271 272 // If both custom fonts failed to load, ensure we have a working font family 273 if (regularDataErr != nil || regularErr != nil) && (boldDataErr != nil || boldErr != nil) { 274 log.Warn(ctx, "all custom fonts failed to load, using system default") 275 fontAHN = canvas.NewFontFamily("sans-serif") 276 } 277 278 // Set black background 279 canvasCtx.SetFillColor(bgColor) 280 canvasCtx.DrawPath(0, 0, canvas.Rectangle(ogWidth, ogHeight)) 281 canvasCtx.Fill() 282 283 // Create neutral-800 rounded card 284 canvasCtx.SetFillColor(cardColor) 285 canvasCtx.DrawPath(cardPadding, cardPadding, canvas.RoundedRectangle(cardWidth, cardHeight, cardRadius)) 286 canvasCtx.Fill() 287 288 // Add subtle border to card 289 canvasCtx.SetStrokeColor(cardBorderColor) 290 canvasCtx.SetStrokeWidth(1) 291 canvasCtx.DrawPath(cardPadding, cardPadding, canvas.RoundedRectangle(cardWidth, cardHeight, cardRadius)) 292 canvasCtx.Stroke() 293 294 // Try to download and decode the image in memory 295 var img image.Image 296 if imageURL != "" { 297 imageData, downloadErr := downloadImage(ctx, imageURL) 298 if downloadErr != nil { 299 log.Warn(ctx, "failed to download profile image", "username", username, "image_url", imageURL, "error", downloadErr) 300 } else { 301 // Decode image directly from memory 302 reader := bytes.NewReader(imageData) 303 var err error 304 img, _, err = image.Decode(reader) 305 if err != nil { 306 log.Warn(ctx, "failed to decode image", "username", username, "error", err) 307 img = nil 308 } 309 } 310 } 311 312 if img == nil { 313 // Fallback to placeholder if download or loading fails - positioned within card 314 canvasCtx.SetFillColor(placeholderColor) 315 canvasCtx.DrawPath(imageX, 50, canvas.RoundedRectangle(100, 120, 8)) 316 canvasCtx.Fill() 317 318 imageFace := fontAHN.Face(placeholderFontSize, placeholderTextColor, canvas.FontBold, canvas.FontNormal) 319 imageText := canvas.NewTextBox(imageFace, "Streamplace", 100, 30, canvas.Center, canvas.Center, &canvas.TextOptions{}) 320 canvasCtx.DrawText(imageX, 100, imageText) 321 } else { 322 // High-quality avatar processing with circular masking 323 avatarDisplaySize := imageRadius * 2 / imageDPMM 324 avatarSize := int(avatarDisplaySize * canvasDPMM) 325 326 // High-quality scaling with center cropping 327 bounds := img.Bounds() 328 srcWidth, srcHeight := bounds.Dx(), bounds.Dy() 329 330 // Calculate square crop (center crop for circular fit) 331 cropSize := srcWidth 332 if srcHeight < cropSize { 333 cropSize = srcHeight 334 } 335 cropOffsetX := (srcWidth - cropSize) / 2 336 cropOffsetY := (srcHeight - cropSize) / 2 337 cropRect := image.Rect( 338 bounds.Min.X+cropOffsetX, 339 bounds.Min.Y+cropOffsetY, 340 bounds.Min.X+cropOffsetX+cropSize, 341 bounds.Min.Y+cropOffsetY+cropSize, 342 ) 343 344 scaledAvatar := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize)) 345 draw.CatmullRom.Scale(scaledAvatar, scaledAvatar.Bounds(), img, cropRect, draw.Over, nil) 346 347 // Create circular alpha mask 348 mask := image.NewAlpha(image.Rect(0, 0, avatarSize, avatarSize)) 349 center := avatarSize / 2 350 radius := float64(center) 351 352 // Generate anti-aliased circular mask 353 for y := 0; y < avatarSize; y++ { 354 for x := 0; x < avatarSize; x++ { 355 dx := float64(x - center) 356 dy := float64(y - center) 357 distance := math.Sqrt(dx*dx + dy*dy) 358 359 if distance <= radius { 360 alpha := 255.0 361 if distance > radius-1 { 362 alpha = 255.0 * (radius - distance) 363 } 364 mask.SetAlpha(x, y, color.Alpha{uint8(alpha)}) 365 } 366 } 367 } 368 369 // Apply circular mask 370 maskedAvatar := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize)) 371 imagedraw.DrawMask(maskedAvatar, maskedAvatar.Bounds(), scaledAvatar, image.Point{}, mask, image.Point{}, imagedraw.Over) 372 373 // Add circular border 374 avatarCenterX := imageX + avatarDisplaySize/2 375 avatarCenterY := imageY + avatarDisplaySize/2 376 canvasCtx.SetStrokeColor(imageBorderColor) 377 canvasCtx.SetStrokeWidth(3) 378 canvasCtx.DrawPath(avatarCenterX, avatarCenterY, canvas.Circle(avatarDisplaySize/2)) 379 canvasCtx.Stroke() 380 381 // Draw the final circular avatar 382 canvasCtx.DrawImage(imageX, imageY, maskedAvatar, canvas.DPMM(canvasDPMM)) 383 } 384 385 // Create unified responsive "Join @handle" text 386 joinUserContent := fmt.Sprintf("Join @%s", handle) 387 388 availableWidth := textAvailableWidth // Full available width for the text 389 joinText, _ := createResponsiveJoinText(fontAHN, joinUserContent, availableWidth) 390 canvasCtx.DrawText(textStartX, joinY, joinText) 391 392 // Add "streaming on Stream.place" subtitle 393 onFace := fontAHN.Face(subtitleFontSize, subtitleColor, canvas.FontRegular, canvas.FontNormal) 394 onText := canvas.NewTextBox(onFace, "streaming on Stream.place", 250, 30, canvas.Left, canvas.Center, &canvas.TextOptions{}) 395 canvasCtx.DrawText(textStartX, subtitleY, onText) 396 397 // Add user description or promotional text 398 descFace := fontAHN.Face(descFontSize, descColor, canvas.FontRegular, canvas.FontNormal) 399 descText := canvas.NewTextBox(descFace, description, 230, 30, canvas.Left, canvas.Center, &canvas.TextOptions{}) 400 canvasCtx.DrawText(textStartX, descY, descText) 401 402 b := &bytes.Buffer{} 403 if err := c.Write(b, renderers.JPEG(canvas.DPMM(canvasDPMM))); err != nil { 404 return nil, fmt.Errorf("failed to render canvas to buffer: %w", err) 405 } 406 407 return b.Bytes(), nil 408} 409 410// getAtkinsonRegular returns the regular Atkinson Hyperlegible Next font data from app filesystem 411func getAtkinsonRegular() ([]byte, error) { 412 files, err := app.Assets() 413 if err != nil { 414 return nil, fmt.Errorf("failed to get app assets: %w", err) 415 } 416 417 file, err := files.Open("fonts/AtkinsonHyperlegibleNext-Regular.ttf") 418 if err != nil { 419 return nil, fmt.Errorf("failed to open regular font: %w", err) 420 } 421 defer file.Close() 422 423 data, err := io.ReadAll(file) 424 if err != nil { 425 return nil, fmt.Errorf("failed to read regular font: %w", err) 426 } 427 428 return data, nil 429} 430 431// getAtkinsonBold returns the bold Atkinson Hyperlegible Next font data from app filesystem 432func getAtkinsonBold() ([]byte, error) { 433 files, err := app.Assets() 434 if err != nil { 435 return nil, fmt.Errorf("failed to get app assets: %w", err) 436 } 437 438 file, err := files.Open("fonts/AtkinsonHyperlegibleNext-Bold.ttf") 439 if err != nil { 440 return nil, fmt.Errorf("failed to open bold font: %w", err) 441 } 442 defer file.Close() 443 444 data, err := io.ReadAll(file) 445 if err != nil { 446 return nil, fmt.Errorf("failed to read bold font: %w", err) 447 } 448 449 return data, nil 450} 451 452func (s *Server) fetchUserProfile(ctx context.Context, username string) (*bsky.ActorDefs_ProfileViewDetailed, error) { 453 // Use ATSync to resolve username to DID, then fetch full profile from Bluesky 454 var actor string 455 456 // First try to resolve via internal DB 457 repo, err := s.ATSync.Model.GetRepoByHandleOrDID(username) 458 if err != nil { 459 return nil, fmt.Errorf("%w: %w", ErrUserNotFound, err) 460 } else if repo != nil { 461 // Use the DID as it's the most reliable identifier 462 actor = repo.DID 463 } else { 464 return nil, fmt.Errorf("no repo found for username: %s (%w)", username, ErrUserNotFound) 465 } 466 467 // Fetch full profile from Bluesky public API 468 client := &xrpc.Client{ 469 Host: "https://public.api.bsky.app", 470 } 471 472 profile, err := bsky.ActorGetProfile(ctx, client, actor) 473 if err != nil { 474 return nil, fmt.Errorf("failed to fetch profile from Bluesky for '%s': %w", actor, err) 475 } 476 477 if profile == nil { 478 return nil, fmt.Errorf("received nil profile from Bluesky API for '%s'", actor) 479 } 480 481 return profile, nil 482}