mirror of
https://github.com/agregarr/agregarr.git
synced 2026-05-05 09:49:34 -05:00
b778430ab3
fix #45
803 lines
23 KiB
TypeScript
803 lines
23 KiB
TypeScript
import TheMovieDb from '@server/api/themoviedb';
|
|
import logger from '@server/logger';
|
|
import axios from 'axios';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import sharp from 'sharp';
|
|
|
|
export interface PosterGenerationConfig {
|
|
collectionName: string;
|
|
collectionType?: string;
|
|
collectionSubtype?: string;
|
|
mediaType?: 'movie' | 'tv';
|
|
template?: string;
|
|
items?: CollectionItemWithPoster[];
|
|
}
|
|
|
|
export interface CollectionItemWithPoster {
|
|
title: string;
|
|
type: 'movie' | 'tv';
|
|
tmdbId?: number;
|
|
year?: number;
|
|
posterUrl?: string;
|
|
}
|
|
|
|
export interface ColorScheme {
|
|
primaryColor: string;
|
|
secondaryColor: string;
|
|
textColor: string;
|
|
accentColor: string;
|
|
}
|
|
|
|
// Color schemes based on collection types
|
|
const COLOR_SCHEMES: Record<string, ColorScheme> = {
|
|
trakt: {
|
|
primaryColor: '#ed2224',
|
|
secondaryColor: '#1f1a1a', // Consistent gentle dark with red tint
|
|
textColor: '#ffffff',
|
|
accentColor: '#ff4444',
|
|
},
|
|
tmdb: {
|
|
primaryColor: '#01b4e4',
|
|
secondaryColor: '#0d253f', // Already good - gentle dark blue
|
|
textColor: '#ffffff',
|
|
accentColor: '#90cea1',
|
|
},
|
|
imdb: {
|
|
primaryColor: '#f5c518',
|
|
secondaryColor: '#1f1c0d', // Changed from pure black to gentle dark with yellow tint
|
|
textColor: '#ffffff', // Changed to white for better contrast
|
|
accentColor: '#f5c518',
|
|
},
|
|
letterboxd: {
|
|
primaryColor: '#2c3440',
|
|
secondaryColor: '#1a1f24', // Slightly adjusted for consistency
|
|
textColor: '#ffffff',
|
|
accentColor: '#00e054',
|
|
},
|
|
tautulli: {
|
|
primaryColor: '#cc7b19',
|
|
secondaryColor: '#1f1a15', // Consistent gentle dark with orange tint
|
|
textColor: '#ffffff',
|
|
accentColor: '#ff9933',
|
|
},
|
|
overseerr: {
|
|
primaryColor: '#5a5ce6',
|
|
secondaryColor: '#1a1a2e', // Already good - gentle dark purple
|
|
textColor: '#ffffff',
|
|
accentColor: '#7b7dff',
|
|
},
|
|
hub: {
|
|
primaryColor: '#e5a00d',
|
|
secondaryColor: '#1f1c15', // Consistent gentle dark with yellow tint
|
|
textColor: '#ffffff',
|
|
accentColor: '#ffc107',
|
|
},
|
|
default: {
|
|
primaryColor: '#6366f1',
|
|
secondaryColor: '#1e1b4b', // Already good - gentle dark indigo
|
|
textColor: '#ffffff',
|
|
accentColor: '#818cf8',
|
|
},
|
|
};
|
|
|
|
const POSTER_WIDTH = 500;
|
|
const POSTER_HEIGHT = 750;
|
|
const LOGO_SIZE = 60;
|
|
const ITEM_POSTER_WIDTH = 140; // Width for individual item posters in the grid
|
|
const ITEM_POSTER_HEIGHT = 210; // Height for individual item posters (1.5 aspect ratio)
|
|
|
|
// Path to service logos
|
|
const LOGOS_PATH = path.join(process.cwd(), 'src', 'assets', 'services');
|
|
|
|
// Service type to logo file mapping
|
|
const SERVICE_LOGO_MAP: Record<string, string> = {
|
|
trakt: 'trakt.svg',
|
|
tmdb: 'tmdb.svg',
|
|
imdb: 'imdb.svg',
|
|
letterboxd: 'letterboxd.svg',
|
|
tautulli: 'tautulli.svg',
|
|
overseerr: 'overseerr.svg',
|
|
plex: 'plex.svg',
|
|
};
|
|
|
|
/**
|
|
* Get color scheme for a collection type
|
|
*/
|
|
function getColorScheme(collectionType?: string): ColorScheme {
|
|
if (!collectionType) return COLOR_SCHEMES.default;
|
|
return COLOR_SCHEMES[collectionType.toLowerCase()] || COLOR_SCHEMES.default;
|
|
}
|
|
|
|
/**
|
|
* Fetch poster URLs from TMDB for collection items
|
|
*/
|
|
async function fetchTMDbPosterUrls(
|
|
items: CollectionItemWithPoster[]
|
|
): Promise<CollectionItemWithPoster[]> {
|
|
const tmdb = new TheMovieDb();
|
|
const itemsWithPosters: CollectionItemWithPoster[] = [];
|
|
|
|
logger.debug(`Fetching TMDB posters for ${items.length} items`);
|
|
|
|
for (const item of items.slice(0, 4)) {
|
|
// Only fetch first 4 items
|
|
let posterUrl: string | undefined;
|
|
|
|
logger.debug(`Processing item: ${item.title}`, {
|
|
type: item.type,
|
|
tmdbId: item.tmdbId,
|
|
year: item.year,
|
|
});
|
|
|
|
if (item.tmdbId) {
|
|
try {
|
|
if (item.type === 'movie') {
|
|
const movieDetails = await tmdb.getMovie({ movieId: item.tmdbId });
|
|
if (movieDetails.poster_path) {
|
|
posterUrl = `https://image.tmdb.org/t/p/w300${movieDetails.poster_path}`;
|
|
logger.debug(`Found movie poster for ${item.title}: ${posterUrl}`);
|
|
} else {
|
|
logger.debug(`No poster_path found for movie ${item.title}`);
|
|
}
|
|
} else if (item.type === 'tv') {
|
|
const tvDetails = await tmdb.getTvShow({ tvId: item.tmdbId });
|
|
if (tvDetails.poster_path) {
|
|
posterUrl = `https://image.tmdb.org/t/p/w300${tvDetails.poster_path}`;
|
|
logger.debug(`Found TV poster for ${item.title}: ${posterUrl}`);
|
|
} else {
|
|
logger.debug(`No poster_path found for TV show ${item.title}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`Failed to fetch TMDB poster for ${item.title}:`, error);
|
|
}
|
|
} else {
|
|
logger.debug(`No TMDB ID available for ${item.title}`);
|
|
}
|
|
|
|
itemsWithPosters.push({ ...item, posterUrl });
|
|
}
|
|
|
|
logger.debug(
|
|
`Returning ${itemsWithPosters.length} items, ${
|
|
itemsWithPosters.filter((i) => i.posterUrl).length
|
|
} with posters`
|
|
);
|
|
return itemsWithPosters;
|
|
}
|
|
|
|
/**
|
|
* Download and convert image to base64 for SVG embedding
|
|
*/
|
|
async function downloadImageAsBase64(url: string): Promise<string | null> {
|
|
try {
|
|
const response = await axios.get(url, { responseType: 'arraybuffer' });
|
|
const buffer = Buffer.from(response.data);
|
|
|
|
// Convert to JPEG and resize to optimize
|
|
const processedBuffer = await sharp(buffer)
|
|
.jpeg({ quality: 85 })
|
|
.resize(ITEM_POSTER_WIDTH, ITEM_POSTER_HEIGHT, {
|
|
fit: 'cover',
|
|
position: 'center',
|
|
})
|
|
.toBuffer();
|
|
|
|
return `data:image/jpeg;base64,${processedBuffer.toString('base64')}`;
|
|
} catch (error) {
|
|
logger.warn(`Failed to download image ${url}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load SVG logo content from filesystem
|
|
*/
|
|
async function loadServiceLogo(serviceType: string): Promise<string | null> {
|
|
try {
|
|
const logoFilename = SERVICE_LOGO_MAP[serviceType.toLowerCase()];
|
|
if (!logoFilename) {
|
|
logger.debug(`No logo mapping found for service type: ${serviceType}`);
|
|
return null;
|
|
}
|
|
|
|
const logoPath = path.join(LOGOS_PATH, logoFilename);
|
|
if (!fs.existsSync(logoPath)) {
|
|
logger.warn(`Logo file not found: ${logoPath}`);
|
|
return null;
|
|
}
|
|
|
|
const svgContent = await fs.promises.readFile(logoPath, 'utf8');
|
|
logger.debug(`Loaded logo for service type: ${serviceType}`, { logoPath });
|
|
return svgContent;
|
|
} catch (error) {
|
|
logger.warn(`Failed to load logo for service type: ${serviceType}`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a logo placeholder for services without SVG logos
|
|
*/
|
|
function createLogoPlaceholder(serviceType: string): string {
|
|
const letter = serviceType.charAt(0).toUpperCase();
|
|
const colorScheme = getColorScheme(serviceType);
|
|
|
|
return `
|
|
<circle cx="0" cy="0" r="${LOGO_SIZE / 2}"
|
|
fill="${colorScheme.accentColor}" opacity="0.8"/>
|
|
<text x="0" y="6"
|
|
font-family="Arial, sans-serif" font-size="24" font-weight="bold"
|
|
text-anchor="middle" fill="${colorScheme.textColor}">
|
|
${letter}
|
|
</text>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Process and embed SVG logo content for use in poster
|
|
* Standardized to consistent height for all logos
|
|
*/
|
|
function embedServiceLogo(
|
|
svgContent: string,
|
|
x: number,
|
|
y: number,
|
|
targetHeight: number = LOGO_SIZE
|
|
): string {
|
|
try {
|
|
// Extract the svg tag and its attributes
|
|
const svgMatch = svgContent.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i);
|
|
const svgTagMatch = svgContent.match(/<svg[^>]*>/i);
|
|
|
|
if (!svgMatch || !svgTagMatch) {
|
|
return createLogoPlaceholder('unknown');
|
|
}
|
|
|
|
const innerContent = svgMatch[1];
|
|
const svgTag = svgTagMatch[0];
|
|
|
|
// Extract viewBox or width/height to determine original dimensions
|
|
let width = 100,
|
|
height = 100; // defaults
|
|
|
|
const viewBoxMatch = svgTag.match(/viewBox=["']([^"']+)["']/i);
|
|
if (viewBoxMatch) {
|
|
const viewBoxValues = viewBoxMatch[1].split(/[\s,]+/);
|
|
if (viewBoxValues.length >= 4) {
|
|
width = parseFloat(viewBoxValues[2]) - parseFloat(viewBoxValues[0]);
|
|
height = parseFloat(viewBoxValues[3]) - parseFloat(viewBoxValues[1]);
|
|
}
|
|
} else {
|
|
// Try to extract width and height attributes
|
|
const widthMatch = svgTag.match(/width=["']([^"']+)["']/i);
|
|
const heightMatch = svgTag.match(/height=["']([^"']+)["']/i);
|
|
|
|
if (widthMatch)
|
|
width = parseFloat(widthMatch[1].replace(/px|pt|em|rem/, ''));
|
|
if (heightMatch)
|
|
height = parseFloat(heightMatch[1].replace(/px|pt|em|rem/, ''));
|
|
}
|
|
|
|
// Calculate scale to make all logos the same HEIGHT (not size)
|
|
// This ensures consistent vertical presence regardless of aspect ratio
|
|
const heightScale = targetHeight / height;
|
|
const scaledWidth = width * heightScale;
|
|
|
|
// Create a group with the logo content, scaled to consistent height
|
|
return `
|
|
<g transform="translate(${x}, ${y})">
|
|
<g transform="scale(${heightScale}) translate(${
|
|
-scaledWidth / (2 * heightScale)
|
|
}, ${-targetHeight / (2 * heightScale)})">
|
|
${innerContent}
|
|
</g>
|
|
</g>
|
|
`;
|
|
} catch (error) {
|
|
logger.warn('Failed to embed SVG logo:', error);
|
|
return createLogoPlaceholder('unknown');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escape XML/SVG special characters in text
|
|
*/
|
|
function escapeXml(text: string): string {
|
|
return text
|
|
.replace(/&/g, '&') // Must be first
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* Get actual text width using a more accurate character width mapping for Arial Bold
|
|
*/
|
|
function getTextWidth(text: string, fontSize: number): number {
|
|
// Conservative character width estimation for Arial Bold
|
|
// Using generous values to prevent clipping - better to wrap early than clip
|
|
const charWidths: Record<string, number> = {
|
|
// Wide characters - increased significantly
|
|
M: 0.85,
|
|
W: 1.1,
|
|
m: 0.85,
|
|
w: 0.85,
|
|
// Medium-wide characters - increased
|
|
A: 0.8,
|
|
B: 0.8,
|
|
C: 0.85,
|
|
D: 0.85,
|
|
G: 0.9,
|
|
H: 0.85,
|
|
N: 0.85,
|
|
O: 0.9,
|
|
Q: 0.9,
|
|
R: 0.85,
|
|
U: 0.85,
|
|
V: 0.8,
|
|
X: 0.8,
|
|
Y: 0.8,
|
|
Z: 0.8,
|
|
a: 0.7,
|
|
b: 0.7,
|
|
c: 0.65,
|
|
d: 0.7,
|
|
e: 0.7,
|
|
g: 0.7,
|
|
h: 0.7,
|
|
n: 0.7,
|
|
o: 0.7,
|
|
p: 0.7,
|
|
q: 0.7,
|
|
r: 0.45,
|
|
u: 0.7,
|
|
v: 0.65,
|
|
x: 0.65,
|
|
y: 0.65,
|
|
z: 0.65,
|
|
// Numbers - increased
|
|
'0': 0.7,
|
|
'1': 0.7,
|
|
'2': 0.7,
|
|
'3': 0.7,
|
|
'4': 0.7,
|
|
'5': 0.7,
|
|
'6': 0.7,
|
|
'7': 0.7,
|
|
'8': 0.7,
|
|
'9': 0.7,
|
|
// Medium characters - increased
|
|
E: 0.8,
|
|
F: 0.75,
|
|
I: 0.4,
|
|
J: 0.65,
|
|
K: 0.8,
|
|
L: 0.7,
|
|
P: 0.8,
|
|
S: 0.8,
|
|
T: 0.75,
|
|
f: 0.4,
|
|
i: 0.35,
|
|
j: 0.35,
|
|
k: 0.65,
|
|
l: 0.35,
|
|
s: 0.65,
|
|
t: 0.4,
|
|
// Special characters - increased
|
|
' ': 0.4,
|
|
'.': 0.4,
|
|
',': 0.4,
|
|
':': 0.4,
|
|
';': 0.4,
|
|
'!': 0.4,
|
|
'?': 0.7,
|
|
'&': 0.8,
|
|
"'": 0.3,
|
|
'"': 0.5,
|
|
'-': 0.45,
|
|
_: 0.7,
|
|
'(': 0.45,
|
|
')': 0.45,
|
|
'[': 0.4,
|
|
']': 0.4,
|
|
'{': 0.45,
|
|
'}': 0.45,
|
|
'|': 0.35,
|
|
'/': 0.4,
|
|
'\\': 0.4,
|
|
'@': 1.2,
|
|
'#': 0.7,
|
|
$: 0.7,
|
|
'%': 1.0,
|
|
'^': 0.6,
|
|
'*': 0.5,
|
|
'+': 0.7,
|
|
'=': 0.7,
|
|
'<': 0.7,
|
|
'>': 0.7,
|
|
};
|
|
|
|
let totalWidth = 0;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const char = text[i];
|
|
const charWidth = charWidths[char] || 0.7; // More generous default for unknown characters
|
|
totalWidth += charWidth * fontSize;
|
|
}
|
|
|
|
// Add 10% safety margin to prevent clipping
|
|
return totalWidth * 1.1;
|
|
}
|
|
|
|
/**
|
|
* Wrap text to fit within specified width, keeping whole words intact
|
|
*/
|
|
function wrapTextKeepWords(
|
|
text: string,
|
|
maxWidth: number,
|
|
fontSize: number
|
|
): string[] {
|
|
const words = text.split(' ');
|
|
const lines: string[] = [];
|
|
let currentLine = '';
|
|
|
|
for (const word of words) {
|
|
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
const lineWidth = getTextWidth(testLine, fontSize);
|
|
|
|
if (lineWidth <= maxWidth) {
|
|
currentLine = testLine;
|
|
} else {
|
|
// Line would be too wide, start a new line
|
|
if (currentLine) {
|
|
lines.push(currentLine);
|
|
currentLine = word;
|
|
} else {
|
|
// Single word is too wide, but keep it anyway
|
|
currentLine = word;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (currentLine) {
|
|
lines.push(currentLine);
|
|
}
|
|
|
|
return lines.length > 0 ? lines : [text];
|
|
}
|
|
|
|
/**
|
|
* Create multi-line text with proper word wrapping
|
|
*/
|
|
function createWrappedText(
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
maxWidth: number,
|
|
fontSize: number,
|
|
fill: string,
|
|
filter?: string
|
|
): string {
|
|
const lines = wrapTextKeepWords(text, maxWidth, fontSize);
|
|
const lineHeight = fontSize * 1.1; // Tighter line height for cleaner look
|
|
|
|
// Start from the provided Y position (no centering - position downward from Y)
|
|
const startY = y;
|
|
|
|
return lines
|
|
.map((line, index) => {
|
|
const escapedLine = escapeXml(line);
|
|
const lineY = startY + index * lineHeight + fontSize; // Add fontSize to account for text baseline
|
|
|
|
return `
|
|
<text x="${x}" y="${lineY}"
|
|
font-family="Helvetica Neue, Segoe UI, Arial, sans-serif"
|
|
font-size="${fontSize}"
|
|
font-weight="600"
|
|
text-anchor="middle"
|
|
fill="${fill}"
|
|
${filter ? ` filter="${filter}"` : ''}
|
|
letter-spacing="-0.01em">
|
|
${escapedLine}
|
|
</text>
|
|
`;
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
/**
|
|
* Create poster grid with item images
|
|
*/
|
|
function createPosterGrid(
|
|
items: CollectionItemWithPoster[],
|
|
startX: number,
|
|
startY: number
|
|
): string {
|
|
if (!items.length) return '';
|
|
|
|
const spacing = 16; // Space between posters - increased for better spacing
|
|
const itemWidth = ITEM_POSTER_WIDTH;
|
|
const itemHeight = ITEM_POSTER_HEIGHT;
|
|
|
|
return items
|
|
.slice(0, 4)
|
|
.map((item, index) => {
|
|
const col = index % 2;
|
|
const row = Math.floor(index / 2);
|
|
const x = startX + col * (itemWidth + spacing);
|
|
const y = startY + row * (itemHeight + spacing);
|
|
|
|
if (item.posterUrl) {
|
|
return `
|
|
<g transform="translate(${x}, ${y})">
|
|
<!-- Poster shadow -->
|
|
<rect x="2" y="4"
|
|
width="${itemWidth}"
|
|
height="${itemHeight}"
|
|
fill="rgba(0,0,0,0.2)"
|
|
rx="6"/>
|
|
<!-- Poster image -->
|
|
<image xlink:href="${item.posterUrl}"
|
|
x="0" y="0"
|
|
width="${itemWidth}"
|
|
height="${itemHeight}"
|
|
preserveAspectRatio="xMidYMid slice"/>
|
|
<!-- Border -->
|
|
<rect x="0" y="0"
|
|
width="${itemWidth}"
|
|
height="${itemHeight}"
|
|
fill="none"
|
|
stroke="rgba(255,255,255,0.15)"
|
|
stroke-width="1"
|
|
rx="6"/>
|
|
</g>
|
|
`;
|
|
} else {
|
|
// Fallback placeholder
|
|
return `
|
|
<g transform="translate(${x}, ${y})">
|
|
<!-- Placeholder shadow -->
|
|
<rect x="2" y="4"
|
|
width="${itemWidth}"
|
|
height="${itemHeight}"
|
|
fill="rgba(0,0,0,0.15)"
|
|
rx="6"/>
|
|
<!-- Placeholder background -->
|
|
<rect x="0" y="0"
|
|
width="${itemWidth}"
|
|
height="${itemHeight}"
|
|
fill="rgba(255,255,255,0.08)"
|
|
stroke="rgba(255,255,255,0.15)"
|
|
stroke-width="1"
|
|
rx="6"/>
|
|
<!-- Placeholder text -->
|
|
<text x="${itemWidth / 2}" y="${itemHeight / 2}"
|
|
font-family="Helvetica Neue, Segoe UI, Arial, sans-serif"
|
|
font-size="11"
|
|
font-weight="500"
|
|
text-anchor="middle"
|
|
fill="rgba(255,255,255,0.5)"
|
|
dominant-baseline="central">
|
|
${escapeXml(
|
|
item.title.length > 14
|
|
? item.title.substring(0, 14) + '...'
|
|
: item.title
|
|
)}
|
|
</text>
|
|
</g>
|
|
`;
|
|
}
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
/**
|
|
* Generate SVG poster content with new layout
|
|
*/
|
|
async function generatePosterSVG(
|
|
config: PosterGenerationConfig
|
|
): Promise<string> {
|
|
const colorScheme = getColorScheme(config.collectionType);
|
|
const { collectionName, collectionType, items = [] } = config;
|
|
|
|
// Fixed layout sections with consistent proportions
|
|
const topBuffer = 40; // Fixed buffer above logo
|
|
const logoSectionHeight = topBuffer + LOGO_SIZE + 30; // Total space for logo section with bottom spacing
|
|
const logoY = topBuffer + LOGO_SIZE / 2; // Logo centered in top portion of its section
|
|
|
|
const titleSectionStart = logoSectionHeight;
|
|
const titleSectionHeight = 100; // Fixed height for title section (reduced to fit better)
|
|
const titleSectionEnd = titleSectionStart + titleSectionHeight;
|
|
|
|
const gridSectionStart = titleSectionEnd;
|
|
const bottomBuffer = 30; // Fixed buffer at bottom
|
|
const gridSectionHeight = POSTER_HEIGHT - gridSectionStart - bottomBuffer;
|
|
|
|
// Title area calculations - text will shrink to fit
|
|
const titleAreaHeight = titleSectionHeight - 20; // Leave some padding in title section
|
|
const maxTitleWidth = POSTER_WIDTH - 60; // Padding on sides
|
|
|
|
// Dynamic font sizing to better fill the space
|
|
let fontSize = Math.min(50, Math.max(18, titleAreaHeight / 2));
|
|
let textLines = wrapTextKeepWords(collectionName, maxTitleWidth, fontSize);
|
|
let lineHeight = fontSize * 1.1;
|
|
let textBlockHeight = textLines.length * lineHeight;
|
|
|
|
// Reduce font size if text doesn't fit
|
|
while (textBlockHeight > titleAreaHeight && fontSize > 14) {
|
|
fontSize -= 1;
|
|
textLines = wrapTextKeepWords(collectionName, maxTitleWidth, fontSize);
|
|
lineHeight = fontSize * 1.1;
|
|
textBlockHeight = textLines.length * lineHeight;
|
|
}
|
|
|
|
// Position title to better fill its section
|
|
const titleY = titleSectionStart; // Start right at the beginning of title section
|
|
|
|
// Load service logo with improved positioning
|
|
let logoContent = '';
|
|
if (collectionType && collectionType !== 'hub') {
|
|
const logoSvg = await loadServiceLogo(collectionType);
|
|
if (logoSvg) {
|
|
logoContent = embedServiceLogo(
|
|
logoSvg,
|
|
POSTER_WIDTH / 2,
|
|
logoY,
|
|
LOGO_SIZE
|
|
);
|
|
} else {
|
|
logoContent = `
|
|
<g transform="translate(${POSTER_WIDTH / 2}, ${logoY})">
|
|
${createLogoPlaceholder(collectionType)}
|
|
</g>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Fetch poster URLs if items are provided
|
|
let itemsWithPosters: CollectionItemWithPoster[] = [];
|
|
if (items.length > 0) {
|
|
try {
|
|
itemsWithPosters = await fetchTMDbPosterUrls(items);
|
|
// Download and convert images to base64 for embedding
|
|
for (const item of itemsWithPosters) {
|
|
if (item.posterUrl) {
|
|
const base64Image = await downloadImageAsBase64(item.posterUrl);
|
|
if (base64Image) {
|
|
item.posterUrl = base64Image;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to fetch poster URLs for items:', error);
|
|
}
|
|
}
|
|
|
|
// Calculate poster grid positioning within its fixed section
|
|
const totalGridWidth = ITEM_POSTER_WIDTH * 2 + 16; // 2 posters + 1 spacing
|
|
const totalGridHeight = ITEM_POSTER_HEIGHT * 2 + 16; // 2 rows + 1 spacing
|
|
const gridX = (POSTER_WIDTH - totalGridWidth) / 2; // Center horizontally
|
|
const gridY = gridSectionStart + (gridSectionHeight - totalGridHeight) / 2; // Center in grid section
|
|
|
|
const posterGridContent = createPosterGrid(itemsWithPosters, gridX, gridY);
|
|
|
|
return `
|
|
<svg width="${POSTER_WIDTH}" height="${POSTER_HEIGHT}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
<defs>
|
|
<linearGradient id="backgroundGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
<stop offset="0%" style="stop-color:${
|
|
colorScheme.secondaryColor
|
|
};stop-opacity:1" />
|
|
<stop offset="40%" style="stop-color:${
|
|
colorScheme.primaryColor
|
|
};stop-opacity:0.85" />
|
|
<stop offset="100%" style="stop-color:${
|
|
colorScheme.secondaryColor
|
|
};stop-opacity:1" />
|
|
</linearGradient>
|
|
<filter id="textShadow">
|
|
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.4"/>
|
|
</filter>
|
|
<filter id="logoGlow">
|
|
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.2"/>
|
|
</filter>
|
|
<filter id="posterShadow">
|
|
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000000" flood-opacity="0.25"/>
|
|
</filter>
|
|
<radialGradient id="overlayGradient" cx="50%" cy="0%" r="120%">
|
|
<stop offset="0%" style="stop-color:rgba(255,255,255,0.1);stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:rgba(0,0,0,0.1);stop-opacity:1" />
|
|
</radialGradient>
|
|
</defs>
|
|
|
|
<!-- Background -->
|
|
<rect width="${POSTER_WIDTH}" height="${POSTER_HEIGHT}" fill="url(#backgroundGradient)"/>
|
|
|
|
<!-- Subtle overlay for depth -->
|
|
<rect width="${POSTER_WIDTH}" height="${POSTER_HEIGHT}" fill="url(#overlayGradient)" opacity="0.6"/>
|
|
|
|
<!-- Service logo (fixed at top with glow) -->
|
|
<g filter="url(#logoGlow)">
|
|
${logoContent}
|
|
</g>
|
|
|
|
<!-- Collection name (responsive sizing with improved shadow) -->
|
|
${createWrappedText(
|
|
collectionName,
|
|
POSTER_WIDTH / 2,
|
|
titleY,
|
|
maxTitleWidth,
|
|
fontSize,
|
|
colorScheme.textColor,
|
|
'url(#textShadow)'
|
|
)}
|
|
|
|
<!-- Poster grid with shadow -->
|
|
<g filter="url(#posterShadow)">
|
|
${posterGridContent}
|
|
</g>
|
|
</svg>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Generate a poster image buffer from configuration
|
|
*/
|
|
export async function generatePosterBuffer(
|
|
config: PosterGenerationConfig
|
|
): Promise<Buffer> {
|
|
try {
|
|
logger.info('Generating poster', {
|
|
name: config.collectionName,
|
|
type: config.collectionType,
|
|
subtype: config.collectionSubtype,
|
|
mediaType: config.mediaType,
|
|
});
|
|
|
|
// Generate SVG content
|
|
const svgContent = await generatePosterSVG(config);
|
|
|
|
// Convert SVG to PNG using Sharp
|
|
const buffer = await sharp(Buffer.from(svgContent))
|
|
.png({ quality: 90 })
|
|
.toBuffer();
|
|
|
|
logger.info('Poster generated successfully', {
|
|
name: config.collectionName,
|
|
bufferSize: buffer.length,
|
|
});
|
|
|
|
return buffer;
|
|
} catch (error) {
|
|
logger.error('Failed to generate poster', {
|
|
config,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw new Error('Failed to generate poster');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a cache key for poster generation configuration (legacy - now using hash-based filenames)
|
|
*/
|
|
export function getPosterCacheKey(config: PosterGenerationConfig): string {
|
|
const configString = JSON.stringify({
|
|
name: config.collectionName,
|
|
type: config.collectionType || '',
|
|
subtype: config.collectionSubtype || '',
|
|
mediaType: config.mediaType || '',
|
|
template: config.template || '',
|
|
});
|
|
|
|
// Simple hash for cache key (used for logging/debugging only)
|
|
let hash = 0;
|
|
for (let i = 0; i < configString.length; i++) {
|
|
const char = configString.charCodeAt(i);
|
|
hash = (hash << 5) - hash + char;
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
|
|
return `generated_${Math.abs(hash).toString(36)}`;
|
|
}
|