/**
* 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 ``;
}
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 };
}