Files
Goldebek/scripts/optimize-images.mjs
T
Per Hoener 0d45331f5c
Deploy / validate-dispatch (push) Successful in 43s
Deploy / scheduled-rebuild-dev (push) Skipped
Deploy / scheduled-rebuild-master (push) Skipped
Deploy / rollback-dev (push) Skipped
Deploy / rollback-master (push) Skipped
Deploy / changes (push) Successful in 13s
Deploy / secret-scan (push) Successful in 9s
Deploy / app-quality (push) Successful in 11m21s
Deploy / security-deps (push) Successful in 14s
Deploy / context (push) Failing after 1s
Deploy / app-image (push) Skipped
Deploy / deploy-dev (push) Skipped
Deploy / deploy-master (push) Skipped
Initial commit
2026-09-18 23:37:19 +02:00

642 lines
22 KiB
JavaScript

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);
}
}