feat: caching for author's avatars and og:images
This commit is contained in:
parent
11bc60c103
commit
06c523c667
2 changed files with 322 additions and 68 deletions
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,25 +151,39 @@ 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
: '';
|
||||
};
|
||||
|
||||
// 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) {
|
||||
|
@ -69,3 +191,4 @@ export async function GET({ props }) {
|
|||
return new Response('Error generating image', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue