Monorepo for Aesthetic.Computer
aesthetic.computer
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 === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : 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;