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
642 lines
22 KiB
JavaScript
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);
|
|
}
|
|
}
|