Prompt Library

页面与区块 · 综合行业与应用页面 · 公开

sea serenade

“sea serenade”|来源 MotionSites|原分类 网站

#网站#MotionSites
查看来源 ↗
Prompt 原文29,067 字符
# PROMPT: Recreate "COSTA SERENADE" — scroll-scrubbed cinematic one-pager

Build a single self-contained `index.html` (everything inline: CSS + JS in one file, no build step). It is a scroll-driven one-page site for a fictional dawn-swim crew on the Ligurian coast, set over bright coastal drone footage. The page has NO scrolling content in the normal sense — every visible layer is `position: fixed`, and three invisible spacer divs ("runways") create scroll height. One scroll listener + one rAF loop convert `scrollY` into CSS custom properties and a video scrub. The footage shows RAW — there is deliberately NO film grain and NO darkening scrim anywhere.

---

## 1. HEAD / ASSETS

- `<html lang="en">`, `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`
- Title: `COSTA SERENADE · Cala Rossa dawn swims`
- Meta description: `A dawn-swim crew working open water and sea caves along the old Ligurian coastline.`
- Google Fonts (with preconnect to fonts.googleapis.com and fonts.gstatic.com crossorigin):
  `https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;700&family=Noto+Sans+JP:wght@400;700&display=swap`
- GSAP 3.12.5 core + ScrollTrigger, both `defer`, from jsdelivr:
  `https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js` and `.../ScrollTrigger.min.js`
- mp4box 0.5.2 is loaded LAZILY by JS (see frame bank): `https://cdn.jsdelivr.net/npm/mp4box@0.5.2/dist/mp4box.all.min.js`

### Videos (exact URLs)
- **HERO (scrubbed by scroll, never played):**
  `https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260808_120410_d7d16648-77a7-47bd-af8a-33566d426956.mp4`
  Attributes: `class="stage__media" data-hero crossorigin="anonymous" poster="hero-2.jpg" muted playsinline preload="auto"`.
  `crossorigin="anonymous"` is REQUIRED — CloudFront answers `access-control-allow-origin: *` when an Origin header is sent, and the WebCodecs frame bank depends on fetching+decoding these bytes.
  The engine assumes this class of clip carries a single keyframe at t=0 (which is why the frame bank exists); frame count and duration are read at runtime, never hardcoded.
- **REVEAL (plays + loops when the split opens, never scrubbed):**
  `https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260808_120637_d08e0ebe-0c8c-4c68-912b-a84adf1c1211.mp4`
  Attributes: `class="reveal__video" data-reveal muted loop playsinline preload="auto"`. No poster.
- Poster `hero-2.jpg` is a local still (a deliberate leftover from an earlier shoot; it shows before the file lands and is the only image reduced-motion visitors see).

---

## 2. GLOBAL CSS / DESIGN TOKENS

- `*, *::before, *::after { box-sizing: border-box; }`
- `html, body { margin:0; padding:0; width:100%; overscroll-behavior:none; background:#000; }`
- `html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }`
- Negative tracking on `*` (NOT body, so each element resolves the em against its own size): `* { letter-spacing: -0.02em; }`
- Body font: `'Inter Tight', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; color: var(--ink); line-height: 1.3;`
- `a { color: inherit; text-decoration: none; }`
- `:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }`
- `::selection { background: var(--ink); color: #000; }`

### `:root` tokens
```css
--frame: 24px;
--ink: #ffffff;                              /* zero accent — all colour from footage */
--fs-display: clamp(42px, 8.2vw, 160px);
--shrink-end: 0.51;                          /* overwritten by JS measure() */
--fs-statement: calc(var(--fs-display) * var(--shrink-end));
--leading-display: 1;
--fs-jp: clamp(11px, 1.4vw, 26px);
--fs-micro: 12px;
--fs-nano: 12px;
--fs-lede: 14px;
--ease-expo: cubic-bezier(0.16, 1, 0.3, 1);
--runway: 350vh;                             /* hero section scroll length */
--runway-2: 120vh;                           /* section 2 */
--runway-3: 220vh;                           /* section 3 */
/* Written per-frame by JS; these rested values are the correct no-JS paint: */
--shrink: 1;  --exit: 0;  --exit-jp: 0;  --reveal: 0;  --s2: 0;  --s3: 0;
/* Hero plate exit keyed to the reveal's tail (fades inside the vanishing corner sliver): */
--stage-out: clamp(0, calc((var(--reveal) - 0.93) / 0.05), 1);
```
There is NO `--noise-opacity` token — grain was removed from this design.

