feat(backend): Skript zur Umwandlung des Altbestands nach AVIF
Bilder, die vor der Umstellung hochgeladen wurden, liegen unverkleinert im Originalformat im Repo - damals war sharp im Container kaputt, es gab also weder Verkleinerung noch Formatwandlung. Das Skript schickt sie durch dieselbe Verarbeitung wie einen frischen Upload. node dist/scripts/convert-images-to-avif.js # nur anzeigen node dist/scripts/convert-images-to-avif.js --apply # wirklich tun Ohne --apply wird nichts geschrieben. Ablauf: Workspace auf den Repo-Stand bringen, jedes referenzierte Bild umwandeln und nach seinem Inhalt benennen (Event-Titel, Alt-Text, Zweck), die Verweise in Events, Gallery und Textbereichen nachziehen, die Vorgaenger wegraeumen und committen. Weggeraeumt wird nur, was das CMS selbst angelegt hat. Ein handgepflegtes event_karaoke.jpg bekommt zwar eine AVIF-Fassung und der Verweis zeigt darauf, das Original bleibt aber liegen und wird im Bericht genannt. saveImageBuffer aus upload.service.ts herausgeloest, damit Upload und Skript nachweislich dieselbe Verarbeitung benutzen statt zweier Kopien. replaceImageUrls ersetzt Pfade in den Textbereichen, ohne die restliche JSON-Struktur anzufassen. Durchgespielt an vier Bildern zu je ~656 KB: 2.56 MB -> 0.16 MB. Die Vorschau schreibt nichts, ein zweiter Lauf findet nichts mehr, die Highlights-Liste bleibt unversehrt und alle Bilder werden danach als image/avif ausgeliefert. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Einmalige Umwandlung des Altbestands nach AVIF.
|
||||
*
|
||||
* Bilder, die vor der Umstellung hochgeladen wurden, liegen unverkleinert
|
||||
* und im Originalformat im Repo - damals war sharp im Container kaputt, es
|
||||
* gab also weder Verkleinerung noch Formatwandlung. Dieses Skript schickt
|
||||
* sie durch dieselbe Verarbeitung wie einen frischen Upload, benennt sie
|
||||
* nach ihrem Inhalt und zieht die Verweise in der Datenbank mit.
|
||||
*
|
||||
* Aufruf im Container:
|
||||
* node dist/scripts/convert-images-to-avif.js # nur anzeigen
|
||||
* node dist/scripts/convert-images-to-avif.js --apply # wirklich tun
|
||||
*
|
||||
* Ohne --apply wird nichts geschrieben.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, initDatabase } from '../config/database.js';
|
||||
import { events, galleryImages, contentSections } from '../db/schema.js';
|
||||
import { GitService } from '../services/git.service.js';
|
||||
import { AssetService, MANAGED_IMAGE_DIRS } from '../services/asset.service.js';
|
||||
import { saveImageBuffer, UploadSubdir } from '../services/upload.service.js';
|
||||
import { replaceImageUrls, dropImageIfUnused } from '../services/image-refs.service.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
const assets = new AssetService();
|
||||
|
||||
interface Candidate {
|
||||
url: string;
|
||||
preferredName: string;
|
||||
}
|
||||
|
||||
/** /images/events/foo.jpg -> events */
|
||||
function subdirOf(url: string): UploadSubdir | null {
|
||||
const match = /^\/images\/(events|gallery|content)\//.exec(url);
|
||||
return match ? (match[1] as UploadSubdir) : null;
|
||||
}
|
||||
|
||||
function absolutePathOf(url: string): string {
|
||||
return path.join(env.GIT_WORKSPACE_DIR, 'public', url.replace(/^\/+/, ''));
|
||||
}
|
||||
|
||||
/** Sprechender Name fuer ein Bild aus einem Textbereich. */
|
||||
function contentImageName(section: string, key: string, content: any): string {
|
||||
if (section === 'welcome' && key === 'imageUrl') return 'willkommen';
|
||||
if (section === 'drinks' && key === 'monthlySpecialImage') {
|
||||
return content?.monthlySpecialName || 'monats-hit';
|
||||
}
|
||||
const whiskey = /^whiskeyImage(\d)$/.exec(key);
|
||||
if (whiskey) return `whiskey-${whiskey[1]}`;
|
||||
return `${section}-bild`;
|
||||
}
|
||||
|
||||
function asObject(value: any): Record<string, any> {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
async function collectCandidates(): Promise<Candidate[]> {
|
||||
const byUrl = new Map<string, string>();
|
||||
|
||||
const remember = (url: any, name: string) => {
|
||||
if (typeof url !== 'string' || !url.startsWith('/images/')) return;
|
||||
if (!byUrl.has(url)) byUrl.set(url, name);
|
||||
};
|
||||
|
||||
for (const row of (await db.select().from(events)) as any[]) {
|
||||
remember(row.imageUrl, row.title);
|
||||
}
|
||||
|
||||
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
||||
remember(row.imageUrl, row.altText);
|
||||
}
|
||||
|
||||
for (const row of (await db.select().from(contentSections)) as any[]) {
|
||||
const content = asObject(row.contentJson);
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
remember(value, contentImageName(row.sectionName, key, content));
|
||||
}
|
||||
}
|
||||
|
||||
return [...byUrl.entries()].map(([url, preferredName]) => ({ url, preferredName }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(APPLY ? '=== Umwandlung nach AVIF ===' : '=== Vorschau (nichts wird geschrieben) ===\n');
|
||||
|
||||
initDatabase();
|
||||
|
||||
// Workspace auf den Stand des Repos bringen
|
||||
const git = new GitService();
|
||||
await git.initialize();
|
||||
console.log(`Workspace: ${env.GIT_WORKSPACE_DIR}\n`);
|
||||
|
||||
const candidates = await collectCandidates();
|
||||
const mapping = new Map<string, string>();
|
||||
|
||||
let bytesBefore = 0;
|
||||
let bytesAfter = 0;
|
||||
let skipped = 0;
|
||||
let missing = 0;
|
||||
|
||||
for (const { url, preferredName } of candidates) {
|
||||
const subdir = subdirOf(url);
|
||||
|
||||
if (!subdir) {
|
||||
console.log(` uebersprungen (ausserhalb der Upload-Ordner): ${url}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.toLowerCase().endsWith('.avif')) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = absolutePathOf(url);
|
||||
if (!assets.resolveInDirs(url, MANAGED_IMAGE_DIRS) || !fs.existsSync(source)) {
|
||||
console.log(` FEHLT auf der Platte, Verweis bleibt: ${url}`);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const input = fs.readFileSync(source);
|
||||
bytesBefore += input.length;
|
||||
|
||||
if (!APPLY) {
|
||||
console.log(` ${url} (${(input.length / 1024).toFixed(0)} KB) -> benannt nach "${preferredName}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const saved = await saveImageBuffer(input, subdir, { preferredName });
|
||||
const outSize = fs.statSync(absolutePathOf(saved.imageUrl)).size;
|
||||
bytesAfter += outSize;
|
||||
mapping.set(url, saved.imageUrl);
|
||||
|
||||
console.log(
|
||||
` ${url} ${(input.length / 1024).toFixed(0)} KB -> ${saved.imageUrl} ${(outSize / 1024).toFixed(0)} KB`
|
||||
);
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.log(`\n${candidates.length - skipped - missing} Bild(er) wuerden umgewandelt.`);
|
||||
console.log(`Gesamtgroesse aktuell: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log('\nZum Ausfuehren: node dist/scripts/convert-images-to-avif.js --apply');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mapping.size === 0) {
|
||||
console.log('\nNichts umzuwandeln.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verweise in der Datenbank nachziehen
|
||||
for (const row of (await db.select().from(events)) as any[]) {
|
||||
const next = mapping.get(row.imageUrl);
|
||||
if (next) await db.update(events).set({ imageUrl: next }).where(eq(events.id, row.id));
|
||||
}
|
||||
|
||||
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
||||
const next = mapping.get(row.imageUrl);
|
||||
if (next) await db.update(galleryImages).set({ imageUrl: next }).where(eq(galleryImages.id, row.id));
|
||||
}
|
||||
|
||||
for (const row of (await db.select().from(contentSections)) as any[]) {
|
||||
const updated = replaceImageUrls(asObject(row.contentJson), mapping);
|
||||
await db
|
||||
.update(contentSections)
|
||||
.set({ contentJson: updated, updatedAt: new Date() })
|
||||
.where(eq(contentSections.sectionName, row.sectionName));
|
||||
}
|
||||
|
||||
console.log('\nVerweise in der Datenbank aktualisiert.');
|
||||
|
||||
// Vorgaenger wegraeumen - dropImageIfUnused loescht nur, was das CMS
|
||||
// selbst angelegt hat. Handgepflegte Dateien wie event_karaoke.jpg
|
||||
// bleiben liegen, obwohl jetzt eine AVIF-Fassung existiert.
|
||||
let removed = 0;
|
||||
let kept = 0;
|
||||
|
||||
for (const oldUrl of mapping.keys()) {
|
||||
if (await dropImageIfUnused(oldUrl)) {
|
||||
removed++;
|
||||
} else if (fs.existsSync(absolutePathOf(oldUrl))) {
|
||||
console.log(` Original behalten (nicht vom CMS angelegt): ${oldUrl}`);
|
||||
kept++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${mapping.size} Bild(er) umgewandelt, ${removed} Original(e) entfernt, ${kept} behalten.`);
|
||||
console.log(
|
||||
`Groesse: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB -> ${(bytesAfter / 1024 / 1024).toFixed(2)} MB` +
|
||||
` (${(100 - (bytesAfter / bytesBefore) * 100).toFixed(1)} % gespart)`
|
||||
);
|
||||
|
||||
const hash = await git.commitAndPush('Bestandsbilder nach AVIF umgewandelt');
|
||||
console.log(`\nCommit: ${hash}`);
|
||||
console.log('Fertig. Woodpecker baut die Seite jetzt neu.');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error('\nFehlgeschlagen:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -69,6 +69,30 @@ export async function collectReferencedImageUrls(): Promise<Set<string>> {
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt Bildpfade in einem beliebigen Content-JSON anhand einer Zuordnung
|
||||
* alt -> neu. Die Struktur bleibt dabei unveraendert.
|
||||
*/
|
||||
export function replaceImageUrls(value: any, mapping: Map<string, string>): any {
|
||||
if (typeof value === 'string') {
|
||||
return mapping.get(value) ?? value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => replaceImageUrls(entry, mapping));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, any> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
out[key] = replaceImageUrls(entry, mapping);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Alle Bildpfade aus einem beliebigen Content-JSON. */
|
||||
export function extractImageUrls(value: any): Set<string> {
|
||||
const out = new Set<string>();
|
||||
|
||||
@@ -69,9 +69,6 @@ export async function saveUploadedImage(
|
||||
): Promise<SavedImage> {
|
||||
const { preferredName, log } = options;
|
||||
|
||||
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
@@ -87,6 +84,35 @@ export async function saveUploadedImage(
|
||||
throw error;
|
||||
}
|
||||
|
||||
return saveImageBuffer(inputBuffer, subdir, {
|
||||
preferredName: preferredName || originalBaseName(file),
|
||||
fallbackExtension: '.' + ((file.mimetype || '').split('/')[1] || 'bin')
|
||||
.replace(/[^a-z0-9]/gi, '').toLowerCase(),
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kern der Bildverarbeitung: verkleinern, nach AVIF wandeln, unter einem
|
||||
* sprechenden Namen ablegen und als CMS-eigene Datei vermerken.
|
||||
*
|
||||
* Wird sowohl vom Upload als auch vom Umwandlungsskript fuer den Altbestand
|
||||
* benutzt, damit beide Wege identisch arbeiten.
|
||||
*/
|
||||
export async function saveImageBuffer(
|
||||
inputBuffer: Buffer,
|
||||
subdir: UploadSubdir,
|
||||
options: {
|
||||
preferredName?: string;
|
||||
fallbackExtension?: string;
|
||||
log?: { warn: (obj: any, msg: string) => void };
|
||||
} = {}
|
||||
): Promise<SavedImage> {
|
||||
const { preferredName, fallbackExtension = '.bin', log } = options;
|
||||
|
||||
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
let outBuffer: Buffer;
|
||||
let outExt = '.avif';
|
||||
|
||||
@@ -101,13 +127,11 @@ export async function saveUploadedImage(
|
||||
} catch (err) {
|
||||
log?.warn({ err }, 'Sharp processing failed, using original image');
|
||||
outBuffer = inputBuffer;
|
||||
const extFromMime = (file.mimetype || '').split('/')[1] || 'bin';
|
||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
outExt = fallbackExtension;
|
||||
}
|
||||
|
||||
const base = slugify(preferredName || '') || slugify(originalBaseName(file));
|
||||
const urlDir = `/images/${subdir}`;
|
||||
const filename = findFreeName(base, outExt, urlDir, MANAGED_IMAGE_DIRS);
|
||||
const filename = findFreeName(slugify(preferredName || ''), outExt, urlDir, MANAGED_IMAGE_DIRS);
|
||||
|
||||
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user