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.
This commit is contained in:
Florian Wilde
2026-09-01 19:24:52 +02:00
parent f9ce2f7021
commit 6f5bdcc20c
3 changed files with 1105 additions and 12 deletions
+195
View File
@@ -0,0 +1,195 @@
/**
* 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 };
}