Files
flimmer/prisma/demo/posters.ts
T
Florian Wilde 6f5bdcc20c feat: Beispielbibliothek mit lokal erzeugten Postern
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, also entstehen abstrakte Poster im
Flimmer-Look direkt im Bild-Cache.

Der Katalog ist bewusst erfunden: das Seed-Skript soll ohne TMDB-Schlüssel
laufen und keine fremden Metadaten ins Repository ziehen. Die Sichtungen sind
über die letzten Monate verteilt, damit Kalender und Statistik später etwas
zu zeigen haben.
2026-09-01 19:24:52 +02:00

196 lines
7.6 KiB
TypeScript

/**
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 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 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="${width}" y2="${height}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#171227"/>
<stop offset="1" stop-color="#0a0810"/>
</linearGradient>
<radialGradient id="glow" cx="${width * 0.72}" cy="${height * 0.24}" r="${width * 0.85}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="${warm}" stop-opacity="0.85"/>
<stop offset="0.45" stop-color="${cool}" stop-opacity="0.32"/>
<stop offset="1" stop-color="${cool}" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shade" x1="0" y1="${height * 0.42}" x2="0" y2="${height}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0a0810" stop-opacity="0"/>
<stop offset="1" stop-color="#0a0810" stop-opacity="0.92"/>
</linearGradient>
<linearGradient id="rule" x1="0" y1="0" x2="${width}" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="${warm}"/>
<stop offset="1" stop-color="${cool}"/>
</linearGradient>
</defs>
<rect width="${width}" height="${height}" fill="url(#bg)"/>
<rect width="${width}" height="${height}" fill="url(#glow)"/>
<g opacity="0.16" stroke="${warm}" fill="none" stroke-width="${1.4 * scale}">
${Array.from({ length: 7 }, (_, index) => {
const r = (0.18 + index * 0.13) * width;
return `<circle cx="${width * 0.72}" cy="${height * 0.26}" r="${r.toFixed(1)}"/>`;
}).join("\n ")}
</g>
<rect width="${width}" height="${height}" fill="url(#shade)"/>
<rect x="${24 * scale}" y="${blockBottom - fontSize * lines.length - 22 * scale}" width="${44 * scale}" height="${3 * scale}" rx="${1.5 * scale}" fill="url(#rule)"/>
<text x="${24 * scale}" y="${firstLineY}" font-family="Segoe UI, Inter, Helvetica, Arial, sans-serif" font-size="${fontSize}" font-weight="700" fill="#f4f1fa">
${lines
.map(
(line, index) =>
`<tspan x="${24 * scale}" dy="${index === 0 ? 0 : Math.round(fontSize * 1.14)}">${escapeXml(line)}</tspan>`,
)
.join("\n ")}
</text>
<text x="${24 * scale}" y="${blockBottom + Math.round(26 * scale)}" font-family="Segoe UI, Inter, Helvetica, Arial, sans-serif" font-size="${Math.round(15 * scale)}" fill="#aca5c2">${escapeXml(subtitle)}</text>
</svg>`;
}
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 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="${width}" y2="${height}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#171227"/>
<stop offset="1" stop-color="#0a0810"/>
</linearGradient>
<radialGradient id="glow" cx="${width * 0.3}" cy="${height * 0.2}" r="${width * 0.7}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="${warm}" stop-opacity="0.7"/>
<stop offset="0.5" stop-color="${cool}" stop-opacity="0.28"/>
<stop offset="1" stop-color="${cool}" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="${width}" height="${height}" fill="url(#bg)"/>
<rect width="${width}" height="${height}" fill="url(#glow)"/>
</svg>`;
}
/** 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<GeneratedArt> {
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 };
}