Files
Sauci/scripts/app-store-screenshots/generate.js
T
2025-12-30 21:24:25 +00:00

313 lines
9.5 KiB
JavaScript

import sharp from "sharp";
import { createCanvas, loadImage } from "@napi-rs/canvas";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// App Store screenshot dimensions
const DIMENSIONS = {
"6.7": { width: 1290, height: 2796 }, // iPhone 15 Pro Max, 14 Pro Max
"6.5": { width: 1284, height: 2778 }, // iPhone 14 Plus, 13 Pro Max
"5.5": { width: 1242, height: 2208 }, // iPhone 8 Plus (still required)
};
// Screenshot configurations with marketing text
const SCREENSHOTS = [
{
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 19.57.14.png",
headline: "Your Couple Dashboard",
subheadline: "Track matches, packs & explore together",
outputName: "01-home",
},
{
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 20.10.53.png",
headline: "Curated Question Packs",
subheadline: "From first dates to spicy adventures",
outputName: "02-packs",
},
{
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 19.57.35.png",
headline: "Swipe on Questions",
subheadline: "Answer yes, no, or maybe together",
outputName: "03-swipe",
},
{
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 21.00.55.png",
headline: "Express Yourself",
subheadline: "Swipe right on what excites you",
outputName: "04-swipe-yes",
},
{
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 19.57.51.png",
headline: "It's a Match!",
subheadline: "Celebrate when you both say yes",
outputName: "05-match",
},
{
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 20.02.50.png",
headline: "Discover Your Matches",
subheadline: "See where you both agree",
outputName: "06-home-matches",
},
{
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 20.01.31.png",
headline: "Chat About It",
subheadline: "Every match unlocks a conversation",
outputName: "07-chat",
},
];
// Brand colors (matching app theme)
const COLORS = {
background: {
start: "#0f0f1a", // Dark purple-black
end: "#1a1a2e", // Slightly lighter
},
accent: {
pink: "#ec4899",
purple: "#8b5cf6",
},
text: {
primary: "#ffffff",
secondary: "rgba(255, 255, 255, 0.7)",
},
};
async function createGradientBackground(width, height) {
// Create a gradient background using canvas
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// Create vertical gradient
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, COLORS.background.start);
gradient.addColorStop(0.5, COLORS.background.end);
gradient.addColorStop(1, COLORS.background.start);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// Add subtle accent glow at top
const glowGradient = ctx.createRadialGradient(
width / 2,
0,
0,
width / 2,
0,
width * 0.8
);
glowGradient.addColorStop(0, "rgba(236, 72, 153, 0.15)");
glowGradient.addColorStop(0.5, "rgba(139, 92, 246, 0.08)");
glowGradient.addColorStop(1, "transparent");
ctx.fillStyle = glowGradient;
ctx.fillRect(0, 0, width, height * 0.5);
return canvas.toBuffer("image/png");
}
async function addTextOverlay(
backgroundBuffer,
width,
height,
headline,
subheadline
) {
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// Draw the background first
const img = await sharp(backgroundBuffer).toBuffer();
const backgroundImage = await loadImage(img);
ctx.drawImage(backgroundImage, 0, 0);
// Calculate text positions (top area)
const textAreaTop = height * 0.05;
// Draw headline
const headlineSize = Math.round(width * 0.065);
ctx.font = `bold ${headlineSize}px -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif`;
ctx.fillStyle = COLORS.text.primary;
ctx.textAlign = "center";
ctx.textBaseline = "top";
// Add text shadow for better readability
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
ctx.shadowBlur = 20;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 4;
ctx.fillText(headline, width / 2, textAreaTop);
// Draw subheadline
const subheadlineSize = Math.round(width * 0.035);
ctx.font = `${subheadlineSize}px -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif`;
ctx.fillStyle = COLORS.text.secondary;
ctx.shadowBlur = 10;
ctx.fillText(subheadline, width / 2, textAreaTop + headlineSize + 20);
return canvas.toBuffer("image/png");
}
async function generateScreenshot(config, targetSize, inputDir, outputDir) {
const { width, height } = DIMENSIONS[targetSize];
const { source, headline, subheadline, outputName } = config;
console.log(
` Generating ${outputName} for ${targetSize}" display (${width}x${height})...`
);
// Load source screenshot
const sourcePath = path.join(inputDir, source);
const sourceImage = sharp(sourcePath);
const sourceMetadata = await sourceImage.metadata();
// Calculate screenshot placement
// Screenshot should take up about 75% of height, centered horizontally
const screenshotMaxHeight = Math.round(height * 0.75);
const screenshotMaxWidth = Math.round(width * 0.85);
// Calculate scale to fit
const scaleHeight = screenshotMaxHeight / sourceMetadata.height;
const scaleWidth = screenshotMaxWidth / sourceMetadata.width;
const scale = Math.min(scaleHeight, scaleWidth);
const screenshotWidth = Math.round(sourceMetadata.width * scale);
const screenshotHeight = Math.round(sourceMetadata.height * scale);
// Position screenshot (centered horizontally, below text area)
const screenshotX = Math.round((width - screenshotWidth) / 2);
const screenshotY = Math.round(height * 0.18); // Start below text
// Create gradient background
const backgroundBuffer = await createGradientBackground(width, height);
// Add text overlay
const withText = await addTextOverlay(
backgroundBuffer,
width,
height,
headline,
subheadline
);
// Resize source screenshot with rounded corners
const roundedCorners = Math.round(screenshotWidth * 0.06);
const resizedScreenshot = await sourceImage
.resize(screenshotWidth, screenshotHeight, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
// Create rounded corner mask
const maskCanvas = createCanvas(screenshotWidth, screenshotHeight);
const maskCtx = maskCanvas.getContext("2d");
maskCtx.beginPath();
maskCtx.roundRect(0, 0, screenshotWidth, screenshotHeight, roundedCorners);
maskCtx.fillStyle = "white";
maskCtx.fill();
const roundedScreenshot = await sharp(resizedScreenshot)
.composite([
{
input: await sharp(maskCanvas.toBuffer("image/png"))
.resize(screenshotWidth, screenshotHeight)
.toBuffer(),
blend: "dest-in",
},
])
.toBuffer();
// Add drop shadow effect by creating a shadow layer
const shadowOffset = 20;
const shadowBlur = 40;
const shadowCanvas = createCanvas(
screenshotWidth + shadowBlur * 2,
screenshotHeight + shadowBlur * 2
);
const shadowCtx = shadowCanvas.getContext("2d");
shadowCtx.shadowColor = "rgba(0, 0, 0, 0.4)";
shadowCtx.shadowBlur = shadowBlur;
shadowCtx.shadowOffsetX = 0;
shadowCtx.shadowOffsetY = shadowOffset;
shadowCtx.fillStyle = "black";
shadowCtx.beginPath();
shadowCtx.roundRect(
shadowBlur,
shadowBlur,
screenshotWidth,
screenshotHeight,
roundedCorners
);
shadowCtx.fill();
// Composite everything together
const finalImage = await sharp(withText)
.composite([
{
input: shadowCanvas.toBuffer("image/png"),
left: screenshotX - shadowBlur,
top: screenshotY - shadowBlur,
blend: "multiply",
},
{
input: roundedScreenshot,
left: screenshotX,
top: screenshotY,
},
])
.png()
.toBuffer();
// Save output
const outputPath = path.join(outputDir, `${outputName}-${targetSize}in.png`);
await fs.writeFile(outputPath, finalImage);
console.log(` Saved: ${outputPath}`);
}
async function main() {
console.log("App Store Screenshot Generator\n");
const inputDir = path.resolve(__dirname, "../../etc/ios_sim");
const outputDir = path.resolve(__dirname, "output");
// Create output directory
await fs.mkdir(outputDir, { recursive: true });
// Check for source files
console.log("Checking source screenshots...");
for (const config of SCREENSHOTS) {
const sourcePath = path.join(inputDir, config.source);
try {
await fs.access(sourcePath);
console.log(` Found: ${config.source}`);
} catch {
console.error(` Missing: ${config.source}`);
process.exit(1);
}
}
console.log("\nGenerating screenshots...\n");
// Generate for each size
for (const targetSize of Object.keys(DIMENSIONS)) {
console.log(`\n${targetSize}" Display:`);
for (const config of SCREENSHOTS) {
await generateScreenshot(config, targetSize, inputDir, outputDir);
}
}
console.log("\n\nDone! Screenshots saved to:", outputDir);
console.log("\nGenerated files:");
const files = await fs.readdir(outputDir);
for (const file of files.sort()) {
console.log(` - ${file}`);
}
}
main().catch(console.error);