Dev Log

Services hero: replace static image with self-hosted Dirt 2 Doors video

· Claude Opus 4.7 · Bot
servicesvideoheroperformancecomponents

What was done

1. Source video re-encoded (.tmp/hero/Dirt 2 Doors.mp4 -> public/videos/services-hero.mp4)

The source was 36 MB at 1620x1080 with an AAC audio track (the video is silent ambient anyway). Re-encoded with ffmpeg:

ffmpeg -i Dirt2Doors.mp4 -an -c:v libx264 -preset slow -crf 25 \
  -profile:v high -pix_fmt yuv420p -movflags +faststart \
  -vf "scale='min(1280,iw)':-2" services-hero.mp4
  • -an strips audio (silent loop, no point shipping the track)
  • -crf 25 is a sweet spot between size and quality at 720p hero scale
  • -movflags +faststart puts the moov atom at the front so the browser can begin playing as soon as the metadata arrives
  • scale='min(1280,iw)':-2 caps width at 1280 (source was 1620p, overkill for a ~500px display slot)

Result: 36 MB -> 5.4 MB (85% reduction). At ~500px display the visual is indistinguishable from the source. preload="metadata" defers the actual data fetch past LCP.

WebM was also generated (VP9 CRF 33) but came in at 5.5 MB — slightly larger than the H.264 version for this content, so it was discarded. MP4 only.

2. PageHero component extended (src/components/PageHero.astro)

Added three optional props:

  • heroVideoMp4?: string — path to the MP4 under public/
  • heroVideoWebm?: string — optional WebM source (preferred when present)
  • heroVideoLoops?: number — defaults to 3; 0 means infinite loop

Render priority inside .hero-visual:

  1. If heroVideoMp4 set -> render <video> block with the existing heroImage rendered as poster (LCP stays optimized — same webp variant the image-only path would use)
  2. Else if heroImage set -> existing responsive <Image> block (unchanged)
  3. Else -> text-only hero (unchanged)

The video tag:

<video poster={posterUrl} muted autoplay playsinline preload="metadata"
       data-max-loops={heroVideoLoops} aria-label={heroAlt}>
  {heroVideoWebm && <source src={heroVideoWebm} type="video/webm" />}
  <source src={heroVideoMp4} type="video/mp4" />
</video>

aria-label carries the heroAlt for screen readers; the video itself is decorative.

3. Inline 3-loop script (gated by hasVideo, shipped only on pages that opt in)

HTML5 <video> cannot natively cap loop count. ~12 lines of inline vanilla JS:

var prefersReduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll("video[data-max-loops]").forEach(function (v) {
  if (prefersReduced) { v.removeAttribute("autoplay"); v.pause(); return; }
  var max = parseInt(v.getAttribute("data-max-loops"), 10);
  if (!max || max <= 0) { v.setAttribute("loop", ""); return; }
  var n = 1;
  v.addEventListener("ended", function () {
    if (n < max) { n++; v.play(); } // else: stop on last frame
  });
});

prefers-reduced-motion: reduce -> autoplay is removed and the poster image stays visible. WCAG-compliant default for users with motion sensitivity.

is:inline keeps it un-bundled — no JS bundle weight. CLAUDE.md’s no-JS rule is met (this is the case the rule explicitly allows: “absolutely necessary”; HTML5 has no native loop cap).

4. /services/ wired to the new variant (src/pages/services/index.astro)

Kept the existing services2.png import as the poster, added two new props:

<PageHero
  ...
  heroImage={heroImg}
  heroVideoMp4="/videos/services-hero.mp4"
  heroVideoLoops={3}
/>

Image-only behavior on every other page that uses PageHero is unchanged — heroVideoMp4 is opt-in.

Why

User asked to replace the static services hero with a video to give the page more presence. Source video is owned (Bailey’s Dirt-to-Doors footage), so we self-host instead of embedding YouTube — preserves the site’s zero-JS / privacy-conscious posture and keeps LCP under our control.

User specified silent loop, 3 plays then stop on the final frame — gives the page motion when first viewed but doesn’t pull attention away once the user is reading the rest of the page.

Tradeoffs

  • +5.4 MB MP4 in public/videos/. Mitigated by preload="metadata" — only metadata (~50 KB) fetches before the video starts playing; the bulk download happens after LCP.
  • +~12 lines of inline JS on pages that opt into the video variant. Gated by data-max-loops so it’s a no-op on every other page.
  • No fallback for autoplay-blocked browsers: if the user has aggressively blocked autoplay, the poster image stays visible — graceful degradation.
  • WebM not shipped: VP9 didn’t beat H.264 for this 30s clip. If a future hero has more motion / is longer, regenerate WebM and the component will prefer it automatically.

Verification

  • npm run build clean
  • dist/services/index.html contains the <video> tag with the right poster + sources + data-max-loops="3"
  • dist/videos/services-hero.mp4 shipped at 5.4 MB
  • Poster references existing /_astro/services2.<hash>.webp 1000w variant (no extra image generated)
  • Inline script with the loop counter present in the rendered HTML

Operator follow-up

After SFTP-uploading dist/:

  1. Verify the new videos/ directory uploaded — the file is in dist/videos/services-hero.mp4. cPanel/Filezilla should pick it up.
  2. Cloudflare cache purge — particularly important here so the video file is served fresh and any stale /services/ HTML is invalidated.
  3. Spot-check on /services/:
    • Hero shows poster image immediately
    • Video starts playing muted, plays back to start automatically twice (3 plays total), settles on its last frame
    • Mobile (iOS Safari): video plays in-line, doesn’t fullscreen
    • DevTools -> Rendering -> emulate prefers-reduced-motion: reduce -> reload -> poster only, no autoplay

Files affected

  • Created: public/videos/services-hero.mp4 (5.4 MB, 720p H.264, no audio)
  • Modified: src/components/PageHero.astro (added video variant + inline loop script)
  • Modified: src/pages/services/index.astro (added heroVideoMp4 + heroVideoLoops props)
  • Created: this devlog entry
Feedback