From a2bdbf9035427adf1645c9f70eb9e7651d8de490 Mon Sep 17 00:00:00 2001 From: Kenzo Date: Tue, 11 Aug 2026 13:46:27 +0200 Subject: [PATCH] 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) --- backend/src/scripts/convert-images-to-avif.ts | 218 ++++++++++++++++++ backend/src/services/image-refs.service.ts | 24 ++ backend/src/services/upload.service.ts | 38 ++- 3 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 backend/src/scripts/convert-images-to-avif.ts diff --git a/backend/src/scripts/convert-images-to-avif.ts b/backend/src/scripts/convert-images-to-avif.ts new file mode 100644 index 0000000..a03b18f --- /dev/null +++ b/backend/src/scripts/convert-images-to-avif.ts @@ -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 { + 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 { + const byUrl = new Map(); + + 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(); + + 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); + }); diff --git a/backend/src/services/image-refs.service.ts b/backend/src/services/image-refs.service.ts index 875e80a..0e4bfea 100644 --- a/backend/src/services/image-refs.service.ts +++ b/backend/src/services/image-refs.service.ts @@ -69,6 +69,30 @@ export async function collectReferencedImageUrls(): Promise> { 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): 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 = {}; + 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 { const out = new Set(); diff --git a/backend/src/services/upload.service.ts b/backend/src/services/upload.service.ts index 5e2f1e9..27f975f 100644 --- a/backend/src/services/upload.service.ts +++ b/backend/src/services/upload.service.ts @@ -69,9 +69,6 @@ export async function saveUploadedImage( ): Promise { 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 { + 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);