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);