fix: performance
This commit is contained in:
1 parent
108a686d7c
commit
f516438de3
19 files changed
+188
-171
No files matched your search
@@ -14,6 +14,7 @@ dist
|
||||
.npm-cache
|
||||
coverage
|
||||
.eslintcache
|
||||
src/assets/optimized
|
||||
|
||||
# Lokale Log- und Debug-Dateien
|
||||
npm-debug.log*
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# Generierte Build- und Test-Ausgaben
|
||||
/dist
|
||||
/coverage
|
||||
/src/assets/optimized
|
||||
|
||||
# Betriebssystem-Dateileichen
|
||||
.DS_Store
|
||||
|
||||
+4
-4
@@ -26,8 +26,8 @@
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "src/assets",
|
||||
"output": "assets"
|
||||
"input": "src/assets/optimized",
|
||||
"output": "assets/optimized"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
@@ -108,8 +108,8 @@
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "src/assets",
|
||||
"output": "assets"
|
||||
"input": "src/assets/optimized",
|
||||
"output": "assets/optimized"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
|
||||
+4
-4
@@ -25,11 +25,11 @@
|
||||
"fix": "npm run version:place && npm run lint:fix && npm run text:fix && npm run format",
|
||||
"------ BUILD": "",
|
||||
"generate:static-files": "node scripts/generate-static-files.cjs",
|
||||
"generate:assets": "npm run generate:static-files",
|
||||
"compress:assets": "node scripts/compress-assets.cjs",
|
||||
"build": "npm run generate:assets && ng build --configuration production && npm run compress:assets && node scripts/copy-static-root-files.cjs",
|
||||
"generate:optimized-assets": "node scripts/generate-optimized-assets.cjs",
|
||||
"generate:assets": "npm run generate:static-files && npm run generate:optimized-assets",
|
||||
"build": "npm run generate:assets && ng build --configuration production && node scripts/copy-static-root-files.cjs",
|
||||
"------ STARTUP": "",
|
||||
"test": "ng test",
|
||||
"test": "npm run generate:assets && ng test",
|
||||
"dev": "npm run generate:assets && ng serve",
|
||||
"start": "node dist/server/server.mjs",
|
||||
"------ CONFIGS": "",
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const distAssetRoots = [
|
||||
path.join(process.cwd(), 'dist', 'assets'),
|
||||
path.join(process.cwd(), 'dist', 'browser', 'assets'),
|
||||
];
|
||||
|
||||
const losslessTransformers = {
|
||||
'.jpg': (pipeline) =>
|
||||
pipeline.jpeg({
|
||||
quality: 90,
|
||||
mozjpeg: true,
|
||||
chromaSubsampling: '4:4:4',
|
||||
optimizeScans: true,
|
||||
}),
|
||||
'.jpeg': (pipeline) =>
|
||||
pipeline.jpeg({
|
||||
quality: 90,
|
||||
mozjpeg: true,
|
||||
chromaSubsampling: '4:4:4',
|
||||
optimizeScans: true,
|
||||
}),
|
||||
'.png': (pipeline) => pipeline.png({ compressionLevel: 9, effort: 10 }),
|
||||
'.gif': (pipeline) => pipeline.gif({ effort: 10, colours: 256 }),
|
||||
'.webp': (pipeline) => pipeline.webp({ lossless: true, effort: 6 }),
|
||||
'.avif': (pipeline) => pipeline.avif({ lossless: true, effort: 9 }),
|
||||
'.tif': (pipeline) => pipeline.tiff({ compression: 'lzw' }),
|
||||
'.tiff': (pipeline) => pipeline.tiff({ compression: 'lzw' }),
|
||||
};
|
||||
|
||||
const imageExtensions = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.webp',
|
||||
'.avif',
|
||||
'.gif',
|
||||
'.svg',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
'.bmp',
|
||||
]);
|
||||
|
||||
const skippedNoLosslessEncoder = new Set(['.svg', '.bmp']);
|
||||
|
||||
function getAllFilesRecursively(directory) {
|
||||
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...getAllFilesRecursively(fullPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function compressLossless(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const transform = losslessTransformers[ext];
|
||||
|
||||
if (!transform) {
|
||||
return { status: 'unsupported', beforeBytes: 0, afterBytes: 0 };
|
||||
}
|
||||
|
||||
const beforeBuffer = fs.readFileSync(filePath);
|
||||
const beforeBytes = beforeBuffer.byteLength;
|
||||
const optimizedBuffer = await transform(sharp(beforeBuffer)).toBuffer();
|
||||
const afterBytes = optimizedBuffer.byteLength;
|
||||
|
||||
if (afterBytes < beforeBytes) {
|
||||
fs.writeFileSync(filePath, optimizedBuffer);
|
||||
return { status: 'optimized', beforeBytes, afterBytes };
|
||||
}
|
||||
|
||||
return { status: 'unchanged', beforeBytes, afterBytes: beforeBytes };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const existingRoots = distAssetRoots
|
||||
.filter((rootPath) => fs.existsSync(rootPath))
|
||||
.filter((rootPath, index, list) => list.indexOf(rootPath) === index);
|
||||
|
||||
if (existingRoots.length === 0) {
|
||||
console.log('Asset compression skipped: no dist asset directory found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageFiles = existingRoots
|
||||
.flatMap((rootPath) => getAllFilesRecursively(rootPath))
|
||||
.filter((filePath) => imageExtensions.has(path.extname(filePath).toLowerCase()));
|
||||
|
||||
let optimizedCount = 0;
|
||||
let unchangedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let totalSavedBytes = 0;
|
||||
|
||||
for (const filePath of imageFiles) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (skippedNoLosslessEncoder.has(ext)) {
|
||||
skippedCount += 1;
|
||||
console.log(
|
||||
`Skip (no lossless encoder configured): ${path.relative(process.cwd(), filePath)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await compressLossless(filePath);
|
||||
if (result.status === 'optimized') {
|
||||
optimizedCount += 1;
|
||||
const savedBytes = result.beforeBytes - result.afterBytes;
|
||||
totalSavedBytes += savedBytes;
|
||||
console.log(`Optimized: ${path.relative(process.cwd(), filePath)} (-${savedBytes} bytes)`);
|
||||
} else if (result.status === 'unchanged') {
|
||||
unchangedCount += 1;
|
||||
console.log(`Unchanged: ${path.relative(process.cwd(), filePath)}`);
|
||||
} else {
|
||||
skippedCount += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
skippedCount += 1;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Skip (error): ${path.relative(process.cwd(), filePath)} (${message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Asset compression complete. Optimized: ${optimizedCount}, unchanged: ${unchangedCount}, skipped: ${skippedCount}, saved: ${totalSavedBytes} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
run()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
console.error(`Asset compression failed: ${message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const sourceRoot = path.join(process.cwd(), 'src', 'assets', 'unoptimized');
|
||||
const targetRoot = path.join(process.cwd(), 'src', 'assets', 'optimized');
|
||||
|
||||
const rasterExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.tif', '.tiff']);
|
||||
const passthroughExtensions = new Set(['.svg']);
|
||||
|
||||
function collectFilesRecursively(directoryPath) {
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directoryPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectFilesRecursively(fullPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function ensureCleanDirectory(directoryPath) {
|
||||
fs.rmSync(directoryPath, { recursive: true, force: true });
|
||||
fs.mkdirSync(directoryPath, { recursive: true });
|
||||
}
|
||||
|
||||
function getProfile(relativePath) {
|
||||
const normalizedRelativePath = relativePath.split(path.sep).join('/').toLowerCase();
|
||||
|
||||
if (normalizedRelativePath === 'background.jpg' || normalizedRelativePath === 'background.jpeg') {
|
||||
return { maxWidth: 1920, quality: 82, alphaQuality: 100 };
|
||||
}
|
||||
|
||||
if (normalizedRelativePath.startsWith('logos/')) {
|
||||
return { maxWidth: 640, quality: 90, alphaQuality: 92, nearLossless: true };
|
||||
}
|
||||
|
||||
if (normalizedRelativePath.startsWith('portrait/')) {
|
||||
return { maxWidth: 1600, quality: 84, alphaQuality: 92 };
|
||||
}
|
||||
|
||||
return { maxWidth: 1600, quality: 82, alphaQuality: 92 };
|
||||
}
|
||||
|
||||
async function writeOptimizedAsset(sourceFilePath) {
|
||||
const relativePath = path.relative(sourceRoot, sourceFilePath);
|
||||
const extension = path.extname(sourceFilePath).toLowerCase();
|
||||
const targetRelativePath = passthroughExtensions.has(extension)
|
||||
? relativePath
|
||||
: relativePath.replace(/\.[^.]+$/, '.webp');
|
||||
const targetFilePath = path.join(targetRoot, targetRelativePath);
|
||||
|
||||
fs.mkdirSync(path.dirname(targetFilePath), { recursive: true });
|
||||
|
||||
if (passthroughExtensions.has(extension)) {
|
||||
fs.copyFileSync(sourceFilePath, targetFilePath);
|
||||
return {
|
||||
sourceRelativePath: relativePath,
|
||||
targetRelativePath,
|
||||
beforeBytes: fs.statSync(sourceFilePath).size,
|
||||
afterBytes: fs.statSync(targetFilePath).size,
|
||||
mode: 'copied',
|
||||
};
|
||||
}
|
||||
|
||||
const profile = getProfile(relativePath);
|
||||
const inputBuffer = fs.readFileSync(sourceFilePath);
|
||||
const inputBytes = inputBuffer.byteLength;
|
||||
const image = sharp(inputBuffer, { animated: false }).rotate();
|
||||
const metadata = await image.metadata();
|
||||
|
||||
let pipeline = image;
|
||||
if ((metadata.width ?? 0) > profile.maxWidth) {
|
||||
pipeline = pipeline.resize({
|
||||
width: profile.maxWidth,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
|
||||
const outputBuffer = await pipeline
|
||||
.webp({
|
||||
quality: profile.quality,
|
||||
alphaQuality: profile.alphaQuality,
|
||||
effort: 6,
|
||||
smartSubsample: true,
|
||||
nearLossless: profile.nearLossless ?? false,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
fs.writeFileSync(targetFilePath, outputBuffer);
|
||||
|
||||
return {
|
||||
sourceRelativePath: relativePath,
|
||||
targetRelativePath,
|
||||
beforeBytes: inputBytes,
|
||||
afterBytes: outputBuffer.byteLength,
|
||||
mode: 'converted',
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!fs.existsSync(sourceRoot)) {
|
||||
console.log('Optimized asset generation skipped: no unoptimized asset directory found.');
|
||||
return;
|
||||
}
|
||||
|
||||
ensureCleanDirectory(targetRoot);
|
||||
|
||||
const assetFiles = collectFilesRecursively(sourceRoot).filter((filePath) => {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
return rasterExtensions.has(extension) || passthroughExtensions.has(extension);
|
||||
});
|
||||
|
||||
let convertedCount = 0;
|
||||
let copiedCount = 0;
|
||||
let totalSavedBytes = 0;
|
||||
|
||||
for (const assetFile of assetFiles) {
|
||||
const result = await writeOptimizedAsset(assetFile);
|
||||
const savedBytes = result.beforeBytes - result.afterBytes;
|
||||
totalSavedBytes += savedBytes;
|
||||
|
||||
if (result.mode === 'copied') {
|
||||
copiedCount += 1;
|
||||
console.log(
|
||||
`Copied: ${result.sourceRelativePath} -> ${result.targetRelativePath} (${result.afterBytes} bytes)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
convertedCount += 1;
|
||||
console.log(
|
||||
`Optimized: ${result.sourceRelativePath} -> ${result.targetRelativePath} (${savedBytes >= 0 ? `-${savedBytes}` : `+${Math.abs(savedBytes)}`} bytes)`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Optimized asset generation complete. Converted: ${convertedCount}, copied: ${copiedCount}, net saved: ${totalSavedBytes} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
run()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
console.error(`Optimized asset generation failed: ${message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -7,5 +7,5 @@ import { RouterOutlet } from '@angular/router';
|
||||
templateUrl: './app.component.html',
|
||||
})
|
||||
export class AppComponent {
|
||||
title: string = 'Angular-Portfolio-Seite';
|
||||
title: string = 'Lechner Julian';
|
||||
}
|
||||
@@ -391,7 +391,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
? Number.POSITIVE_INFINITY
|
||||
: portfolioEyebrow.getBoundingClientRect().top + window.scrollY;
|
||||
const isMobileViewport: boolean = window.innerWidth <= 700;
|
||||
const mobileRevealDistance: number = window.innerHeight / 25;
|
||||
const mobileRevealDistance: number = window.innerHeight / 15;
|
||||
const triggerDistance: number = isMobileViewport
|
||||
? mobileRevealDistance
|
||||
: Math.max(portfolioTriggerY, 1);
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7
-7
@@ -74,27 +74,27 @@ export const global: Global = {
|
||||
],
|
||||
portraitHighlights: [
|
||||
{
|
||||
image: 'assets/portrait/me.png',
|
||||
image: 'assets/optimized/portrait/me.webp',
|
||||
text: 'ME',
|
||||
},
|
||||
{
|
||||
image: 'assets/portrait/love.jpg',
|
||||
image: 'assets/optimized/portrait/love.webp',
|
||||
text: 'My Love',
|
||||
},
|
||||
{
|
||||
image: 'assets/portrait/scuba.jpg',
|
||||
image: 'assets/optimized/portrait/scuba.webp',
|
||||
text: 'Scuba Diving',
|
||||
},
|
||||
{
|
||||
image: 'assets/portrait/trains.jpg',
|
||||
image: 'assets/optimized/portrait/trains.webp',
|
||||
text: 'Trains',
|
||||
},
|
||||
{
|
||||
image: 'assets/portrait/traveling.jpg',
|
||||
image: 'assets/optimized/portrait/traveling.webp',
|
||||
text: 'Traveling',
|
||||
},
|
||||
{
|
||||
image: 'assets/portrait/culture.jpg',
|
||||
image: 'assets/optimized/portrait/culture.webp',
|
||||
text: 'Culture',
|
||||
},
|
||||
],
|
||||
@@ -105,7 +105,7 @@ export const global: Global = {
|
||||
description:
|
||||
'I currently work primarily for the viennese company SobIT GmbH. This company develops software for the healthcare industry. - Development of modern desktop, mobile, and web applications to support healthcare delivery.',
|
||||
languages: ['TypeScript', 'C#', 'WPF', 'YML', 'XAML'],
|
||||
icon: 'assets/logos/sobit.png',
|
||||
icon: 'assets/optimized/logos/sobit.webp',
|
||||
CircleIcon: false,
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
|
||||
<link rel="icon" type="image/png" href="assets/portrait/me.png" />
|
||||
<link rel="icon" type="image/webp" href="assets/optimized/portrait/me.webp" />
|
||||
<title>Lechner Julian</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+5
-3
@@ -50,7 +50,7 @@ body {
|
||||
radial-gradient(circle at top left, rgba(217, 162, 140, 0.22), transparent 26%),
|
||||
radial-gradient(circle at bottom right, rgba(127, 78, 63, 0.28), transparent 28%),
|
||||
linear-gradient(180deg, rgba(9, 9, 12, 0.4), rgba(8, 8, 11, 0.82)),
|
||||
url('assets/background.jpg') center / cover fixed no-repeat;
|
||||
url('assets/optimized/background.webp') center / cover fixed no-repeat;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -916,6 +916,8 @@ hr {
|
||||
|
||||
.footer-inner a {
|
||||
color: var(--text-primary);
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -1009,8 +1011,8 @@ hr {
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
|
||||
Reference in new issue
Block a user