---

## 3. LAYER STACK (all fixed, bottom to top)

1. **`.stage`** (z 0): `position:fixed; inset:0; background:#000; overflow:hidden; opacity: calc(1 - var(--stage-out));`
   - `.stage__media` (the hero video): `width/height 100%; object-fit:cover; object-position:50% 50%; display:block; filter:blur(0px);` (blur declared so GSAP interpolates deterministically). Mobile (≤767px): `object-position: 66% 50%`.
   - `.stage__canvas` (`data-canvas`, aria-hidden): absolutely fills stage, `opacity:0; transition:opacity 200ms linear; pointer-events:none;` → `opacity:1` when `[data-live]` is set. This is the WebCodecs frame-bank surface; every failure mode just leaves it invisible.
   - NO `.stage::after` scrim, NO wash of any kind — the plate shows exactly as shot.
2. **`.reveal`** (z 5): the diagonal split (see §5).
3. **`.sec2`** and **`.sec3`** (z 8): fixed text layers with `mix-blend-mode: difference`.
4. **`.frame`** (z 10): the hero's typographic layer, also `mix-blend-mode: difference`.

There is NO grain layer anywhere — no `.grain` element, no grain keyframes.

CRITICAL blend rule: `mix-blend-mode: difference` MUST sit on the fixed layers (`.frame`, `.sec2`, `.sec3`) themselves, never on text children — a fixed element forms its own stacking context, so a blend on a child would resolve against empty transparency and do nothing. Note the accepted trade: difference-blended white type over bright daylight footage washes out in places; that is by instruction, not an oversight.

---

## 4. THE REVEAL (diagonal split → section-3 card)

A centred clipping frame whose WIDTH grows (not a clip-path), rotated 45°, with the video inside counter-rotated so the picture stays upright while the opening edge is diagonal.

