import sharp from 'sharp'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, '..'); const IMAGE_CACHE_ROOT = process.env.IMAGE_CACHE_ROOT?.trim(); const CACHE_ROOT = IMAGE_CACHE_ROOT ? path.resolve(IMAGE_CACHE_ROOT, 'manifests') : path.join(ROOT, '.cache', 'image-manifests'); const CACHED_ORIGINALS_ROOT = IMAGE_CACHE_ROOT ? path.resolve(IMAGE_CACHE_ROOT, 'originals') : null; const CACHED_OUTPUTS_ROOT = IMAGE_CACHE_ROOT ? path.resolve(IMAGE_CACHE_ROOT, 'optimized') : null; const IMAGE_PIPELINE_VERSION = 1; const PUBLIC_IMAGES_ROOT = path.join(ROOT, 'public/images'); const DEFAULT_MEDIA_ROOT = path.join(ROOT, '.media-source'); const LEGACY_MEDIA_ROOT = path.join(ROOT, 'assets'); // Optional project-specific alt texts keyed by source image name. const altTexts = {}; const quality = { jpeg: 85, webp: 80, avif: 50, png: 9 }; const supportedExts = new Set(['.jpg', '.jpeg', '.png']); const SHOULD_PRUNE = process.argv.includes('--prune'); const REQUIRE_IMAGE_SOURCES = process.env.IMAGES_SOURCE_REQUIRED === 'true'; function resolveSourceDir(relativePath) { const envRoot = process.env.MEDIA_SOURCE_ROOT?.trim(); const candidates = [ envRoot ? path.resolve(ROOT, envRoot, relativePath) : null, path.join(DEFAULT_MEDIA_ROOT, relativePath), path.join(LEGACY_MEDIA_ROOT, relativePath), ].filter(Boolean); for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return path.join(DEFAULT_MEDIA_ROOT, relativePath); } /** * Pipeline-Konfigurationen */ const pipelines = [ { name: 'content', sourceDir: resolveSourceDir('original-content'), outputDir: path.join(PUBLIC_IMAGES_ROOT, 'content'), outputRelativeDir: 'content', recursive: true, sizes: [ { suffix: '-sm', width: 800, height: 600 }, { suffix: '-md', width: 1000, height: 750 }, ], formats: ['original', 'webp', 'avif'], deleteOriginal: false, generateJson: false, }, { name: 'slider', sourceDir: resolveSourceDir('original-slider'), outputDir: path.join(PUBLIC_IMAGES_ROOT, 'slider'), outputRelativeDir: 'slider', recursive: false, sizes: [ { suffix: '-sm', width: 640, height: 480 }, { suffix: '-md', width: 1280, height: 960 }, { suffix: '', width: 1920, height: 1440 }, ], formats: ['jpg', 'webp', 'avif'], deleteOriginal: false, generateJson: true, jsonFileName: 'slider-images.json', }, { name: 'secondary-slider', sourceDir: resolveSourceDir('original-secondary-slider'), outputDir: path.join(PUBLIC_IMAGES_ROOT, 'secondary-slider'), outputRelativeDir: 'secondary-slider', recursive: false, sizes: [ { suffix: '-sm', width: 640, height: 480 }, { suffix: '-md', width: 1280, height: 960 }, { suffix: '', width: 1920, height: 1440 }, ], formats: ['jpg', 'webp', 'avif'], deleteOriginal: false, generateJson: true, jsonFileName: 'slider-images.json', }, { name: 'herosections', sourceDir: resolveSourceDir('original-herosections'), outputDir: path.join(PUBLIC_IMAGES_ROOT, 'backgrounds'), outputRelativeDir: 'backgrounds', recursive: false, sizes: [ { suffix: '', width: 1920, height: 1440 }, { suffix: '-mobile', width: 1024, height: 768 }, ], formats: ['jpg', 'webp', 'avif'], deleteOriginal: false, generateJson: false, }, ]; function isVariantFile(fileName, sizeSuffixes) { const ext = path.extname(fileName).toLowerCase(); const base = path.basename(fileName, path.extname(fileName)).toLowerCase(); if (ext === '.webp' || ext === '.avif') return true; if (base.endsWith('-temp')) return true; return sizeSuffixes.some((s) => s && base.endsWith(s)); } function collectImages(dirPath, recursive, sizeSuffixes) { const collected = []; const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory() && recursive) { collected.push(...collectImages(fullPath, true, sizeSuffixes)); continue; } if (!entry.isFile()) continue; const ext = path.extname(entry.name).toLowerCase(); if (!supportedExts.has(ext)) continue; if (isVariantFile(entry.name, sizeSuffixes)) continue; collected.push(fullPath); } return collected; } function collectBaseImageRecords(dirPath, recursive, sizeSuffixes) { if (!fs.existsSync(dirPath)) return []; return collectImages(dirPath, recursive, sizeSuffixes) .map((filePath) => ({ name: path.basename(filePath, path.extname(filePath)), ext: path.extname(filePath), relDir: path.relative(dirPath, path.dirname(filePath)), })) .sort((a, b) => a.name.localeCompare(b.name)); } function collectOutputFileNames(dirPath) { if (!fs.existsSync(dirPath)) return []; return fs.readdirSync(dirPath, { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name); } function readJsonManifest(manifestPath) { try { if (!fs.existsSync(manifestPath)) return []; const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function getBaseOutputRecords(outputFileNames, sizeSuffixes) { return outputFileNames .filter((fileName) => { const ext = path.extname(fileName).toLowerCase(); return supportedExts.has(ext) && !isVariantFile(fileName, sizeSuffixes); }) .map((fileName) => ({ name: path.basename(fileName, path.extname(fileName)), ext: path.extname(fileName), })) .sort((a, b) => a.name.localeCompare(b.name)); } function manifestKeyForRecord(record) { return record.relDir ? `${record.relDir}/${record.name}` : record.name; } function stripGeneratedSuffix(baseName, sizeSuffixes) { const sortedSuffixes = [...sizeSuffixes] .filter(Boolean) .sort((a, b) => b.length - a.length); for (const suffix of sortedSuffixes) { if (baseName.endsWith(suffix)) { return baseName.slice(0, -suffix.length); } } return baseName; } function collectGeneratedOutputFiles(dirPath, rootDir = dirPath) { if (!fs.existsSync(dirPath)) return []; const files = []; for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { files.push(...collectGeneratedOutputFiles(fullPath, rootDir)); continue; } if (!entry.isFile()) continue; const ext = path.extname(entry.name).toLowerCase(); if (!new Set([...supportedExts, '.webp', '.avif']).has(ext)) continue; files.push({ path: fullPath, fileName: entry.name, relDir: path.relative(rootDir, path.dirname(fullPath)), }); } return files; } function removeEmptyDirs(dirPath, rootDir) { if (!fs.existsSync(dirPath) || dirPath === rootDir) return; if (fs.readdirSync(dirPath).length > 0) return; fs.rmdirSync(dirPath); removeEmptyDirs(path.dirname(dirPath), rootDir); } function pruneGeneratedAssets(config, sourceRecords, sizeSuffixes) { const sourceKeys = new Set(sourceRecords.map(manifestKeyForRecord)); let removed = 0; for (const outputFile of collectGeneratedOutputFiles(config.outputDir)) { const baseName = stripGeneratedSuffix( path.basename(outputFile.fileName, path.extname(outputFile.fileName)), sizeSuffixes, ); const key = outputFile.relDir ? `${outputFile.relDir}/${baseName}` : baseName; if (sourceKeys.has(key)) continue; fs.rmSync(outputFile.path); removeEmptyDirs(path.dirname(outputFile.path), config.outputDir); removed++; } if (removed > 0) { console.log(`[${config.name}] ${removed} verwaiste Bilddatei(en) entfernt`); } } export function buildImageManifestEntries({ existingManifest = [], outputFileNames = [], sourceRecords = [], sizeSuffixes = [], altTextsByName = altTexts, sourceIsComplete = false, } = {}) { const outputRecords = getBaseOutputRecords(outputFileNames, sizeSuffixes); const outputNames = new Set(outputRecords.map(manifestKeyForRecord)); const sourceNames = new Set(sourceRecords.map(manifestKeyForRecord)); const sourceByName = new Map(sourceRecords.map((record) => [manifestKeyForRecord(record), record])); const outputByName = new Map(outputRecords.map((record) => [manifestKeyForRecord(record), record])); const webpNames = new Set(outputFileNames .filter((fileName) => path.extname(fileName).toLowerCase() === '.webp') .map((fileName) => path.basename(fileName, path.extname(fileName)).replace(/-(sm|md|mobile)$/i, ''))); const avifNames = new Set(outputFileNames .filter((fileName) => path.extname(fileName).toLowerCase() === '.avif') .map((fileName) => path.basename(fileName, path.extname(fileName)).replace(/-(sm|md|mobile)$/i, ''))); const entries = new Map(); const addEntry = (record, existing = {}) => { if (!record?.name || !record?.ext) return; const key = manifestKeyForRecord(record); entries.set(key, { name: record.name, ext: record.ext, alt: existing.alt ?? altTextsByName[record.name], webp: webpNames.has(record.name), avif: avifNames.has(record.name), }); }; for (const item of existingManifest) { if (!item?.name) continue; const key = manifestKeyForRecord(item); if (sourceIsComplete && !sourceNames.has(key)) continue; if (!sourceIsComplete && !outputNames.has(key) && !sourceNames.has(key)) continue; const record = sourceByName.get(key) ?? outputByName.get(key) ?? item; addEntry(record, item); } const defaultRecords = sourceIsComplete || existingManifest.length > 0 ? sourceRecords : outputRecords; for (const record of defaultRecords) { if (!entries.has(manifestKeyForRecord(record))) { addEntry(record); } } return [...entries.values()] .filter((entry) => entry.name && entry.ext) .sort((a, b) => a.name.localeCompare(b.name)); } async function collectOutputMetadata(outputDir, outputFileNames) { const metadata = new Map(); await Promise.all(outputFileNames.map(async (fileName) => { const ext = path.extname(fileName).toLowerCase(); if (![...supportedExts, '.webp', '.avif'].includes(ext)) return; const dimensions = await sharp(path.join(outputDir, fileName)).metadata(); if (!dimensions.width || !dimensions.height) return; metadata.set(fileName, { width: dimensions.width, height: dimensions.height, }); })); return metadata; } export function addImageManifestMetadata({ entries = [], outputMetadata = new Map(), sizes = [], fallbackExtension = '.jpg', } = {}) { return entries.flatMap((entry) => { const variantsForExtension = (extension) => sizes.flatMap(({ suffix }) => { const file = `${entry.name}${suffix}${extension}`; const dimensions = outputMetadata.get(file); return dimensions ? [{ file, ...dimensions }] : []; }); const fallback = variantsForExtension(fallbackExtension); const base = fallback.find((variant) => variant.file === `${entry.name}${fallbackExtension}`) ?? fallback.at(-1); if (!base) return []; return [{ ...entry, width: base.width, height: base.height, variants: { fallback, webp: variantsForExtension('.webp'), avif: variantsForExtension('.avif'), }, }]; }); } async function writeImageManifest(config, outDir, sourceRecords, sizeSuffixes, sourceIsComplete) { const jsonName = config.jsonFileName || 'images.json'; const jsonPath = path.join(outDir, jsonName); const outputFileNames = collectOutputFileNames(outDir); const entries = buildImageManifestEntries({ existingManifest: readJsonManifest(jsonPath), outputFileNames, sourceRecords, sizeSuffixes, sourceIsComplete, }); const fallbackExtension = config.formats.includes('original') ? entries[0]?.ext : '.jpg'; const outputMetadata = await collectOutputMetadata(outDir, outputFileNames); const imagesList = addImageManifestMetadata({ entries, outputMetadata, sizes: config.sizes, fallbackExtension, }); fs.writeFileSync(jsonPath, JSON.stringify(imagesList, null, 2)); } function fileHash(filePath) { return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex'); } function getEncoder(extLower) { if (extLower === '.png') { return (pipeline) => pipeline.png({ compressionLevel: quality.png, adaptiveFiltering: true }); } return (pipeline) => pipeline.jpeg({ quality: quality.jpeg, progressive: true, mozjpeg: true }); } function getFormatPriority(format) { if (format === 'original' || format === 'jpg') return 0; if (format === 'webp') return 1; if (format === 'avif') return 2; return 3; } function replaceFile(fromPath, toPath) { if (fs.existsSync(toPath)) { fs.rmSync(toPath); } fs.renameSync(fromPath, toPath); } function copyFileWithParents(sourcePath, destinationPath) { fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.copyFileSync(sourcePath, destinationPath); } function pruneCachedOriginals(config, sourceRecords) { if (!CACHED_ORIGINALS_ROOT) return; const cachedDir = path.join(CACHED_ORIGINALS_ROOT, config.name); const sourceKeys = new Set(sourceRecords.map(manifestKeyForRecord)); for (const cachedFile of collectGeneratedOutputFiles(cachedDir)) { const baseName = path.basename(cachedFile.fileName, path.extname(cachedFile.fileName)); const key = cachedFile.relDir ? `${cachedFile.relDir}/${baseName}` : baseName; if (sourceKeys.has(key)) continue; fs.rmSync(cachedFile.path); removeEmptyDirs(path.dirname(cachedFile.path), cachedDir); } } function mirrorCachedOutput(config, outDir) { if (!CACHED_OUTPUTS_ROOT) return; // These directories contain only generated variants. Replacing them makes a // removed Git source disappear from the image that is being built as well. fs.rmSync(config.outputDir, { recursive: true, force: true }); fs.mkdirSync(path.dirname(config.outputDir), { recursive: true }); fs.cpSync(outDir, config.outputDir, { recursive: true }); } function hasFallbackVariants(config, fileOutDir, baseName, ext) { const fallbackFormat = config.formats.includes('original') ? 'original' : 'jpg'; const fallbackExt = fallbackFormat === 'original' ? ext : '.jpg'; return config.sizes.every((size) => fs.existsSync(path.join(fileOutDir, `${baseName}${size.suffix}${fallbackExt}`)) ); } async function runPipeline(config) { if (!fs.existsSync(config.sourceDir)) { const outDir = config.outputDir ?? config.sourceDir; const hasGeneratedAssets = fs.existsSync(outDir); if (REQUIRE_IMAGE_SOURCES || !hasGeneratedAssets) { throw new Error(`[${config.name}] Quellverzeichnis fehlt: ${config.sourceDir}`); } console.warn( `[${config.name}] Quellverzeichnis fehlt (${config.sourceDir}). ` + `Verwende vorhandene optimierte Assets in ${outDir}.` ); return; } const outDir = CACHED_OUTPUTS_ROOT ? path.join(CACHED_OUTPUTS_ROOT, config.outputRelativeDir) : (config.outputDir ?? config.sourceDir); if (!fs.existsSync(outDir)) { fs.mkdirSync(outDir, { recursive: true }); } if (!fs.existsSync(CACHE_ROOT)) { fs.mkdirSync(CACHE_ROOT, { recursive: true }); } // Manifest for hash caching outside the public asset tree const manifestPath = path.join(CACHE_ROOT, `${config.name}.json`); let manifest = {}; try { if (fs.existsSync(manifestPath)) { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } } catch { manifest = {}; } const sizeSuffixes = config.sizes.map((s) => s.suffix); const files = collectImages(config.sourceDir, config.recursive, sizeSuffixes); const sourceRecords = collectBaseImageRecords(config.sourceDir, config.recursive, sizeSuffixes); const sourceIsComplete = fs.existsSync(config.sourceDir); const outputConfig = { ...config, outputDir: outDir }; let manifestChanged = false; if (SHOULD_PRUNE && sourceIsComplete) { pruneGeneratedAssets(outputConfig, sourceRecords, sizeSuffixes); pruneCachedOriginals(config, sourceRecords); for (const key of Object.keys(manifest)) { if (!sourceRecords.some((record) => manifestKeyForRecord(record) === key)) { delete manifest[key]; manifestChanged = true; } } } if (files.length === 0) { console.log(`[${config.name}] Keine Bilder gefunden.`); if (config.generateJson) { await writeImageManifest(config, outDir, sourceRecords, sizeSuffixes, sourceIsComplete); } if (manifestChanged) { fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); } mirrorCachedOutput(config, outDir); return; } console.log(`[${config.name}] ${files.length} Bilder gefunden`); let processed = 0; for (const inputPath of files) { const file = path.basename(inputPath); const ext = path.extname(file); const extLower = ext.toLowerCase(); const baseName = path.basename(file, ext); const relDir = path.relative(config.sourceDir, path.dirname(inputPath)); const fileOutDir = relDir ? path.join(outDir, relDir) : outDir; const hash = fileHash(inputPath); const manifestKey = relDir ? `${relDir}/${baseName}` : baseName; if (CACHED_ORIGINALS_ROOT) { const cachedOriginalPath = path.join(CACHED_ORIGINALS_ROOT, config.name, relDir, file); if (manifest[manifestKey]?.hash !== hash || !fs.existsSync(cachedOriginalPath)) { copyFileWithParents(inputPath, cachedOriginalPath); } } if ( manifest[manifestKey]?.hash === hash && manifest[manifestKey]?.optimized && manifest[manifestKey]?.version === IMAGE_PIPELINE_VERSION && hasFallbackVariants(config, fileOutDir, baseName, ext) ) { continue; } if (!fs.existsSync(fileOutDir)) { fs.mkdirSync(fileOutDir, { recursive: true }); } let totalVariantSize = 0; const originalSize = fs.statSync(inputPath).size; const orderedFormats = [...config.formats].sort((left, right) => getFormatPriority(left) - getFormatPriority(right)); for (const size of config.sizes) { const resizeOpts = { width: size.width, height: size.height, fit: 'inside', withoutEnlargement: true }; let fallbackVariantSize = Number.POSITIVE_INFINITY; for (const format of orderedFormats) { let outputPath; let pipeline = sharp(inputPath).rotate().resize(resizeOpts); if (format === 'original') { outputPath = path.join(fileOutDir, `${baseName}${size.suffix}${ext}`); pipeline = getEncoder(extLower)(pipeline); } else if (format === 'jpg') { outputPath = path.join(fileOutDir, `${baseName}${size.suffix}.jpg`); pipeline = pipeline.jpeg({ quality: quality.jpeg, progressive: true, mozjpeg: true }); } else if (format === 'webp') { outputPath = path.join(fileOutDir, `${baseName}${size.suffix}.webp`); pipeline = pipeline.webp({ quality: quality.webp }); } else if (format === 'avif') { outputPath = path.join(fileOutDir, `${baseName}${size.suffix}.avif`); pipeline = pipeline.avif({ quality: quality.avif }); } const tempPath = `${outputPath}.tmp`; if (fs.existsSync(tempPath)) { fs.rmSync(tempPath); } await pipeline.toFile(tempPath); const candidateSize = fs.statSync(tempPath).size; const existingSize = fs.existsSync(outputPath) ? fs.statSync(outputPath).size : null; const isDerivedVariant = format === 'webp' || format === 'avif'; if (isDerivedVariant && Number.isFinite(fallbackVariantSize) && candidateSize >= fallbackVariantSize) { fs.rmSync(tempPath); if (existingSize !== null && existingSize < fallbackVariantSize) { totalVariantSize += existingSize; } else if (existingSize !== null && SHOULD_PRUNE) { fs.rmSync(outputPath); } continue; } if (existingSize !== null && existingSize <= candidateSize) { fs.rmSync(tempPath); totalVariantSize += existingSize; if (format === 'original' || format === 'jpg') { fallbackVariantSize = existingSize; } continue; } replaceFile(tempPath, outputPath); totalVariantSize += candidateSize; if (format === 'original' || format === 'jpg') { fallbackVariantSize = candidateSize; } } } manifest[manifestKey] = { hash, optimized: true, originalSize, version: IMAGE_PIPELINE_VERSION, timestamp: new Date().toISOString(), }; manifestChanged = true; if (config.deleteOriginal) { fs.rmSync(inputPath); } if (totalVariantSize < originalSize) { const saving = (((originalSize - totalVariantSize) / originalSize) * 100).toFixed(1); console.log(` ${file} → ${saving}% Ersparnis`); } else { console.log(` ${file} → Varianten aktualisiert`); } processed++; } // Generate JSON manifest if needed if (config.generateJson) { await writeImageManifest(config, outDir, sourceRecords, sizeSuffixes, sourceIsComplete); } if (manifestChanged) { fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); } mirrorCachedOutput(config, outDir); if (processed > 0) { console.log(`[${config.name}] ${processed} Bild(er) optimiert`); } else { console.log(`[${config.name}] Alle Bilder aktuell`); } } // Run all pipelines if (path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) { for (const pipeline of pipelines) { await runPipeline(pipeline); } }