/** * Erzeugt Poster und Hintergrundbilder für die Demo-Titel. * * Warum überhaupt: Flimmer ist eine posterlastige Anwendung. Ohne Bilder sieht * die Beispielbibliothek aus wie ein Fehler statt wie eine Bibliothek. Echte * TMDB-Poster lassen sich nicht mitliefern (Rechte, und es gäbe sie ohne * Schlüssel gar nicht), also entstehen hier abstrakte Poster im Flimmer-Look — * warmes Glühen auf dunklem Grund, Titel darauf. * * Die Dateien landen direkt im Bild-Cache unter demselben Namen, den * `src/lib/images.ts` berechnen würde. Damit funktioniert die Auslieferung über * /api/image ohne jede Sonderbehandlung. */ import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import sharp from "sharp"; /** Muss zu `cacheFile()` in src/lib/images.ts passen. */ function cacheFileName(size: string, path: string): string { const key = createHash("sha256").update(`${size}${path}`).digest("hex"); return `${key}${path.slice(path.lastIndexOf("."))}`; } /** Deterministischer Farbton je Titel — derselbe Titel bekommt immer dasselbe Poster. */ function hue(seed: string): number { const hash = createHash("sha256").update(seed).digest(); return (hash[0]! << 8 | hash[1]!) % 360; } function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** Bricht den Titel auf mehrere Zeilen um, damit er auf das Poster passt. */ function wrap(text: string, maxChars: number, maxLines: number): string[] { const words = text.split(/\s+/); const lines: string[] = []; let current = ""; for (const word of words) { const candidate = current ? `${current} ${word}` : word; if (candidate.length <= maxChars) { current = candidate; } else { if (current) lines.push(current); current = word; } } if (current) lines.push(current); if (lines.length > maxLines) { const kept = lines.slice(0, maxLines); kept[maxLines - 1] = `${kept[maxLines - 1]!.slice(0, maxChars - 1)}…`; return kept; } return lines; } type PosterInput = { title: string; subtitle: string; seed: string; }; function posterSvg({ title, subtitle, seed }: PosterInput, width: number, height: number): string { const base = hue(seed); const warm = `hsl(${(base + 20) % 360} 92% 62%)`; const cool = `hsl(${(base + 320) % 360} 88% 58%)`; const scale = width / 342; const fontSize = Math.round(30 * scale); const lines = wrap(title, 17, 3); const blockBottom = height - Math.round(52 * scale); const firstLineY = blockBottom - (lines.length - 1) * Math.round(fontSize * 1.14); return ` ${Array.from({ length: 7 }, (_, index) => { const r = (0.18 + index * 0.13) * width; return ``; }).join("\n ")} ${lines .map( (line, index) => `${escapeXml(line)}`, ) .join("\n ")} ${escapeXml(subtitle)} `; } function backdropSvg({ seed }: PosterInput, width: number, height: number): string { const base = hue(seed); const warm = `hsl(${(base + 20) % 360} 92% 62%)`; const cool = `hsl(${(base + 320) % 360} 88% 58%)`; return ` `; } /** Größen, die das UI anfragt. */ const POSTER_SIZES: { size: string; width: number }[] = [ { size: "w92", width: 92 }, { size: "w154", width: 154 }, { size: "w185", width: 185 }, { size: "w342", width: 342 }, { size: "w500", width: 500 }, ]; const BACKDROP_SIZES: { size: string; width: number }[] = [ { size: "w780", width: 780 }, { size: "w1280", width: 1280 }, ]; export type GeneratedArt = { posterPath: string; backdropPath: string }; /** * Erzeugt Poster und Backdrop für einen Titel und legt sie im Bild-Cache ab. * Zurück kommen die TMDB-artigen Pfade, die in der Datenbank landen. */ export async function generateArtwork( dataDir: string, input: PosterInput, ): Promise { const stem = createHash("sha256").update(input.seed).digest("hex").slice(0, 24); const posterPath = `/${stem}p.jpg`; const backdropPath = `/${stem}b.jpg`; const dir = join(dataDir, "images"); await mkdir(dir, { recursive: true }); for (const { size, width } of POSTER_SIZES) { const height = Math.round(width * 1.5); const svg = posterSvg(input, width, height); const buffer = await sharp(Buffer.from(svg)).jpeg({ quality: 88 }).toBuffer(); await writeFile(join(dir, cacheFileName(size, posterPath)), buffer); } for (const { size, width } of BACKDROP_SIZES) { const height = Math.round((width * 9) / 16); const svg = backdropSvg(input, width, height); const buffer = await sharp(Buffer.from(svg)).jpeg({ quality: 84 }).toBuffer(); await writeFile(join(dir, cacheFileName(size, backdropPath)), buffer); } return { posterPath, backdropPath }; }