```css
.reveal {
  --reveal-span: calc(72vw + 72vh);   /* 0.707*(100vw+100vh)+margin: fullscreen exactly at --reveal:1 */
  --card-k: 0.26;                     /* section-3 card scale (≈374×234 at 1440×900) */
  --rot: calc(var(--reveal) * 45deg * (1 - var(--s3)));   /* section 3 UNWINDS the 45° */
  position: fixed; top:50%; left:50%;
  width: calc(var(--reveal) * var(--reveal-span));
  height: var(--reveal-span);
  transform: translate(-50%,-50%) rotate(var(--rot))
             scale(calc(1 - var(--s3) * (1 - var(--card-k))));
  overflow: hidden; z-index: 5; pointer-events: none;
  background: rgba(0,0,0, calc(1 - var(--s3)));   /* black must GO as it becomes a card */
}
.reveal__video {
  position:absolute; top:50%; left:50%; width:100vw; height:100vh; object-fit:cover;
  transform: translate(-50%,-50%) rotate(calc(var(--rot) * -1));  /* reads --rot: one source */
  display:block;
}
```
Section 3 shrinks the whole thing to a centred upright card: the rotation unwinds (else the diamond's own edges walk into frame) and the black background fades out (else the card is a black square with a letterboxed video).

---

## 5. RUNWAYS (only in-flow elements)

```html
<div class="runway" aria-hidden="true"></div>      <!-- height: var(--runway) -->
<div class="runway-2" aria-hidden="true"></div>    <!-- height: var(--runway-2) -->
<div class="runway-3" aria-hidden="true"></div>    <!-- height: var(--runway-3) -->
```
All `pointer-events:none`.

---

## 6. THE FRAME (hero type layer)

`.frame`: fixed, inset 0, z 10, padding on each side `max(var(--frame), env(safe-area-inset-*))`, `display:grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto 1fr auto; pointer-events:none;` (re-enabled per link: `.frame a { pointer-events:auto; }`), `mix-blend-mode:difference`. `.frame > * { min-width: 0; }`.
Hard 50% column: clock, lede, copyright all share the left edge of the right half; the wordmark owns the left half.

### Micro text
`.micro { font-size: var(--fs-micro); font-weight:500; line-height:1.2; text-transform:uppercase; letter-spacing:0.08em; }`
Hit-area helper: `.hit { position:relative; } .hit::after { content:''; position:absolute; inset:-16px -8px; }` (44px targets without moving anything).

### Brand (h1, grid area 1/1)
```html
<h1 class="brand">
  <span class="brand__slot">
    <span class="brand__line" data-role="main">Costa</span>
    <span class="brand__line" data-role="alt" aria-hidden="true">Enter</span>
    <span class="brand__line" data-role="crew" aria-hidden="true">Crew</span>
  </span>
  <span class="brand__slot">
    <span class="brand__line" data-role="main">Serenade</span>
    <span class="brand__line" data-role="alt" aria-hidden="true">The&nbsp;Blue</span>
    <span class="brand__line" data-role="crew" aria-hidden="true">Of&nbsp;Twelve</span>
  </span>
  <span class="brand__jp" lang="ja" aria-hidden="true">
    <span>コスタ</span><span>・</span><span>セレナーデ</span>
  </span>
</h1>
```
- `.brand`: `display:flex; column; align-items:stretch; width:fit-content; transform: scale(var(--shrink)); transform-origin: top left; margin:0;` Shrinks into the corner and STAYS (it becomes the section-2/3 heading).
- `.brand__slot`: `display:grid; overflow:hidden; margin-left:-0.02em;` (cancels side bearing). All three versions of a line occupy the SAME grid cell (`grid-area: 1/1` on `.brand__line`), so the slot takes the widest line's width and nothing reflows on swap; overflow clips the travelling line. First slot: `margin-top:-0.13em` (trims Inter Tight's ascent dead space so the cap meets the frame edge).
- `.brand__line`: `font-size:var(--fs-display); font-weight:700; line-height:var(--leading-display); letter-spacing:-0.03em; text-transform:uppercase; white-space:nowrap; filter:blur(0px); transition: transform 380ms var(--ease-expo); will-change:transform;` — a TRANSITION, not keyframes, so scrubbing back and forth retargets mid-flight and reversing is free.
- Roll states (vertical slot-machine): rest — `[data-role="alt"] { transform: translateY(100%); }`, `[data-role="crew"] { translateY(100%); }`. `.brand[data-title="alt"]`: main → `translateY(-100%)`, alt → `0`. `.brand[data-title="crew"]`: main AND alt → `-100%`, crew → `0`. Second slot's line gets `transition-delay: 80ms` (two-beat stagger).
- `.brand__char` (per-letter spans, JS-split): `display:inline-block; text-align:center;` — widths locked in JS to the measured Latin advance so kana flips never shift the line.
- `.brand__char[data-jp]`: `font-family:'Noto Sans JP','Hiragino Sans','Yu Gothic',sans-serif; font-weight:700; font-size:0.92em; letter-spacing:0; transform: scaleX(0.813);` (0.92em matches measured ink height; 0.813 condenses the kana advance to sit in the locked width).
- `.brand__jp`: `display:flex; justify-content:space-between; margin-top:0.05em; font-family:'Noto Sans JP',...; font-weight:400; font-size:var(--fs-jp); line-height:1; letter-spacing:0;` — spread exactly as wide as the longest display line via the stretch/fit-content parent. `.brand__jp > span { filter: blur(0px); }`

### Scroll exit (shared gesture: opacity + blur + lift)
```css
.brand__jp, .lede, .foot {
  opacity: calc(1 - var(--exit));
  filter: blur(calc(var(--exit) * 12px));
  transform: translate3d(0, calc(var(--exit) * -28px), 0);
}
.brand__jp { --exit: var(--exit-jp); }  /* katakana on its own earlier clock; must come AFTER */
```
Do NOT redeclare `filter` on these elements anywhere later (it silently kills the blur).

