recipes: replace placeholder images with OKLAB dominant color backgrounds

Instead of generating/serving 20px placeholder images with blur CSS, extract
a perceptually accurate dominant color (Gaussian-weighted OKLAB average) and
use it as a solid background-color while the full image loads. Removes
placeholder image generation, blur CSS/JS, and placeholder directory references
across upload flows, API routes, service worker, and all card/hero components.
Adds admin bulk tool to backfill colors for existing recipes.
This commit is contained in:
2026-02-17 18:12:36 +01:00
parent 0ea09e424e
commit 53da9ad26d
21 changed files with 592 additions and 108 deletions
@@ -26,7 +26,7 @@ export const POST: RequestHandler = async ({request, locals}) => {
if (oldShortName !== newShortName) {
// Rename image files in all three directories
const imageDirectories = ['full', 'thumb', 'placeholder'];
const imageDirectories = ['full', 'thumb'];
const staticPath = join(process.cwd(), 'static', 'rezepte');
for (const dir of imageDirectories) {
@@ -5,6 +5,7 @@ import { IMAGE_DIR } from '$env/static/private';
import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
import { validateImageFile } from '$utils/imageValidation';
import { extractDominantColor } from '$utils/imageProcessing';
/**
* Secure image upload endpoint for recipe images
@@ -13,7 +14,7 @@ import { validateImageFile } from '$utils/imageValidation';
* - Requires authentication
* - 5-layer validation (size, magic bytes, MIME, extension, Sharp)
* - Uses FormData instead of base64 JSON (more efficient, more secure)
* - Generates full/thumb/placeholder versions
* - Generates full/thumb versions + dominant color extraction
* - Content hash for cache busting
*
* @route POST /api/rezepte/img/add
@@ -109,31 +110,20 @@ export const POST = (async ({ request, locals }) => {
await sharp(thumbBuffer).toFile(thumbHashedPath);
await sharp(thumbBuffer).toFile(thumbUnhashedPath);
console.log('[API:ImgAdd] Thumbnail images saved');
console.log('[API:ImgAdd] Thumbnail images saved');
// Save placeholder (20px width) - both hashed and unhashed versions
console.log('[API:ImgAdd] Processing placeholder...');
const placeholderBuffer = await sharp(buffer)
.resize({ width: 20 })
.toFormat('webp')
.webp({ quality: 60 })
.toBuffer();
console.log('[API:ImgAdd] Placeholder buffer created, size:', placeholderBuffer.length, 'bytes');
// Extract dominant color
console.log('[API:ImgAdd] Extracting dominant color...');
const color = await extractDominantColor(buffer);
console.log('[API:ImgAdd] Dominant color:', color);
const placeholderHashedPath = path.join(IMAGE_DIR, 'rezepte', 'placeholder', hashedFilename);
const placeholderUnhashedPath = path.join(IMAGE_DIR, 'rezepte', 'placeholder', unhashedFilename);
console.log('[API:ImgAdd] Saving placeholder to:', { placeholderHashedPath, placeholderUnhashedPath });
await sharp(placeholderBuffer).toFile(placeholderHashedPath);
await sharp(placeholderBuffer).toFile(placeholderUnhashedPath);
console.log('[API:ImgAdd] Placeholder images saved ✓');
console.log('[API:ImgAdd] Upload completed successfully ✓');
console.log('[API:ImgAdd] Upload completed successfully');
return json({
success: true,
msg: 'Image uploaded successfully',
filename: hashedFilename,
unhashedFilename: unhashedFilename
unhashedFilename: unhashedFilename,
color
});
} catch (err: any) {
// Re-throw errors that already have status codes
@@ -17,7 +17,7 @@ export const POST = (async ({ request, locals}) => {
const basename = data.name || hashedFilename.replace(/\.[a-f0-9]{8}\.webp$/, '').replace(/\.webp$/, '');
const unhashedFilename = basename + '.webp';
[ "full", "thumb", "placeholder"].forEach((folder) => {
[ "full", "thumb"].forEach((folder) => {
// Delete hashed version
unlink(path.join(IMAGE_DIR, "rezepte", folder, hashedFilename), (e) => {
if(e) console.warn(`Could not delete hashed: ${folder}/${hashedFilename}`, e);
@@ -26,7 +26,7 @@ export const POST = (async ({ request, locals}) => {
newFilename = data.new_name + ".webp";
}
[ "full", "thumb", "placeholder"].forEach((folder) => {
[ "full", "thumb"].forEach((folder) => {
const old_path = path.join(IMAGE_DIR, "rezepte", folder, oldFilename)
rename(old_path, path.join(IMAGE_DIR, "rezepte", folder, newFilename), (e) => {
console.log(e)
@@ -142,6 +142,7 @@ export const GET: RequestHandler = async ({ params }) => {
mediapath: img.mediapath,
alt: translatedImages[index]?.alt || img.alt || '',
caption: translatedImages[index]?.caption || img.caption || '',
color: img.color || '',
}));
}
@@ -0,0 +1,159 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { Recipe } from '$models/Recipe.js';
import { IMAGE_DIR } from '$env/static/private';
import { extractDominantColor } from '$utils/imageProcessing';
import { join } from 'path';
import { access, constants } from 'fs/promises';
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
const body = await request.json();
const { filter = 'missing', limit = 50 } = body;
let query: any = { images: { $exists: true, $ne: [] } };
if (filter === 'missing') {
query = {
images: {
$elemMatch: {
mediapath: { $exists: true },
$or: [{ color: { $exists: false } }, { color: '' }],
},
},
};
}
const recipes = await Recipe.find(query).limit(limit);
if (recipes.length === 0) {
return json({
success: true,
processed: 0,
message: 'No recipes found matching criteria',
});
}
const results: Array<{
shortName: string;
name: string;
color: string;
status: 'ok' | 'error';
error?: string;
}> = [];
for (const recipe of recipes) {
const image = recipe.images[0];
if (!image?.mediapath) continue;
// Try unhashed filename first (always exists), fall back to hashed
const basename = image.mediapath
.replace(/\.[a-f0-9]{8}\.webp$/, '')
.replace(/\.webp$/, '');
const unhashedFilename = basename + '.webp';
const candidates = [
join(IMAGE_DIR, 'rezepte', 'full', unhashedFilename),
join(IMAGE_DIR, 'rezepte', 'full', image.mediapath),
join(IMAGE_DIR, 'rezepte', 'thumb', unhashedFilename),
join(IMAGE_DIR, 'rezepte', 'thumb', image.mediapath),
];
let imagePath: string | null = null;
for (const candidate of candidates) {
try {
await access(candidate, constants.R_OK);
imagePath = candidate;
break;
} catch {
// try next
}
}
if (!imagePath) {
results.push({
shortName: recipe.short_name,
name: recipe.name,
color: '',
status: 'error',
error: 'Image file not found on disk',
});
continue;
}
try {
const color = await extractDominantColor(imagePath);
recipe.images[0].color = color;
await recipe.save();
results.push({
shortName: recipe.short_name,
name: recipe.name,
color,
status: 'ok',
});
} catch (err) {
console.error(`Failed to extract color for ${recipe.short_name}:`, err);
results.push({
shortName: recipe.short_name,
name: recipe.name,
color: '',
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
});
}
}
return json({
success: true,
processed: results.filter(r => r.status === 'ok').length,
failed: results.filter(r => r.status === 'error').length,
results,
});
} catch (err) {
console.error('Error in bulk color recalculation:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, err instanceof Error ? err.message : 'Failed to recalculate colors');
}
};
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
const totalWithImages = await Recipe.countDocuments({
images: { $exists: true, $ne: [] },
});
const missingColor = await Recipe.countDocuments({
images: {
$elemMatch: {
mediapath: { $exists: true },
$or: [{ color: { $exists: false } }, { color: '' }],
},
},
});
const withColor = totalWithImages - missingColor;
return json({
totalWithImages,
missingColor,
withColor,
});
} catch (err) {
throw error(500, 'Failed to fetch statistics');
}
};