import axios from "axios"; import shuffleArray from "shuffle-array"; import {createCanvas, loadImage} from "canvas"; const TMDB_API_BASE_URL = "https://api.themoviedb.org/3"; const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/original"; /** * Fetches trending movie poster paths from TMDB. * @param {string} apiKey - The TMDB API key. * @param {string} language - The language for the movie results (e.g., 'de-DE'). * @returns {Promise} A promise that resolves to an array of poster image URLs. * @throws {Error} If the API request fails or no results are found. */ async function fetchTrendingMoviePosters(apiKey, language) { const url = `${TMDB_API_BASE_URL}/trending/movie/week?language=${language}`; try { const response = await axios.get(url, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'Movie Posters Service (v1.0.0)' } }); if (!response.data || !response.data.results || response.data.results.length === 0) { throw new Error("No trending movies found from TMDB."); } const posterUrls = response.data.results .filter(item => item.poster_path) .map(item => `${TMDB_IMAGE_BASE_URL}${item.poster_path}`); if (posterUrls.length === 0) { throw new Error("No valid poster paths found in the trending movies results."); } return posterUrls; } catch (error) { console.error("Error fetching data from TMDB:", error.message); if (axios.isAxiosError(error) && error.response) { console.error("TMDB API Response Status:", error.response.status); console.error("TMDB API Response Data:", error.response.data); throw new Error(`Failed to fetch trending movies from TMDB: ${error.response.statusText || error.message}`); } throw error; } } /** * Konvertiert einen RGBA8888-Pixelbuffer in einen RGB565 Little-Endian Buffer. * @param {Uint8ClampedArray} rgbaBuffer - Der Eingabebuffer (R, G, B, A, R, G, B, A, ...) * @param {number} width - Bildbreite * @param {number} height - Bildhöhe * @returns {Buffer} Ein Buffer mit RGB565-Daten (2 Bytes pro Pixel, Little Endian). */ function convertRgbaToRgb565(rgbaBuffer, width, height) { const pixelCount = width * height; const rgb565Buffer = Buffer.alloc(pixelCount * 2); for (let i = 0; i < pixelCount; i++) { const r = rgbaBuffer[i * 4 + 0]; const g = rgbaBuffer[i * 4 + 1]; const b = rgbaBuffer[i * 4 + 2]; const r5 = (r >> 3) & 0x1F; const g6 = (g >> 2) & 0x3F; const b5 = (b >> 3) & 0x1F; const rgb565Value = (r5 << 11) | (g6 << 5) | b5; rgb565Buffer.writeUInt16LE(rgb565Value, i * 2); } return rgb565Buffer; } /** * Draws images horizontally onto the canvas, scaling and centering them. * @param {CanvasRenderingContext2D} ctx - The canvas rendering context. * @param {string[]} imageUrls - Array of image URLs to draw. * @param {number} count - The number of images to draw. * @param {number} canvasWidth - The width of the canvas. * @param {number} canvasHeight - The height of the canvas. */ async function drawHorizontalPosters(ctx, imageUrls, count, canvasWidth, canvasHeight) { const imageWidth = canvasWidth / count; for (let i = 0; i < count; i++) { if (!imageUrls[i]) continue; try { const image = await loadImage(imageUrls[i]); const scaledHeight = image.height * (imageWidth / image.width); const yPos = (canvasHeight - scaledHeight) / 2; ctx.drawImage(image, imageWidth * i, yPos, imageWidth, scaledHeight); } catch (error) { console.error(`Error loading or drawing image ${i} (${imageUrls[i]}):`, error.message); ctx.fillStyle = 'grey'; ctx.fillRect(imageWidth * i, 0, imageWidth, canvasHeight); ctx.fillStyle = 'white'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('Error', imageWidth * i + imageWidth / 2, canvasHeight / 2); } } } /** * Draws images vertically onto the canvas, scaling and centering them. * @param {CanvasRenderingContext2D} ctx - The canvas rendering context. * @param {string[]} imageUrls - Array of image URLs to draw. * @param {number} count - The number of images to draw. * @param {number} canvasWidth - The width of the canvas. * @param {number} canvasHeight - The height of the canvas. */ async function drawVerticalPosters(ctx, imageUrls, count, canvasWidth, canvasHeight) { const imageHeight = canvasHeight / count; for (let i = 0; i < count; i++) { if (!imageUrls[i]) continue; try { const image = await loadImage(imageUrls[i]); const scaledWidth = image.width * (imageHeight / image.height); const xPos = (canvasWidth - scaledWidth) / 2; ctx.drawImage(image, xPos, imageHeight * i, scaledWidth, imageHeight); } catch (error) { console.error(`Error loading or drawing image ${i} (${imageUrls[i]}):`, error.message); ctx.fillStyle = 'grey'; ctx.fillRect(0, imageHeight * i, canvasWidth, imageHeight); ctx.fillStyle = 'white'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('Error', canvasWidth / 2, imageHeight * i + imageHeight / 2); } } } /** * Generates the final output buffer or JSON based on requested format. * @param {Canvas} canvas - The canvas object. * @param {CanvasRenderingContext2D} ctx - The canvas rendering context. * @param {'png' | 'jpeg'} imageFormat - The desired image format ('png' or 'jpeg'). * @param {'image' | 'lvgl' | 'lvgl_binary'} outputType - The desired output type ('image' or 'lvgl'). * @returns {{contentType: string, data: Buffer | string}} An object containing the content type and the output data (Buffer for image, string for LVGL JSON). * @throws {Error} If an invalid format or output type is provided. */ function generateOutput(canvas, ctx, imageFormat, outputType) { switch (outputType) { case 'lvgl_binary': { const rawRgbaData = ctx.getImageData(0, 0, canvas.width, canvas.height).data; const rgb565Buffer = convertRgbaToRgb565(rawRgbaData, canvas.width, canvas.height); return { contentType: 'application/octet-stream', contentDisposition: 'attachment; filename="image.bin"', data: rgb565Buffer } } case 'lvgl': { const rawRgbaData = ctx.getImageData(0, 0, canvas.width, canvas.height).data; const rgb565Buffer = convertRgbaToRgb565(rawRgbaData, canvas.width, canvas.height); const lvglColorFormat = 'LV_COLOR_FORMAT_RGB565'; const bytesPerPixel = 2; const dataSize = canvas.width * canvas.height * bytesPerPixel; let cArrayString = `const uint8_t image_data_map[${dataSize}] = {`; for (let i = 0; i < rgb565Buffer.length; i++) { cArrayString += '0x' + rgb565Buffer[i].toString(16).padStart(2, '0').toUpperCase(); if (i < rgb565Buffer.length - 1) { cArrayString += ', '; } } cArrayString += '};'; const result = { width: canvas.width, height: canvas.height, colorFormat: lvglColorFormat, pixelFormat: 'RGB565', data_size: dataSize, c_array: cArrayString }; return { contentType: 'application/json', data: JSON.stringify(result, null, 2) }; } case 'image': { let buffer; let contentType; switch (imageFormat) { case 'jpeg': buffer = canvas.toBuffer('image/jpeg'); contentType = 'image/jpeg'; break; case 'png': buffer = canvas.toBuffer('image/png'); contentType = 'image/png'; break; default: console.warn(`Invalid image format requested: ${imageFormat}. Defaulting to PNG.`); buffer = canvas.toBuffer('image/png'); contentType = 'image/png'; } return {contentType, data: buffer}; } default: throw new Error(`Invalid output type specified: ${outputType}`); } } /** * Creates a composite poster image from trending movie posters. * * @param {object} req - Express request object. * @param {object} res - Express response object. * @param {number} width - Desired width of the final poster. * @param {number} height - Desired height of the final poster. * @param {number} count - Number of movie posters to include. * @param {'horizontal' | 'vertical'} orientation - Layout orientation of the posters. * @param {boolean} shuffleImages - Whether to shuffle the fetched posters. * @param {string} backgroundColor - Background color for the canvas (hex code). * @param {'png' | 'jpeg'} format - Output image format (if output is 'image'). * @param {string} language - Language for fetching movie posters (e.g., 'de-DE'). * @param {'image' | 'lvgl'} output - Desired output type. */ export async function createPoster(req, res, width, height, count, orientation, shuffleImages, backgroundColor, format, language, output) { const apiKey = process.env.TMDB_API_KEY || req.header("x-api-key"); if (!apiKey) { return res.status(401).send({error: "API key is missing. Provide it via x-api-key header or TMDB_API_KEY environment variable."}); } try { let imageUrls = await fetchTrendingMoviePosters(apiKey, language); if (imageUrls.length < count) { console.warn(`Warning: Fetched ${imageUrls.length} posters, but ${count} were requested. Repeating posters.`); const originalUrls = [...imageUrls]; while (imageUrls.length < count) { imageUrls.push(...originalUrls.slice(0, count - imageUrls.length)); } } if (shuffleImages) { shuffleArray(imageUrls); } const selectedImageUrls = imageUrls.slice(0, count); const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d', {pixelFormat: 'RGB24'}); ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, canvas.width, canvas.height); if (orientation === 'horizontal') { await drawHorizontalPosters(ctx, selectedImageUrls, count, canvas.width, canvas.height); } else if (orientation === 'vertical') { await drawVerticalPosters(ctx, selectedImageUrls, count, canvas.width, canvas.height); } else { throw new Error(`Invalid orientation specified: ${orientation}`); } const {contentType, data, contentDisposition} = generateOutput(canvas, ctx, format, output); if (contentDisposition) { res.setHeader('Content-Disposition', contentDisposition); } res.status(200).type(contentType).end(data); } catch (error) { console.error("Error creating poster:", error.message); res.status(500).send({error: `Failed to create poster: ${error.message}`}); } }