fork
Configure Feed
Select the types of activity you want to include in your feed.
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.
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);