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.
196 lines
7.6 KiB
TypeScript
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, "&")
|
|
.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 `<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 };
|
|
}
|