Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,20 @@ Generate OG images by making GET requests to specific endpoints:
/og/comment/[comment-id] # Generic route
```

### Coming Soon
#### Entity Images
```
/og/track/[track-id] # Track OG images
/og/user/[user-id] # User profile OG images
/og/collection/[collection-id] # Collection OG images
/track/[track-id]
/collection/[collection-id]
/user/[user-id]
/coin/[ticker]
```

#### Weekly Rotation Images
```
/weekly-rotation/[handle] # 2x2 collage of the first four tracks in the user's current mix
```
The web app appends `?week=YYYY-WW` so URL-keyed scraper caches roll over with the mix.

## Architecture

The project follows a clean, feature-based architecture:
Expand Down
73 changes: 73 additions & 0 deletions src/components/ArtworkCollage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { blendWithWhite } from "../utils/blendWithWhite";

const BORDER_WIDTH = 2;

interface ArtworkCollageProps {
/**
* Up to four artwork URLs, in display order: top-left, top-right,
* bottom-left, bottom-right. Fewer than four are padded with `fallback`.
*/
srcs: string[];
fallback: string;
size?: number;
gap?: number;
dominantColor?: string;
style?: React.CSSProperties;
}

/**
* A 2x2 grid of artwork in the same frame `Artwork` uses, for surfaces that
* have no single cover image of their own (a generated mix, a lineup). A
* single image is not special-cased into a full-bleed square: the grid is
* the visual signature of "this is a set of tracks", and a one-track set is
* still a set.
*/
export function ArtworkCollage({
srcs,
fallback,
size = 598,
gap = 4,
dominantColor,
style = {},
}: ArtworkCollageProps) {
const borderColor = dominantColor ? blendWithWhite(dominantColor.replace("#", ""), 0.1) : "#FFF";
const cells = [0, 1, 2, 3].map((i) => srcs[i] ?? fallback);
// Satori lays out border-box, so the border comes out of the inner width.
// A cell sized from the outer width doesn't fit two per row and the grid
// silently collapses into a single column.
const innerSize = size - 2 * BORDER_WIDTH;
const cellSize = (innerSize - gap) / 2;

return (
<div
style={{
width: `${size}px`,
height: `${size}px`,
backgroundColor: "#E7E7EA",
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
gap: `${gap}px`,
position: "relative",
overflow: "hidden",
boxSizing: "border-box",
border: `${BORDER_WIDTH}px solid ${borderColor}`,
borderRadius: "20px",
...style,
}}
>
{cells.map((src, i) => (
<img
key={i}
src={src}
alt={`Artwork ${i + 1}`}
style={{
width: `${cellSize}px`,
height: `${cellSize}px`,
objectFit: "cover",
}}
/>
))}
</div>
);
}
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { userRoute } from "./routes/user";
import { coinRoute } from "./routes/coin";
import { coinsRoute } from "./routes/coins";
import { defaultRoute } from "./routes/default";
import { weeklyRotationRoute } from "./routes/weeklyRotation";

const app = new Hono()
.use("*", logger())
Expand All @@ -19,6 +20,7 @@ const app = new Hono()
.route("/coin", coinRoute)
.route("/coins", coinsRoute)
.route("/default", defaultRoute)
.route("/weekly-rotation", weeklyRotationRoute)
.route("/og/comment", commentRoute); // Legacy route support

// Health check and info endpoint
Expand All @@ -35,9 +37,10 @@ app.get("/", async (c) => {
user: "/user/:id",
coin: "/coin/:ticker",
coins: "/coins",
"weekly-rotation": "/weekly-rotation/:handle",
"comment (legacy)": "/og/comment/:id",
},
implemented: ["default", "airdrop", "comment", "track", "collection", "user", "coin", "coins"],
implemented: ["default", "airdrop", "comment", "track", "collection", "user", "coin", "coins", "weekly-rotation"],
});
});

Expand Down
177 changes: 177 additions & 0 deletions src/routes/weeklyRotation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { Hono } from "hono";
import { BaseLayout } from "../components/BaseLayout";
import { AudiusLogoHorizontal } from "../components/AudiusLogoHorizontal";
import { PlayButton } from "../components/PlayButton";
import { ContentTag } from "../components/ContentTag";
import { Title } from "../components/Title";
import { UserName } from "../components/UserName";
import { ArtworkCollage } from "../components/ArtworkCollage";
import { getBadgeTier } from "../utils/badge";
import { getLocalFonts } from "../utils/getFonts";
import { APIService } from "../api";
import { getDominantColor } from "../utils/getDominantColor";
import { loadImage } from "../utils/loadImage";
import { getImageUrlWithFallback } from "../utils/fetchImageWithFallback";
import { createDiscordFriendlyImageResponse } from "../utils/imageResponse";
import type { SquareImage, UserData } from "../types";

// How many of the mix's tracks make up the collage.
const COLLAGE_SIZE = 4;

// Must match the limit the apps request, so this hits the same server-side
// cache entry (the API keys its cache on the limit) instead of forcing a
// second run of the ranking query.
const MIX_LIMIT = 30;

// The mix rolls over on Wednesday 00:00 UTC. The web app also stamps the
// period into the image URL as a query param, so scrapers that cache by URL
// pick up the new week; this header covers everything in between.
const ROLLOVER_WEEKDAY_UTC = 3; // Sunday = 0

interface WeeklyRotationTrack {
id: string;
artwork?: SquareImage;
}

interface UserByHandleResponse {
data?: UserData | UserData[];
}

interface WeeklyRotationResponse {
data?: WeeklyRotationTrack[];
}

function secondsUntilNextRollover(now: Date): number {
const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
let daysAhead = (ROLLOVER_WEEKDAY_UTC - next.getUTCDay() + 7) % 7;
if (daysAhead === 0) daysAhead = 7;
next.setUTCDate(next.getUTCDate() + daysAhead);
return Math.max(60, Math.floor((next.getTime() - now.getTime()) / 1000));
}

/**
* OG card for a user's Weekly Rotation: a 2x2 collage of the first four
* tracks' artwork, tinted by the top track, with the listener's name.
*
* Keyed by handle rather than user id because that's what the shareable
* URL carries (`/explore/weekly-rotation/:handle`).
*/
export const weeklyRotationRoute = new Hono().get("/:handle", async (c) => {
try {
const handle = c.req.param("handle");
if (!handle) return c.json({ error: "Missing handle" }, 400);

const apiService = new APIService(c);

const userResponse: UserByHandleResponse = await apiService.fetch(
`/v1/full/users/handle/${encodeURIComponent(handle)}`,
);
const user = Array.isArray(userResponse.data) ? userResponse.data[0] : userResponse.data;
if (!user?.id) return c.json({ error: "User not found" }, 404);

const mixResponse: WeeklyRotationResponse = await apiService.fetch(
`/v1/users/${user.id}/weekly-rotation?limit=${MIX_LIMIT}`,
);
const tracks = mixResponse.data ?? [];

// Resolve the collage artwork in parallel; each one independently falls
// back through the track's mirrors.
const artworkUrls = await Promise.all(
tracks.slice(0, COLLAGE_SIZE).map((track) => getImageUrlWithFallback(track.artwork, "480x480")),
);
const resolvedArtwork = artworkUrls.filter((url): url is string => !!url);

const dominantColor = resolvedArtwork[0]
? await getDominantColor(resolvedArtwork[0], tracks[0]?.artwork?.mirrors)
: undefined;

const blankArtwork = (await loadImage(c, "/images/blank-artwork.png"))!;

const userName = user.name;
const isVerified = user.is_verified;
const tier = getBadgeTier(user.total_audio_balance);

const font = await getLocalFonts(c, [
{ path: "Inter-Bold.ttf", weight: 700 },
{ path: "Inter-Regular.ttf", weight: 500 },
{ path: "Inter-Light.ttf", weight: 300 },
]);

const renderContent = () => (
<BaseLayout>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
padding: "16px",
gap: "16px",
width: "1200px",
height: "630px",
boxSizing: "border-box",
background: dominantColor || "#000",
}}
>
<ArtworkCollage srcs={resolvedArtwork} fallback={blankArtwork} dominantColor={dominantColor} />

<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "flex-start",
padding: "32px",
filter: "drop-shadow(0px 4px 4px rgba(0,0,0,0.1))",
background: "transparent",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
width: "490px",
height: "40px",
marginBottom: "56px",
}}
>
<ContentTag text="mix" color={dominantColor} shadow />
<AudiusLogoHorizontal height={40} shadow />
</div>

<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
width: "490px",
marginBottom: "56px",
}}
>
<Title shadow>Weekly Rotation</Title>
<UserName name={userName} shadow isVerified={isVerified} tier={tier} backgroundColor={dominantColor} />
</div>

<PlayButton size={140} shadow />
</div>
</div>
</BaseLayout>
);

const response = createDiscordFriendlyImageResponse(renderContent(), {
width: 1200,
height: 630,
fonts: Array.isArray(font) ? [...font] : [font],
});
// Not immutable, unlike the entity cards: the same URL means a new
// image once the week rolls over.
response.headers.set("Cache-Control", `public, max-age=${secondsUntilNextRollover(new Date())}`);
return response;
} catch (error: any) {
console.error("Weekly Rotation OG Image generation error:", error);
return c.json({ error: "Failed to generate weekly rotation image", details: error.message }, 500);
}
});

export default weeklyRotationRoute;