### Chrome (grid area 1/2): `display:flex; justify-content:space-between; align-items:flex-start; gap:16px;`
- Clock: `<time class="clock__time" id="clock" aria-live="off">[ 02:41 AM ]</time>` + `<span class="clock__zone">Italia<br>Central Time</span>` (`.clock__zone`: `margin-top:3px; font-size:var(--fs-nano); line-height:1.15; opacity:.92;`). `.clock__time { font-variant-numeric: tabular-nums; }`
- Nav (`aria-label="Primary"`, gap `clamp(14px,1.5vw,26px)`): links `Routes / Moorings / Film / Crew` (hrefs `#routes`, `#moorings`, `#film`, `#crew`), each `class="hit"`.
- Contact link: `↳&nbsp;Swim with us` (`&#8627;`), href `#contact`, class `contact micro hit`.
- Hover (fine pointers only): `.nav a:hover, .contact:hover, .foot a:hover { opacity:.55; }` with `transition: opacity 200ms var(--ease-expo)`.

### Lede (grid 2/2, right column)
`align-self:end; margin: 0 0 clamp(40px, 19vh, 220px); max-width: clamp(240px, 23vw, 340px); font-size:var(--fs-lede); font-weight:400; line-height:1.45; letter-spacing:-0.01em;` — NO `filter` property here.
Copy: *"We swim the cove before the first ferry rounds the head. Four crossings. Cold clear water, the caverns to ourselves, and nobody down here until the market boats come off the Cala Rossa slipway."*

### Foot (grid 3/2)
`display:flex; justify-content:space-between; align-items:baseline;` children `filter:blur(0px)`. Content: `©2026` and `Scroll down`.

---

## 7. SECTION 2 (fixed layer, z 8)

```html
<section class="sec2" aria-label="The day in numbers">
  <h2 class="s2-statement">Four laps of the bay. From first light to last boat.</h2>
</section>
```
`.sec2`: fixed, inset 0, same safe-area padding as `.frame`, `display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr auto; pointer-events:none; mix-blend-mode:difference;`
`.s2-statement` (grid 2/2, align-self:end): typographically IDENTICAL to the shrunk title — `font-size:var(--fs-statement); font-weight:700; line-height:var(--leading-display); letter-spacing:-0.03em; text-transform:uppercase; margin:0;`
JS splits it into `.s2-word` spans; each word carries `--w0` (entry window start, on `--s2`) and `--x0` (exit window start, on `--s3`):
```css
.s2-word {
  display:inline-block;
  --in:  clamp(0, calc((var(--s2) - var(--w0)) / 0.09), 1);
  --out: clamp(0, calc((var(--s3) - var(--x0)) / 0.09), 1);
  opacity: calc(var(--in) - var(--out));
  filter: blur(calc((1 - var(--in)) * 12px + var(--out) * 12px));
  transform: translate3d(0, calc((1 - var(--in)) * 18px + var(--out) * -28px), 0);
}
```
Words type themselves in one-by-one on scroll, then cascade out in the same order on section 3's clock. JS writes ONLY `--s2`/`--s3`; every window is CSS math (scroll back = perfect reverse, zero timers).

---

## 8. SECTION 3 (fixed layer, z 8)

