// Pixel, 23.08.26.13.09 // An endpoint to transform / affect input pixels from // a painting before returning them. // Usage: /pixel/widthxheight/bucket/painting or add an extension. // Test URLs: https://aesthetic.local:8888/api/pixel/1650x1650/@jeffrey/painting/2023.8.24.16.21.09.123.png // https://aesthetic.local:8888/api/pixel/1650x1650/Lw2OYs0H.png // https://aesthetic.local:8888/api/pixel/1650x1650:contain/@jeffrey/painting/2023.9.04.21.10.34.574.png // ^ mode for fit /* #region 🏁 TODO + Done - [x] Add a compositor for stickers that would be faster than Printful. - [x] Add a fitMode. - [x] Nearest neighbor scale a painting after opening it. #endregion */ // builder from @netlify/functions was imported here but never used. // Removed require() to support ESM-only environments (lith). import sharp from "sharp"; import QRCode from "qrcode"; import { readFileSync, mkdirSync, copyFileSync, existsSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { execSync } from "child_process"; import { respond } from "../../backend/http.mjs"; const dev = process.env.CONTEXT === "dev"; // Load Comic Relief Bold font and register with fontconfig let comicReliefFontAvailable = false; try { const __dirname = dirname(fileURLToPath(import.meta.url)); const fontSrc = join(__dirname, "../fonts/ComicRelief-Bold.ttf"); const fontDir = "/tmp/fonts"; const fontDest = join(fontDir, "ComicRelief-Bold.ttf"); if (!existsSync(fontDest)) { mkdirSync(fontDir, { recursive: true }); copyFileSync(fontSrc, fontDest); // Update fontconfig cache execSync(`fc-cache -f ${fontDir} 2>/dev/null || true`); console.log("✅ Comic Relief font registered with fontconfig"); } comicReliefFontAvailable = true; } catch (e) { console.warn("⚠️ Comic Relief font setup failed:", e.message); } async function fun(event, context) { if ( event.httpMethod === "GET" && (event.headers["host"] === "aesthetic.computer" || dev) ) { const params = event.path.replace("/api/pixel/", "").split("/"); let [pre, premode] = params[0].split(":"); premode ||= "fill"; // or "contain", or "conform" or "sticker" let [mode, compose] = premode.split("-"); const clear = compose === "clear"; // TODO: Eventually use a "-clear" option to keep the backdrop transparent. // 23.09.06.02.27 const resolution = pre.split("x").map((n) => parseInt(n)); let slug = params.slice(1).join("/"); let imageUrl; // Declare imageUrl at function scope // Check for QR code parameter (for print files that need QR baked in) // Usage: /api/pixel/2700x1050:contain-clear/CODE.png?qr=mug~+CODE&via=kidlispcode const qrSlug = event.queryStringParameters?.qr; const qrPosition = event.queryStringParameters?.qrpos || "bottom-right"; // bottom-right, bottom-left, top-right, top-left const viaCode = event.queryStringParameters?.via; // KidLisp source code for rotated side label // Check for + prefix (print/product code) - e.g., "+abc123.png" or "+abc123" // Print codes use + prefix like # for paintings, $ for kidlisp, ! for tapes const printCodePattern = /^\+([a-z0-9]{6})(\.png|\.webp)?$/; const printCodeMatch = slug.match(printCodePattern); if (printCodeMatch) { const productCode = printCodeMatch[1]; console.log(`🖨️ Looking up print/product by code: +${productCode}`); try { const { getProduct } = await import("../../backend/products.mjs"); const product = await getProduct(productCode); if (product?.preview) { imageUrl = product.preview; console.log(`✅ Resolved +${productCode} to preview: ${imageUrl}`); } else { // Try direct S3 path for products imageUrl = `https://art.aesthetic.computer/products/${productCode}.webp`; console.log(`📦 No preview cached, trying S3: ${imageUrl}`); } } catch (error) { console.error(`❌ Error looking up product code: ${error.message}`); imageUrl = `https://art.aesthetic.computer/products/${productCode}.webp`; } } // Check if slug is a painting code (short alphanumeric without path separators) // Examples: "mgy.png", "abc", "t84.png", "8BzEwZGt.png" const codePattern = /^([a-zA-Z0-9]{2,10})(\.png)?$/; const codeMatch = !imageUrl && slug.match(codePattern); if (codeMatch) { const code = codeMatch[1]; // Extract code without extension console.log(`🔍 Looking up painting by code: ${code}`); try { const { connect } = await import("../../backend/database.mjs"); const database = await connect(); const painting = await database.db.collection('paintings').findOne({ code }); if (painting) { // Build slug from painting data // Handle combined slugs (split to get image slug only) let imageSlug = painting.slug; if (imageSlug.includes(':')) { [imageSlug] = imageSlug.split(':'); } // Construct DO Spaces URL directly (same logic as media.js edge function) const bucket = painting.user ? "user-aesthetic-computer" : "art-aesthetic-computer"; const key = painting.user ? `${encodeURIComponent(painting.user)}/painting/${imageSlug}.png` : `${imageSlug}.png`; imageUrl = `https://${bucket}.sfo3.digitaloceanspaces.com/${key}`; console.log(`✅ Resolved code ${code} to DO Spaces URL: ${imageUrl}`); } else { // No DB record - try guest art bucket directly (anonymous uploads) imageUrl = `https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/${code}.png`; console.log(`📦 No DB record for ${code}, trying guest bucket: ${imageUrl}`); } await database.disconnect(); } catch (error) { console.error(`❌ Error looking up painting code: ${error.message}`); // Fall back to guest bucket on DB error too imageUrl = `https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/${code}.png`; console.log(`📦 DB error, trying guest bucket: ${imageUrl}`); } } // Fall back to constructing URL from slug if not set by code lookup if (!imageUrl) { imageUrl = `https://${event.headers["host"]}/media/${slug}`; } if (!imageUrl) return respond(400, { message: "Image URL not provided." }); try { const { got } = await import("got"); const response = await got(imageUrl, { responseType: "buffer", https: { rejectUnauthorized: !dev, }, }); // Resize the image using nearest neighbor filtering with "sharp" // Docs: https://sharp.pixelplumbing.com/api-resize let buffer; const original = await sharp(response.body); const md = await original.metadata(); console.log("Resolution:", resolution, "Image:", imageUrl); console.log("Metadata:", md); const width = resolution[0]; const height = resolution[1] || resolution[0]; const long = Math.max(md.width, md.height); const kernel = width > md.width || height > md.height ? sharp.kernel.nearest // For scaling up. : sharp.kernel.lanczos3; // For scaling down. if (mode !== "sticker") { // 🟠. Simple resizing. // Make sure the original image has a colored backdrop. // TODO: Should this actually be a weird gray, or a gradient? // or maybe it should be random? let combinedImage; if (!clear) { combinedImage = await sharp({ create: { width: md.width, height: md.height, channels: 4, background: { r: 32, g: 32, b: 32, alpha: 1 }, }, }) .composite([{ input: await original.toBuffer() }]) .png() .toBuffer(); } // Use "conform" to resize to the longest aspect. let resolution; if (mode === "conform") { resolution = md.width >= md.height ? { width: width } : { height: height }; mode = "inside"; } else { resolution = { width, height }; } let resizedImage = sharp(clear ? await original.toBuffer() : combinedImage) .resize({ ...resolution, fit: mode, kernel, background: { r: 0, g: 0, b: 0, alpha: 0 }, }); // 🔲 Add QR code and KidLisp.com label to print file // Position them at the edges of the CONTENT, not the transparent area if (qrSlug || viaCode) { const composites = []; // First, get the resized image and find content bounds const resizedBuffer = await resizedImage.png().toBuffer(); const resizedMeta = await sharp(resizedBuffer).metadata(); // For mug wraps (wide images), content is centered horizontally // Calculate where the actual artwork sits within the transparent area const aspectRatio = md.width / md.height; const targetAspect = width / height; let contentWidth, contentHeight, contentLeft, contentTop; if (aspectRatio > targetAspect) { // Image is wider - fits width, has vertical padding contentWidth = width; contentHeight = Math.floor(width / aspectRatio); contentLeft = 0; contentTop = Math.floor((height - contentHeight) / 2); } else { // Image is taller - fits height, has horizontal padding (common for mug) contentHeight = height; contentWidth = Math.floor(height * aspectRatio); contentLeft = Math.floor((width - contentWidth) / 2); contentTop = 0; } console.log(`📐 Content bounds: ${contentWidth}x${contentHeight} at (${contentLeft}, ${contentTop})`); // QR code size based on content height (12% - smaller) const qrSize = Math.floor(contentHeight * 0.15); // Bigger QR const labelMargin = Math.floor(contentHeight * 0.02); // Generate QR code - position at LEFT side, BELOW the URL label if (qrSlug) { const qrUrl = `https://aesthetic.computer/${qrSlug}`; console.log(`🔲 Adding QR code: ${qrUrl} (${qrSize}px)`); try { const qrBuffer = await QRCode.toBuffer(qrUrl, { type: "png", width: qrSize, margin: 1, color: { dark: "#000000", light: "#ffffff" }, errorCorrectionLevel: "H", }); // Add white border then black border (no shadow) const whiteBorder = 4; const blackBorder = 2; // Create QR with white border const whiteBorderedQr = await sharp(qrBuffer) .extend({ top: whiteBorder, bottom: whiteBorder, left: whiteBorder, right: whiteBorder, background: { r: 255, g: 255, b: 255, alpha: 255 }, }) .png() .toBuffer(); // Add black border around that const paddedQr = await sharp(whiteBorderedQr) .extend({ top: blackBorder, bottom: blackBorder, left: blackBorder, right: blackBorder, background: { r: 0, g: 0, b: 0, alpha: 255 }, }) .png() .toBuffer(); const qrMeta = await sharp(paddedQr).metadata(); // Position: float RIGHT of content with gap, bottom aligned exactly with content const qrGap = 15; // Gap between QR and content const qrLeft = contentLeft + contentWidth + qrGap; // Right side const qrTop = contentTop + contentHeight - qrMeta.height; // Bottom aligned exactly composites.push({ input: paddedQr, left: Math.max(0, qrLeft), top: qrTop }); console.log(`✅ QR code outside content edge: (${qrLeft}, ${qrTop})`); } catch (qrError) { console.error("❌ QR code generation failed:", qrError.message); } } // Generate KidLisp.com label with proper colors (rotated 90° on left side of content) if (viaCode) { // KidLisp letter colors from kidlisp.com homepage const letterColors = { 'K': '#FF6B6B', 'd': '#FFE66D', 'L': '#95E1D3', 's': '#AA96DA', 'p': '#70D6FF', '.': '#95E1D3', // Green dot 'c': '#FF6B6B', 'o': '#9370DB', 'm': '#90EE90', // com matches play/stop/delete buttons '$': '#FFE66D', // Dollar sign in yellow '/': '#888888', // Slash in grey }; const labelText = `KidLisp.com/$${viaCode}`; const fontSize = Math.floor(contentHeight * 0.055); // Calculate actual text length needed // After -90° rotation: text runs bottom-to-top // SVG width = thickness of text (font height) // SVG height = length of text string const textLength = Math.ceil(labelText.length * fontSize * 0.6); const labelHeight = Math.ceil(fontSize * 1.5); // SVG width (horizontal) - font thickness const labelWidth = textLength + fontSize; // SVG height (vertical) - text length + padding console.log(`🏷️ Adding KidLisp label: ${labelText} (${fontSize}px)`); try { // Build individual tspan elements for each character with its color let tspans = ''; let blackTspans = ''; // For shadow let iCount = 0; // Track which 'i' we're on in KidLisp const dollarIndex = labelText.indexOf('$'); for (let i = 0; i < labelText.length; i++) { const char = labelText[i]; let color; if (i === dollarIndex) { // $ is yellow color = '#FFE66D'; } else if (i > dollarIndex) { // Code characters after $ are all green color = '#95E1D3'; } else if (char === 'i') { // First 'i' in KidLisp is teal, second is pink iCount++; color = iCount === 1 ? '#4ECDC4' : '#F38181'; } else { color = letterColors[char] || '#70D6FF'; } // Escape special chars for SVG const safeChar = char === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : char; tspans += `${safeChar}`; blackTspans += `${safeChar}`; } // Create rotated label SVG // Comic Relief is now installed system-wide for librsvg const fontFamily = "'Comic Relief', 'Comic Sans MS', cursive, sans-serif"; // After -90° rotation with text-anchor="start": // - Anchor point (x,y) is where the FIRST character sits // - Text extends UPWARD from there (toward y=0) // Position anchor near bottom so text extends upward into SVG const textX = labelHeight / 2; const textY = labelWidth - fontSize * 0.3; // Near bottom, text extends up const labelSvg = ` ${blackTspans} ${tspans} `; const labelBuffer = await sharp(Buffer.from(labelSvg)).png().toBuffer(); const labelMeta = await sharp(labelBuffer).metadata(); // Position: LEFT side of content, bottom aligned with content (like QR on right) const labelGap = 8; // Further from content const labelLeft = contentLeft - labelMeta.width - labelGap; // Left side of content // Bottom align with content bottom, nudge down a bit const labelTop = contentTop + contentHeight - labelMeta.height + 12; composites.push({ input: labelBuffer, left: Math.max(0, labelLeft), top: labelTop }); console.log(`✅ KidLisp label outside content edge: (${labelLeft}, ${labelTop})`); } catch (labelError) { console.error("❌ KidLisp label generation failed:", labelError.message); } } // Apply all composites if (composites.length > 0) { const resizedBuffer = await resizedImage.png().toBuffer(); buffer = await sharp(resizedBuffer) .composite(composites) .png() .toBuffer(); } else { buffer = await resizedImage.png().toBuffer(); } } else { buffer = await resizedImage.png().toBuffer(); } } else if (mode === "sticker") { // 🟠 Complex sticker mockup. const scalingFactor = md.width > md.height ? width / md.width : height / md.height; const margin = 0.1; // const marginPx = 128;//Math.floor(long * scalingFactor * margin); const marginPx = Math.floor(long * scalingFactor * margin); const rectWidth = Math.floor(md.width * scalingFactor) - marginPx; const rectHeight = Math.floor(md.height * scalingFactor) - marginPx; let combinedImage; if (!clear) { combinedImage = await sharp({ create: { width: md.width, height: md.height, channels: 4, background: { r: 32, g: 32, b: 32, alpha: 1 }, }, }) .composite([{ input: await original.toBuffer() }]) .png() .toBuffer(); } const resizedBuffer = await sharp( clear ? await original.toBuffer() : combinedImage, ) .resize({ width: rectWidth, // Adjusting the target dimensions for the padding height: rectHeight, fit: "fill", kernel, background: getRandomColor(), // { r: 0, g: 0, b: 0, alpha: 1 }, }) .toBuffer(); const radius = Math.floor(long * scalingFactor * 0.02), pad = Math.floor(long * scalingFactor * 0.05); const svg = ` `; const rectangleBuffer = await sharp(Buffer.from(svg)).toBuffer(); // Composite the resized rectangle to the rounded sheet. buffer = await sharp(rectangleBuffer) .composite([{ input: resizedBuffer }]) .png() .toBuffer(); } return { statusCode: 200, headers: { "Content-Type": "image/png", "Content-Length": buffer.length.toString(), }, body: buffer.toString("base64"), ttl: 60, isBase64Encoded: true, }; } catch (error) { return respond(500, { message: "Internal Server Error", error: error.message, }); } } else { return respond(405, { message: "Method Not Allowed" }); } } function getRandomColor() { return { r: Math.floor(Math.random() * 256), g: Math.floor(Math.random() * 256), b: Math.floor(Math.random() * 256), alpha: 1, }; } export const handler = fun;