Live video on the AT Protocol
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}