Add AVIF image converter utility
38
README.md
@ -32,6 +32,41 @@ pnpm preview
|
|||||||
pnpm deploy
|
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
|
## 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/`).
|
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
|
│ └── sections/ # Homepage sections
|
||||||
├── layouts/ # Page layouts
|
├── layouts/ # Page layouts
|
||||||
├── pages/ # Routes
|
├── pages/ # Routes
|
||||||
└── styles/ # Global styles
|
├── styles/ # Global styles
|
||||||
|
└── utils/ # Utility scripts (AVIF converter, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|||||||
@ -8,7 +8,11 @@
|
|||||||
"preview": "astro build && wrangler pages dev",
|
"preview": "astro build && wrangler pages dev",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"deploy": "astro build && wrangler pages deploy",
|
"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": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "^12.6.12",
|
"@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');
|
||||||