Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 164 lines 6.1 kB view raw
1#!/usr/bin/env node 2// thumbnail.mjs 3// Shared thumbnail generator for paintings 4// Uses Sharp (like pixel.js) to create 512x512 thumbnails for ATProto 5 6import sharp from "sharp"; 7 8/** 9 * Generate a thumbnail from an image URL or buffer 10 * @param {string|Buffer} source - Image URL or Buffer 11 * @param {Object} options - Options 12 * @param {number} options.size - Thumbnail size (default: 512) 13 * @param {string} options.format - Output format 'png' or 'jpeg' (default: 'png') 14 * @param {string} options.fit - How to fit: 'cover', 'contain', 'fill' (default: 'contain') 15 * @param {number} options.quality - JPEG quality 1-100 (default: 90) 16 * @param {string} options.background - Background color for letterboxing (default: '#000000') 17 * @returns {Promise<Buffer>} Thumbnail buffer 18 */ 19export async function generateThumbnail(source, options = {}) { 20 const size = options.size || 512; 21 const format = options.format || "png"; 22 const fit = options.fit || "contain"; // 'contain' letterboxes, 'cover' crops 23 const quality = options.quality || 90; 24 const background = options.background || "#000000"; // Black letterbox 25 26 try { 27 let imageBuffer; 28 29 // If source is a URL, fetch it 30 if (typeof source === "string") { 31 const { got } = await import("got"); 32 const response = await got(source, { 33 responseType: "buffer", 34 https: { rejectUnauthorized: process.env.CONTEXT !== "dev" }, 35 }); 36 imageBuffer = response.body; 37 } else if (Buffer.isBuffer(source)) { 38 imageBuffer = source; 39 } else { 40 throw new Error("Source must be a URL string or Buffer"); 41 } 42 43 // Get image metadata to check if we need to upscale 44 const metadata = await sharp(imageBuffer).metadata(); 45 const isSmaller = metadata.width < size || metadata.height < size; 46 47 // Create thumbnail with Sharp 48 let pipeline = sharp(imageBuffer).resize({ 49 width: size, 50 height: size, 51 fit, // 'contain' preserves aspect ratio with letterboxing 52 kernel: isSmaller ? sharp.kernel.nearest : sharp.kernel.lanczos3, // Nearest neighbor for pixel art upscaling 53 background, // Letterbox color 54 }); 55 56 // Choose output format 57 if (format === "jpeg" || format === "jpg") { 58 pipeline = pipeline.jpeg({ quality }); 59 } else { 60 pipeline = pipeline.png(); 61 } 62 63 const thumbnail = await pipeline.toBuffer(); 64 return thumbnail; 65 } catch (error) { 66 throw new Error(`Failed to generate thumbnail: ${error.message}`); 67 } 68} 69 70/** 71 * Generate thumbnail from an aesthetic.computer painting slug 72 * @param {string} slug - Painting slug (timestamp or short code) 73 * @param {string} handleOrCode - User handle (@username) or user code (acXXXXX) - optional, for user paintings 74 * @param {Object} options - Options (same as generateThumbnail) 75 * @returns {Promise<Buffer>} Thumbnail buffer 76 */ 77export async function getThumbnailFromSlug(slug, handleOrCode = null, options = {}) { 78 const size = options.size || 512; 79 const cleanSlug = slug.replace(/\.(png|zip)$/i, ""); 80 const isDev = process.env.CONTEXT === "dev"; 81 82 let imageUrl; 83 84 if (handleOrCode) { 85 // User painting: use /api/pixel endpoint which already resizes with Sharp 86 const cleanIdentifier = handleOrCode.replace(/^@/, ""); 87 const host = isDev ? "https://localhost:8888" : "https://aesthetic.computer"; 88 imageUrl = `${host}/api/pixel/${size}:contain/@${cleanIdentifier}/painting/${cleanSlug}.png`; 89 90 // Fetch the pre-resized image from /api/pixel 91 try { 92 const { got } = await import("got"); 93 const response = await got(imageUrl, { 94 responseType: "buffer", 95 https: { rejectUnauthorized: !isDev }, 96 }); 97 console.log(`🖼️ Fetched via /api/pixel: ${imageUrl}`); 98 return response.body; 99 } catch (error) { 100 console.warn(`⚠️ Failed to fetch from /api/pixel: ${error.message}`); 101 // Fall back to manual resize from /media endpoint 102 imageUrl = `${host}/media/@${cleanIdentifier}/painting/${cleanSlug}.png`; 103 } 104 } else { 105 // Anonymous/guest painting: Direct DO Spaces URL (public bucket) 106 imageUrl = `https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/${cleanSlug}.png`; 107 } 108 109 console.log(`🖼️ Fetching: ${imageUrl}`); 110 return generateThumbnail(imageUrl, options); 111} 112 113/** 114 * Get thumbnail stats (size in bytes, dimensions) 115 * @param {Buffer} thumbnail - Thumbnail buffer 116 * @returns {Promise<Object>} Stats { size, width, height, format } 117 */ 118export async function getThumbnailStats(thumbnail) { 119 const metadata = await sharp(thumbnail).metadata(); 120 return { 121 size: thumbnail.length, 122 width: metadata.width, 123 height: metadata.height, 124 format: metadata.format, 125 }; 126} 127 128// CLI usage: node thumbnail.mjs <slug> [handle] 129if (process.argv[1]?.endsWith('thumbnail.mjs')) { 130 (async () => { 131 const slug = process.argv[2]; 132 const handle = process.argv[3]; 133 134 if (!slug) { 135 console.error("Usage: node thumbnail.mjs <slug> [handle]"); 136 console.error("Examples:"); 137 console.error(" node thumbnail.mjs 2023.8.24.16.21.09.123 jeffrey"); 138 console.error(" node thumbnail.mjs Lw2OYs0H"); 139 process.exit(1); 140 } 141 142 try { 143 console.log(`\n🎨 Generating thumbnail for: ${slug}`); 144 if (handle) console.log(` Handle: @${handle.replace(/^@/, "")}`); 145 146 const thumbnail = await getThumbnailFromSlug(slug, handle); 147 const stats = await getThumbnailStats(thumbnail); 148 149 console.log(`\n✅ Thumbnail generated!`); 150 console.log(` Size: ${(stats.size / 1024).toFixed(2)} KB`); 151 console.log(` Dimensions: ${stats.width}x${stats.height}`); 152 console.log(` Format: ${stats.format}`); 153 154 // Save to file 155 const outputFile = `thumbnail-${slug.replace(/[/@:.]/g, "-")}.png`; 156 const fs = await import("fs/promises"); 157 await fs.writeFile(outputFile, thumbnail); 158 console.log(` Saved to: ${outputFile}\n`); 159 } catch (error) { 160 console.error(`\n❌ Error: ${error.message}\n`); 161 process.exit(1); 162 } 163 })(); 164}