Monorepo for Aesthetic.Computer aesthetic.computer
at main 519 lines 22 kB view raw
1// Pixel, 23.08.26.13.09 2// An endpoint to transform / affect input pixels from 3// a painting before returning them. 4 5// Usage: /pixel/widthxheight/bucket/painting or add an extension. 6 7// Test URLs: https://aesthetic.local:8888/api/pixel/1650x1650/@jeffrey/painting/2023.8.24.16.21.09.123.png 8// https://aesthetic.local:8888/api/pixel/1650x1650/Lw2OYs0H.png 9// https://aesthetic.local:8888/api/pixel/1650x1650:contain/@jeffrey/painting/2023.9.04.21.10.34.574.png 10// ^ mode for fit 11 12/* #region 🏁 TODO 13 + Done 14 - [x] Add a compositor for stickers that would be faster than Printful. 15 - [x] Add a fitMode. 16 - [x] Nearest neighbor scale a painting after opening it. 17#endregion */ 18 19// builder from @netlify/functions was imported here but never used. 20// Removed require() to support ESM-only environments (lith). 21import sharp from "sharp"; 22import QRCode from "qrcode"; 23import { readFileSync, mkdirSync, copyFileSync, existsSync } from "fs"; 24import { join, dirname } from "path"; 25import { fileURLToPath } from "url"; 26import { execSync } from "child_process"; 27import { respond } from "../../backend/http.mjs"; 28const dev = process.env.CONTEXT === "dev"; 29 30// Load Comic Relief Bold font and register with fontconfig 31let comicReliefFontAvailable = false; 32try { 33 const __dirname = dirname(fileURLToPath(import.meta.url)); 34 const fontSrc = join(__dirname, "../fonts/ComicRelief-Bold.ttf"); 35 const fontDir = "/tmp/fonts"; 36 const fontDest = join(fontDir, "ComicRelief-Bold.ttf"); 37 38 if (!existsSync(fontDest)) { 39 mkdirSync(fontDir, { recursive: true }); 40 copyFileSync(fontSrc, fontDest); 41 // Update fontconfig cache 42 execSync(`fc-cache -f ${fontDir} 2>/dev/null || true`); 43 console.log("✅ Comic Relief font registered with fontconfig"); 44 } 45 comicReliefFontAvailable = true; 46} catch (e) { 47 console.warn("⚠️ Comic Relief font setup failed:", e.message); 48} 49 50async function fun(event, context) { 51 if ( 52 event.httpMethod === "GET" && 53 (event.headers["host"] === "aesthetic.computer" || dev) 54 ) { 55 const params = event.path.replace("/api/pixel/", "").split("/"); 56 let [pre, premode] = params[0].split(":"); 57 premode ||= "fill"; // or "contain", or "conform" or "sticker" 58 let [mode, compose] = premode.split("-"); 59 const clear = compose === "clear"; 60 // TODO: Eventually use a "-clear" option to keep the backdrop transparent. 61 // 23.09.06.02.27 62 const resolution = pre.split("x").map((n) => parseInt(n)); 63 let slug = params.slice(1).join("/"); 64 let imageUrl; // Declare imageUrl at function scope 65 66 // Check for QR code parameter (for print files that need QR baked in) 67 // Usage: /api/pixel/2700x1050:contain-clear/CODE.png?qr=mug~+CODE&via=kidlispcode 68 const qrSlug = event.queryStringParameters?.qr; 69 const qrPosition = event.queryStringParameters?.qrpos || "bottom-right"; // bottom-right, bottom-left, top-right, top-left 70 const viaCode = event.queryStringParameters?.via; // KidLisp source code for rotated side label 71 72 // Check for + prefix (print/product code) - e.g., "+abc123.png" or "+abc123" 73 // Print codes use + prefix like # for paintings, $ for kidlisp, ! for tapes 74 const printCodePattern = /^\+([a-z0-9]{6})(\.png|\.webp)?$/; 75 const printCodeMatch = slug.match(printCodePattern); 76 77 if (printCodeMatch) { 78 const productCode = printCodeMatch[1]; 79 console.log(`🖨️ Looking up print/product by code: +${productCode}`); 80 81 try { 82 const { getProduct } = await import("../../backend/products.mjs"); 83 const product = await getProduct(productCode); 84 85 if (product?.preview) { 86 imageUrl = product.preview; 87 console.log(`✅ Resolved +${productCode} to preview: ${imageUrl}`); 88 } else { 89 // Try direct S3 path for products 90 imageUrl = `https://art.aesthetic.computer/products/${productCode}.webp`; 91 console.log(`📦 No preview cached, trying S3: ${imageUrl}`); 92 } 93 } catch (error) { 94 console.error(`❌ Error looking up product code: ${error.message}`); 95 imageUrl = `https://art.aesthetic.computer/products/${productCode}.webp`; 96 } 97 } 98 99 // Check if slug is a painting code (short alphanumeric without path separators) 100 // Examples: "mgy.png", "abc", "t84.png", "8BzEwZGt.png" 101 const codePattern = /^([a-zA-Z0-9]{2,10})(\.png)?$/; 102 const codeMatch = !imageUrl && slug.match(codePattern); 103 104 if (codeMatch) { 105 const code = codeMatch[1]; // Extract code without extension 106 console.log(`🔍 Looking up painting by code: ${code}`); 107 108 try { 109 const { connect } = await import("../../backend/database.mjs"); 110 const database = await connect(); 111 const painting = await database.db.collection('paintings').findOne({ code }); 112 113 if (painting) { 114 // Build slug from painting data 115 // Handle combined slugs (split to get image slug only) 116 let imageSlug = painting.slug; 117 if (imageSlug.includes(':')) { 118 [imageSlug] = imageSlug.split(':'); 119 } 120 121 // Construct DO Spaces URL directly (same logic as media.js edge function) 122 const bucket = painting.user 123 ? "user-aesthetic-computer" 124 : "art-aesthetic-computer"; 125 const key = painting.user 126 ? `${encodeURIComponent(painting.user)}/painting/${imageSlug}.png` 127 : `${imageSlug}.png`; 128 imageUrl = `https://${bucket}.sfo3.digitaloceanspaces.com/${key}`; 129 console.log(`✅ Resolved code ${code} to DO Spaces URL: ${imageUrl}`); 130 } else { 131 // No DB record - try guest art bucket directly (anonymous uploads) 132 imageUrl = `https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/${code}.png`; 133 console.log(`📦 No DB record for ${code}, trying guest bucket: ${imageUrl}`); 134 } 135 136 await database.disconnect(); 137 } catch (error) { 138 console.error(`❌ Error looking up painting code: ${error.message}`); 139 // Fall back to guest bucket on DB error too 140 imageUrl = `https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/${code}.png`; 141 console.log(`📦 DB error, trying guest bucket: ${imageUrl}`); 142 } 143 } 144 145 // Fall back to constructing URL from slug if not set by code lookup 146 if (!imageUrl) { 147 imageUrl = `https://${event.headers["host"]}/media/${slug}`; 148 } 149 150 if (!imageUrl) return respond(400, { message: "Image URL not provided." }); 151 152 try { 153 const { got } = await import("got"); 154 const response = await got(imageUrl, { 155 responseType: "buffer", 156 https: { 157 rejectUnauthorized: !dev, 158 }, 159 }); 160 161 // Resize the image using nearest neighbor filtering with "sharp" 162 // Docs: https://sharp.pixelplumbing.com/api-resize 163 let buffer; 164 165 const original = await sharp(response.body); 166 const md = await original.metadata(); 167 console.log("Resolution:", resolution, "Image:", imageUrl); 168 console.log("Metadata:", md); 169 const width = resolution[0]; 170 const height = resolution[1] || resolution[0]; 171 const long = Math.max(md.width, md.height); 172 const kernel = 173 width > md.width || height > md.height 174 ? sharp.kernel.nearest // For scaling up. 175 : sharp.kernel.lanczos3; // For scaling down. 176 177 if (mode !== "sticker") { 178 // 🟠. Simple resizing. 179 180 // Make sure the original image has a colored backdrop. 181 // TODO: Should this actually be a weird gray, or a gradient? 182 // or maybe it should be random? 183 let combinedImage; 184 if (!clear) { 185 combinedImage = await sharp({ 186 create: { 187 width: md.width, 188 height: md.height, 189 channels: 4, 190 background: { r: 32, g: 32, b: 32, alpha: 1 }, 191 }, 192 }) 193 .composite([{ input: await original.toBuffer() }]) 194 .png() 195 .toBuffer(); 196 } 197 198 // Use "conform" to resize to the longest aspect. 199 let resolution; 200 if (mode === "conform") { 201 resolution = 202 md.width >= md.height ? { width: width } : { height: height }; 203 mode = "inside"; 204 } else { 205 resolution = { width, height }; 206 } 207 208 let resizedImage = sharp(clear ? await original.toBuffer() : combinedImage) 209 .resize({ 210 ...resolution, 211 fit: mode, 212 kernel, 213 background: { r: 0, g: 0, b: 0, alpha: 0 }, 214 }); 215 216 // 🔲 Add QR code and KidLisp.com label to print file 217 // Position them at the edges of the CONTENT, not the transparent area 218 if (qrSlug || viaCode) { 219 const composites = []; 220 221 // First, get the resized image and find content bounds 222 const resizedBuffer = await resizedImage.png().toBuffer(); 223 const resizedMeta = await sharp(resizedBuffer).metadata(); 224 225 // For mug wraps (wide images), content is centered horizontally 226 // Calculate where the actual artwork sits within the transparent area 227 const aspectRatio = md.width / md.height; 228 const targetAspect = width / height; 229 let contentWidth, contentHeight, contentLeft, contentTop; 230 231 if (aspectRatio > targetAspect) { 232 // Image is wider - fits width, has vertical padding 233 contentWidth = width; 234 contentHeight = Math.floor(width / aspectRatio); 235 contentLeft = 0; 236 contentTop = Math.floor((height - contentHeight) / 2); 237 } else { 238 // Image is taller - fits height, has horizontal padding (common for mug) 239 contentHeight = height; 240 contentWidth = Math.floor(height * aspectRatio); 241 contentLeft = Math.floor((width - contentWidth) / 2); 242 contentTop = 0; 243 } 244 245 console.log(`📐 Content bounds: ${contentWidth}x${contentHeight} at (${contentLeft}, ${contentTop})`); 246 247 // QR code size based on content height (12% - smaller) 248 const qrSize = Math.floor(contentHeight * 0.15); // Bigger QR 249 const labelMargin = Math.floor(contentHeight * 0.02); 250 251 // Generate QR code - position at LEFT side, BELOW the URL label 252 if (qrSlug) { 253 const qrUrl = `https://aesthetic.computer/${qrSlug}`; 254 255 console.log(`🔲 Adding QR code: ${qrUrl} (${qrSize}px)`); 256 257 try { 258 const qrBuffer = await QRCode.toBuffer(qrUrl, { 259 type: "png", 260 width: qrSize, 261 margin: 1, 262 color: { dark: "#000000", light: "#ffffff" }, 263 errorCorrectionLevel: "H", 264 }); 265 266 // Add white border then black border (no shadow) 267 const whiteBorder = 4; 268 const blackBorder = 2; 269 270 // Create QR with white border 271 const whiteBorderedQr = await sharp(qrBuffer) 272 .extend({ 273 top: whiteBorder, bottom: whiteBorder, left: whiteBorder, right: whiteBorder, 274 background: { r: 255, g: 255, b: 255, alpha: 255 }, 275 }) 276 .png() 277 .toBuffer(); 278 279 // Add black border around that 280 const paddedQr = await sharp(whiteBorderedQr) 281 .extend({ 282 top: blackBorder, bottom: blackBorder, left: blackBorder, right: blackBorder, 283 background: { r: 0, g: 0, b: 0, alpha: 255 }, 284 }) 285 .png() 286 .toBuffer(); 287 288 const qrMeta = await sharp(paddedQr).metadata(); 289 // Position: float RIGHT of content with gap, bottom aligned exactly with content 290 const qrGap = 15; // Gap between QR and content 291 const qrLeft = contentLeft + contentWidth + qrGap; // Right side 292 const qrTop = contentTop + contentHeight - qrMeta.height; // Bottom aligned exactly 293 294 composites.push({ input: paddedQr, left: Math.max(0, qrLeft), top: qrTop }); 295 console.log(`✅ QR code outside content edge: (${qrLeft}, ${qrTop})`); 296 } catch (qrError) { 297 console.error("❌ QR code generation failed:", qrError.message); 298 } 299 } 300 301 // Generate KidLisp.com label with proper colors (rotated 90° on left side of content) 302 if (viaCode) { 303 // KidLisp letter colors from kidlisp.com homepage 304 const letterColors = { 305 'K': '#FF6B6B', 'd': '#FFE66D', 'L': '#95E1D3', 306 's': '#AA96DA', 'p': '#70D6FF', 307 '.': '#95E1D3', // Green dot 308 'c': '#FF6B6B', 'o': '#9370DB', 'm': '#90EE90', // com matches play/stop/delete buttons 309 '$': '#FFE66D', // Dollar sign in yellow 310 '/': '#888888', // Slash in grey 311 }; 312 313 const labelText = `KidLisp.com/$${viaCode}`; 314 const fontSize = Math.floor(contentHeight * 0.055); 315 316 // Calculate actual text length needed 317 // After -90° rotation: text runs bottom-to-top 318 // SVG width = thickness of text (font height) 319 // SVG height = length of text string 320 const textLength = Math.ceil(labelText.length * fontSize * 0.6); 321 const labelHeight = Math.ceil(fontSize * 1.5); // SVG width (horizontal) - font thickness 322 const labelWidth = textLength + fontSize; // SVG height (vertical) - text length + padding 323 324 console.log(`🏷️ Adding KidLisp label: ${labelText} (${fontSize}px)`); 325 326 try { 327 // Build individual tspan elements for each character with its color 328 let tspans = ''; 329 let blackTspans = ''; // For shadow 330 let iCount = 0; // Track which 'i' we're on in KidLisp 331 const dollarIndex = labelText.indexOf('$'); 332 333 for (let i = 0; i < labelText.length; i++) { 334 const char = labelText[i]; 335 let color; 336 337 if (i === dollarIndex) { 338 // $ is yellow 339 color = '#FFE66D'; 340 } else if (i > dollarIndex) { 341 // Code characters after $ are all green 342 color = '#95E1D3'; 343 } else if (char === 'i') { 344 // First 'i' in KidLisp is teal, second is pink 345 iCount++; 346 color = iCount === 1 ? '#4ECDC4' : '#F38181'; 347 } else { 348 color = letterColors[char] || '#70D6FF'; 349 } 350 351 // Escape special chars for SVG 352 const safeChar = char === '<' ? '&lt;' : char === '>' ? '&gt;' : char === '&' ? '&amp;' : char; 353 tspans += `<tspan fill="${color}">${safeChar}</tspan>`; 354 blackTspans += `<tspan fill="#000">${safeChar}</tspan>`; 355 } 356 357 // Create rotated label SVG 358 // Comic Relief is now installed system-wide for librsvg 359 const fontFamily = "'Comic Relief', 'Comic Sans MS', cursive, sans-serif"; 360 361 // After -90° rotation with text-anchor="start": 362 // - Anchor point (x,y) is where the FIRST character sits 363 // - Text extends UPWARD from there (toward y=0) 364 // Position anchor near bottom so text extends upward into SVG 365 const textX = labelHeight / 2; 366 const textY = labelWidth - fontSize * 0.3; // Near bottom, text extends up 367 368 const labelSvg = ` 369 <svg width="${labelHeight}" height="${labelWidth}" xmlns="http://www.w3.org/2000/svg"> 370 <!-- Shadow layer - solid black offset down-right in rotated space --> 371 <text x="${textX + 2}" y="${textY - 2}" 372 font-family="${fontFamily}" 373 font-size="${fontSize}" font-weight="bold" 374 text-anchor="start" dominant-baseline="middle" 375 letter-spacing="0.02em" 376 transform="rotate(-90, ${textX + 2}, ${textY - 2})">${blackTspans}</text> 377 <!-- Main colored text --> 378 <text x="${textX}" y="${textY}" 379 font-family="${fontFamily}" 380 font-size="${fontSize}" font-weight="bold" 381 text-anchor="start" dominant-baseline="middle" 382 letter-spacing="0.02em" 383 transform="rotate(-90, ${textX}, ${textY})">${tspans}</text> 384 </svg> 385 `; 386 const labelBuffer = await sharp(Buffer.from(labelSvg)).png().toBuffer(); 387 const labelMeta = await sharp(labelBuffer).metadata(); 388 389 // Position: LEFT side of content, bottom aligned with content (like QR on right) 390 const labelGap = 8; // Further from content 391 const labelLeft = contentLeft - labelMeta.width - labelGap; // Left side of content 392 // Bottom align with content bottom, nudge down a bit 393 const labelTop = contentTop + contentHeight - labelMeta.height + 12; 394 395 composites.push({ input: labelBuffer, left: Math.max(0, labelLeft), top: labelTop }); 396 console.log(`✅ KidLisp label outside content edge: (${labelLeft}, ${labelTop})`); 397 } catch (labelError) { 398 console.error("❌ KidLisp label generation failed:", labelError.message); 399 } 400 } 401 402 // Apply all composites 403 if (composites.length > 0) { 404 const resizedBuffer = await resizedImage.png().toBuffer(); 405 buffer = await sharp(resizedBuffer) 406 .composite(composites) 407 .png() 408 .toBuffer(); 409 } else { 410 buffer = await resizedImage.png().toBuffer(); 411 } 412 } else { 413 buffer = await resizedImage.png().toBuffer(); 414 } 415 } else if (mode === "sticker") { 416 // 🟠 Complex sticker mockup. 417 const scalingFactor = 418 md.width > md.height ? width / md.width : height / md.height; 419 420 const margin = 0.1; 421 // const marginPx = 128;//Math.floor(long * scalingFactor * margin); 422 const marginPx = Math.floor(long * scalingFactor * margin); 423 const rectWidth = Math.floor(md.width * scalingFactor) - marginPx; 424 const rectHeight = Math.floor(md.height * scalingFactor) - marginPx; 425 426 let combinedImage; 427 if (!clear) { 428 combinedImage = await sharp({ 429 create: { 430 width: md.width, 431 height: md.height, 432 channels: 4, 433 background: { r: 32, g: 32, b: 32, alpha: 1 }, 434 }, 435 }) 436 .composite([{ input: await original.toBuffer() }]) 437 .png() 438 .toBuffer(); 439 } 440 441 const resizedBuffer = await sharp( 442 clear ? await original.toBuffer() : combinedImage, 443 ) 444 .resize({ 445 width: rectWidth, // Adjusting the target dimensions for the padding 446 height: rectHeight, 447 fit: "fill", 448 kernel, 449 background: getRandomColor(), // { r: 0, g: 0, b: 0, alpha: 1 }, 450 }) 451 .toBuffer(); 452 453 const radius = Math.floor(long * scalingFactor * 0.02), 454 pad = Math.floor(long * scalingFactor * 0.05); 455 456 const svg = ` 457 <svg width="${rectWidth + marginPx}" height="${ 458 rectHeight + marginPx 459 }" xmlns="http://www.w3.org/2000/svg"> 460 <defs> 461 <filter id="dropshadow" height="130%"> 462 <feGaussianBlur in="SourceAlpha" stdDeviation="3"/> 463 <feOffset dx="2" dy="2" result="offsetblur"/> 464 <feComponentTransfer> 465 <feFuncA type="linear" slope="0.5"/> 466 </feComponentTransfer> 467 <feMerge> 468 <feMergeNode/> 469 <feMergeNode in="SourceGraphic"/> 470 </feMerge> 471 </filter> 472 </defs> 473 <rect x="${marginPx / 2 - pad / 2}" y="${ 474 marginPx / 2 - pad / 2 475 }" width="${rectWidth + pad}" height="${ 476 rectHeight + pad 477 }" rx="${radius}" ry="${radius}" fill="white" filter="url(#dropshadow)" /> 478 </svg>`; 479 480 const rectangleBuffer = await sharp(Buffer.from(svg)).toBuffer(); 481 482 // Composite the resized rectangle to the rounded sheet. 483 buffer = await sharp(rectangleBuffer) 484 .composite([{ input: resizedBuffer }]) 485 .png() 486 .toBuffer(); 487 } 488 489 return { 490 statusCode: 200, 491 headers: { 492 "Content-Type": "image/png", 493 "Content-Length": buffer.length.toString(), 494 }, 495 body: buffer.toString("base64"), 496 ttl: 60, 497 isBase64Encoded: true, 498 }; 499 } catch (error) { 500 return respond(500, { 501 message: "Internal Server Error", 502 error: error.message, 503 }); 504 } 505 } else { 506 return respond(405, { message: "Method Not Allowed" }); 507 } 508} 509 510function getRandomColor() { 511 return { 512 r: Math.floor(Math.random() * 256), 513 g: Math.floor(Math.random() * 256), 514 b: Math.floor(Math.random() * 256), 515 alpha: 1, 516 }; 517} 518 519export const handler = fun;