← All notes

No footage, no shoot: a hero video for forty cents

July 27, 20267 min read

This is the first site I've put live with a video hero, and it's up now:

centroalternativoom.com — Centro Alternativo OM, an alternative-medicine practice in Nuevo Laredo, on the Texas border.

The clinic's landing page: a full-viewport video of acupuncture needles behind a serif headline reading Consultorio de Medicina Alternativa en Nuevo Laredo, with a pause control in the bottom-left corner
Full viewport, one video, a directional scrim so the headline stays readable — and a pause button, bottom left.

The brief asked for a full-viewport hero that moves. The problem with that brief is the same one every small business has: there is no footage. No b-roll, no videographer, no budget for a shoot, and stock video of a generic massage table would have looked exactly like what it is. What the clinic did have was a library of still images they already owned.

So the video was generated from one of those stills — and it cost forty cents.

Eight seconds, forty cents

The generation itself is the least interesting part, which is the point. An image-to-video call to google/veo-3.1-lite through OpenRouter's POST /api/v1/videos: you hand it a first frame and a prompt describing the camera move, poll the job, and get an MP4 back. Eight seconds, 1080p, 16:9, no audio track. $0.40.

The price is worth dwelling on, because the spread between models is enormous and most of it buys capability this job doesn't need. Per second of 1080p output, as the API reports it today:

model $/second at 1080p
Veo 3.1 Lite (no audio) 0.05
Wan 2.6 (image-to-video) 0.15
Grok Imagine Video 1.5 0.25
Sora 2 Pro 0.50

Grok Imagine is five times Veo Lite per second, and its capability row doesn't even declare which aspect ratios it supports — which for a 16:9 hero is the one thing I need guaranteed. Sora 2 Pro is ten times. Those models are priced for footage that has to act: multi-shot instructions, world-state persistence, physics. What this hero needed was a slow push over an object that isn't going anywhere. You don't pay for a stunt team to film a still life.

The one gotcha that cost real time: Veo rejects WebPUnsupported image format. Expected JPEG or PNG. My own CDN converts every upload to WebP by default, which is normally exactly what I want and here made the input frame unusable. The fix was to route the first frame through an ephemeral no-transcode bucket, which also means the source frame self-deletes in 24 hours instead of living forever next to the client's real assets.

The loop that wouldn't close

Eight seconds of generated video will not loop. Nobody tells you this, and it's obvious in hindsight: the model animates away from your first frame, so second eight is nowhere near second zero. Restart it and the picture snaps back — a visible, rhythmic jolt every eight seconds at the top of the page, which reads as broken even to someone who can't say why.

Here's the same measurement I made on the finished file, mean absolute pixel difference between frames, 0–255 per channel:

frames compared difference
start vs. end of the raw 8 s generation 88.40
start vs. end of the shipped file 2.91

The fix is a ping-pong: play the eight seconds forward, then play them backward, and ship the sixteen seconds as one file. Now the last frame is the first frame, the seam is gone, and the loop is perfect by construction rather than by luck. In FFmpeg it's one filter graph — split the stream, reverse one copy, concatenate:

ffmpeg -i push.mp4 -filter_complex \
  "[0:v]split[a][b];[b]reverse[r];[a][r]concat=n=2:v=1[out]" \
  -map "[out]" -movflags +faststart hero.mp4

It also happens to look better than the original. A camera that pushes in and then drifts back out reads as breathing. A camera that only pushes reads as a camera that ran out of tape.

The shipped loop — eight seconds forward, eight back. Watch the seam at the halfway point and at the end; there isn't one.

What a hero video is allowed to weigh

The finished 1080p cut is 3.7 MB. That is a perfectly reasonable number for a desktop hero and a completely unacceptable one for a phone on border-town LTE, so there are two cuts — 1080p at 3.7 MB and 720p at 1.26 MB — and three rules about how they load.

No <source> element in the markup. The server renders the <video> tag with a poster and nothing else. The source URL is assigned in JavaScript after the client has measured itself:

const small = window.matchMedia('(max-width: 767px)').matches;
el.src = small ? HERO_MEDIA.mobile : HERO_MEDIA.desktop;
el.load();

If you put both cuts in <source> tags with media queries, you're trusting the browser's source-selection heuristics, and some of them will start fetching before they've settled. Assigning src once, from code, means a phone provably never opens a connection to the 3.7 MB file.

preload="none". The poster is a WebP still of frame zero — around 70 KB — so the hero is fully composed, headline and all, before a single byte of video is requested.

+faststart. In an MP4 the moov atom is the index; if it's written after the media data, a browser has to pull the entire file before it can render frame one. faststart moves it to the front. On both shipped cuts the moov box sits at byte 32, right behind ftyp — which took ten seconds to verify and would have been an invisible, unexplainable second of black otherwise.

The hard part was getting it to play

Everything above took an afternoon. Then real people opened the page on real phones and the reports came back: the video doesn't start on its own.

The first cause was mine. I had gated the video behind prefers-reduced-motion — reduced motion, no autoplay, poster forever. It felt like the responsible default. It isn't, not for this: iOS ships "Reduce Motion" as a common comfort setting, so a large slice of visitors were getting a still photograph where the whole design premise was movement. And the criterion that actually governs this — WCAG 2.2.2, Pause, Stop, Hide — doesn't ask you to suppress motion, it asks you to give people a way to stop it. There is a pause control in the bottom-left corner, the video is muted, and there is no parallax and nothing that flies. So it plays, and anyone who wants it still can stop it in one tap. Honest tradeoff, stated out loud rather than defaulted into.

The rest of the causes are not mine and can't be forced. iOS Low Power Mode, Android data saver, desktop energy saver — all of them refuse autoplay by policy, and no amount of muted playsinline argues with them. But every one of those policies is waiting for the same thing: a user gesture. So when the first play() is refused, the page arms a one-shot retry on the first gesture of any kind:

const events = ['pointerdown', 'touchstart', 'keydown', 'scroll'];

scroll is the one that matters. Nobody hunts for a play button on a landing page, but everybody scrolls within two seconds of arriving — and that scroll is a valid gesture, so the video simply starts, and the visitor never learns that it had been refused.

And the button reads the element, not my intentions. The play/pause icon is driven by the video's own onPlay and onPause events, never by what the code asked for. Browsers suspend playback on their own — battery, backgrounded tab, a policy that changes mid-session — and a pause button showing ⏸ over a frozen frame is worse than no button at all, because now the interface is lying to someone who is trying to use it.

Where it landed

Forty cents of generation, a reversed copy of itself so the loop closes, two cuts so a phone doesn't pay for a desktop, and a play attempt that quietly tries again on your first scroll. None of the interesting work was the AI part. The AI part was the cheap part.

See it: centroalternativoom.com.