Initial commit
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
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
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user