feat(video): add custom reel player with local mp4 support

Replaced Frame.io link with embedded local video player for the studio reel.

## Changes
- Created ReelPlayer component with custom HTML5 video controls
  - Play/pause, volume, fullscreen, progress bar with scrubbing
  - Loading and error states with user-friendly messages
  - Dark theme styling with orange (#ff4d00) accents and sharp corners
  - Responsive design for mobile/tablet/desktop

- Integrated ReelPlayer into Temp-Placeholder (Work section)
  - Replaced external Frame.io link with local /reel.mp4
  - Maintains minimal aesthetic with proper animations

- Fixed middleware whitelist issue
  - Added /reel.mp4 to middleware allowlist (src/middleware.ts:8)
  - Prevents 307 redirect that was causing "text/html" Content-Type error

- Added video file headers to next.config.ts
  - Ensures proper video/mp4 MIME type for all .mp4 files

- Updated CLAUDE.md documentation
  - Added critical warning about middleware whitelist in "Common pitfalls"
  - Added rule #9 to "Agents operating rules" for public/ file additions
  - Future-proofs against this issue happening again

## Technical Details
- Video: 146MB, H.264 codec, 4K resolution (3840x2160)
- Player handles large file buffering gracefully
- ReadyState check prevents loading overlay persistence
- All controls accessible and keyboard-friendly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nicholai 2025-10-23 03:05:27 -06:00
parent bedd355b78
commit a06b2607c7
6 changed files with 472 additions and 168 deletions

333
CLAUDE.md
View File

@ -1,168 +1,211 @@
# CLAUDE.md # Agents Guide (Single Source of Truth)
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This document is the canonical reference for humans and `Agents` working in this repository. It standardizes stack, structure, workflows, and guardrails so contributions stay consistent and safe.
## Project Overview ## 1) Scope and goals
Biohazard VFX is a modern Next.js 15 website for a visual effects studio, deployed to Cloudflare Workers using OpenNext. The site showcases portfolio work, services, and provides a multi-step contact form for client intake. * Make onboarding fast with a single place to look
* Define conventions so changes are predictable
* Provide exact commands that `Agents` can run without guesswork
* Prevent accidental regressions in routing, theming, SEO, and deployment
**Tech Stack:** ## 2) Tech stack
- Next.js 15.5.4 with App Router
- TypeScript (strict mode)
- React 19.1.0
- Tailwind CSS 4
- shadcn/ui (New York style)
- Framer Motion for animations
- Cloudflare Workers deployment via OpenNext
## Development Commands * **Framework**: Next.js 15.5.4, React 19, TypeScript
* **Styling**: Tailwind CSS 4, shadcn/ui
* **Animation**: Framer Motion
* **Forms**: react-hook-form + Zod
* **Platform**: Cloudflare Workers via OpenNext
* **Package manager**: npm
* **Node**: LTS 20 or 22
```bash ## 3) Project layout
# Development
npm run dev # Start dev server with Turbopack
npm run type-check # Run TypeScript compiler (no emit)
npm run lint # Run ESLint
# Building ```
npm run build # Standard Next.js build root
npm run build:open-next # Build for Cloudflare Workers (runs next build + open-next build) ├─ src/
│ ├─ app/ # App Router pages and layouts
# Production │ │ ├─ (marketing)/ # Example route groups
npm start # Start Next.js production server (not used for Cloudflare deployment) │ │ ├─ api/ # Route handlers
│ │ └─ layout.tsx # Root layout, see rules below
│ ├─ components/ # Reusable UI
│ ├─ data/ # JSON or TS data objects consumed by pages
│ ├─ lib/ # Utilities, hooks, server actions
│ ├─ styles/ # globals.css, tailwind utilities if applicable
│ └─ types/ # Shared types
├─ public/ # Static assets
├─ next.config.ts
├─ tailwind.config.ts
├─ wrangler.toml # Cloudflare deploy config
└─ package.json
``` ```
## Deployment (Cloudflare Workers) ### Import aliases
The site deploys to Cloudflare Workers, not Vercel. Critical deployment files: * Prefer absolute imports using `@` mapped to `src` via `tsconfig.json` paths.
- **`wrangler.toml`**: Cloudflare Workers configuration with routes for biohazardvfx.com ## 4) Authoritative UI system
- **`open-next.config.ts`**: OpenNext adapter configuration for Cloudflare
- **`next.config.ts`**: Ignores lint/TypeScript errors during build (required for deployment)
**Deploy commands:** * **Theme**: dark mode is the default. Do not introduce light-first designs without approval.
```bash * **Typography**: default to Geist and Geist Mono via CSS variables. If adding a new font, add it as a variable and document it here before use.
npx opennextjs-cloudflare build # Build for Cloudflare * **Components**: use shadcn/ui primitives. Extend with local wrappers placed in `src/components/ui/`.
npx wrangler deploy # Deploy to Cloudflare Workers * **Spacing and rhythm**: follow Tailwind 4 defaults. Prefer utility classes over custom CSS unless componentized.
* **Animation**: keep motion subtle. Framer Motion only for meaningful transitions.
## 5) Routing and layout rules
* The **root layout** owns global providers, theme class, `<Nav />`, and `<Footer />`. Do not duplicate these in child layouts.
* Pages live in `src/app`. Keep server components as the default. Promote to client component only when needed.
* Metadata must be defined per route with the Next.js Metadata API.
## 6) SEO and metadata
* Use the Metadata API for title, description, Open Graph, and Twitter cards.
* Add structured data with JSON-LD in the root layout or specific routes when required.
* All pages must render a unique `title` and `description` suitable for indexing.
## 7) Forms and validation
* Use `react-hook-form` with Zod schemas.
* Surface field-level errors and a generic submit error. Never swallow validation errors.
## 8) Images and assets
* Use Next Image component for remote images.
* If a new external domain is introduced, add it to `next.config.ts` remote patterns and document it here.
* Keep `public/` for truly static assets only.
## 9) Environment variables
Provide a `.env.sample` and keep it in sync. Typical keys:
```
NEXT_PUBLIC_SITE_URL=
RESEND_API_KEY=
CF_PAGES_URL=
``` ```
**Live URLs:** Do not commit real secrets. `Agents` must fail a task rather than hardcode a secret.
- Production: https://biohazardvfx.com
- Worker: https://biohazard-vfx-website.nicholaivogelfilms.workers.dev
**Cloudflare-specific requirements:** ## 10) Local development
- Requires `nodejs_compat` compatibility flag
- Compatibility date: `2024-09-23` or later
- Assets binding configured at `.open-next/assets`
## Architecture ```
# install
npm ci
### Path Aliases # run dev server
- `@/*` maps to `./src/*` (configured in tsconfig.json) npm run dev
- shadcn/ui aliases: `@/components`, `@/components/ui`, `@/lib/utils`
### App Structure (Next.js App Router) # type checks and linting
npm run typecheck
npm run lint
Pages are in `src/app/`: # build and preview
- `page.tsx` - Homepage with hero, featured projects, capabilities npm run build
- `about/page.tsx` - Studio origins, values, testimonials npm run start
- `services/page.tsx` - Service offerings with ServiceCard components
- `portfolio/page.tsx` - Masonry grid portfolio layout
- `contact/page.tsx` - Multi-step contact form
- `layout.tsx` - Root layout with Navigation and Footer (removed from individual pages)
**Important:** Navigation and Footer components are only in the root layout. Individual pages should NOT include them.
### Data Files
Content is separated from components in `src/data/`:
- `projects.ts` - Project data with Project interface (id, title, description, category, thumbnailUrl, videoUrl, aspectRatio, featured, tags)
- `services.ts` - Services data
When adding new projects or services, update these files rather than hardcoding data in components.
### Component Organization
Custom components in `src/components/`:
- **Layout:** Navigation, Footer
- **Page sections:** Hero, ContactSection, MissionSection, BrandingSection
- **Content display:** ProjectCard, ServiceCard, MasonryGrid, ProjectShowcase, VideoPlayer, VideoPreview
- **Interactive:** MultiStepForm (4-step client intake), HorizontalAccordion
- **Visual effects:** DepthMap, CursorDotBackground, ScrollProgressBar, SectionDivider
- **Third-party:** InstagramFeed, ClientLogoGrid
shadcn/ui components in `src/components/ui/`:
- Uses New York style variant
- Includes: accordion, button, card, dialog, hover-card, input, label, navigation-menu, progress, select, separator, textarea
### Styling System
**Fonts:** Nine Google Fonts preloaded in layout.tsx:
- Geist Sans (primary), Geist Mono
- Bebas Neue, Orbitron, Inter, JetBrains Mono, Space Mono, Rajdhani, Exo 2
- Available as CSS variables: `--font-geist-sans`, `--font-bebas`, etc.
**Theme:**
- Dark mode by default (`className="dark"` on html element)
- CSS variables in `src/app/globals.css`
- shadcn/ui config in `components.json` (New York style, neutral base color)
**Tailwind:**
- Tailwind CSS 4 with PostCSS
- Config: `tailwind.config.ts`
- Global styles: `src/app/globals.css`
### SEO & Metadata
All pages include Next.js Metadata API:
- Open Graph tags
- Twitter cards
- Canonical links
- JSON-LD structured data (Organization schema in root layout)
- metadataBase: `https://biohazardvfx.com`
### Forms & Validation
MultiStepForm component uses:
- react-hook-form for form state
- zod for validation (via @hookform/resolvers)
- 4 steps: Project Type → Budget/Timeline → Project Details → Contact Info
- Progress indicator using shadcn/ui Progress component
### Images
Next.js Image optimization configured in next.config.ts:
- Remote patterns allowed: `images.unsplash.com`
- `unoptimized: false` (optimization enabled)
## Key Constraints
1. **Cloudflare deployment:** Do not suggest Vercel-specific features that won't work with OpenNext
2. **Build config:** Lint and TypeScript errors are ignored during build (required for deployment). Do not remove these settings from next.config.ts
3. **Dark theme only:** Site uses dark mode by default. Do not create light mode variants unless explicitly requested
4. **Layout structure:** Navigation and Footer are in root layout only. Do not add them to individual pages
5. **Data separation:** Project and service data lives in `src/data/`. Keep content separate from components
## Common Tasks
**Add a new page:**
1. Create directory in `src/app/`
2. Add `page.tsx` with metadata export
3. Update Navigation component at `src/components/Navigation.tsx`
**Add a new shadcn/ui component:**
```bash
npx shadcn@latest add [component-name]
``` ```
**Update project portfolio:** Notes
1. Edit `src/data/projects.ts`
2. Add new Project object to projects array
3. Ensure aspectRatio is set correctly for masonry layout
**Update services:** * The Next build may be configured to ignore ESLint and TS errors for production bundling speed. CI still gates on `lint` and `typecheck` before merge.
1. Edit `src/data/services.ts`
2. Add service object with required fields ## 11) Deployment on Cloudflare Workers with OpenNext
### Required wrangler.toml settings
```
name = "site-worker"
main = ".open-next/worker/index.mjs"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
assets = { directory = ".open-next/assets" }
```
### Build and deploy
```
# produce OpenNext build artifacts
npx open-next@latest build
# deploy worker and assets
npx wrangler deploy .open-next/worker
```
Guidelines
* Always run `npm run typecheck` and `npm run lint` before build.
* Ensure `assets.directory` matches the OpenNext output.
* Keep the compatibility date at or after 2024-09-23.
## 12) Branching, commits, and CI
* **Default branch**: `main` is protected.
* **Workflow**: feature branches -> PR -> required checks -> squash merge.
* **Commit format**: Conventional Commits. Examples
* `feat: add contact form schema`
* `fix: correct Image remote pattern`
* `chore: bump dependencies`
* **Required checks**
* `npm run lint`
* `npm run typecheck`
* `npm run build` can be optional locally if CI runs it, but must succeed before deploy.
## 13) Testing
* **Unit**: place tests close to sources, name with `.test.ts` or `.test.tsx`.
* **E2E**: optional Playwright. If used, add a `playwright.config.ts` and a `npm run e2e` script.
## 14) Data and content
* Non-secret content belongs in `src/data` as TS modules or JSON. Keep it presentation-agnostic.
* Do not fetch static project data in client components. Prefer server components or file imports.
## 15) `Agents` operating rules
1. Read this guide before making changes.
2. Do not alter the root layout structure for global nav or footer. Extend only via component props or slots.
3. Before adding a dependency, justify it in the PR description and update this document if it affects conventions.
4. When creating pages, set Metadata and verify unique title and description.
5. If a build ignores ESLint or TS errors, CI still blocks merges on `lint` and `typecheck`. Fix the errors instead of bypassing checks.
6. Never commit secrets. Use `.env` and keep `.env.sample` current.
7. If you change image domains or fonts, document the change here.
8. Prefer small, reviewable PRs. Include screenshots for UI changes.
9. **When adding files to `public/`**, always update the middleware whitelist in `src/middleware.ts` (line 8) to allow access to the new files.
## 16) Common pitfalls
* Adding a remote image domain but forgetting to allow it in `next.config.ts`.
* Introducing a client component unnecessarily and breaking streaming or SSR.
* Duplicating navigation inside nested layouts.
* Styling drift by bypassing Tailwind utilities and shadcn primitives.
* **⚠️ CRITICAL - Middleware Whitelist**: `src/middleware.ts` redirects ALL routes to `/` except explicitly whitelisted paths. When adding new static assets to `public/` (images, videos, PDFs, etc.), you MUST add the path to the middleware allowlist (line 8) or the file will return a 307 redirect to `/` instead of serving. Common symptom: video/image returns "text/html" Content-Type error.
## 17) Quick command reference
```
# install deps
npm ci
# develop
npm run dev
# quality gates
npm run lint
npm run typecheck
# build and preview
npm run build
npm run start
# open-next build and deploy
npx open-next@latest build
npx wrangler deploy .open-next/worker
```
## 18) Change management
* Any modification to guardrails in sections 4 to 12 requires a PR that updates this document.
* Keep this file the single place that defines expectations for humans and `Agents`.
**Modify theme colors:**
1. Edit CSS variables in `src/app/globals.css`
2. Theme uses neutral base color with CSS variables for theming

View File

@ -20,6 +20,20 @@ const nextConfig: NextConfig = {
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
// Custom headers for video files
async headers() {
return [
{
source: "/:path*.mp4",
headers: [
{
key: "Content-Type",
value: "video/mp4",
},
],
},
];
},
}; };
export default nextConfig; export default nextConfig;

BIN
public/reel.mp4 Normal file

Binary file not shown.

View File

@ -0,0 +1,258 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { Play, Pause, Volume2, VolumeX, Maximize, AlertCircle } from "lucide-react";
interface ReelPlayerProps {
src: string;
className?: string;
}
export function ReelPlayer({ src, className = "" }: ReelPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
};
const handleEnded = () => {
setIsPlaying(false);
};
const handleError = (e: Event) => {
setIsLoading(false);
const videoEl = e.target as HTMLVideoElement;
const errorCode = videoEl.error?.code;
const errorMessage = videoEl.error?.message;
let userMessage = "Failed to load video. ";
switch (errorCode) {
case 1:
userMessage += "Video loading was aborted.";
break;
case 2:
userMessage += "Network error occurred.";
break;
case 3:
userMessage += "Video format not supported by your browser.";
break;
case 4:
userMessage += "Video source not found.";
break;
default:
userMessage += errorMessage || "Unknown error.";
}
setError(userMessage);
console.error("Video error:", errorCode, errorMessage);
};
const handleCanPlay = () => {
console.log("Video canplay event fired");
setIsLoading(false);
setError(null);
};
const handleLoadedData = () => {
console.log("Video loadeddata event fired");
setIsLoading(false);
};
video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("ended", handleEnded);
video.addEventListener("error", handleError);
video.addEventListener("canplay", handleCanPlay);
video.addEventListener("loadeddata", handleLoadedData);
// Check if video is already loaded (in case events fired before listeners attached)
if (video.readyState >= 3) {
// HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
console.log("Video already loaded, readyState:", video.readyState);
setIsLoading(false);
if (video.duration) {
setDuration(video.duration);
}
}
return () => {
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("ended", handleEnded);
video.removeEventListener("error", handleError);
video.removeEventListener("canplay", handleCanPlay);
video.removeEventListener("loadeddata", handleLoadedData);
};
}, []);
const togglePlay = async () => {
const video = videoRef.current;
if (!video || error) return;
try {
if (isPlaying) {
video.pause();
setIsPlaying(false);
} else {
await video.play();
setIsPlaying(true);
}
} catch (err) {
console.error("Play error:", err);
setError("Unable to play video. " + (err as Error).message);
setIsPlaying(false);
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !isMuted;
setIsMuted(!isMuted);
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const video = videoRef.current;
const progressBar = progressBarRef.current;
if (!video || !progressBar) return;
const rect = progressBar.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percentage = clickX / rect.width;
video.currentTime = percentage * video.duration;
};
const toggleFullscreen = () => {
const video = videoRef.current;
if (!video) return;
if (!document.fullscreenElement) {
video.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className={`relative bg-black border border-white/10 ${className}`}>
{/* Video Element */}
<video
ref={videoRef}
className="w-full aspect-video bg-black"
onClick={togglePlay}
preload="auto"
playsInline
>
<source src={src} type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* Loading State */}
{isLoading && !error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="text-white text-sm">Loading video...</div>
</div>
)}
{/* Error State */}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 p-4">
<AlertCircle className="w-12 h-12 text-[#ff4d00] mb-3" />
<div className="text-white text-sm text-center max-w-md">
{error}
</div>
<div className="text-gray-400 text-xs mt-2">
Try refreshing the page or using a different browser.
</div>
</div>
)}
{/* Custom Controls */}
{!error && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/70 to-transparent p-4">
{/* Progress Bar */}
<div
ref={progressBarRef}
className="w-full h-1 bg-white/20 cursor-pointer mb-3 relative"
onClick={handleProgressClick}
>
<div
className="h-full bg-[#ff4d00] transition-all duration-100"
style={{ width: `${progress}%` }}
/>
</div>
{/* Controls Row */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
{/* Play/Pause Button */}
<button
onClick={togglePlay}
className="text-white hover:text-[#ff4d00] transition-colors"
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5" />
)}
</button>
{/* Volume Button */}
<button
onClick={toggleMute}
className="text-white hover:text-[#ff4d00] transition-colors"
aria-label={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)}
</button>
{/* Time Display */}
<span className="text-white text-sm font-mono">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* Fullscreen Button */}
<button
onClick={toggleFullscreen}
className="text-white hover:text-[#ff4d00] transition-colors"
aria-label="Fullscreen"
>
<Maximize className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -5,6 +5,7 @@ import { InstagramFeed } from "./InstagramFeed";
import { ScrollProgressBar } from "./ScrollProgressBar"; import { ScrollProgressBar } from "./ScrollProgressBar";
import { SectionDivider } from "./SectionDivider"; import { SectionDivider } from "./SectionDivider";
import { VideoPreview } from "./VideoPreview"; import { VideoPreview } from "./VideoPreview";
import { ReelPlayer } from "./ReelPlayer";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { DepthMap } from "./DepthMap"; import { DepthMap } from "./DepthMap";
@ -332,33 +333,21 @@ export function TempPlaceholder() {
{/* Work Section */} {/* Work Section */}
<section id="work" className="mb-16 md:mb-20"> <section id="work" className="mb-16 md:mb-20">
<motion.p <motion.p
className="mb-4 text-base sm:text-lg" className="mb-6 text-base sm:text-lg"
variants={itemVariants} variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }} transition={{ duration: 0.4, ease: "easeOut" }}
> >
<strong>&gt; Here's our reel:</strong>{" "} <strong>&gt; Here's our reel:</strong>
<motion.a
href="https://f.io/Wgx3EAHu"
className="inline-block break-words relative"
style={{ color: '#ff4d00' }}
target="_blank"
rel="noopener noreferrer"
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<span className="relative inline-block">
Biohazard Reel Mar 2025 - Frame.io
<motion.span
className="absolute bottom-0 left-0 h-[1px] bg-current"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: 'left', width: '100%' }}
/>
</span>
</motion.a>
</motion.p> </motion.p>
<motion.div
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
className="mb-8"
>
<ReelPlayer src="/reel.mp4" />
</motion.div>
<SectionDivider /> <SectionDivider />
<motion.p <motion.p

View File

@ -5,7 +5,7 @@ export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Allow only the home page and Next.js internal routes // Allow only the home page and Next.js internal routes
if (pathname === '/' || pathname.startsWith('/_next') || pathname.startsWith('/favicon.') || pathname === '/OLIVER.jpeg' || pathname === '/OLIVER_depth.jpeg' || pathname === '/no_pigeons_zone.gif') { if (pathname === '/' || pathname.startsWith('/_next') || pathname.startsWith('/favicon.') || pathname === '/OLIVER.jpeg' || pathname === '/OLIVER_depth.jpeg' || pathname === '/no_pigeons_zone.gif' || pathname === '/reel.mp4') {
return NextResponse.next(); return NextResponse.next();
} }