diff --git a/README.md b/README.md index 37031b7..a6222f3 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,41 @@ pnpm preview pnpm deploy ``` +## Image Optimization + +The project includes a utility script to convert images to AVIF format for optimal web performance. + +### Converting Images to AVIF + +AVIF images are typically 80-98% smaller than JPEGs/PNGs while maintaining excellent quality, making them ideal for web use. + +```bash +# Convert all images in src/assets/ +pnpm run convert:avif:all + +# Convert only JPEG images +pnpm run convert:avif:jpeg + +# Convert only PNG images +pnpm run convert:avif:png + +# Custom quality (0-100, default: 65) +node src/utils/convert-to-avif.js --jpeg --quality 80 +``` + +**Features:** +- Preserves original images +- Skips already-converted files +- Shows file size savings +- Supports JPEG, PNG, WebP, GIF, BMP, and TIFF + +**Requirements:** +- ffmpeg must be installed: + - Linux: `sudo apt install ffmpeg` + - macOS: `brew install ffmpeg` + +See `src/utils/README.md` for detailed documentation. + ## Creating Blog Posts Blog posts are created as MDX files in the `src/content/blog/` directory. The file name becomes the URL slug (e.g., `my-post.mdx` → `/blog/my-post/`). @@ -203,7 +238,8 @@ src/ │ └── sections/ # Homepage sections ├── layouts/ # Page layouts ├── pages/ # Routes -└── styles/ # Global styles +├── styles/ # Global styles +└── utils/ # Utility scripts (AVIF converter, etc.) ``` ## Deployment diff --git a/package.json b/package.json index 9e2c843..ed96874 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "preview": "astro build && wrangler pages dev", "astro": "astro", "deploy": "astro build && wrangler pages deploy", - "cf-typegen": "wrangler types" + "cf-typegen": "wrangler types", + "convert:avif": "node src/utils/convert-to-avif.js", + "convert:avif:all": "node src/utils/convert-to-avif.js --all", + "convert:avif:jpeg": "node src/utils/convert-to-avif.js --jpeg", + "convert:avif:png": "node src/utils/convert-to-avif.js --png" }, "dependencies": { "@astrojs/cloudflare": "^12.6.12", diff --git a/src/assets/PENCIL_1.3.1_wipe.avif b/src/assets/PENCIL_1.3.1_wipe.avif new file mode 100644 index 0000000..86f4cce Binary files /dev/null and b/src/assets/PENCIL_1.3.1_wipe.avif differ diff --git a/src/assets/blog-placeholder-1.avif b/src/assets/blog-placeholder-1.avif new file mode 100644 index 0000000..9480c0e Binary files /dev/null and b/src/assets/blog-placeholder-1.avif differ diff --git a/src/assets/blog-placeholder-2.avif b/src/assets/blog-placeholder-2.avif new file mode 100644 index 0000000..73f3fc9 Binary files /dev/null and b/src/assets/blog-placeholder-2.avif differ diff --git a/src/assets/blog-placeholder-3.avif b/src/assets/blog-placeholder-3.avif new file mode 100644 index 0000000..5ff93a6 Binary files /dev/null and b/src/assets/blog-placeholder-3.avif differ diff --git a/src/assets/blog-placeholder-4.avif b/src/assets/blog-placeholder-4.avif new file mode 100644 index 0000000..8059858 Binary files /dev/null and b/src/assets/blog-placeholder-4.avif differ diff --git a/src/assets/blog-placeholder-5.avif b/src/assets/blog-placeholder-5.avif new file mode 100644 index 0000000..a1ed108 Binary files /dev/null and b/src/assets/blog-placeholder-5.avif differ diff --git a/src/assets/blog-placeholder-about.avif b/src/assets/blog-placeholder-about.avif new file mode 100644 index 0000000..a8d119b Binary files /dev/null and b/src/assets/blog-placeholder-about.avif differ diff --git a/src/assets/claude-nuke.avif b/src/assets/claude-nuke.avif new file mode 100644 index 0000000..f6f0a33 Binary files /dev/null and b/src/assets/claude-nuke.avif differ diff --git a/src/assets/foxrenderfarm-arch-linux.avif b/src/assets/foxrenderfarm-arch-linux.avif new file mode 100644 index 0000000..fc024c7 Binary files /dev/null and b/src/assets/foxrenderfarm-arch-linux.avif differ diff --git a/src/assets/g-star-image.avif b/src/assets/g-star-image.avif new file mode 100644 index 0000000..2009e5f Binary files /dev/null and b/src/assets/g-star-image.avif differ diff --git a/src/assets/nicholai-closeup-portrait.avif b/src/assets/nicholai-closeup-portrait.avif new file mode 100644 index 0000000..b53121c Binary files /dev/null and b/src/assets/nicholai-closeup-portrait.avif differ diff --git a/src/assets/nicholai-medium-portrait.avif b/src/assets/nicholai-medium-portrait.avif new file mode 100644 index 0000000..ba00428 Binary files /dev/null and b/src/assets/nicholai-medium-portrait.avif differ diff --git a/src/utils/README.md b/src/utils/README.md new file mode 100644 index 0000000..a73bf3d --- /dev/null +++ b/src/utils/README.md @@ -0,0 +1,69 @@ +# Utilities + +This directory contains utility scripts for the project. + +## Image Conversion Script + +### convert-to-avif.js + +Converts images in the `assets/` directory to AVIF format using ffmpeg. Original images are preserved, and `.avif` versions are created alongside them. + +**Prerequisites:** +- ffmpeg must be installed on your system + - Linux: `sudo apt install ffmpeg` + - macOS: `brew install ffmpeg` + - Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html) + +**Usage via pnpm scripts:** + +```bash +# Show help and available options +pnpm run convert:avif + +# Convert all supported formats (jpeg, png, webp, gif, bmp, tiff) +pnpm run convert:avif:all + +# Convert only JPEG images +pnpm run convert:avif:jpeg + +# Convert only PNG images +pnpm run convert:avif:png + +# Convert with custom quality (0-100, default: 65) +node src/utils/convert-to-avif.js --jpeg --quality 80 + +# Convert multiple formats at once +node src/utils/convert-to-avif.js --jpeg --png +``` + +**Options:** +- `--all` - Convert all supported formats +- `--jpeg` - Convert JPEG/JPG files only +- `--png` - Convert PNG files only +- `--webp` - Convert WebP files only +- `--gif` - Convert GIF files only +- `--bmp` - Convert BMP files only +- `--tiff` - Convert TIFF files only +- `--quality ` - Set quality (0-100, default: 65) + +**Quality Guide:** +- High (80+): Larger file sizes, excellent quality +- Medium (60-75): Balanced file size and quality (recommended) +- Low (40-55): Smaller files, good for web performance + +**Features:** +- Preserves original images +- Skips files that already have AVIF versions +- Shows file size savings +- Progress indicators +- Error handling and reporting + +**Example output:** +``` +🎨 Converting 3 image(s) to AVIF format +📁 Source: /path/to/assets +⚙️ Quality: 65 + +🔄 [1/3] Converting blog-placeholder-1.jpg... + ✅ Created blog-placeholder-1.avif (45.2KB, 67.3% smaller) +``` diff --git a/src/utils/convert-to-avif.js b/src/utils/convert-to-avif.js new file mode 100644 index 0000000..7248c06 --- /dev/null +++ b/src/utils/convert-to-avif.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +/** + * Image to AVIF Converter + * + * Converts images in the assets/ directory to AVIF format using ffmpeg. + * Originals are preserved, and .avif versions are created alongside them. + * + * Usage: + * node utils/convert-to-avif.js --all + * node utils/convert-to-avif.js --jpeg + * node utils/convert-to-avif.js --png + * node utils/convert-to-avif.js --jpeg --png + * node utils/convert-to-avif.js --webp + * + * Options: + * --all Convert all supported formats (jpeg, png, webp, gif, bmp, tiff) + * --jpeg Convert JPEG/JPG files only + * --png Convert PNG files only + * --webp Convert WebP files only + * --gif Convert GIF files only + * --bmp Convert BMP files only + * --tiff Convert TIFF files only + * --quality Set quality (default: 65, range: 0-100) + */ + +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Get __dirname equivalent in ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Configuration +const ASSETS_DIR = path.join(__dirname, '../assets'); +const DEFAULT_QUALITY = 65; + +// Parse command line arguments +const args = process.argv.slice(2); +const options = { + all: args.includes('--all'), + jpeg: args.includes('--jpeg'), + png: args.includes('--png'), + webp: args.includes('--webp'), + gif: args.includes('--gif'), + bmp: args.includes('--bmp'), + tiff: args.includes('--tiff'), + quality: DEFAULT_QUALITY +}; + +// Parse quality option +const qualityIndex = args.indexOf('--quality'); +if (qualityIndex !== -1 && args[qualityIndex + 1]) { + const quality = parseInt(args[qualityIndex + 1]); + if (!isNaN(quality) && quality >= 0 && quality <= 100) { + options.quality = quality; + } else { + console.error('⚠️ Invalid quality value. Using default: 65'); + } +} + +// If no specific format is selected, show help +if (!options.all && !options.jpeg && !options.png && !options.webp && !options.gif && !options.bmp && !options.tiff) { + console.log(` +Image to AVIF Converter + +Usage: + node utils/convert-to-avif.js [options] + +Options: + --all Convert all supported formats + --jpeg Convert JPEG/JPG files only + --png Convert PNG files only + --webp Convert WebP files only + --gif Convert GIF files only + --bmp Convert BMP files only + --tiff Convert TIFF files only + --quality Set quality (0-100, default: 65) + +Examples: + node utils/convert-to-avif.js --all + node utils/convert-to-avif.js --jpeg --png + node utils/convert-to-avif.js --jpeg --quality 80 + `); + process.exit(0); +} + +// Check if ffmpeg is installed +try { + execSync('ffmpeg -version', { stdio: 'ignore' }); +} catch (error) { + console.error('❌ ffmpeg is not installed or not in PATH'); + console.error(' Install it with: sudo apt install ffmpeg (Linux) or brew install ffmpeg (macOS)'); + process.exit(1); +} + +// Check if assets directory exists +if (!fs.existsSync(ASSETS_DIR)) { + console.error(`❌ Assets directory not found: ${ASSETS_DIR}`); + process.exit(1); +} + +// Define supported formats +const formatExtensions = { + jpeg: ['.jpg', '.jpeg'], + png: ['.png'], + webp: ['.webp'], + gif: ['.gif'], + bmp: ['.bmp'], + tiff: ['.tiff', '.tif'] +}; + +// Determine which extensions to process +let extensionsToProcess = []; +if (options.all) { + extensionsToProcess = Object.values(formatExtensions).flat(); +} else { + Object.keys(formatExtensions).forEach(format => { + if (options[format]) { + extensionsToProcess.push(...formatExtensions[format]); + } + }); +} + +// Get all image files in assets directory +const allFiles = fs.readdirSync(ASSETS_DIR); +const imageFiles = allFiles.filter(file => { + const ext = path.extname(file).toLowerCase(); + return extensionsToProcess.includes(ext); +}); + +if (imageFiles.length === 0) { + console.log('ℹ️ No matching images found in assets directory'); + process.exit(0); +} + +console.log(`\n🎨 Converting ${imageFiles.length} image(s) to AVIF format`); +console.log(`📁 Source: ${ASSETS_DIR}`); +console.log(`⚙️ Quality: ${options.quality}`); +console.log(''); + +let successCount = 0; +let skipCount = 0; +let errorCount = 0; + +// Convert each image +imageFiles.forEach((file, index) => { + const inputPath = path.join(ASSETS_DIR, file); + const outputPath = path.join(ASSETS_DIR, path.basename(file, path.extname(file)) + '.avif'); + + // Skip if AVIF already exists + if (fs.existsSync(outputPath)) { + console.log(`⏭️ [${index + 1}/${imageFiles.length}] Skipping ${file} (AVIF already exists)`); + skipCount++; + return; + } + + try { + console.log(`🔄 [${index + 1}/${imageFiles.length}] Converting ${file}...`); + + // Run ffmpeg conversion + // -i: input file + // -c:v libaom-av1: use AV1 codec for AVIF + // -still-picture 1: encode as still image + // -crf: quality (0=best, 63=worst, 65 is good balance) + // -y: overwrite output file if it exists + execSync( + `ffmpeg -i "${inputPath}" -c:v libaom-av1 -still-picture 1 -crf ${100 - options.quality} -y "${outputPath}"`, + { stdio: 'ignore' } + ); + + const inputStats = fs.statSync(inputPath); + const outputStats = fs.statSync(outputPath); + const savings = ((1 - outputStats.size / inputStats.size) * 100).toFixed(1); + + console.log(` ✅ Created ${path.basename(outputPath)} (${(outputStats.size / 1024).toFixed(1)}KB, ${savings}% smaller)`); + successCount++; + } catch (error) { + console.error(` ❌ Failed to convert ${file}: ${error.message}`); + errorCount++; + } +}); + +// Summary +console.log('\n' + '='.repeat(50)); +console.log(`✨ Conversion complete!`); +console.log(` ✅ Converted: ${successCount}`); +if (skipCount > 0) console.log(` ⏭️ Skipped: ${skipCount}`); +if (errorCount > 0) console.log(` ❌ Failed: ${errorCount}`); +console.log('='.repeat(50) + '\n');