mirror of
https://github.com/zoo-apps/zoolabs.io.git
synced 2026-10-30 14:56:27 +00:00
Blue has never appeared. Every clip in public/bg_video was a 132-byte Git LFS pointer, so the door has been a plain dark rectangle since it went live — and nothing anywhere said so: the file existed, the request answered 200, the content-type was video/mp4, and the page rendered. Only its SIZE was wrong. The real files were in LFS on the remote the whole time. Two things then kept them off screen, and both are the same shape as the first. A CDN caches by URL, and `/bg_video/emotion/Happiness.mp4` was the same URL before and after the bytes behind it changed — so once the real clips reached the origin, the edge went on serving the pointers it already had, for a day at the origin's own max-age. Each clip's URL now carries a hash of its own contents, computed before the build, so new bytes cannot inherit an old URL and a clip that did not change is not re-fetched. Purging is the other answer and it is worse: it needs a credential at deploy time, it is a step someone has to remember, and it throws away a whole zone to fix one file. And a single video element cannot change what it plays without going blank — set src, call load(), and it holds nothing until the first frame of the next clip arrives. That is a black flash in the middle of a sentence, at exactly the moment the page is asking to be watched. There are two elements now, stacked: the one you can see keeps playing while the other loads the next clip in silence, and they trade opacity only once that one says it can play. A clip that fails to load never gets its turn, so a dead connection leaves the current one running — a beluga at rest beats a black rectangle. Proven end to end against the live site: the clip plays at 1920x1080, the free lane answers with real science, and asking why beluga calves are born grey dissolves the resting loop into Interest.
88 lines
2.8 KiB
TypeScript
88 lines
2.8 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
|
|
/**
|
|
* The clip behind the page, and the dissolve between clips.
|
|
*
|
|
* One video element cannot change what it is playing without going blank: set
|
|
* `src`, call `load()`, and the element holds nothing until the first frame of
|
|
* the next clip has arrived over the network. That is a black flash in the
|
|
* middle of a sentence, every time Blue's feeling changes — the exact moment
|
|
* the page is asking to be watched.
|
|
*
|
|
* So there are two elements, stacked. The one you can see keeps playing while
|
|
* the other loads the next clip in silence; only once that one says it can play
|
|
* do they trade opacity, and only then do they trade roles. Nothing is ever on
|
|
* screen that has not already buffered, so there is no frame to lose.
|
|
*
|
|
* A clip that fails to load never gets its turn — the fade is driven by
|
|
* `canplay`, so a 404 or a dead connection simply leaves the current clip
|
|
* running. Better a beluga at rest than a black rectangle.
|
|
*/
|
|
|
|
export default function Film({
|
|
src,
|
|
loop,
|
|
onEnded,
|
|
}: {
|
|
src: string
|
|
/** A feeling holds; a resting clip plays once and hands over to the next. */
|
|
loop: boolean
|
|
onEnded?: () => void
|
|
}) {
|
|
// Which of the two elements is the one being watched.
|
|
const [front, setFront] = useState(0)
|
|
// What each element is playing. Both start on the same clip so the first
|
|
// paint has a frame rather than a fade from nothing.
|
|
const [reel, setReel] = useState<[string, string]>([src, src])
|
|
const el = [useRef<HTMLVideoElement>(null), useRef<HTMLVideoElement>(null)]
|
|
|
|
useEffect(() => {
|
|
if (reel[front] === src) return
|
|
|
|
const back = 1 - front
|
|
const next = el[back].current
|
|
if (!next) return
|
|
|
|
// Point the hidden element at the new clip and wait. `canplay` is the
|
|
// promise that a frame exists; anything earlier would show the blank we
|
|
// are here to avoid.
|
|
let live = true
|
|
const show = () => {
|
|
if (!live) return
|
|
void next.play().catch(() => {})
|
|
setFront(back)
|
|
}
|
|
|
|
setReel((r) => (back === 0 ? [src, r[1]] : [r[0], src]))
|
|
next.addEventListener('canplay', show, { once: true })
|
|
return () => {
|
|
live = false
|
|
next.removeEventListener('canplay', show)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [src, front, reel])
|
|
|
|
return (
|
|
<>
|
|
{[0, 1].map((i) => (
|
|
<video
|
|
key={i}
|
|
ref={el[i]}
|
|
src={reel[i]}
|
|
className="deep-film"
|
|
style={{ opacity: i === front ? 1 : 0 }}
|
|
autoPlay
|
|
muted
|
|
playsInline
|
|
preload="auto"
|
|
loop={loop}
|
|
aria-hidden
|
|
// Only the clip being watched decides when it has finished. The one
|
|
// behind is buffering, and its `ended` is nobody's business.
|
|
onEnded={i === front ? onEnded : undefined}
|
|
/>
|
|
))}
|
|
</>
|
|
)
|
|
}
|