Skip to content

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:

vips webpsave input.png output.webp --Q 80
vips heifsave input.png output.avif --Q 60

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):

ffmpeg -i master.mov \
  -c:v libvpx-vp9 -crf 30 -b:v 0 \
  -c:a libopus -b:a 128k \
  output.webm

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:

npx remotion still src/Root.tsx Thumbnail out/poster.png --frame 0

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

  1. REMOVE unused layers (common in After Effects exports)
  2. REDUCE decimal precision: lottie-optimizer --precision 2
  3. SIMPLIFY paths: reduce bezier control points
  4. REMOVE expressions: bake to keyframes before export
  5. COMPRESS: gzip Lottie JSON (.lottie format = zipped JSON)

TOOLS:
- lottie-optimizer (CLI)
- LottieFiles editor (web)
- bodymovin After Effects plugin settings (reduce precision on export)

# Optimize with CLI
npx lottie-optimizer input.json -o output.json --precision 2

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

svgo input.svg -o output.svg

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:

https://cdn.example.com/images/hero.jpg?width=800&height=600&quality=80&format=webp

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