Monorepo for Aesthetic.Computer
aesthetic.computer
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}