Privacy-preserving location sharing with end-to-end encryption coord.is
fork

Configure Feed

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

at master 259 lines 7.6 kB view raw
1import sharp from 'sharp'; 2import { fileURLToPath } from 'url'; 3import { dirname, join } from 'path'; 4import { access, mkdir, readFile } from 'fs/promises'; 5 6const __dirname = dirname(fileURLToPath(import.meta.url)); 7const screenshotsDir = join(__dirname, 'screenshots'); 8const framesDir = join(__dirname, 'frames'); 9const outputDir = join(__dirname, '..', 'public'); 10 11async function fileExists(path) { 12 try { 13 await access(path); 14 return true; 15 } catch { 16 return false; 17 } 18} 19 20async function createDeviceFrame(screenshotPath, device) { 21 const configPath = join(framesDir, device, 'config.json'); 22 const config = JSON.parse(await readFile(configPath, 'utf-8')); 23 24 // Find frame file (could be .png or .webp) 25 let framePath = join(framesDir, device, 'frame.png'); 26 if (!await fileExists(framePath)) { 27 framePath = join(framesDir, device, 'back.webp'); 28 } 29 30 31 // Resize screenshot to match screen dimensions 32 let screenshot = await sharp(screenshotPath) 33 .resize(config.screenWidth, config.screenHeight, { 34 fit: 'cover', 35 position: 'top', 36 }) 37 .toBuffer(); 38 39 // Apply rounded corners to screenshot if specified (for iPhone display) 40 if (config.screenCornerRadius) { 41 const mask = Buffer.from( 42 `<svg width="${config.screenWidth}" height="${config.screenHeight}"> 43 <rect width="${config.screenWidth}" height="${config.screenHeight}" 44 rx="${config.screenCornerRadius}" fill="white"/> 45 </svg>` 46 ); 47 screenshot = await sharp(screenshot) 48 .composite([{ input: mask, blend: 'dest-in' }]) 49 .png() 50 .toBuffer(); 51 } 52 53 // Load the device frame 54 const frame = await sharp(framePath).png().toBuffer(); 55 56 // Composite screenshot behind frame (frame has transparent screen area) 57 let result = await sharp({ 58 create: { 59 width: config.frameWidth, 60 height: config.frameHeight, 61 channels: 4, 62 background: { r: 0, g: 0, b: 0, alpha: 0 }, 63 }, 64 }) 65 .composite([ 66 // Screenshot first (behind) 67 { 68 input: screenshot, 69 top: config.screenY, 70 left: config.screenX, 71 }, 72 // Frame on top 73 { 74 input: frame, 75 top: 0, 76 left: 0, 77 }, 78 ]) 79 .png() 80 .toBuffer(); 81 82 // Add shadow around device 83 const shadowPadding = 50; 84 const resultMeta = await sharp(result).metadata(); 85 86 // Create a black silhouette of the device 87 const silhouette = await sharp({ 88 create: { 89 width: resultMeta.width, 90 height: resultMeta.height, 91 channels: 4, 92 background: { r: 0, g: 0, b: 0, alpha: 255 }, 93 }, 94 }) 95 .composite([ 96 { 97 input: await sharp(result).ensureAlpha().toBuffer(), 98 blend: 'dest-in', 99 }, 100 ]) 101 .png() 102 .toBuffer(); 103 104 // Blur the silhouette for shadow effect 105 const blurredShadow = await sharp(silhouette) 106 .blur(20) 107 .modulate({ lightness: 0.3 }) 108 .png() 109 .toBuffer(); 110 111 const withShadow = await sharp({ 112 create: { 113 width: resultMeta.width + shadowPadding * 2, 114 height: resultMeta.height + shadowPadding * 2, 115 channels: 4, 116 background: { r: 0, g: 0, b: 0, alpha: 0 }, 117 }, 118 }) 119 .composite([ 120 // Shadow (offset down and slightly right) 121 { 122 input: blurredShadow, 123 top: shadowPadding + 10, 124 left: shadowPadding + 5, 125 }, 126 // Device on top 127 { 128 input: result, 129 top: shadowPadding, 130 left: shadowPadding, 131 }, 132 ]) 133 .png() 134 .toBuffer(); 135 136 return withShadow; 137} 138 139async function generateHero() { 140 console.log('Generating hero image...'); 141 142 const iphonePath = join(screenshotsDir, 'iphone.png'); 143 const androidPath = join(screenshotsDir, 'android.png'); 144 145 const hasIphone = await fileExists(iphonePath); 146 const hasAndroid = await fileExists(androidPath); 147 148 if (!hasIphone && !hasAndroid) { 149 console.error('No screenshots found!'); 150 console.error('Add screenshots to: website/scripts/screenshots/'); 151 console.error(' - iphone.png'); 152 console.error(' - android.png'); 153 process.exit(1); 154 } 155 156 console.log(`Found: ${[hasIphone && 'iPhone', hasAndroid && 'Android'].filter(Boolean).join(', ')}`); 157 158 let iphoneFrame, androidFrame; 159 160 if (hasAndroid) { 161 console.log('Creating Android frame (Pixel 7)...'); 162 androidFrame = await createDeviceFrame(androidPath, 'pixel_7'); 163 } 164 165 if (hasIphone) { 166 console.log('Creating iPhone frame (iPhone 17)...'); 167 iphoneFrame = await createDeviceFrame(iphonePath, 'iphone_17'); 168 } 169 170 // Get frame dimensions 171 const iphoneMeta = iphoneFrame ? await sharp(iphoneFrame).metadata() : null; 172 const androidMeta = androidFrame ? await sharp(androidFrame).metadata() : null; 173 174 const composites = []; 175 let canvasWidth, canvasHeight; 176 177 if (hasIphone && hasAndroid) { 178 // Both devices - iPhone left/front, Android right/behind 179 const overlap = 200; 180 const androidOffsetY = 271; // Align with iPhone 181 const iphoneOffsetY = 80; 182 const androidOffsetX = iphoneMeta.width - overlap; // Android starts behind iPhone's right edge 183 184 canvasWidth = iphoneMeta.width + androidMeta.width - overlap; 185 canvasHeight = Math.max(androidMeta.height + androidOffsetY, iphoneMeta.height + iphoneOffsetY); 186 187 // Android first (behind), then iPhone (front) 188 composites.push({ input: androidFrame, top: androidOffsetY, left: androidOffsetX }); 189 composites.push({ input: iphoneFrame, top: iphoneOffsetY, left: 0 }); 190 } else if (hasIphone) { 191 canvasWidth = iphoneMeta.width; 192 canvasHeight = iphoneMeta.height; 193 composites.push({ input: iphoneFrame, top: 0, left: 0 }); 194 } else { 195 canvasWidth = androidMeta.width; 196 canvasHeight = androidMeta.height; 197 composites.push({ input: androidFrame, top: 0, left: 0 }); 198 } 199 200 // Create final image 201 await mkdir(outputDir, { recursive: true }); 202 203 // Full resolution PNG for reference 204 const fullResRaw = await sharp({ 205 create: { 206 width: canvasWidth, 207 height: canvasHeight, 208 channels: 4, 209 background: { r: 0, g: 0, b: 0, alpha: 0 }, 210 }, 211 }) 212 .composite(composites) 213 .png() 214 .toBuffer(); 215 216 // Crop to remove wasted space 217 const cropLeft = 55; 218 const cropRight = 45; 219 const cropTop = 140; 220 const cropBottom = 40; 221 const fullRes = await sharp(fullResRaw) 222 .extract({ 223 left: cropLeft, 224 top: cropTop, 225 width: canvasWidth - cropLeft - cropRight, 226 height: canvasHeight - cropTop - cropBottom, 227 }) 228 .png() 229 .toBuffer(); 230 231 // Update dimensions after crop 232 canvasWidth = canvasWidth - cropLeft - cropRight; 233 canvasHeight = canvasHeight - cropTop - cropBottom; 234 235 // Output full-res PNG for reference/debugging 236 const pngPath = join(outputDir, 'hero-devices.png'); 237 await sharp(fullRes).png().toFile(pngPath); 238 const pngMeta = await sharp(fullRes).metadata(); 239 console.log(`\nSaved: public/hero-devices.png (${pngMeta.width}x${pngMeta.height}, full res)`); 240 241 // Output optimized WebP (resized to max 2000px wide, quality 92) 242 const webpPath = join(outputDir, 'hero-devices.webp'); 243 const maxWidth = 2000; 244 const scale = maxWidth / canvasWidth; 245 const outputWidth = maxWidth; 246 const outputHeight = Math.round(canvasHeight * scale); 247 248 await sharp(fullRes) 249 .resize(outputWidth, outputHeight) 250 .webp({ quality: 92 }) 251 .toFile(webpPath); 252 253 const stats = await import('fs/promises').then(fs => fs.stat(webpPath)); 254 const sizeKB = Math.round(stats.size / 1024); 255 256 console.log(`Saved: public/hero-devices.webp (${outputWidth}x${outputHeight}, ${sizeKB}KB)`); 257} 258 259generateHero().catch(console.error);