Add AVIF image converter utility
38
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
|
||||
|
||||
@ -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",
|
||||
|
||||
BIN
src/assets/PENCIL_1.3.1_wipe.avif
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
src/assets/blog-placeholder-1.avif
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src/assets/blog-placeholder-2.avif
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src/assets/blog-placeholder-3.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/blog-placeholder-4.avif
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
src/assets/blog-placeholder-5.avif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/blog-placeholder-about.avif
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src/assets/claude-nuke.avif
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/foxrenderfarm-arch-linux.avif
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/g-star-image.avif
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/assets/nicholai-closeup-portrait.avif
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/assets/nicholai-medium-portrait.avif
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
69
src/utils/README.md
Normal file
@ -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 <n>` - 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)
|
||||
```
|
||||
192
src/utils/convert-to-avif.js
Normal file
@ -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 <n> 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');
|
||||