Skip to content
Draft
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
197 changes: 112 additions & 85 deletions src/app/api/generate-bracket-image/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,47 @@ import type { BracketState, Matchup, SeededTeam } from "@/types";

export const runtime = "edge";

// Image size presets - smaller sizes render faster
type ImageSize = "hd" | "fullhd" | "4k";

const IMAGE_SIZES: Record<ImageSize, { width: number; height: number }> = {
hd: { width: 1280, height: 720 },
fullhd: { width: 1920, height: 1080 },
"4k": { width: 3840, height: 2160 },
};

interface RequestBody {
bracket: BracketState;
userName: string;
bracketName: string;
size?: ImageSize; // Optional - defaults to "4k" for best quality
}

// Cache for fetched logo data URIs
const logoCache = new Map<string, string>();

// Convert 500px ESPN logo URL to smaller 100px version for faster fetching
function getOptimizedLogoUrl(url: string): string {
// ESPN CDN supports different sizes: /500/, /100/, etc.
return url.replace("/500/", "/100/");
}

async function fetchLogoAsDataUri(url: string): Promise<string> {
if (logoCache.has(url)) {
return logoCache.get(url)!;
// Use optimized (smaller) logo URL for faster fetching
const optimizedUrl = getOptimizedLogoUrl(url);

if (logoCache.has(optimizedUrl)) {
return logoCache.get(optimizedUrl)!;
}

try {
const response = await fetch(url, {
const response = await fetch(optimizedUrl, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; NFLBracket/1.0)",
},
// Enable Next.js edge caching - cache for 24 hours, revalidate in background
cache: "force-cache",
next: { revalidate: 86400 },
});

if (!response.ok) {
Expand All @@ -34,10 +56,10 @@ async function fetchLogoAsDataUri(url: string): Promise<string> {
const contentType = response.headers.get("content-type") || "image/png";
const dataUri = `data:${contentType};base64,${base64}`;

logoCache.set(url, dataUri);
logoCache.set(optimizedUrl, dataUri);
return dataUri;
} catch (error) {
console.error("Failed to fetch logo:", url, error);
console.error("Failed to fetch logo:", optimizedUrl, error);
// Return transparent 1x1 pixel as fallback
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
}
Expand Down Expand Up @@ -78,9 +100,7 @@ function collectLogos(bracket: BracketState): string[] {
}

// Pre-fetch all logos and return a map
async function prefetchLogos(
logoUrls: string[],
): Promise<Map<string, string>> {
async function prefetchLogos(logoUrls: string[]): Promise<Map<string, string>> {
const entries = await Promise.all(
logoUrls.map(async (url) => {
const dataUri = await fetchLogoAsDataUri(url);
Expand Down Expand Up @@ -523,114 +543,121 @@ function SuperBowlImage({
export async function POST(request: NextRequest) {
try {
const body: RequestBody = await request.json();
const { bracket, userName, bracketName } = body;
const { bracket, userName, bracketName, size = "4k" } = body;

// Prefetch all logos
// Prefetch all logos in parallel
const logoUrls = collectLogos(bracket);
const logoMap = await prefetchLogos(logoUrls);

// 4K Image dimensions
const width = 3840;
const height = 2160;
// Get image dimensions based on requested size
const { width, height } = IMAGE_SIZES[size] || IMAGE_SIZES.fullhd;

return new ImageResponse(
(
const imageResponse = new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#000000",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
>
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#000000",
fontFamily: "system-ui, -apple-system, sans-serif",
alignItems: "center",
justifyContent: "center",
height: 140,
background:
"linear-gradient(90deg, #dc2626 0%, #1f1f1f 50%, #2563eb 100%)",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 140,
background:
"linear-gradient(90deg, #dc2626 0%, #1f1f1f 50%, #2563eb 100%)",
color: "#ffffff",
fontSize: 52,
fontWeight: 700,
}}
>
{userName}&apos;s Playoff Bracket
</div>
{bracketName && (
<div
style={{
display: "flex",
color: "#ffffff",
fontSize: 52,
fontWeight: 700,
color: "#a1a1aa",
fontSize: 28,
marginTop: 6,
}}
>
{userName}&apos;s Playoff Bracket
{bracketName}
</div>
{bracketName && (
<div
style={{
display: "flex",
color: "#a1a1aa",
fontSize: 28,
marginTop: 6,
}}
>
{bracketName}
</div>
)}
</div>
)}
</div>

<div
style={{
display: "flex",
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: "40px 80px",
gap: 60,
}}
>
<ConferenceBracketImage
conference="AFC"
bracket={bracket}
logoMap={logoMap}
/>
<SuperBowlImage bracket={bracket} logoMap={logoMap} />
<ConferenceBracketImage
conference="NFC"
bracket={bracket}
logoMap={logoMap}
reversed
/>
</div>
<div
style={{
display: "flex",
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: "40px 80px",
gap: 60,
}}
>
<ConferenceBracketImage
conference="AFC"
bracket={bracket}
logoMap={logoMap}
/>
<SuperBowlImage bracket={bracket} logoMap={logoMap} />
<ConferenceBracketImage
conference="NFC"
bracket={bracket}
logoMap={logoMap}
reversed
/>
</div>

<div
style={{
display: "flex",
justifyContent: "flex-end",
padding: "16px 40px",
color: "#6b7280",
fontSize: 26,
fontWeight: 600,
}}
>
bracket.build • NFL Playoffs 2025-26
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
padding: "16px 40px",
color: "#6b7280",
fontSize: 26,
fontWeight: 600,
}}
>
bracket.build • NFL Playoffs 2025-26
</div>
),
</div>,
{
width,
height,
},
);

// Add cache headers to improve subsequent request performance
// Cache for 1 hour on CDN, allow stale-while-revalidate for 24 hours
const headers = new Headers(imageResponse.headers);
headers.set(
"Cache-Control",
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400",
);

return new Response(imageResponse.body, {
status: 200,
headers,
});
} catch (error) {
console.error("Error generating bracket image:", error);
return new Response(
JSON.stringify({ error: "Failed to generate image" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
return new Response(JSON.stringify({ error: "Failed to generate image" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
11 changes: 11 additions & 0 deletions src/lib/image-generator.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import type { BracketState } from "@/types";

// Image size presets - smaller sizes render faster
export type ImageSize = "hd" | "fullhd" | "4k";

export interface GenerateImageOptions {
userName: string;
bracketName: string;
/**
* Image size preset:
* - "hd" (1280x720) - fastest, good for web sharing
* - "fullhd" (1920x1080) - balanced quality/speed
* - "4k" (3840x2160) - default, highest quality
*/
size?: ImageSize;
}

/**
Expand All @@ -22,6 +32,7 @@ export async function generateBracketImage(
bracket,
userName: options.userName,
bracketName: options.bracketName,
size: options.size,
}),
});

Expand Down