Services hero: replace static image with self-hosted Dirt 2 Doors video
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
-anstrips audio (silent loop, no point shipping the track)-crf 25is a sweet spot between size and quality at 720p hero scale-movflags +faststartputs the moov atom at the front so the browser can begin playing as soon as the metadata arrivesscale='min(1280,iw)':-2caps 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:
- If
heroVideoMp4set -> render<video>block with the existingheroImagerendered asposter(LCP stays optimized — same webp variant the image-only path would use) - Else if
heroImageset -> existing responsive<Image>block (unchanged) - 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 bypreload="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-loopsso 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 buildcleandist/services/index.htmlcontains the<video>tag with the right poster + sources +data-max-loops="3"dist/videos/services-hero.mp4shipped at 5.4 MB- Poster references existing
/_astro/services2.<hash>.webp1000w variant (no extra image generated) - Inline script with the loop counter present in the rendered HTML
Operator follow-up
After SFTP-uploading dist/:
- Verify the new
videos/directory uploaded — the file is indist/videos/services-hero.mp4. cPanel/Filezilla should pick it up. - Cloudflare cache purge — particularly important here so the video file is served fresh and any stale
/services/HTML is invalidated. - 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(addedheroVideoMp4+heroVideoLoopsprops) - Created: this devlog entry