feat: caching for author's avatars and og:images

This commit is contained in:
grassblock 2025-05-31 18:35:11 +08:00
parent 11bc60c103
commit 06c523c667
2 changed files with 322 additions and 68 deletions

View file

@ -1,5 +1,82 @@
import { getCollection } from 'astro:content';
import { getImage } from "astro:assets";
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
// Simple in-memory cache for frequently accessed avatars
const AVATAR_CACHE = new Map();
const CACHE_MAX_SIZE = 50; // Maximum number of avatars to keep in memory
const CACHE_DIR = 'node_modules/.astro/avatar-cache';
const AVATAR_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
const MC_CACHE_TIME = 7 * 24 * 60 * 60; // 7 days in seconds for HTTP caching
// Cache helpers
async function ensureCacheDir() {
try {
await mkdir(CACHE_DIR, { recursive: true });
} catch (err) {
console.warn('Failed to create cache directory:', err);
}
}
async function getCachedAvatar(cacheKey) {
// Check memory cache first
if (AVATAR_CACHE.has(cacheKey)) {
const { data, timestamp } = AVATAR_CACHE.get(cacheKey);
// Validate that cache entry isn't too old
if (Date.now() - timestamp < AVATAR_CACHE_TTL) {
return data;
}
// Remove stale cache entry
AVATAR_CACHE.delete(cacheKey);
}
// Check file cache
const filePath = join(CACHE_DIR, `${cacheKey}.png`);
try {
if (existsSync(filePath)) {
const stat = await import('node:fs/promises').then(f => f.stat(filePath));
// Check if file cache is still valid
if (Date.now() - stat.mtimeMs < AVATAR_CACHE_TTL) {
const data = await readFile(filePath);
// Update memory cache
addToMemoryCache(cacheKey, data);
return data;
}
}
} catch (err) {
console.warn(`Failed to read cached avatar ${cacheKey}:`, err);
}
return null;
}
async function cacheAvatar(cacheKey, data) {
// Add to memory cache
addToMemoryCache(cacheKey, data);
// Add to file cache
try {
await ensureCacheDir();
await writeFile(join(CACHE_DIR, `${cacheKey}.png`), data);
} catch (err) {
console.warn(`Failed to cache avatar ${cacheKey}:`, err);
}
}
function addToMemoryCache(key, data) {
// If cache is full, remove oldest entry
if (AVATAR_CACHE.size >= CACHE_MAX_SIZE) {
const oldestKey = AVATAR_CACHE.keys().next().value;
AVATAR_CACHE.delete(oldestKey);
}
AVATAR_CACHE.set(key, {
data,
timestamp: Date.now()
});
}
export async function getStaticPaths() {
const authorsData = await getCollection('authors');
@ -11,8 +88,22 @@ export async function getStaticPaths() {
export async function GET({ props }) {
const { author } = props;
const authorId = author.id;
// Try to retrieve from cache first
if (author.data.avatar) {
const cacheKey = `avatar-${authorId}`;
const cachedAvatar = await getCachedAvatar(cacheKey);
if (cachedAvatar) {
return new Response(cachedAvatar, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': `public, max-age=${MC_CACHE_TIME}`
}
});
}
try {
const optimizedImage = await getImage({
src: author.data.avatar,
@ -28,11 +119,15 @@ export async function GET({ props }) {
}
const imageBuffer = await imageResponse.arrayBuffer();
const bufferData = new Uint8Array(imageBuffer);
return new Response(imageBuffer, {
// Cache the avatar for future requests
await cacheAvatar(cacheKey, bufferData);
return new Response(bufferData, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400' // Cache for 24 hours
'Cache-Control': `public, max-age=${MC_CACHE_TIME}`
}
});
} catch (error) {
@ -46,11 +141,22 @@ export async function GET({ props }) {
}
const username = author.data.mcplayerid;
const cacheKey = `mc-${username}`;
// Check cache for Minecraft avatar
const cachedMcAvatar = await getCachedAvatar(cacheKey);
if (cachedMcAvatar) {
return new Response(cachedMcAvatar, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': `public, max-age=${MC_CACHE_TIME}`
}
});
}
try {
// get Minecraft profile by username
// Get Minecraft profile by username
const profileResponse = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`);
if (!profileResponse.ok) {
return new Response('Player not found', { status: 404 });
}
@ -58,31 +164,43 @@ export async function GET({ props }) {
const profile = await profileResponse.json();
const uuid = profile.id;
// get skin data from session server
// Get skin data from session server
const sessionResponse = await fetch(`https://sessionserver.mojang.com/session/minecraft/profile/${uuid}`);
const sessionData = await sessionResponse.json();
if (!sessionResponse.ok) {
return new Response('Session data not found', { status: 404 });
}
const sessionData = await sessionResponse.json();
const texturesProperty = sessionData.properties.find((prop) => prop.name === 'textures');
if (!texturesProperty) {
return new Response('Textures not found', { status: 404 });
}
const texturesData = JSON.parse(atob(texturesProperty.value));
const skinUrl = texturesData.textures.SKIN?.url;
if (!skinUrl) {
return new Response('Skin not found', { status: 404 });
}
// get skin image from the URL
// Get skin image from the URL
const skinResponse = await fetch(skinUrl);
const skinBuffer = await skinResponse.arrayBuffer();
// render the Minecraft head image
// Render the Minecraft head image
const headImage = await renderMinecraftHead(new Uint8Array(skinBuffer));
// Cache the rendered head
await cacheAvatar(cacheKey, headImage);
return new Response(headImage, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600', // 缓存1小时
'Cache-Control': `public, max-age=${MC_CACHE_TIME}`
},
});
} catch (error) {
console.error('Error fetching Minecraft head:', error);
return new Response('Internal server error', { status: 500 });
@ -93,7 +211,7 @@ async function renderMinecraftHead(skinData) {
// Use sharp library to process images
const sharp = (await import('sharp')).default;
// Load the skin image
// Load the skin image once and get metadata
const skinImage = sharp(skinData);
const metadata = await skinImage.metadata();
const { width, height } = metadata;
@ -102,54 +220,67 @@ async function renderMinecraftHead(skinData) {
const isNewFormat = height === 64;
const headSize = 8; // Head is 8x8 pixels
const scale = 8; // Scale factor, final output is 64x64
const offset = -1; // 3D-like effect offset
// 3D-like effect: slightly offset hat layer
// TODO: real 3D effect, which would require more complex rendering
const offset = -1; // Negative value moves up/left (creates 3D effect)
// Extract head base layer and prepare for compositing if needed
let extractionPromises = [
// Head base layer extraction promise
await skinImage
.clone()
.extract({left: 8, top: 8, width: headSize, height: headSize})
.png()
.toBuffer()
];
// Extract head base layer (8x8 pixels)
const headBase = await skinImage
.clone() // Clone to avoid modifying original
.extract({ left: 8, top: 8, width: headSize, height: headSize })
.png()
.toBuffer();
// If new format, also extract hat layer in parallel
if (isNewFormat && width >= 48 && height >= 16) {
extractionPromises.push(
skinImage
.clone()
.extract({ left: 40, top: 8, width: headSize, height: headSize })
.png()
.toBuffer()
);
}
// Wait for all extractions to complete in parallel
const [headBase, hatLayer] = await Promise.all(extractionPromises);
// Start with base layer
let finalHead = sharp(headBase).resize(headSize * scale, headSize * scale, {
kernel: 'nearest' // Keep pixel art style
kernel: 'nearest', // Keep pixel art style
fastShrinkOnLoad: true // Performance optimization
});
// If new format and has hat layer, composite hat layer
if (isNewFormat) {
// If we have a hat layer, composite it
if (hatLayer) {
try {
// Check if we're in bounds before extracting hat layer
if (width >= 48 && height >= 16) {
// Extract hat layer (8x8 pixels)
const hatLayer = await skinImage
.clone() // Clone to avoid modifying original
.extract({ left: 40, top: 8, width: headSize, height: headSize })
.png()
.toBuffer();
const hatResized = await sharp(hatLayer)
.resize(headSize * scale, headSize * scale, {
kernel: 'nearest',
fastShrinkOnLoad: true
})
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer();
// Resize hat layer
const hatResized = await sharp(hatLayer)
.resize(headSize * scale, headSize * scale, { kernel: 'nearest' })
.png()
.toBuffer();
// Composite base layer and hat layer with offset for 3D effect
finalHead = finalHead.composite([{
input: hatResized,
left: offset * scale, // Apply scaled offset horizontally
top: offset * scale, // Apply scaled offset vertically
blend: 'over'
}]);
}
finalHead = finalHead.composite([{
input: hatResized,
left: offset * scale,
top: offset * scale,
blend: 'over'
}]);
} catch (error) {
// If hat layer processing fails, just use base layer
console.warn('Failed to process hat layer:', error);
}
}
const result = await finalHead.png().toBuffer();
// Optimize PNG compression
const result = await finalHead.png({
compressionLevel: 9,
adaptiveFiltering: true,
force: true
}).toBuffer();
return new Uint8Array(result);
}
}

View file

@ -1,6 +1,65 @@
import { getCollection } from 'astro:content';
import sharp from 'sharp';
import {getImage} from "astro:assets";
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
// Ensure cache directory exists
const CACHE_DIR = 'node_modules/.astro/og-cache';
// Cache for external images to avoid repeated processing
const externalImageCache = new Map();
// Cache for generated OG images (memory cache)
const ogImageCache = new Map();
// Initialize cache directory
async function ensureCacheDir() {
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
} catch (error) {
console.error('Error creating cache directory:', error);
}
}
// Create hash for consistent filenames
function createHash(str) {
return crypto.createHash('md5').update(str).digest('hex');
}
// File-based cache operations
const fileCache = {
async get(key) {
try {
const filePath = path.join(CACHE_DIR, `${createHash(key)}.webp`);
const stats = await fs.stat(filePath);
// Check if file exists and is not too old (30 days)
const now = new Date();
const fileAge = (now - stats.mtime) / (1000 * 60 * 60 * 24);
if (fileAge > 30) {
return null; // File is too old
}
return await fs.readFile(filePath);
} catch (error) {
return null; // File doesn't exist or can't be read
}
},
async set(key, data) {
try {
await ensureCacheDir();
const filePath = path.join(CACHE_DIR, `${createHash(key)}.webp`);
await fs.writeFile(filePath, data);
return true;
} catch (error) {
console.error('Error writing to file cache:', error);
return false;
}
}
};
export async function getStaticPaths() {
const blogEntries = await getCollection('posts');
@ -11,13 +70,22 @@ export async function getStaticPaths() {
// get the post has a external featured.* image files
async function getExternalImage(post) {
// Check cache first
if (externalImageCache.has(post.slug)) {
return externalImageCache.get(post.slug);
}
const featuredImages = import.meta.glob(`/src/content/posts/*/featured.*`, {import: 'default', eager: true});
const matchedImage = Object.keys(featuredImages).find(path => path.includes(post.slug));
let matchedImage_;
if (matchedImage) {
matchedImage_ = await getImage({src: featuredImages[matchedImage], format: 'webp'}) || null;
}
return matchedImage_?.src;
// Store in cache
const result = matchedImage_?.src;
externalImageCache.set(post.slug, result);
return result;
}
// Function to check for images in markdown without rendering
@ -30,12 +98,52 @@ function checkForImages(markdownContent) {
// This function dynamically generates og:images for posts that don't have a featured image
export async function GET({ props }) {
const {post} = props;
const ExternalImageURL = await getExternalImage(post);
const hasImage = checkForImages(post.body);
// Generate consistent cache key
const cacheKey = `${post.slug}-${post.id}`;
// Check in-memory cache first (fastest)
if (ogImageCache.has(cacheKey)) {
return new Response(ogImageCache.get(cacheKey), {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': `"${cacheKey}"`
}
});
}
// Then check file cache (persists between server restarts)
const cachedFile = await fileCache.get(cacheKey);
if (cachedFile) {
// Store in memory cache for faster subsequent access
ogImageCache.set(cacheKey, cachedFile);
return new Response(cachedFile, {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': `"${cacheKey}"`,
'X-Cache': 'HIT-FILE'
}
});
}
try {
// Check if a custom cover image already exists
if (post.data.cover || ExternalImageURL || hasImage) {
// set it to empty to prevent the image generation
// Short-circuit early if we know we won't generate an image
if (post.data.cover) {
return new Response(null);
}
// Only fetch external image if needed
const ExternalImageURL = await getExternalImage(post);
if (ExternalImageURL) {
return new Response(null);
}
// Only check for images if needed
const hasImage = checkForImages(post.body);
if (hasImage) {
return new Response(null);
}
@ -43,29 +151,44 @@ export async function GET({ props }) {
const width = 1280;
const height = 720;
// Create a simple image with text
const svg = `
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<rect width="${width}" height="${height}" fill="#2e3440"/>
<text x="50%" y="40%" font-family="JetBrains Mono, monospace" font-size="50" text-anchor="middle" fill="#eceff4">${post.data.title}</text>
<text x="50%" y="60%" font-family="JetBrains Mono, monospace" font-size="30" text-anchor="middle" fill="#81a1c1">${post.data.description}</text>
</svg>
`;
// Sanitize text for SVG
const sanitizeText = (text) => {
return text
? text.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
: '';
};
// Convert SVG to WebP
const title = sanitizeText(post.data.title);
const description = sanitizeText(post.data.description);
// Create a simple image with text - optimized SVG
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"><rect width="${width}" height="${height}" fill="#2e3440"/><text x="50%" y="40%" font-family="JetBrains Mono, monospace" font-size="50" text-anchor="middle" fill="#eceff4">${title}</text><text x="50%" y="60%" font-family="JetBrains Mono, monospace" font-size="30" text-anchor="middle" fill="#81a1c1">${description}</text></svg>`;
// Convert SVG to WebP with optimized settings
const buffer = await sharp(Buffer.from(svg))
.toFormat('webp')
.webp({ quality: 80, lossless: false })
.toBuffer();
// Return the image
// Store in both memory and file caches
ogImageCache.set(cacheKey, buffer);
await fileCache.set(cacheKey, buffer);
// Return the image with proper caching headers
return new Response(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000'
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': `"${cacheKey}"`,
'X-Cache': 'MISS'
}
});
} catch (error) {
console.error('Error generating image:', error);
return new Response('Error generating image', { status: 500 });
}
}
}