Add avatar-bubbles endpoint to bskyogcard (#10327)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: rafaeleyng <rafaeleyng@gmail.com>
This commit is contained in:
@@ -3,14 +3,11 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "tsx ./src/bin.ts",
|
||||
"dev": "tsx watch --clear-screen=false ./src/bin.ts",
|
||||
"dev": "node --watch-path ./src --import tsx ./src/bin.ts",
|
||||
"build": "tsc && cp -r src/assets dist/",
|
||||
"install-fonts": "node scripts/install-fonts.ts"
|
||||
"install-fonts": "tsx scripts/install-fonts.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.12.19-next.0",
|
||||
@@ -28,7 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import cluster, {Worker} from 'node:cluster'
|
||||
import cluster, {type Worker} from 'node:cluster'
|
||||
|
||||
import {envInt} from '@atproto/common'
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import {Img} from './Img.js'
|
||||
|
||||
const SCALE = 3
|
||||
const BASE_SIZE = 120
|
||||
const PADDING = 4
|
||||
const CONTENT_SCALE = (BASE_SIZE - PADDING * 2) / BASE_SIZE
|
||||
|
||||
export const AVATAR_BUBBLES_SIZE = BASE_SIZE * SCALE
|
||||
|
||||
type BubbleConfig = {size: number; x: number; y: number}
|
||||
|
||||
const LAYOUTS: Record<1 | 2 | 3 | 4, BubbleConfig[]> = {
|
||||
1: [
|
||||
{size: 76, x: 0, y: 0},
|
||||
{size: 76, x: 44, y: 44},
|
||||
],
|
||||
2: [
|
||||
{size: 76, x: 0, y: 0},
|
||||
{size: 76, x: 44, y: 44},
|
||||
],
|
||||
3: [
|
||||
{size: 68, x: 0.5, y: 0},
|
||||
{size: 56, x: 40.5, y: 64},
|
||||
{size: 46, x: 73.5, y: 20},
|
||||
],
|
||||
4: [
|
||||
{size: 68, x: 1, y: 1},
|
||||
{size: 56, x: 63, y: 52},
|
||||
{size: 42, x: 17, y: 77},
|
||||
{size: 32, x: 75, y: 12},
|
||||
],
|
||||
}
|
||||
|
||||
const PERSON_PATH =
|
||||
'M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867ZM12.233 12.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z'
|
||||
|
||||
export function AvatarBubbles(props: {images: (Buffer | null)[]}) {
|
||||
const count = Math.min(props.images.length, 4) as 1 | 2 | 3 | 4
|
||||
const layout = LAYOUTS[count] ?? LAYOUTS[2]
|
||||
|
||||
const bubbles = layout.map((bubble, i) => ({
|
||||
...bubble,
|
||||
image: props.images[i] ?? null,
|
||||
}))
|
||||
|
||||
// For the 2-avi layout, render in reverse so the first (top-left) paints last (on top)
|
||||
if (count <= 2) {
|
||||
bubbles.reverse()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: AVATAR_BUBBLES_SIZE,
|
||||
height: AVATAR_BUBBLES_SIZE,
|
||||
position: 'relative',
|
||||
}}>
|
||||
{bubbles.map((bubble, i) => (
|
||||
<AvatarBubble
|
||||
key={i}
|
||||
image={bubble.image}
|
||||
size={bubble.size * CONTENT_SCALE * SCALE}
|
||||
x={bubble.x * CONTENT_SCALE * SCALE + PADDING * SCALE}
|
||||
y={bubble.y * CONTENT_SCALE * SCALE + PADDING * SCALE}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBubble(props: {
|
||||
image: Buffer | null
|
||||
size: number
|
||||
x: number
|
||||
y: number
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
left: props.x,
|
||||
top: props.y,
|
||||
width: props.size,
|
||||
height: props.size,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{props.image ? (
|
||||
<Img width="100%" height="100%" src={props.image} />
|
||||
) : (
|
||||
<Placeholder size={props.size} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Placeholder(props: {size: number}) {
|
||||
const iconSize = props.size * 0.5
|
||||
const offset = (props.size - iconSize) / 2
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: props.size,
|
||||
height: props.size,
|
||||
backgroundColor: '#b0b0b0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<svg
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
viewBox="0 0 24 24"
|
||||
style={{position: 'absolute', left: offset, top: offset}}>
|
||||
<path d={PERSON_PATH} fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
// @NOTE satori does not currently support webp, see vercel/satori#273
|
||||
function detectMime(buf: Buffer): string {
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable bsky-internal/avoid-unwrapped-text */
|
||||
import React from 'react'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
|
||||
import {Butterfly} from './Butterfly.js'
|
||||
import {Img} from './Img.js'
|
||||
|
||||
@@ -4,7 +4,7 @@ import {fileURLToPath} from 'node:url'
|
||||
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
|
||||
import {Config} from './config.js'
|
||||
import {type Config} from './config.js'
|
||||
|
||||
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {type Express} from 'express'
|
||||
import satori from 'satori'
|
||||
|
||||
import {
|
||||
AVATAR_BUBBLES_SIZE,
|
||||
AvatarBubbles,
|
||||
} from '../components/AvatarBubbles.js'
|
||||
import {type AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {
|
||||
getImage,
|
||||
handler,
|
||||
hideAvatarLabels,
|
||||
originVerifyMiddleware,
|
||||
} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/avatar-bubbles',
|
||||
originVerifyMiddleware(ctx),
|
||||
handler(async (req, res) => {
|
||||
const didsParam = req.query.dids
|
||||
if (typeof didsParam !== 'string') {
|
||||
return res.status(400).end('missing dids parameter')
|
||||
}
|
||||
const dids = didsParam.split(',').filter(Boolean)
|
||||
if (dids.length < 1 || dids.length > 4) {
|
||||
return res.status(400).end('dids must contain 1-4 DIDs')
|
||||
}
|
||||
|
||||
let profiles
|
||||
try {
|
||||
const result = await ctx.appviewAgent.api.app.bsky.actor.getProfiles({
|
||||
actors: dids,
|
||||
})
|
||||
profiles = result.data.profiles
|
||||
} catch (err) {
|
||||
httpLogger.warn({err, dids}, 'could not fetch profiles')
|
||||
return res.status(502).end('could not fetch profiles')
|
||||
}
|
||||
|
||||
const images = await Promise.all(
|
||||
dids.map(async did => {
|
||||
const profile = profiles.find(p => p.did === did)
|
||||
if (!profile?.avatar) return null
|
||||
if (profile.labels?.some(l => hideAvatarLabels.has(l.val)))
|
||||
return null
|
||||
try {
|
||||
return await getImage(profile.avatar)
|
||||
} catch (err) {
|
||||
httpLogger.warn({err, did}, 'could not fetch avatar image')
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const svg = await satori(<AvatarBubbles images={images} />, {
|
||||
fonts: ctx.fonts,
|
||||
height: AVATAR_BUBBLES_SIZE,
|
||||
width: AVATAR_BUBBLES_SIZE,
|
||||
})
|
||||
const output = await resvg.renderAsync(svg)
|
||||
res.statusCode = 200
|
||||
res.setHeader('content-type', 'image/png')
|
||||
res.setHeader('cdn-tag', dids.join(','))
|
||||
return res.end(output.asPng())
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Express} from 'express'
|
||||
import {type Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {type AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Express} from 'express'
|
||||
import {type Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {type AppContext} from '../context.js'
|
||||
import {default as avatarBubbles} from './avatar-bubbles.js'
|
||||
import {default as health} from './health.js'
|
||||
import {default as starterPack} from './starter-pack.js'
|
||||
|
||||
@@ -9,5 +10,6 @@ export * from './util.js'
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
app = health(ctx, app) // GET /_health
|
||||
app = starterPack(ctx, app) // GET /start/:actor/:rkey
|
||||
app = avatarBubbles(ctx, app) // GET /avatar-bubbles?dids=...
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import React from 'react'
|
||||
import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {type Express} from 'express'
|
||||
@@ -14,7 +13,12 @@ import {
|
||||
import {type AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {loadEmojiAsSvg} from '../util.js'
|
||||
import {handler, originVerifyMiddleware} from './util.js'
|
||||
import {
|
||||
getImage,
|
||||
handler,
|
||||
hideAvatarLabels,
|
||||
originVerifyMiddleware,
|
||||
} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
@@ -87,35 +91,3 @@ export default function (ctx: AppContext, app: Express) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function getImage(url: string) {
|
||||
const response = await fetch(ensureJpeg(url))
|
||||
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
|
||||
if (response.status !== 200) return null
|
||||
return Buffer.from(arrayBuf)
|
||||
}
|
||||
|
||||
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
|
||||
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
|
||||
function ensureJpeg(url: string) {
|
||||
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
|
||||
}
|
||||
|
||||
const hideAvatarLabels = new Set([
|
||||
'!hide',
|
||||
'!warn',
|
||||
'porn',
|
||||
'sexual',
|
||||
'nudity',
|
||||
'sexual-figurative',
|
||||
'graphic-media',
|
||||
'gore',
|
||||
'self-harm',
|
||||
'sensitive',
|
||||
'security',
|
||||
'impersonation',
|
||||
'scam',
|
||||
'spam',
|
||||
'misleading',
|
||||
'inauthentic',
|
||||
])
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
|
||||
import {
|
||||
type ErrorRequestHandler,
|
||||
type Request,
|
||||
type RequestHandler,
|
||||
type Response,
|
||||
} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {type AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
|
||||
export type Handler = (req: Request, res: Response) => Awaited<void>
|
||||
@@ -34,3 +39,35 @@ export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
}
|
||||
return res.status(500).end('server error')
|
||||
}
|
||||
|
||||
export async function getImage(url: string) {
|
||||
const response = await fetch(ensureJpeg(url))
|
||||
const arrayBuf = await response.arrayBuffer()
|
||||
if (response.status !== 200) return null
|
||||
return Buffer.from(arrayBuf)
|
||||
}
|
||||
|
||||
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
|
||||
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
|
||||
function ensureJpeg(url: string) {
|
||||
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
|
||||
}
|
||||
|
||||
export const hideAvatarLabels = new Set([
|
||||
'!hide',
|
||||
'!warn',
|
||||
'porn',
|
||||
'sexual',
|
||||
'nudity',
|
||||
'sexual-figurative',
|
||||
'graphic-media',
|
||||
'gore',
|
||||
'self-harm',
|
||||
'sensitive',
|
||||
'security',
|
||||
'impersonation',
|
||||
'scam',
|
||||
'spam',
|
||||
'misleading',
|
||||
'inauthentic',
|
||||
])
|
||||
|
||||
@@ -393,7 +393,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
|
||||
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
|
||||
|
||||
"@types/react@^18.3.0":
|
||||
"@types/react@^18.3.1":
|
||||
version "18.3.28"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.28.tgz#0a85b1a7243b4258d9f626f43797ba18eb5f8781"
|
||||
integrity sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==
|
||||
|
||||
@@ -280,6 +280,16 @@ export default defineConfig(
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* bskyogcard - server-side, Node.js imports are fine
|
||||
*/
|
||||
{
|
||||
files: ['bskyogcard/**/*.{js,jsx,ts,tsx}'],
|
||||
rules: {
|
||||
'import-x/no-nodejs-modules': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Test files configuration
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user