```html
<section class="sec3" aria-label="The crew">
  <p class="s3-left"  style="--i:0">Twelve of us. Four have swum this water since 2019, the rest turned up one at a time and stayed.</p>
  <p class="s3-right" style="--i:1">No patches and no club. Two boats minimum, five maximum, and nobody swims ahead of whoever knows the coves.</p>
  <p class="s3-bottom">Twelve of us<br>Nine boats<br>One route</p>
</section>
```
`.sec3`: same fixed/padded/difference pattern; `grid-template-columns:1fr 1fr; grid-template-rows:auto 1fr auto;` `.sec3 > * { margin:0; }`
- `.s3-left/.s3-right` (margin notes, lede treatment verbatim): row 2, `align-self:center; max-width:24ch; font-size:var(--fs-lede); font-weight:400; line-height:1.45; letter-spacing:-0.01em;` left: col 1 / justify-self:start; right: col 2 / justify-self:end / text-align:right. Reveal: `--in: clamp(0, calc((var(--s3) - (0.62 + var(--i) * 0.10)) / 0.22), 1);` with the standard opacity/blur-12/rise-18px trio.
- `.s3-bottom`: `grid-column:1/-1; grid-row:3; align-self:end; text-align:center;` same display treatment as `.s2-statement` (`--fs-statement`, 700, -0.03em, uppercase). JS word-splits it into `.s3-word` (same numbers as `.s2-word`, entry only, read off `--s3`); the hand-set `<br>`s are load-bearing — the splitter must walk childNodes and preserve them.

---

## 9. RESPONSIVE

- `@media (max-width: 899px)`: `.nav { display:none; }`
- `@media (max-width: 767px)`:
  `--frame:20px; --fs-display: clamp(38px, 12.6vw, 96px); --fs-jp: clamp(10px, 2.1vw, 18px); --fs-lede:16px` (below 16 iOS auto-zooms).
  `.frame` → 1 column, rows `auto auto 1fr auto`; chrome row 1, brand row 2 (`margin-top:5vh`), lede row 3 (`margin-bottom:7vh; max-width:42ch`), foot row 4. `.stage__media { object-position: 66% 50%; }` `.sec2` → 1 column, statement `grid-area: 2/1`. `.sec3` rows → `auto 1fr auto auto` with `column-gap: var(--frame)`; `.s3-bottom` row 3; notes drop to row 4 side-by-side (`align-self:end; max-width:none; margin-top:1.2em`).
- `@media (max-width:767px) and (max-height:560px)` (landscape phone): `.sec2, .sec3 { --fs-statement: 5.5vh; }` `.sec3` back to 3 rows; notes back beside the card at row 2, `max-width: calc((100vw - 26vw)/2 - var(--frame)*2)`; `.reveal { --card-k: 0.16; }`
- `@media (prefers-reduced-motion: reduce)`: kill nav/contact transitions and brand-line transitions (title still swaps, without travel); `.s2-word { filter:none; transform:none; }` (opacity-gating only). In JS: no scrub, no frame bank, no reveal playback, no GSAP intro.

---

## 10. JAVASCRIPT

### 10a. Live Italian clock (IIFE, runs at parse time)
`Intl.DateTimeFormat('en-US', { timeZone:'Europe/Rome', hour:'2-digit', minute:'2-digit', hour12:true })` → paints `[ HH:MM AM ]` into `#clock` plus a `datetime` ISO attribute. First tick aligned to the minute boundary via `setTimeout(60000 - Date.now() % 60000)`, then `setInterval` 60s.

### 10b. Scroll narrative (main IIFE)
One scroll listener writes the CSS variables directly (zero latency, works with throttled rAF); one rAF loop owns only the video scrub (it carries a lerp). `hero.pause()` immediately — the hero is scrubbed, never played.

**CONFIG (exact values):**
```js
shrink:     [0.00, 0.40],
dissolveJp: [0.05, 0.34],   // katakana leaves with the title
dissolve:   [0.45, 0.70],   // lede + footer leave much later, over the open reveal
reveal:     [0.68, 1.00],   // [0] is DERIVED from revealVh in remeasure()
revealVh:   80,             // the split travels a fixed 80vh of scroll
brandEndPx: 120, brandMaxScale: 0.51,
lerp: 0.08, epsilon: 0.01, endMargin: 0.05,
startOffset: 0, holdAt: null, holdProgress: 0.40, endAt: null,  // neutral: linear time map
swaps: 7, swapWindow: [0.03, 0.38], swapHalf: 0.011,
s2Words: [0.02, 0.26], s2WordDur: 0.09,
s2Exit:  [0.02, 0.32],
s3Words: [0.55, 0.95],
titleHysteresis: 0.01, burstSteps: 3, burstGap: 90
```

