Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions components/hero-video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import Image from "next/image";

const POSTER_SRC = "/photos/herograin.jpg";
// Issue #135: the brewery still replaces the grain hero photo so the static
// experience looks intentional instead of duplicating the hero above it.
const POSTER_SRC = "/photos/video-still.jpg";
const WEBM_SRC = "/videos/ddbwebvid.webm";
const MP4_SRC = "/videos/ddbwebvid.mp4";

Expand All @@ -25,7 +27,7 @@ function useMediaQuery(query: string): boolean {
export function HeroVideo() {
const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const isSmallScreen = useMediaQuery("(max-width: 768px)");
const [canPlay, setCanPlay] = useState(false);
const [playing, setPlaying] = useState(false);
const [inView, setInView] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
Expand Down Expand Up @@ -53,12 +55,26 @@ export function HeroVideo() {
if (!video || !inView || reducedMotion || isSmallScreen) return;

// Load metadata first so the browser can decide codec, then play.
// play() itself initiates the fetch — no explicit load() needed.
video.preload = "metadata";
void video.play().catch(() => {
// Autoplay may be blocked by browser policy; the poster remains visible.
void video.play().catch((err: unknown) => {
// Autoplay may be refused by browser policy (e.g. low-power mode);
// the still stays visible either way. Dev-only diagnostic keeps
// expected rejections (NotAllowedError) distinguishable from real
// decode/network failures without sending noise to monitoring.
if (process.env.NODE_ENV !== "production") {
console.info(
"[HeroVideo] play() rejected — static still remains.",
err instanceof DOMException ? err.name : err,
video.error?.code
);
}
});
}, [inView, reducedMotion, isSmallScreen]);

// Deliberate: small screens get only the still — a multi-megabyte
// decorative autoplay video is not worth the data/battery cost on
// phones, and the approved still is a complete design, not a fallback.
const showStaticPoster = reducedMotion || isSmallScreen;

return (
Expand All @@ -83,17 +99,33 @@ export function HeroVideo() {
<video
ref={videoRef}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-700 ${
canPlay ? "opacity-100" : "opacity-0"
playing ? "opacity-100" : "opacity-0"
}`}
muted
loop
playsInline
preload="none"
aria-hidden="true"
onCanPlay={() => setCanPlay(true)}
// `playing` — not `canplay`: a refused play() still reaches
// canplay (the fetch proceeds), which would fade in a frozen
// first frame. Fading in on actual playback keeps the approved
// still up whenever the video is not genuinely playing.
onPlaying={() => setPlaying(true)}
>
<source src={WEBM_SRC} type="video/webm" />
<source src={MP4_SRC} type="video/mp4" />
{/* The error event on the last <source> means every candidate
failed — the still remains; log only in development. */}
<source
src={MP4_SRC}
type="video/mp4"
onError={() => {
if (process.env.NODE_ENV !== "production") {
console.info(
"[HeroVideo] no playable source — static still remains."
);
}
}}
/>
Your browser does not support the video tag.
</video>
)}
Expand Down
12 changes: 7 additions & 5 deletions docs/operations/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,13 @@ re-creates the contention it solves.

## Findings reviewed and intentionally left alone

- **Hero video files** (`ddbwebvid.mp4` 13.3MB, `ddbwebvid.webm` 4.2MB):
large, but the video only loads when the section scrolls into view on
desktop without reduced-motion — it never competes with initial load.
A lower-bitrate re-encode is a content/quality decision for the owner,
not an engineering default.
- **Hero video files** (`ddbwebvid.mp4` 2.1MB, `ddbwebvid.webm` 2.2MB):
re-encoded in #135 (H.264 High CRF 27, VP9 CRF 33, both ~0.8Mbps, no
audio, MP4 faststart) — down from 13.6MB/4.3MB at equal SSIM ≥0.98.
The video only loads when the section scrolls into view on desktop
without reduced-motion — it never competes with initial load. The
static poster is `photos/video-still.jpg` (approved brewery still,
distinct from the `herograin.jpg` hero image above it).
- **`/beers/[slug]` is now static** (Issue #104): `generateStaticParams`
enumerates build-time slugs with `dynamicParams = false`, reading via
`getDocsFromServer` so a backend failure throws and fails the build
Expand Down
Binary file added public/photos/video-still.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/photos/video-still.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/videos/ddbwebvid.mp4
Binary file not shown.
Binary file modified public/videos/ddbwebvid.webm
Binary file not shown.
7 changes: 4 additions & 3 deletions scripts/hero-video-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ async function check(contextOptions, label) {
await page.waitForTimeout(800);

const videoCount = await page.locator("video").count();
const posterCount = await page.locator('img[src*="herograin"]').count();
const stillCount = await page.locator('img[src*="video-still"]').count();

// Count only the brewery section image/poster (the hero has one too).
console.log(`${label}: video elements=${videoCount}, herograin images=${posterCount}`);
// The brewery section poster is the video still (Issue #135); the grain
// hero image belongs to the hero section above it.
console.log(`${label}: video elements=${videoCount}, video-still images=${stillCount}`);
await context.close();
}

Expand Down
157 changes: 157 additions & 0 deletions smoke-tests/hero-video.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { test, expect } from "./fixtures";

// Brewery video section (Issue #135). The decorative <video> is intentionally
// gated: reduced-motion and viewports <=768px get only the approved brewery
// still. On desktop the still is always rendered underneath and the video
// fades in over it only once it can actually play — the static state is a
// complete design, never an error screen.
//
// Headless browsers cannot prove real decoding/playback, so these tests
// assert the DOM contract (sources, attributes, gating, graceful failure);
// media bytes themselves are verified out-of-band with ffprobe.

const brewerySection = (page: import("playwright").Page) =>
page.locator("section", {
has: page.getByRole("heading", { name: "The Brewery", exact: true }),
});

test("brewery section uses the approved still, not the grain hero image", async ({
page,
}) => {
await page.goto("/");
const section = brewerySection(page);
await expect(section).toBeVisible();

// The section poster is the distinct brewery still…
await expect(section.locator('img[src*="video-still"]')).toBeVisible();
// …and never the duplicated grain hero photo.
await expect(section.locator('img[src*="herograin"]')).toHaveCount(0);

// The main hero above keeps the grain image.
const hero = page.locator("section").first();
await expect(hero.locator('img[src*="herograin"]')).toBeVisible();

// Section remains a complete, readable design.
await expect(
section.getByRole("heading", { name: "The Brewery", exact: true })
).toBeVisible();
await expect(section.getByRole("link", { name: "Learn More" })).toBeVisible();
});

test("desktop mounts a muted looping video with webm-first sources", async ({
page,
}) => {
await page.goto("/");
const section = brewerySection(page);
const video = section.locator("video");
await expect(video).toBeAttached();
await expect(video).toHaveAttribute("aria-hidden", "true");
await expect(video).toHaveAttribute("muted", "");
await expect(video).toHaveAttribute("loop", "");
await expect(video).toHaveAttribute("playsinline", "");
await expect(video).toHaveAttribute("preload", "none");
// No poster attribute — the SSR'd attribute would fetch the raw file
// before hydration, duplicating the optimized image download.
await expect(video).not.toHaveAttribute("poster");

const sources = video.locator("source");
await expect(sources).toHaveCount(2);
await expect(sources.nth(0)).toHaveAttribute("src", "/videos/ddbwebvid.webm");
await expect(sources.nth(0)).toHaveAttribute("type", "video/webm");
await expect(sources.nth(1)).toHaveAttribute("src", "/videos/ddbwebvid.mp4");
await expect(sources.nth(1)).toHaveAttribute("type", "video/mp4");
});

test("the still stays underneath as the video starts playing", async ({
page,
}) => {
await page.goto("/");
const section = brewerySection(page);
await section.scrollIntoViewIfNeeded();

const still = section.locator('img[src*="video-still"]');
const video = section.locator("video");
await expect(still).toBeVisible();

// Headless Chromium decodes and plays these sources for real, so the
// `playing` event must arrive and flip the video to opacity-100. The
// still never leaves the DOM underneath — before or after the fade.
await expect(video).toHaveClass(/opacity-100/, { timeout: 15000 });
await expect(still).toBeVisible();
});

test("a refused play() leaves the still intact without an error surface", async ({
page,
}) => {
// Simulate an autoplay refusal (e.g. low-power mode) before any page
// code, and answer media requests with an empty body — deterministic
// and leaves no in-flight fetch to abort at teardown.
await page.addInitScript(() => {
HTMLMediaElement.prototype.play = () =>
Promise.reject(new DOMException("Blocked", "NotAllowedError"));
});
await page.route("**/videos/*", (route) =>
route.fulfill({ status: 200, body: "" })
);
await page.goto("/");
const section = brewerySection(page);
await section.scrollIntoViewIfNeeded();

await expect(section.locator('img[src*="video-still"]')).toBeVisible();
await expect(
section.getByRole("heading", { name: "The Brewery", exact: true })
).toBeVisible();
// The video mounts but never fades in; nothing breaks.
const video = section.locator("video");
await expect(video).toBeAttached();
await expect(video).toHaveClass(/opacity-0/);
});

test("the video layer cannot shift layout when it appears", async ({
page,
}) => {
await page.goto("/");
const section = brewerySection(page);
await section.scrollIntoViewIfNeeded();
const before = await section.boundingBox();
// The video is absolutely positioned inside the fixed-height section.
const position = await section
.locator("video")
.evaluate((el) => getComputedStyle(el).position);
expect(position).toBe("absolute");
await page.waitForTimeout(1500);
const after = await section.boundingBox();
expect(after?.height).toBe(before?.height);
expect(after?.width).toBe(before?.width);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth
)
).toBe(false);
});

test("small viewports get only the still — no video element at all", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto("/");
const section = brewerySection(page);
await expect(section.locator("video")).toHaveCount(0);
await expect(section.locator('img[src*="video-still"]')).toBeVisible();
await expect(
section.getByRole("heading", { name: "The Brewery", exact: true })
).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth
)
).toBe(false);
});

test("reduced motion gets only the approved still", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
const section = brewerySection(page);
await expect(section.locator("video")).toHaveCount(0);
await expect(section.locator('img[src*="video-still"]')).toBeVisible();
});
Loading