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:
@@ -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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user