**KANA map (fixed partner per letter — covers every letter used by all three title states):**
```js
{ T:'タ', O:'オ', K:'カ', Y:'ヤ', N:'ナ', C:'ク', U:'ウ', R:'ラ', E:'エ',
  S:'サ', A:'ア', D:'ダ', B:'バ', L:'ル', V:'ヴ', W:'ワ', F:'フ', H:'ハ' }
```

**Progress:** `progress = clamp(scrollY / (runway.offsetHeight - innerHeight))`; `p2 = clamp((scrollY - span)/span2)`; `p3 = clamp((scrollY - span - span2)/span3)`. Writes `--shrink` (`1 - (1-shrinkEnd) * norm(progress, shrink)`), `--exit`, `--exit-jp`, `--reveal`, `--s2`, `--s3` (all `.toFixed(4)`).

**Reveal video control (in onScroll):** when `progress >= CONFIG.reveal[0]` crosses as an EDGE → `currentTime=0; play()`; when it un-crosses → `pause()`. Separately, if wanted && paused (browser suspended a hidden tab) → resume WITHOUT rewinding. Skip all of it under reduced motion. Swallow the play() promise rejection.

**Char splitting:** split every `.brand__line`'s text into `.brand__char` spans (store `data-latin`), bucketed by role `{main, alt, crew}`.

