DOMAIN:VISUAL_PRODUCTION:ASSET_OPTIMIZATION¶
OWNER: felice
UPDATED: 2026-03-24
SCOPE: Format optimization, responsive images, video compression, Lottie, SVG, CDN delivery
AGENTS: felice (primary), karel (CDN delivery), floris/floor (frontend integration)
PARENT: Visual Production
OPTIM:IMAGE_FORMAT_DECISION_TREE¶
FORMAT_SELECTION¶
START: What kind of image?
│
├─ Vector (logo, icon, simple illustration)?
│ └─ SVG
│
├─ Needs transparency?
│ ├─ Complex transparency (gradients, semi-transparent)?
│ │ ├─ Browser target includes Safari < 16.4?
│ │ │ ├─ YES → WebP (lossy, alpha channel)
│ │ │ └─ NO → AVIF (best compression with alpha)
│ │ └─ fallback: PNG
│ └─ Simple cutout transparency?
│ └─ WebP (lossy, alpha) with PNG fallback
│
├─ Photograph or complex imagery?
│ ├─ Primary target modern browsers?
│ │ └─ AVIF (primary) + WebP (fallback) + JPEG (legacy fallback)
│ └─ Must support all browsers?
│ └─ WebP (primary) + JPEG (fallback)
│
├─ Screenshot or UI mockup?
│ └─ WebP lossless or PNG (sharp edges need lossless or high-quality lossy)
│
├─ Animation (short loop)?
│ ├─ Vector animation → Lottie
│ └─ Raster animation → WebP animated or MP4 (never GIF for production)
│
└─ Fallback / unknown
└─ WebP (lossy 80%) + JPEG fallback
FORMAT_COMPARISON¶
| Format | Compression | Transparency | Animation | Browser Support | Best For |
|---|---|---|---|---|---|
| AVIF | lossy 60% — 50% smaller than JPEG | yes | yes | Chrome 85+, FF 93+, Safari 16.4+ | photos, hero images |
| WebP | lossy 80% — 30% smaller than JPEG | yes | yes | all modern (98%+) | default web format |
| PNG | lossless only | yes | no (APNG) | universal | source files, screenshots |
| JPEG | lossy 80% | no | no | universal | legacy fallback only |
| SVG | N/A (vector) | inherent | via SMIL/CSS | universal | logos, icons, diagrams |
| GIF | lossless, 256 colors | binary only | yes | universal | AVOID — use WebP/MP4 |
BENCHMARKS¶
Tested on a 1920x1080 photograph (original: 4.2MB PNG):
| Format | Quality Setting | File Size | Visual Quality | Load Time (3G) |
|---|---|---|---|---|
| JPEG | quality 80 | 280 KB | good | 2.8s |
| WebP | quality 80 | 195 KB | good | 1.9s |
| WebP | quality 60 | 120 KB | acceptable | 1.2s |
| AVIF | quality 60 | 95 KB | good | 0.9s |
| AVIF | quality 40 | 55 KB | acceptable | 0.5s |
RULE: AVIF at quality 60 matches JPEG at quality 80 visually — at 1/3 the size
RULE: WebP at quality 80 is the safe default — excellent quality/size/compatibility balance
RULE: always generate from lossless source (PNG) — never re-encode a JPEG to WebP
OPTIM:IMAGE_CONVERSION¶
SHARP_NODE_JS¶
TOOL: Sharp (libvips bindings for Node.js)
INSTALL: npm install sharp
CONVERT to WebP:
import sharp from 'sharp';
await sharp('input.png')
.resize(1200, 800, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 })
.toFile('output.webp');
CONVERT to AVIF:
await sharp('input.png')
.resize(1200, 800, { fit: 'inside', withoutEnlargement: true })
.avif({ quality: 60, effort: 4 }) // effort 0-9, higher = slower but smaller
.toFile('output.avif');
GENERATE RESPONSIVE SET:
const widths = [400, 800, 1200, 1600, 2400];
const formats = ['webp', 'avif'] as const;
for (const width of widths) {
for (const format of formats) {
const quality = format === 'avif' ? 60 : 80;
await sharp('input.png')
.resize(width, null, { fit: 'inside', withoutEnlargement: true })
.toFormat(format, { quality })
.toFile(`output-${width}.${format}`);
}
}
METADATA_STRIPPING:
await sharp('input.jpg')
.rotate() // auto-rotate based on EXIF, then strip
.withMetadata({ orientation: undefined })
.webp({ quality: 80 })
.toFile('output.webp');
RULE: always strip EXIF metadata — may contain GPS data (privacy)
RULE: withoutEnlargement: true prevents upscaling small images
RULE: fit: 'inside' maintains aspect ratio within bounds
LIBVIPS_CLI¶
For batch processing without Node.js:
PILLOW_PYTHON¶
from PIL import Image
img = Image.open('input.png')
img.save('output.webp', 'WEBP', quality=80, method=6)
img.save('output.avif', 'AVIF', quality=60, speed=4)
OPTIM:RESPONSIVE_IMAGES¶
SRCSET_PATTERN¶
<picture>
<source type="image/avif"
srcset="image-400.avif 400w, image-800.avif 800w, image-1200.avif 1200w, image-1600.avif 1600w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw">
<source type="image/webp"
srcset="image-400.webp 400w, image-800.webp 800w, image-1200.webp 1200w, image-1600.webp 1600w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw">
<img
src="image-800.jpg"
alt="Descriptive alt text for accessibility"
loading="lazy"
decoding="async"
width="800"
height="600"
>
</picture>
NEXT_JS_IMAGE_COMPONENT¶
import Image from 'next/image';
// Responsive image with automatic optimization
<Image
src="/images/hero.png"
alt="Platform dashboard showing real-time analytics"
width={1920}
height={1080}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority // for above-the-fold hero images
quality={80}
/>
// Below-the-fold image (lazy loaded by default)
<Image
src="/images/feature.png"
alt="Feature illustration showing team collaboration"
width={800}
height={600}
sizes="(max-width: 768px) 100vw, 50vw"
/>
NEXT_JS_CONFIG for AVIF:
// next.config.js
module.exports = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
SIZE_BREAKPOINTS¶
Standard breakpoints for responsive images:
| Breakpoint | Width | Use Case |
|---|---|---|
| mobile-sm | 400w | small phone, thumbnail |
| mobile | 640w | phone screen width |
| tablet | 800w | tablet, 2-column layout |
| desktop | 1200w | desktop, primary content |
| desktop-lg | 1600w | wide desktop, hero image |
| retina | 2400w | hero image on retina display |
RULE: generate at least 3 sizes: 400w, 800w, 1200w
RULE: hero images: add 1600w and 2400w for retina displays
RULE: thumbnails: 400w max — don't waste bytes on larger sizes
LOADING_STRATEGY¶
ABOVE_THE_FOLD (hero, header, first viewport):
- loading="eager" (or omit — eager is default)
- fetchpriority="high" — tells browser to prioritize
- decoding="auto"
- Next.js: priority prop
- NO lazy loading — user sees this immediately
BELOW_THE_FOLD (anything not in first viewport):
- loading="lazy" — browser loads when near viewport
- decoding="async" — decode off main thread
- Next.js: default behavior (lazy)
RULE: always set width and height on <img> to prevent CLS (Cumulative Layout Shift)
RULE: hero images should NOT be lazy-loaded
RULE: use CSS aspect-ratio as backup for CLS prevention
ANTI_PATTERN: serving a single 2400px image to all devices
FIX: srcset with appropriate breakpoints
ANTI_PATTERN: using CSS background-image for content images
FIX: <picture> element — better for SEO, accessibility, lazy loading
ANTI_PATTERN: lazy loading hero/above-the-fold images
FIX: fetchpriority="high" for hero images
OPTIM:VIDEO_COMPRESSION¶
CODEC_COMPARISON¶
| Codec | Container | Compression | Browser Support | CPU Decode | Best For |
|---|---|---|---|---|---|
| H.264 | MP4 | baseline | universal | low | default web video |
| H.265/HEVC | MP4 | 40% smaller than H.264 | Safari, Edge, Chrome (partial) | medium | Apple ecosystem |
| VP9 | WebM | 30-50% smaller than H.264 | Chrome, Firefox, Edge | medium | YouTube-style delivery |
| AV1 | MP4/WebM | 30% smaller than VP9 | Chrome 70+, FF 67+, Edge 121+ | high | future default, bandwidth-critical |
DECISION:
- Default: H.264 MP4 (universal, hardware decode everywhere)
- Bandwidth-critical with modern browsers: AV1 with H.264 fallback
- Apple-heavy audience: H.265 with H.264 fallback
- YouTube/streaming: VP9 (YouTube transcodes automatically)
FFMPEG_ENCODING¶
H.264 (web delivery):
ffmpeg -i master.mov \
-c:v libx264 -preset slow -crf 20 \
-c:a aac -b:a 128k \
-movflags +faststart \
-pix_fmt yuv420p \
output.mp4
VP9 (web delivery, better compression):
AV1 (best compression, slow encode):
ffmpeg -i master.mov \
-c:v libaom-av1 -crf 30 -b:v 0 -cpu-used 4 \
-c:a libopus -b:a 128k \
output.mp4
RULE: always include -movflags +faststart for MP4 web delivery
RULE: always use -pix_fmt yuv420p for maximum compatibility
RULE: CRF guide — H.264: 18-23, VP9: 25-35, AV1: 25-35
ADAPTIVE_BITRATE¶
For videos > 30 seconds, generate multiple quality levels:
# 1080p
ffmpeg -i master.mov -c:v libx264 -crf 20 -vf scale=1920:1080 -c:a aac -b:a 128k -movflags +faststart output-1080p.mp4
# 720p
ffmpeg -i master.mov -c:v libx264 -crf 22 -vf scale=1280:720 -c:a aac -b:a 96k -movflags +faststart output-720p.mp4
# 480p
ffmpeg -i master.mov -c:v libx264 -crf 24 -vf scale=854:480 -c:a aac -b:a 64k -movflags +faststart output-480p.mp4
RULE: BunnyCDN Stream handles this automatically — upload master, it transcodes
RULE: only manual multi-quality for self-hosted video
VIDEO_POSTER_FRAMES¶
Every <video> element needs a poster attribute:
<video
src="product-demo.mp4"
poster="product-demo-poster.webp"
preload="none"
width="1920"
height="1080"
>
</video>
GENERATE poster with FFmpeg:
# Extract frame at 2 seconds
ffmpeg -i input.mp4 -ss 2 -frames:v 1 -q:v 2 poster.jpg
# Convert to WebP
sharp poster.jpg -o poster.webp --quality 80
GENERATE poster with Remotion:
RULE: poster frame should be representative of content — not a blank/loading screen
RULE: poster in WebP format for consistency with image optimization
RULE: preload="none" with poster prevents unnecessary video download
OPTIM:LOTTIE_OPTIMIZATION¶
FILE_SIZE_TARGETS¶
| Use Case | Max Size | Example |
|---|---|---|
| Micro-interaction (spinner, checkbox) | 10 KB | loading indicator |
| Icon animation | 25 KB | hamburger → X transition |
| Small illustration | 50 KB | onboarding step graphic |
| Full illustration | 200 KB | hero animation |
| Complex scene | 500 KB | marketing page header |
OPTIMIZATION_TECHNIQUES¶
- REMOVE unused layers (common in After Effects exports)
- REDUCE decimal precision:
lottie-optimizer --precision 2 - SIMPLIFY paths: reduce bezier control points
- REMOVE expressions: bake to keyframes before export
- COMPRESS: gzip Lottie JSON (
.lottieformat = zipped JSON)
TOOLS:
- lottie-optimizer (CLI)
- LottieFiles editor (web)
- bodymovin After Effects plugin settings (reduce precision on export)
INTEGRATION_PATTERNS¶
LAZY_LOAD (recommended):
import dynamic from 'next/dynamic';
const LottiePlayer = dynamic(
() => import('@lottiefiles/react-lottie-player').then((m) => m.Player),
{ ssr: false }
);
<LottiePlayer
autoplay
loop
src="/animations/loading.json"
style={{ width: 200, height: 200 }}
rendererSettings={{ preserveAspectRatio: 'xMidYMid slice' }}
/>
INTERACTION_CONTROLLED:
import { useRef } from 'react';
import { Player } from '@lottiefiles/react-lottie-player';
const playerRef = useRef<Player>(null);
<Player
ref={playerRef}
src="/animations/success.json"
style={{ width: 100, height: 100 }}
keepLastFrame
/>
// Trigger on event
const onSuccess = () => playerRef.current?.play();
RULE: never embed Lottie JSON inline in JavaScript bundle — load from static file or CDN
RULE: always set preserveAspectRatio: 'xMidYMid slice' to prevent distortion
RULE: prefer Lottie over GIF — 10x smaller, resolution independent, interactive
RULE: Lottie is for vector/shape animation — use video for photographic content
OPTIM:SVG_OPTIMIZATION¶
SVGO¶
TOOL: SVGO (SVG Optimizer)
INSTALL: npm install -g svgo
COMMON SAVINGS: 30-60% file size reduction.
SVGO_CONFIG:
// svgo.config.js
module.exports = {
plugins: [
'removeDoctype',
'removeXMLProcInst',
'removeComments',
'removeMetadata',
'removeEditorsNSData',
'removeEmptyAttrs',
'removeEmptyContainers',
'removeUnusedNS',
{ name: 'removeViewBox', active: false }, // KEEP viewBox for responsive
{ name: 'removeDimensions', active: true }, // remove width/height, use viewBox
],
};
RULE: never remove viewBox — required for responsive SVG
RULE: remove width/height attributes — size via CSS instead
RULE: run SVGO on all SVGs before deployment
INLINE_VS_EXTERNAL¶
INLINE SVG:
- for icons used on every page (header, footer)
- saves HTTP request
- allows CSS styling and JS interaction
- adds to HTML document size
EXTERNAL SVG (<img src>):
- for illustrations, decorative content
- cacheable independently
- no CSS/JS interaction
- lazy-loadable
RULE: inline for icons (< 2KB each, interactive)
RULE: external for illustrations (> 2KB, decorative)
OPTIM:BUNNYCDN_IMAGE_PROCESSING¶
URL_PARAMETERS¶
BunnyCDN supports on-the-fly image transformation via URL parameters:
PARAMETERS:
- width — resize to width (maintains aspect ratio)
- height — resize to height (maintains aspect ratio)
- quality — 1-100 (default 85)
- format — webp, avif, or auto (auto uses Accept header)
- sharpen — true/false (sharpen after resize)
- crop — gravity-based: center, top, bottom, left, right
- blur — 1-100 (gaussian blur)
EXAMPLE (responsive with CDN):
<picture>
<source type="image/avif"
srcset="https://cdn.example.com/hero.jpg?width=400&format=avif 400w,
https://cdn.example.com/hero.jpg?width=800&format=avif 800w,
https://cdn.example.com/hero.jpg?width=1200&format=avif 1200w">
<source type="image/webp"
srcset="https://cdn.example.com/hero.jpg?width=400&format=webp 400w,
https://cdn.example.com/hero.jpg?width=800&format=webp 800w,
https://cdn.example.com/hero.jpg?width=1200&format=webp 1200w">
<img src="https://cdn.example.com/hero.jpg?width=800" alt="..." loading="lazy">
</picture>
ADVANTAGES:
- no build-time image generation
- CDN caches transformed variants
- upload master once, serve any size/format
- reduces storage costs
RULE: use format=auto when possible — CDN reads Accept header and serves best format
RULE: set appropriate cache TTL (images: 1 year, with cache-busting via URL hash)
RULE: enable CORS on CDN for cross-origin usage
PRICING: ~$0.005/GB storage + $0.01/GB bandwidth + $9.99/mo for image processing.
OPTIM:LAZY_LOADING_PATTERNS¶
INTERSECTION_OBSERVER¶
For custom lazy loading beyond native loading="lazy":
export function useLazyLoad(ref: React.RefObject<HTMLElement>, rootMargin = '200px') {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, rootMargin]);
return isVisible;
}
USE_CASES:
- lazy-load Lottie animations
- lazy-load video elements
- lazy-load heavy interactive components
- trigger scroll-based animations
RULE: rootMargin: '200px' pre-loads 200px before entering viewport — prevents visible pop-in
RULE: native loading="lazy" is sufficient for <img> — use IntersectionObserver for video/Lottie
CROSS_REFERENCES¶
- Format specs per delivery context: delivery-specs.md
- Image generation (source assets): image-generation.md
- Video compression details: video-production.md
- Accessibility for images and video: accessibility-media.md