**measure()/remeasure():** force `--shrink:1`, clear swaps/bursts and width locks, measure the SLOTS (lines are transformed; slots aren't): `shrinkEnd = min(brandEndPx / brandBlockHeight, brandMaxScale)`; publish `--shrink-end` (this makes `--fs-statement` exactly the shrunk-title size — one formula, not two agreeing numbers). Lock every char's width (all three buckets) to its measured Latin advance. Derive `CONFIG.reveal[0] = max(0.35, 1 - (revealVh/100*innerHeight)/span)`. Re-run on resize AND on `document.fonts.ready` (first pass ran on fallback metrics). `locked` flag gates glyph swapping until measured.

**Glyph swap (scroll-driven, deterministic):** schedule of 7 events tiled across swapWindow with jitter from a deterministic hash (`x = sin(n*127.1+311.7)*43758.5453; return x - floor(x)`), each event `{at, index}`. In `updateSwap(p)`: if `|p - at| < swapHalf`, that ONE char shows its kana (`data-jp` attr + KANA glyph); otherwise restore Latin. Pure function of scroll position — scrolling back reproduces the identical sequence; only one letter at a time.

**Title swap (geometry-solved, not hardcoded):**
- `solveTitleTrigger()`: walk `r` 0.01→1 step 0.005; the title corner (at its SHRUNK position) is inside the rotated strip when `|px·cos(θ) + py·sin(θ)| ≤ r·span/2` with `θ = r·45°`; map that `r` into progress via `CONFIG.reveal`. Fallback 0.95.
- `solveTitleOut()`: for section 3, walk `s` 0.01→0.45 (CEILING 0.45 — on narrow viewports the crossing is unsolvable and the heading must be gone before the shrink ends at 0.55); heading leaves when the shrinking card's edge `(W - kW)/2` passes the title's right edge or bottom, `k = 1 - s(1 - cardK)`.
- `updateTitle(p, p3)`: two thresholds with hysteresis (leaving needs `titleHysteresis` more travel) resolve to one state: `crew` if titleOut, else `alt` if titleOn, else `main` → single `data-title` attribute write on change only. Every change fires a **burst** on the ARRIVING line's chars: `burstSteps` (3) timed flips at `burstGap` (90ms) intervals — clear all kana, flip one hash-picked char — then a final clear. Time-based on purpose (it belongs to the fired swap animation, not to scroll).

**Word splitter** shared by the section-2 statement and section-3 closing line: walks child NODES (preserving `<br>`), wraps words in spans, and stamps each word's window start into a custom property (`--w0` entry / `--x0` exit) by tiling the range: `step = (range[1] - range[0] - dur) / (words - 1)`. The statement gets BOTH windows (s2Words entry on `--s2`, s2Exit exit on `--s3`); the s3 line gets entry only (s3Words on `--s3`).

**Time map:** `scrubTime(p)`: `q = min(1, p / CONFIG.reveal[0])` (video is done when the split starts); window `[scrubStart(), min(endAt ?? ∞, duration - endMargin)]`; linear when holdAt is null, else piecewise: `q ≤ a` maps `[0,a]→[start,holdAt]`, `q > a` maps `[a,1]→[holdAt,safe]` where `a = holdProgress/end`. Guard every inversion (short clip, holdAt out of range).

**Scrub loop (rAF):** two paths, one clock. `smoothed += (target - smoothed) * lerp` with epsilon snap. If the frame bank is live → `bank.draw(smoothed)`. Else if the whole clip is buffered (`buffered().end ≥ duration - 0.1`) AND `!hero.seeking` and delta > epsilon → `hero.currentTime = smoothed` (the `!seeking` guard keeps the single-keyframe seek storm survivable). On `loadeddata`: seed `smoothed = scrubTime(0)` and seek the hero there (don't creep from 0).

### 10c. FRAME BANK (WebCodecs, best-effort, the "smooth path")
Problem: a single-keyframe clip means `currentTime = x` decodes everything from zero (60–260ms) → 4–15fps scrub. Solution: pay the GOP once up front.
- Skip entirely if reduced motion or no `window.VideoDecoder`. Get canvas 2d ctx with `{alpha:false}`. Set `hero.preload='metadata'` while building (don't download the file twice); on ANY failure `revert()`: restore `preload='auto'`, `hero.load()`, log `[frame bank] <why> — staying on the video scrub` via console.info. A `reverted` latch prevents a late `finish()` lighting the canvas after the watchdog fired.
- **60-second watchdog** (a 15s deadline killed working builds on slow connections; the fetch alone measured 19.1s once).
- Inject the mp4box script tag lazily; on load: `MP4Box.createFile()`; `fetch(hero.currentSrc)` → arrayBuffer → `buf.fileStart = 0; file.appendBuffer(buf); file.flush()` — with SEPARATE catches for fetch vs demux (MP4Box runs callbacks synchronously inside appendBuffer, so a demux throw would otherwise masquerade as a network error).
- `file.onReady`: take `videoTracks[0]`; build the codec description from `stsd.entries[0].avcC || .hvcC` written into a `window.DataStream` (BIG_ENDIAN) and `new Uint8Array(stream.buffer, 8)` (strip box header) — note DataStream is a GLOBAL of the mp4box.all bundle, NOT `MP4Box.DataStream`. `decoder.configure({codec: track.codec, codedWidth, codedHeight, description})`; `setExtractionOptions(track.id, null, {nbSamples: Infinity}); file.start();`
- `file.onSamples`: for each sample `decoder.decode(new EncodedVideoChunk({ type: s.is_sync?'key':'delta', timestamp: s.cts*1e6/s.timescale, duration: s.duration*1e6/s.timescale, data: s.data }))`; then `decoder.flush().then(→ flushed=true; finish())`.
- Decoder output: for each VideoFrame, `createImageBitmap` → draw to an offscreen canvas → `toBlob('image/webp', 0.82)` (~150–250KB stills; hundreds of live GPU frames would be dropped by the browser). Track `encodeQueue`; `finish()` only when flushed && queue empty && every slot has a blob. **Sort by presentation timestamp** before banking (samples arrive in decode order with B-frame cts deltas; the decoder's contract emits presentation order, but the sort is cheap insurance). `fps = count / hero.duration`; set `live = true`, `canvas.setAttribute('data-live','')`, prewarm frame 0.
- **Draw path:** LRU cache of 20 decoded ImageBitmaps, prefetch 3 AHEAD in the scroll direction, evict the frame furthest from the playhead (closing bitmaps). `draw(t)`: `i = round(t * fps)` clamped; on cache hit paint, on miss request (previous frame holds — invisible). `paint()` reproduces `object-fit: cover` by hand INCLUDING the mobile 66% object-position (`posX() = matchMedia(max-width:767px) ? 0.66 : 0.5`), with `dpr = min(devicePixelRatio, 2)` via `setTransform`.
- Init order: `remeasure(); document.fonts.ready.then(remeasure); scroll listener ({passive:true}); resize listener; requestAnimationFrame(frame); bank.start()` LAST (page is already interactive on the video path first).

### 10d. GSAP intro (DOMContentLoaded)
Bail if no `window.gsap` or reduced motion. Timeline defaults `{ ease:'expo.out', immediateRender:true }`, `onComplete: gsap.set(TARGETS, {clearProps:'all'})` (hand everything back to the stylesheet). Use **fromTo** (never from — a seeked-over staggered from() can strand start values). Animate the SLOTS, never the lines (line transforms belong to the title swap).
Sequence (position, from → to):
- `.stage__media` @0: `{opacity:0, scale:1.06, blur 12px}` → `{1, 1, 0}` dur 1.6
- slot 1 @0.15 and slot 2 @0.23: `{opacity:0, y:22, blur 12px}` → `{1, 0, 0}` dur 1.0
- `.brand__jp > span` @0.45: `{0, y:14, blur 10px}` → dur 0.8, stagger 0.06
- `.clock, .nav a, .contact` @0.55: `{0, y:10, blur 6px}` → dur 0.7, stagger 0.04
- `.lede` @0.70: `{0, y:16, blur 10px}` → dur 0.9
- `.foot > *` @0.80: `{0, y:10, blur 6px}` → dur 0.7, stagger 0.06

---

## 11. NARRATIVE ARC (what the user experiences)

1. **Load:** black → cinematic cascade (video blurs in, title lines rise, katakana staggers, chrome, lede, footer).
2. **Scroll (hero, 350vh):** the coastal video scrubs frame-locked to scroll (canvas path at display rate once the bank builds). The wordmark shrinks into the corner (to exactly 120px tall, max 0.51×); single letters flicker to katakana one at a time on the way; the katakana row dissolves early (blur+lift), lede+footer dissolve past halfway.
3. **The split (last 80vh):** a diagonal strip opens from the centre — rotating to 45° as it widens — revealing the second looping clip, picture upright. When the strip's edge geometrically reaches the shrunk title, both lines roll vertically: COSTA/SERENADE → ENTER/THE BLUE, with a 3-flip kana burst. The hero plate fades inside the final corner sliver (`--reveal` 0.93→0.98).
4. **Section 2 (120vh):** "FOUR LAPS OF THE BAY. FROM FIRST LIGHT TO LAST BOAT." types itself in word-by-word (bottom-right, exactly the shrunk-title size), over the still-looping reveal clip.
5. **Section 3 (220vh):** the statement cascades out word-by-word; the fullscreen clip unwinds its rotation and shrinks to a centred card (0.26×) while its black frame fades; the heading rolls a third time → CREW/OF TWELVE (with burst) exactly as the card's edge clears it; two margin notes fade in flanking the card (staggered 0.62/0.72); the closing line "TWELVE OF US / NINE BOATS / ONE ROUTE" (hand-set breaks preserved) types itself in centred at the bottom.
6. Scrolling backwards replays EVERYTHING in perfect reverse — every animation is a pure function of scroll position (CSS var math + retargeting transitions); the only timers are the clock and the burst.

## 12. INVARIANTS (do not violate)

- Everything visible is `position:fixed`; only the three runways create height.
- JS writes ONLY CSS custom properties + `video.currentTime` — never layout. The rested `:root` values are the correct no-JS/no-CDN first paint.
- `mix-blend-mode: difference` on the fixed layers only. White ink, zero accent colour.
- NO film grain and NO darkening scrim/wash anywhere — the footage shows exactly as shot.
- The frame bank is pure progressive enhancement: any failure leaves the `<video>` scrub running as if the bank never existed.
- All copy, sizes, windows, and easing values exactly as specified above.
最后更新于 2026/8/24 21:54:29