← ui

film-mosaic

ui

a film as a mosaic of tiles all playing, in its player's box: pinch into time

npx shadcn add @ag/film-mosaic
Already installed? Preview an update
npx shadcn add @ag/film-mosaic --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • New: the mosaic's keyboard. It is one Tab stop, the tile in hand ringed only when the keys moved it; the arrows move through the film (← → a tile, ↑ ↓ a row), past the view's edge paging onto the tile next to it in the film; Enter plays the tile in hand, + zooms into it, - and Esc out. Its tiles' buttons are the hand's and take no Tab stop. Back from the player, the instant it left off is a tick on that tile's line (no longer a ring round it) and that tile is in hand.
  • New: FilmWall, a film as a mosaic of itself in its player's box: every tile plays its own span, muted, in turns as many at once as the machine keeps up with (lib/ambient-budget, the gallery's), or only the tile under the hand with the walls' motion off (FilmWallMotion, a switch for the end of its bar, remembered); given stills, a tile shows its first still until its video presents a frame. Each tile is a VideoPicture with its progress on a MediaLine, its start on its left (only the last also says its end, on its right), its sound in its top-left corner and its zoom in the top-right under the hand. A tile's play hands the same box to player(start) at the instant it showed; FilmWallToggle in the player's bar hands it back at the zoom it was. Spread on a tile (a trackpad's pinch, Safari's, two fingers) and its span becomes the mosaic, one level a pinch; close to go out; zoomed, swipe sideways for the next stretch. The bar above the box says where it is and holds zoom out and the stretches before and after (disabled where they lead nowhere), the captions, the tiles' speed and your controls (children); keys + - 0 ← → Esc. The tiles of a level loop in step: one loop length for the level (loopOf), each tile at the speed that makes its span last it, starting together once all have a frame up. The tiles' speed is "auto" by default (FILM_WALL_SPEED, remembered): a level's loop lasts at least three seconds, so short tiles play slower; or one of the player's speeds. A zoom, a page or a jump is one continuous motion: the wall as it was, drawn once, grows (or slides, or fades) away while the new level grows out of where the tile was. Captions follow the choice every player shares (C); the tile under the hand is heard while the walls' sound is on (M). grid (default 3 × 3) and fit. The zoom state is framework-free (lib/film-zoom-core, any grid).
  • New: FilmMosaic, the strip for many films (one a row in a library): stills in a row, as many as fit the measured width (tile, the narrowest a tile is drawn) and never more than the stills tell apart, each starting on its own first frame, its start on its left and only the last saying its end. Resting on a tile plays its span muted (one video for the strip); a press hands the span to a timebase owner around it (the tile it plays lit, with its playhead), else onPlay, else plays inline (Esc gives the tiles back). One Tab stop, the arrows between tiles. view tiles a part of the film. FilmTile is the tile; the tiling is lib/film-tiles.
Updated 2026-10-12 · release notes

also called storyboard, contact sheet, filmstrip mosaic, video wall, zui, zoomable film, video scrubber

shelf media

uses clip, timebase, playback, video, player, media-line, film-stills, lightbox-motion, audible, remembered, switch, menu, portal, button, key-router, material, tooltip

the demo above, verbatim

film-mosaic.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import type { Captions } from "@/registry/base-nova/lib/captions"
import type { Span } from "@/registry/base-nova/lib/clip"
import { frameStills } from "@/registry/base-nova/lib/film-stills-core"
import { spanWords } from "@/registry/base-nova/lib/film-tiles"
import { FilmMosaic } from "@/registry/base-nova/ui/film-mosaic"
import { useSeekStills, useStills } from "@/registry/base-nova/ui/film-stills"
import {
  FilmWall,
  FilmWallMotion,
  FilmWallToggle,
} from "@/registry/base-nova/ui/film-wall"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import {
  PlayerBar,
  PlayerCaptions,
  PlayerChrome,
  PlayerFoot,
  PlayerTransport,
} from "@/registry/base-nova/ui/player"
import {
  VideoPicture,
  VideoPlayback,
} from "@/registry/base-nova/ui/video-playback"

// #region films
// Each film's facts as its prepared media asset states them (lib/media-asset): its
// length, its frame's size, and the stills cut along it, one a second.
interface Film {
  label: string
  src: string
  duration: number
  width: number
  height: number
  captions?: readonly Captions[]
  frames?: { every: number; count: number; src: string }
}

const INTERVIEW: Film = {
  label: "Cronkite questions Aldrin, July 1969",
  src: "/apollo-11/cronkite-aldrin.mp4",
  duration: 109_009,
  width: 626,
  height: 476,
  frames: {
    every: 1,
    count: 109,
    src: "/gallery-stills/cronkite-aldrin/{i}.jpg",
  },
}
const KEYNOTE: Film = {
  label: "Steve Jobs introduces the iPhone, 2007",
  src: "/jobs/iphone.mp4",
  duration: 118_500,
  width: 640,
  height: 352,
}
const PORTRAIT: Film = {
  label: "Stay hungry, stay foolish (portrait cut)",
  src: "/jobs/hungry-portrait.mp4",
  duration: 40_006,
  width: 360,
  height: 640,
}
const MOON: Film = {
  label: "We choose to go to the Moon",
  src: "/kennedy/moon.mp4",
  duration: 50_050,
  width: 640,
  height: 480,
  captions: [
    {
      src: "/kennedy/moon.en.vtt",
      lang: "en",
      label: "English",
      default: true,
    },
    { src: "/kennedy/moon.es.vtt", lang: "es", label: "español" },
  ],
}
const CDN = "https://cdn.untitled.garden/media/ui/gallery"
/** From the gallery's corpus (Wikimedia Commons), on its CDN. */
const SHELF: Film[] = [
  {
    label: "one small step",
    src: `${CDN}/461a38825c3d7b1172c6d6b2.mp4`,
    duration: 56_112,
    width: 16,
    height: 9,
    frames: {
      every: 1,
      count: 56,
      src: `${CDN}/461a38825c3d7b1172c6d6b2.frame-{i}.jpg`,
    },
  },
  {
    label: "Le Déjeuner des Minet (1906)",
    src: `${CDN}/e141b3900933b1fd907b5d25.mp4`,
    duration: 20_003,
    width: 16,
    height: 9,
    frames: {
      every: 1,
      count: 20,
      src: `${CDN}/e141b3900933b1fd907b5d25.frame-{i}.jpg`,
    },
  },
  {
    label: "a braided river in Tibet",
    src: `${CDN}/ebfc8d8f94158250db14b476.mp4`,
    duration: 20_000,
    width: 16,
    height: 9,
    frames: {
      every: 1,
      count: 20,
      src: `${CDN}/ebfc8d8f94158250db14b476.frame-{i}.jpg`,
    },
  },
]
// #endregion

// #region stills
/** Each film's stills source, made once (a new one each render is a new cache). */
const SECONDS = new Map(
  [INTERVIEW, ...SHELF].map((f) => [
    f.src,
    frameStills(f.frames as NonNullable<Film["frames"]>),
  ]),
)
const stillsOf = (src: string) => {
  const source = SECONDS.get(src)
  if (!source) throw new Error(`no stills for ${src}`)
  return source
}
// #endregion

// #region player
/** The registry's player, in the mosaic's box, started where the pressed tile was; the
 *  mosaic's toggle in its bar comes back. */
function playerFor(film: Film) {
  return (start: number) => (
    <VideoPlayback
      src={film.src}
      label={film.label}
      width={film.width}
      height={film.height}
      captions={film.captions}
      start={start}
      autoPlay
    >
      <VideoPicture>
        <PlayerChrome>
          <PlayerTransport />
          <PlayerFoot>
            <PlayerBar label={film.label} playButton={false}>
              {film.captions && <PlayerCaptions />}
              <FilmWallToggle />
            </PlayerBar>
          </PlayerFoot>
        </PlayerChrome>
      </VideoPicture>
    </VideoPlayback>
  )
}
// #endregion

// #region mosaic
/** The film as a mosaic: every tile playing, the switch for that at the end of its
 *  bar. */
function Mosaic() {
  const stills = useStills([stillsOf(INTERVIEW.src)])
  return (
    <FilmWall {...INTERVIEW} stills={stills} player={playerFor(INTERVIEW)}>
      <FilmWallMotion />
    </FilmWall>
  )
}
// #endregion

// #region shelf
/** One film a row, as a strip: a pass over many without leaving the list. */
function Shelf() {
  return (
    <div className="flex w-full flex-col gap-4">
      {SHELF.map((film) => (
        <ShelfRow key={film.src} film={film} />
      ))}
    </div>
  )
}

function ShelfRow({ film }: { film: Film }) {
  const stills = useStills([stillsOf(film.src)])
  return (
    <div className="flex flex-col gap-1.5">
      <span className="font-medium text-[13px]">{film.label}</span>
      <FilmMosaic
        tile={120}
        label={film.label}
        duration={film.duration}
        aspect={film.width / film.height}
        src={film.src}
        stills={stills}
      />
    </div>
  )
}
// #endregion

// #region owner
/** Under its player: a press hands the span to it, the tile it is playing is lit. */
function WithPlayer() {
  const stills = useStills([stillsOf(INTERVIEW.src)])
  return (
    <VideoPlayback
      src={INTERVIEW.src}
      label={INTERVIEW.label}
      poster="/apollo-11/cronkite-aldrin.jpg"
    >
      <div className="flex w-full flex-col gap-2">
        <VideoPicture aspect="626 / 476" className="rounded-xl">
          <PlayerChrome>
            <PlayerTransport only={["play"]} />
            <PlayerFoot>
              <PlayerBar label={INTERVIEW.label} />
            </PlayerFoot>
          </PlayerChrome>
        </VideoPicture>
        <FilmMosaic
          label={INTERVIEW.label}
          duration={INTERVIEW.duration}
          aspect={INTERVIEW.width / INTERVIEW.height}
          src={INTERVIEW.src}
          stills={stills}
          tile={110}
        />
      </div>
    </VideoPlayback>
  )
}
// #endregion

// #region chosen
/** No player around: the page hears the span. */
function Chosen() {
  const stills = useStills([stillsOf(INTERVIEW.src)])
  const [span, setSpan] = React.useState<Span | null>(null)
  return (
    <div className="flex w-full flex-col gap-2">
      <FilmMosaic
        label={INTERVIEW.label}
        duration={INTERVIEW.duration}
        aspect={INTERVIEW.width / INTERVIEW.height}
        view={{ from: 30_000, to: 60_000 }}
        src={INTERVIEW.src}
        stills={stills}
        tile={100}
        onPlay={setSpan}
      />
      <span className="font-mono text-[11px] text-foreground/60">
        {span
          ? `chosen: ${spanWords(span)}`
          : "half a minute of it, 0:30 to 1:00"}
      </span>
    </div>
  )
}
// #endregion

// #region decoded
/** Nothing prepared: every tile decoded on the page, its rate measured. */
function Decoded() {
  const decoded = useSeekStills(MOON.src, { width: 320 })
  const stills = useStills([decoded.source])
  return (
    <FilmMosaic
      label={MOON.label}
      duration={MOON.duration}
      aspect={MOON.width / MOON.height}
      src={MOON.src}
      stills={stills}
      tile={120}
      className="w-full"
    />
  )
}
// #endregion

export default function FilmMosaicDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="mosaic"
        glance="large"
        title="a film as a mosaic"
        description="Every tile plays its own stretch at once (as many as the machine keeps up with; the rest wait their turn on their first picture); the switch above turns that to only the tile under the hand. Spread two fingers on a tile (or its zoom) and its stretch becomes the mosaic; close them (or Esc) to go back out; swipe sideways, or the arrows, for the next stretch. A tile's play opens the player there, in the same box; its grid button comes back."
        with="films stills player mosaic"
      >
        <Mosaic />
      </Sample>
      <Sample
        name="captions"
        title="every tile captioned"
        description="Captions are the one choice every player shares: on here (C), every tile says its own line, and the player it opens shows them too."
        with="films player"
      >
        <FilmWall {...MOON} player={playerFor(MOON)} />
      </Sample>
      <Sample
        name="portrait"
        title="a portrait film"
        description="A square grid cuts a box of the film's shape into cells of that shape: every tile is the whole frame, tall or wide."
        with="films player"
      >
        <div className="mx-auto w-full max-w-sm">
          <FilmWall {...PORTRAIT} player={playerFor(PORTRAIT)} />
        </div>
      </Sample>
      <Sample
        name="grid"
        title="any grid, cover or contain"
        description="Four across, two down: the cells no longer have the film's shape, so cover crops each frame round its centre and contain shows it whole with bars. No stills: a tile is black until its first frame."
        with="films player"
      >
        <div className="grid w-full gap-6 lg:grid-cols-2">
          <FilmWall
            {...KEYNOTE}
            grid={{ cols: 4, rows: 2 }}
            player={playerFor(KEYNOTE)}
          />
          <FilmWall
            {...KEYNOTE}
            grid={{ cols: 4, rows: 2 }}
            fit="contain"
            player={playerFor(KEYNOTE)}
          />
        </div>
      </Sample>
      <Sample name="glass" title="in glass" with="films player">
        <MaterialProvider material="glass">
          <div className="w-full rounded-2xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-4 sm:p-8">
            <FilmWall {...KEYNOTE} player={playerFor(KEYNOTE)} />
          </div>
        </MaterialProvider>
      </Sample>
      <Sample
        name="shelf"
        title="a shelf of strips"
        description="Many films, one a row: each a strip of stills, the tile under the hand playing. The count follows the row's width."
        with="films stills shelf"
      >
        <Shelf />
      </Sample>
      <Sample
        name="player"
        title="a strip under its player"
        description="A press hands the span to the player; the tile it is playing wears the playhead."
        with="films stills owner"
      >
        <WithPlayer />
      </Sample>
      <Sample name="part" title="a part of a film" with="films stills chosen">
        <Chosen />
      </Sample>
      <Sample
        name="decoded"
        title="nothing prepared"
        description="A film with no stills cut: each tile is decoded here, one hidden video seeking, the film's rate timed first."
        with="films decoded"
      >
        <Decoded />
      </Sample>
    </div>
  )
}

the source, every file an install writes

film-mosaic.tsx
"use client"

// A FILM AS TILES, each tile a span of it (a film library's filmstrip triage: one work
// per row, hover a part to see it move, click to play it there). The count follows the box
// it is given and the stills it has (lib/film-tiles): as many as fit at the narrowest a
// still is read (`tile`, px), never more than the stills tell apart.
//
//   layout   "face": a square of tiles in a box of the film's own shape, every tile
//            that shape too (a player's 3×3 face). "strip": one row.
//   hover    once the hand has meant it (playback's hover intent: resting or moving
//            slowly, never a pass across), the tile under it PLAYS its span, muted and
//            round and round: ONE video for the whole mosaic, on the tile under the
//            hand only (a row of twenty decoders would be twenty downloads). The still
//            a tile shows is its span's first frame, so the play continues the picture
//            instead of cutting to another. Keyboard focus plays the same way; reduced
//            motion plays nothing.
//   a press  inside a timebase owner (VideoPlayback around it) it hands the span to the
//            owner, which plays from its start; the tile holding the owner's instant is
//            lit, its playhead drawn across it. With no owner, `onPlay` hears the span;
//            with neither, the mosaic plays it INLINE: a player takes its place starting
//            there, and Esc (or ×) gives the tiles back, the focus on the tile played.
//   keys     one Tab stop; the arrows move between tiles (a face's in two dimensions),
//            Home and End to the ends; Enter or Space plays. On the key router.
//
// Plain: the tiles on the page, a hairline between them. Glass: the same tiles under a
// glass edge, the rim's light on the media, no mat (CLAUDE.md: glass around media is an
// edge). Each tile is a `FilmTile`, for other layouts.

import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import type { Span } from "@/registry/base-nova/lib/clip"
import type { Stills } from "@/registry/base-nova/lib/film-stills-core"
import {
  spanWords,
  startLabel,
  stripCount,
  tileAt,
  tilesOf,
} from "@/registry/base-nova/lib/film-tiles"
import { shownAt, type Timebase } from "@/registry/base-nova/lib/timebase"
import { Button } from "@/registry/base-nova/ui/button"
import { type Still, StillPicture } from "@/registry/base-nova/ui/film-stills"
import { useEscape, useKeys } from "@/registry/base-nova/ui/key-router"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { GlassEdge, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
  useHoverIntent,
  useOptionalTimebase,
} from "@/registry/base-nova/ui/playback"
import {
  PlayerBar,
  PlayerChrome,
  PlayerFoot,
  PlayerKeys,
  PlayerTransport,
} from "@/registry/base-nova/ui/player"
import { Video } from "@/registry/base-nova/ui/video"
import {
  VideoPicture,
  VideoPlayback,
} from "@/registry/base-nova/ui/video-playback"

/** The narrowest a tile is drawn by default, px: a still a person can still read. */
const TILE = 160
/** Between two tiles, px: the hairline that tells them apart. */
const GAP = 2

/** A time on a tile, in its bottom corner: small mono on a dark chip, readable on any
 *  still; hidden on a tile too narrow to read it. */
const TIME_CHIP =
  "absolute bottom-1 hidden rounded-[3px] bg-black/55 px-1 font-mono text-[10px] text-white/85 tabular-nums leading-4 @min-[7rem]/film-tile:block"

const NO_SUBSCRIBE = () => () => {}

/** The instant a timebase shows, or null with no timebase around. */
function useShownInstant(timebase: Timebase | null): number | null {
  const read = () => (timebase ? shownAt(timebase.get()) : null)
  return React.useSyncExternalStore(
    timebase?.subscribe ?? NO_SUBSCRIBE,
    read,
    read,
  )
}

/** Whether reduced motion is asked for: then no tile plays under the hand. */
function useReducedMotion(): boolean {
  return React.useSyncExternalStore(
    (on) => {
      const q = window.matchMedia("(prefers-reduced-motion: reduce)")
      q.addEventListener("change", on)
      return () => q.removeEventListener("change", on)
    },
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false,
  )
}

/** A box's measured width, px (0 until measured). */
export function useWidth(element: React.RefObject<HTMLElement | null>): number {
  const [width, setWidth] = React.useState(0)
  React.useLayoutEffect(() => {
    const el = element.current
    if (!el) return
    const read = () => setWidth(el.clientWidth)
    const observer = new ResizeObserver(read)
    observer.observe(el)
    read()
    return () => observer.disconnect()
  }, [element])
  return width
}

export interface FilmTileProps {
  stills: Stills<Still>
  span: Span
  /** The film, for the span to play while `moving`. */
  src?: string
  /** What the film is, for the playing span's name. */
  label: string
  /** Playing its span, muted, round and round. */
  moving?: boolean
  /** The owner's instant inside this tile, as a share of it (0..1): its playhead. */
  playhead?: number | null
  /** The last of a run: its end is said too, on its right (no neighbour starts there
   *  to say it). */
  last?: boolean
  className?: string
}

/** One tile: the still at its span's start, the span playing over it while `moving`,
 *  its start in its bottom-left corner (and its end in the right one when it is the
 *  last: a tile's end is the next one's start, said once), when the tile is wide enough
 *  to read them, and the playhead when the owner's instant is inside it. Pictures only:
 *  the control around it (a button, a grid's cell) is the caller's. */
export function FilmTile({
  stills,
  span,
  src,
  label,
  moving = false,
  playhead = null,
  last = false,
  className,
}: FilmTileProps) {
  const length = span.to - span.from
  return (
    <span
      data-slot="film-tile"
      data-moving={moving || undefined}
      className={cn(
        "@container/film-tile relative block size-full overflow-hidden bg-foreground/[0.07]",
        className,
      )}
    >
      <StillPicture stills={stills} at={span.from} />
      {moving && src && (
        <Video
          src={src}
          label={`${label}, ${spanWords(span)}`}
          play="follow"
          playing
          span={span}
          controls={false}
          className="absolute inset-0"
          videoClassName="object-cover"
        />
      )}
      {playhead !== null && (
        // Ink over media, not over a surface: it cannot follow the theme, and its
        // shadow is what keeps it on a pale still.
        <span
          aria-hidden
          data-slot="film-tile-playhead"
          className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-white shadow-[0_0_3px_rgb(0_0_0/0.7)]"
          style={{ left: `${playhead * 100}%` }}
        />
      )}
      <span
        aria-hidden
        data-slot="film-tile-start"
        className={cn(TIME_CHIP, "left-1")}
      >
        {startLabel(span.from, length)}
      </span>
      {last && (
        <span
          aria-hidden
          data-slot="film-tile-end"
          className={cn(TIME_CHIP, "right-1")}
        >
          {startLabel(span.to, length)}
        </span>
      )}
    </span>
  )
}

export interface FilmMosaicProps {
  /** What the film is: the mosaic's name, and its tiles'. */
  label: string
  /** The film's length, ms. */
  duration: number
  /** The film's width over its height: every tile's shape. */
  aspect: number
  /** Where its pictures come from (ui/film-stills `useStills`); null while the ladder
   *  is being made (a decoder timing the film): the box waits, sized. */
  stills: Stills<Still> | null
  /** The film itself: what a tile plays under the hand, and inline. */
  src?: string
  /** The part of the film it tiles, ms. Default the whole film. */
  view?: Span
  /** The narrowest a tile is drawn, px. */
  tile?: number
  /** A tile chosen, when no timebase owner is around: the span to play. Without it
   *  (and with `src`) the mosaic plays the span inline. */
  onPlay?: (span: Span) => void
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  className?: string
}

export function FilmMosaic(props: FilmMosaicProps) {
  const timebase = useOptionalTimebase()
  const [inline, setInline] = React.useState<Span | null>(null)
  const [played, setPlayed] = React.useState<number | null>(null)
  const plays = !timebase && !props.onPlay && !!props.src
  if (inline && props.src)
    return (
      <InlinePlayer
        {...props}
        src={props.src}
        span={inline}
        onClose={() => setInline(null)}
      />
    )
  return (
    <Tiles
      {...props}
      timebase={timebase}
      returnTo={played}
      onChoose={(span, index) => {
        if (timebase) return timebase.controls.seek(span.from, { play: true })
        if (props.onPlay) return props.onPlay(span)
        if (!plays)
          throw new Error(
            `FilmMosaic "${props.label}": a tile chosen with nowhere to play it (no timebase around, no onPlay, no src)`,
          )
        setPlayed(index)
        setInline(span)
      }}
    />
  )
}

function Tiles({
  label,
  duration,
  aspect,
  stills,
  src,
  view: viewProp,
  tile = TILE,
  material,
  scheme,
  className,
  timebase,
  returnTo,
  onChoose,
}: FilmMosaicProps & {
  timebase: Timebase | null
  /** The tile to focus when it mounts (back from playing inline). */
  returnTo: number | null
  onChoose: (span: Span, index: number) => void
}) {
  if (!(duration > 0)) throw new Error(`FilmMosaic "${label}": no duration`)
  if (!(aspect > 0)) throw new Error(`FilmMosaic "${label}": no aspect`)
  const m = useMaterial(material)
  const root = React.useRef<HTMLDivElement>(null)
  const [s, here] = useSurfaceScheme(scheme, root)
  const width = useWidth(root)
  const view = viewProp ?? { from: 0, to: duration }
  const length = view.to - view.from
  const count =
    stills && width > 0 ? stripCount(width, tile, GAP, length, stills.grain) : 0
  const { from, to } = view
  const tiles = React.useMemo(
    () =>
      stills && count > 0
        ? tilesOf({ from, to }, count, stills.place)
        : ([] as Span[]),
    [stills, count, from, to],
  )
  React.useEffect(() => {
    if (stills && tiles.length) stills.want(tiles.map((t) => t.from))
  }, [stills, tiles])

  // The owner's instant: which tile holds it (a re-render only when that changes).
  const at = useShownInstant(timebase)
  const current = at === null ? -1 : tileAt(tiles, at)

  // ── the hand: intent, then the tile under it plays ──
  const reduced = useReducedMotion()
  const [moving, setMoving] = React.useState<number | null>(null)
  const tileUnder = (point: { clientX: number; clientY: number }) => {
    const hit = document
      .elementFromPoint(point.clientX, point.clientY)
      ?.closest<HTMLElement>("[data-slot=film-mosaic-tile]")
    return hit && root.current?.contains(hit) ? Number(hit.dataset.tile) : null
  }
  const intent = useHoverIntent(
    (point) => setMoving(tileUnder(point)),
    () => setMoving(null),
  )

  // ── the keys: one Tab stop, the arrows between tiles ──
  const [focus, setFocus] = React.useState(0)
  const buttons = React.useRef(new Map<number, HTMLButtonElement>())
  const focusTile = (i: number) => {
    const next = Math.min(tiles.length - 1, Math.max(0, i))
    setFocus(next)
    buttons.current.get(next)?.focus()
  }
  // Back from playing inline: the focus where the hand left it.
  // biome-ignore lint/correctness/useExhaustiveDependencies: once, when the tiles first exist
  React.useEffect(() => {
    if (returnTo === null || tiles.length === 0) return
    focusTile(returnTo)
  }, [tiles.length > 0])
  useKeys(
    (event) => {
      if (event.metaKey || event.ctrlKey || event.altKey) return false
      const step: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1 }
      const holds = root.current?.contains(document.activeElement)
      if (!holds) return false
      if (event.key in step) {
        focusTile(focus + (step[event.key] as number))
        return true
      }
      if (event.key === "Home") {
        focusTile(0)
        return true
      }
      if (event.key === "End") {
        focusTile(tiles.length - 1)
        return true
      }
      return false
    },
    { root },
  )

  return (
    // biome-ignore lint/a11y/useSemanticElements: a group of tiles, each a button; a fieldset groups fields
    <div
      ref={here}
      role="group"
      aria-label={label}
      data-slot="film-mosaic"
      data-material={m}
      onPointerMove={(e) => {
        if (e.pointerType !== "touch" && !reduced) intent.move(e)
      }}
      onPointerLeave={() => intent.leave()}
      className={cn(
        "relative isolate overflow-hidden rounded-lg",
        schemeOf(s),
        className,
      )}
      style={
        {
          "--ag-film-mosaic-gap": `${GAP}px`,
          "--ag-film-mosaic-aspect": `${aspect}`,
          "--ag-film-mosaic-columns": `repeat(${Math.max(1, count)}, minmax(0, 1fr))`,
        } as React.CSSProperties
      }
    >
      <div
        data-slot="film-mosaic-tiles"
        className="grid grid-cols-(--ag-film-mosaic-columns) gap-(--ag-film-mosaic-gap)"
      >
        {tiles.length === 0 ? (
          // Measuring, or the stills still coming: the box keeps its place.
          <span
            aria-hidden
            className="block aspect-(--ag-film-mosaic-aspect) bg-foreground/[0.07]"
          />
        ) : (
          tiles.map((span, i) => (
            <button
              key={`${span.from}-${span.to}`}
              ref={(node) => {
                if (node) buttons.current.set(i, node)
                else buttons.current.delete(i)
              }}
              type="button"
              data-slot="film-mosaic-tile"
              data-tile={i}
              data-current={i === current || undefined}
              aria-label={`${spanWords(span)}, play`}
              aria-current={i === current ? "time" : undefined}
              tabIndex={i === focus ? 0 : -1}
              onFocus={(e) => {
                setFocus(i)
                if (!reduced && e.currentTarget.matches(":focus-visible"))
                  setMoving(i)
              }}
              onBlur={() => setMoving((was) => (was === i ? null : was))}
              onClick={() => onChoose(span, i)}
              className="relative block aspect-(--ag-film-mosaic-aspect) cursor-pointer p-0 outline-none focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-current:z-10 data-current:ring-2 data-current:ring-white/80 data-current:ring-inset"
            >
              <FilmTile
                stills={stills as Stills<Still>}
                span={span}
                src={src}
                label={label}
                moving={moving === i}
                last={i === tiles.length - 1}
                playhead={
                  i === current && at !== null
                    ? (at - span.from) / (span.to - span.from)
                    : null
                }
              />
            </button>
          ))
        )}
      </div>
      {m === "glass" && <GlassEdge radius="var(--radius-lg)" scheme="dark" />}
    </div>
  )
}

/** The span played where the tiles were: a player starting at its start, the tiles
 *  back on Esc or ×. */
function InlinePlayer({
  label,
  aspect,
  src,
  span,
  material,
  className,
  onClose,
}: FilmMosaicProps & { src: string; span: Span; onClose: () => void }) {
  const m = useMaterial(material)
  const root = React.useRef<HTMLDivElement>(null)
  useEscape(onClose, { active: true, root })
  return (
    <div
      ref={root}
      data-slot="film-mosaic-inline"
      className={cn("relative", className)}
    >
      <VideoPlayback src={src} label={label} start={span.from} autoPlay>
        <VideoPicture aspect={`${aspect}`} className="rounded-xl">
          <PlayerChrome material={m}>
            <PlayerTransport />
            <PlayerFoot>
              <PlayerBar label={label} />
            </PlayerFoot>
          </PlayerChrome>
          <Button
            variant="secondary"
            size="icon-sm"
            material={m}
            aria-label="back to the tiles"
            onClick={onClose}
            className="absolute top-2 right-2 z-40"
          >
            <X />
          </Button>
        </VideoPicture>
        <PlayerKeys />
      </VideoPlayback>
    </div>
  )
}
film-wall.tsx
"use client"

// A FILM AS A WALL OF ITSELF, in the player's own box: the film cut into tiles, every
// tile playing its own span at once, round and round, muted. A tile's play hands the
// box to the player at the instant that tile was showing; the player's `FilmWallToggle`
// hands it back, the wall paged to where the player had got to, its tile ringed. The
// wall and the player are one box (the film's aspect, as wide as the container), so
// neither moves the page.
//
//   <FilmWall src label duration width height player={(start) => (
//     <VideoPlayback … start={start} autoPlay>
//       <VideoPicture><PlayerChrome>…<PlayerBar><FilmWallToggle /></PlayerBar>…
//   )} />
//
// EACH TILE IS A PLAYER'S PICTURE: a VideoPlayback looping its span (`range`), its
// VideoPicture, its progress the registry's MediaLine, its captions the player's. Tiles
// are kept by their place on the wall, so a zoom or a page seeks the videos already
// loaded rather than starting nine new ones: the old frame holds until the new one is
// presented, never a black tile. They play while the wall is on screen, never under
// reduced motion (each then rests on its span's first frame).
//
// ZOOM IS TIME, by the hand (ui/pinch-input: a trackpad's pinch, Safari's, two fingers).
// Spread on a tile and the wall grows under the fingers; past the distance every viewer
// here takes one rung (lightbox-motion's PINCH_CLOSE) the tile fills the box and its
// span becomes the wall, cut into as many tiles again (lib/film-zoom-core). Close and
// the wall shrinks back into the tile it was, one level up. One pinch, one level. Zoomed
// in, a sideways swipe (two fingers on a trackpad, one on a screen) pages to the next or
// the previous stretch at the same zoom, stopping at the film's ends. The buttons say
// the same for a mouse and the keyboard: a tile's zoom, the head's out and its arrows;
// keys + - 0 ← →, Esc out, C captions, M sound. Down to `finest`, the shortest a tile may
// be.
//
// THE HEAD, along the top (so it never covers a tile's own line along its foot): zoomed,
// where the view sits in the whole film, the window lit on the film's MediaLine (a press
// there pages to that part); under the hand, the view's span and length, the film's,
// the way out and the captions. A TILE, under the hand: its sound, its play and its zoom
// in the middle (the player's transport cluster); its start, length and end over its
// line.
//
// SOUND is the hand's (ui/use-audible's handVoice, the gallery's own rule): every tile
// muted, the one under the hand heard while the walls' sound is on, remembered. CAPTIONS
// are the one choice every player shares (media-captions' CAPTIONS): on here, every tile
// shows its line, and the player it opens shows them too.
//
// THE GRID. Square by default (3 × 3): a square grid cuts a box of the film's aspect
// into cells of that aspect, so every tile is the whole frame, landscape film or
// portrait, with no gap. `grid={{ cols, rows }}` cuts it otherwise; its cells no longer
// wear the film's shape, so `fit` decides: "cover" (default) crops round the centre,
// "contain" shows the whole frame with bars. For a library of mixed shapes keep it
// square.
//
// ITS FACE IS THE PLAYER'S: dark discs plain, glass circles in glass (the `material`
// around it, as every player part reads it), so the wall and the player it becomes never
// look like two things.

import {
  ChevronLeft,
  ChevronRight,
  LayoutGrid,
  ZoomIn,
  ZoomOut,
} from "lucide-react"
import * as React from "react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
import {
  AMBIENT_SAMPLE_MS,
  type AmbientBudget,
  ambientBudget,
  ambientStart,
} from "@/registry/base-nova/lib/ambient-budget"
import {
  type Captions,
  shownOf,
  toggledChoice,
} from "@/registry/base-nova/lib/captions"
import type { Span } from "@/registry/base-nova/lib/clip"
import type { Stills } from "@/registry/base-nova/lib/film-stills-core"
import { spanWords, startLabel } from "@/registry/base-nova/lib/film-tiles"
import {
  canPage,
  canZoomIn,
  canZoomOut,
  columnOf,
  type FilmZoom,
  filmZoom,
  type Grid,
  hold,
  page,
  regrid,
  restill,
  reveal,
  rowOf,
  shownTiles,
  viewOf,
  zoomHome,
  zoomIn,
  zoomOut,
} from "@/registry/base-nova/lib/film-zoom-core"
import { PINCH_CLOSE } from "@/registry/base-nova/lib/lightbox-motion"
import { isBoolean, setting } from "@/registry/base-nova/lib/remembered-core"
import { clockTime } from "@/registry/base-nova/lib/timebase"
import { Button } from "@/registry/base-nova/ui/button"
import { type Still, StillPicture } from "@/registry/base-nova/ui/film-stills"
import { useEscape, useKeys } from "@/registry/base-nova/ui/key-router"
import { watchPressure } from "@/registry/base-nova/ui/machine-pressure"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"
import { schemeOf } from "@/registry/base-nova/ui/material-glass"
import { CAPTIONS } from "@/registry/base-nova/ui/media-captions"
import { lineShare, MediaLine } from "@/registry/base-nova/ui/media-line"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/registry/base-nova/ui/menu"
import { type Client, pinchInput } from "@/registry/base-nova/ui/pinch-input"
import { useTimebase, useTimebaseState } from "@/registry/base-nova/ui/playback"
import {
  PLAYER_BUTTON,
  PLAYER_DISC,
  PLAYER_DISC_SIZE,
  PlayerIcon,
  SPEEDS,
  speedLabel,
} from "@/registry/base-nova/ui/player"
import { PortalTarget } from "@/registry/base-nova/ui/portal"
import { useRemembered } from "@/registry/base-nova/ui/remembered"
import { Switch } from "@/registry/base-nova/ui/switch"
import { Tip } from "@/registry/base-nova/ui/tooltip"
import { handVoice } from "@/registry/base-nova/ui/use-audible"
import {
  useVideoElement,
  VideoPicture,
  VideoPlayback,
  type VideoPlaybackProps,
} from "@/registry/base-nova/ui/video-playback"

export interface FilmWallProps {
  src: string
  /** The film's name, for the wall and each tile. */
  label: string
  /** The film's length, ms. */
  duration: number
  /** The frame's size, px: the box's aspect, the player's. */
  width: number
  height: number
  /** Tiles across and down (see the header). Default 3 × 3. */
  grid?: Grid
  /** How a frame sits in a cell not of its shape. Default "cover". */
  fit?: "cover" | "contain"
  /** The shortest a tile may be, ms. Default 1000. */
  finest?: number
  /** The languages it is captioned in, a WebVTT file each (lib/captions): every tile
   *  shows its own line while captions are on. */
  captions?: readonly Captions[]
  /** Another origin's video (and its captions) with CORS. */
  crossOrigin?: VideoPlaybackProps["crossOrigin"]
  /** The film's stills (film-stills' `useStills`): each tile shows its span's first
   *  picture until its video presents a frame, and the tiles' edges are placed on
   *  them. */
  stills?: Stills<Still> | null
  /** The player that takes the box, started at `start` ms; put `FilmWallToggle` in its
   *  bar to come back. */
  player: (start: number) => React.ReactNode
  /** Its buttons' face: the player's (the material around it by default). */
  material?: Material
  /** The end of its bar, above the box: your controls (`FilmWallMotion`). */
  children?: React.ReactNode
  className?: string
}

/** The walls' motion, kept until changed: on (the default), every tile plays, as many
 *  at once as the machine keeps up with; off, only the tile under the hand. */
export const FILM_WALL_MOTION = setting("film-wall:motion", {
  initial: true,
  keep: "always",
  parse: isBoolean,
})

/** The walls' motion as a switch, to stand beside a wall (or above several): every
 *  tile playing, or only the one under the hand. */
export function FilmWallMotion({
  material,
  className,
}: {
  material?: Material
  className?: string
}) {
  const [on, set] = useRemembered(FILM_WALL_MOTION)
  return (
    <Switch
      checked={on}
      onCheckedChange={set}
      material={material}
      className={className}
    >
      every tile plays
    </Switch>
  )
}

/** How fast the tiles play: "auto" (a level's loop lasts at least `LOOP_MS`, so short
 *  tiles play slower: `loopOf`), or a speed of the player's (`SPEEDS`). Kept until
 *  changed. */
export type FilmWallSpeed = "auto" | (typeof SPEEDS)[number]
export const FILM_WALL_SPEED = setting<FilmWallSpeed>("film-wall:speed", {
  initial: "auto",
  keep: "always",
  parse: (x) =>
    x === "auto" || SPEEDS.includes(x as (typeof SPEEDS)[number])
      ? (x as FilmWallSpeed)
      : undefined,
})

/** The shortest a tile's loop lasts on screen, ms, under "auto": a loop shorter than
 *  this restarts before the eye has read it (a 1 s tile came round every second and
 *  felt bad, Adrian, 2026-10-12), so a shorter span plays slower until its loop lasts
 *  this long. Stated in what is felt: the time a loop takes to watch. */
const LOOP_MS = 3000
/** The slowest a tile goes: below it a picture barely moves. */
const SLOWEST = 0.25
/** How far a tile may drift from its level's clock before it is put back, ms. */
const DRIFT_MS = 120

/** The walls' sound, kept until changed: on, the tile under the hand is heard. */
export const FILM_WALL_SOUND = setting("film-wall:sound", {
  initial: false,
  keep: "always",
  parse: isBoolean,
})
const voice = handVoice(FILM_WALL_SOUND)

const SQUARE: Grid = { cols: 3, rows: 3 }
const NO_CAPTIONS: readonly Captions[] = []

/** The stills a live tile is placed on: whole ms. */
const WHOLE_MS = Math.floor

function assert(ok: unknown, why: string): asserts ok {
  if (!ok) throw new Error(`FilmWall: ${why}`)
}

// ── the sheet's pose, written straight to the element while the hand moves, so a
// gesture never waits on a render: translate then scale, from the wall's top left ──

/** The sheet moved and scaled, px of the wall. */
type Pose = { s: number; x: number; y: number }
const REST: Pose = { s: 1, x: 0, y: 0 }

/** Settles and arrivals: quick, never under reduced motion. */
const SETTLE_MS = 240
const EASE = "cubic-bezier(0.2, 0.8, 0.2, 1)"
/** Slid past this share of the width, a swipe pages. */
const PAGE_AT = 0.2
/** Past a film's end (or a pinch with nowhere to go) the sheet follows the hand this
 *  much, so the hand feels the end instead of nothing. */
const RESIST = 0.2
/** The longest a change waits for its tiles' new frames before showing them anyway. */
const LAND_MS = 400

const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches
const css = (p: Pose) => `translate(${p.x}px, ${p.y}px) scale(${p.s})`

function paint(el: HTMLElement, p: Pose) {
  el.style.transformOrigin = "0 0"
  el.style.transform = css(p)
}
function rest(el: HTMLElement) {
  el.style.transform = ""
  el.style.transformOrigin = ""
}
// ── the motions of a change of view, frame by frame ──

/** A zoom, in or out: long enough to see where it went, short enough not to wait. */
const ZOOM_MS = 460
/** A page sideways. */
const PAGE_MS = 320
/** A jump (the whole film, a press on the head's line): a crossfade. */
const FADE_MS = 220
/** The longest a level's clock waits for its slowest tile's first frame. */
const CLOCK_WAIT_MS = 1500

/** A box on the wall, px from its top left. */
interface Rect {
  x: number
  y: number
  w: number
  h: number
}

/** Where `r` (px of the wall at rest) is on screen under the sheet's `p`. */
const posed = (p: Pose, r: Rect): Rect => ({
  x: p.x + p.s * r.x,
  y: p.y + p.s * r.y,
  w: p.s * r.w,
  h: p.s * r.h,
})

/** A box `e` of the way from `a` to `b`: its centre straight across, its size by equal
 *  ratios (a zoom that feels even, the same growth every frame). */
function between(a: Rect, b: Rect, e: number): Rect {
  const w = a.w * (b.w / a.w) ** e
  const h = a.h * (b.h / a.h) ** e
  const cx = a.x + a.w / 2 + (b.x + b.w / 2 - (a.x + a.w / 2)) * e
  const cy = a.y + a.h / 2 + (b.y + b.h / 2 - (a.y + a.h / 2)) * e
  return { x: cx - w / 2, y: cy - h / 2, w, h }
}

/** `el` (laid out over the wall) moved and scaled so its box `from` lands on `to`. */
function place(el: HTMLElement, from: Rect, to: Rect) {
  const sx = to.w / from.w
  const sy = to.h / from.h
  el.style.transformOrigin = "0 0"
  el.style.transform = `translate(${to.x - from.x * sx}px, ${to.y - from.y * sy}px) scale(${sx}, ${sy})`
}

/** 0 until `from`, then easing to 1 at the end: a fade in the motion's last part. */
const fade = (e: number, from: number) => {
  const t = Math.min(1, Math.max(0, (e - from) / (1 - from)))
  return t * t * (3 - 2 * t)
}

/** One motion: `draw(e)` every frame, `e` eased 0 → 1 over `ms`. From rest it eases
 *  in and out; carried on from the hand (a pinch, a swipe) it only eases out, so the
 *  motion the hand began goes on at its speed. Reduced motion: the end at once. */
function run(
  ms: number,
  handed: boolean,
  draw: (e: number) => void,
): Promise<void> {
  const ease = handed
    ? (t: number) => 1 - (1 - t) ** 3
    : (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2)
  if (reduced()) {
    draw(1)
    return Promise.resolve()
  }
  return new Promise((resolve) => {
    const start = performance.now()
    const frame = (now: number) => {
      const t = Math.min(1, (now - start) / ms)
      draw(ease(t))
      if (t < 1) requestAnimationFrame(frame)
      else resolve()
    }
    requestAnimationFrame(frame)
  })
}

/** The wall as it stands on screen now, drawn on `canvas` (laid over it): every tile's
 *  picture (its video's frame, or the still standing in) where the tile is, as the
 *  tile fits it. Another origin's video draws too (the canvas is only shown, never
 *  read). */
function snapshot(
  section: HTMLElement,
  canvas: HTMLCanvasElement,
  fit: "cover" | "contain",
) {
  const wall = section.getBoundingClientRect()
  const dpr = Math.min(window.devicePixelRatio || 1, 2)
  canvas.width = Math.round(wall.width * dpr)
  canvas.height = Math.round(wall.height * dpr)
  const g = canvas.getContext("2d")
  if (!g) return
  g.setTransform(dpr, 0, 0, dpr, 0, 0)
  g.fillStyle = "#000"
  g.fillRect(0, 0, wall.width, wall.height)
  for (const tile of section.querySelectorAll<HTMLElement>(
    '[data-slot="film-wall-tile"]',
  )) {
    const t = tile.getBoundingClientRect()
    const still = tile.querySelector<HTMLElement>(
      '[data-slot="film-wall-tile-still"]',
    )
    const stillShown = still && getComputedStyle(still).opacity !== "0"
    const video = tile.querySelector("video")
    const picture = stillShown
      ? still.querySelector<HTMLImageElement | HTMLCanvasElement>("img, canvas")
      : video && video.readyState >= 2
        ? video
        : null
    if (!picture) continue
    const pw =
      picture instanceof HTMLVideoElement
        ? picture.videoWidth
        : picture instanceof HTMLImageElement
          ? picture.naturalWidth
          : picture.width
    const ph =
      picture instanceof HTMLVideoElement
        ? picture.videoHeight
        : picture instanceof HTMLImageElement
          ? picture.naturalHeight
          : picture.height
    if (!(pw > 0 && ph > 0)) continue
    const x = t.left - wall.left
    const y = t.top - wall.top
    const k =
      fit === "cover"
        ? Math.max(t.width / pw, t.height / ph)
        : Math.min(t.width / pw, t.height / ph)
    g.save()
    g.beginPath()
    g.rect(x, y, t.width, t.height)
    g.clip()
    g.drawImage(
      picture,
      x + (t.width - pw * k) / 2,
      y + (t.height - ph * k) / 2,
      pw * k,
      ph * k,
    )
    g.restore()
  }
}

/** How long one loop of a level lasts on screen, ms: every tile of the level takes
 *  exactly this long (each at the speed that makes its span last it), so they come
 *  round together. "auto": the longest tile at 1×, never under `LOOP_MS`; a speed: the
 *  longest tile at that speed. */
export const loopOf = (longest: number, speed: FilmWallSpeed) =>
  speed === "auto" ? Math.max(LOOP_MS, longest) : longest / speed

/** From one pose to another, eased; resolves where it lands. */
function animate(el: HTMLElement, from: Pose, to: Pose): Promise<void> {
  paint(el, to)
  if (reduced()) return Promise.resolve()
  return el
    .animate([{ transform: css(from) }, { transform: css(to) }], {
      duration: SETTLE_MS,
      easing: EASE,
    })
    .finished.then(
      () => {},
      () => {},
    )
}

/** A span's length as a person reads it: "13s", "1.5s", "2m 10s". */
function lengthWords(ms: number): string {
  if (ms >= 60_000) {
    const m = Math.floor(ms / 60_000)
    const s = Math.round((ms % 60_000) / 1000)
    return s ? `${m}m ${s}s` : `${m}m`
  }
  if (ms >= 10_000) return `${Math.round(ms / 1000)}s`
  if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`
  return `${Math.round(ms)}ms`
}

// ── the toggle in the player ──────────────────────────────────────────────────────

const WallContext = React.createContext<((at: number) => void) | null>(null)

/** The player's way back to the wall, in its bar: the wall again, paged to where the
 *  player had got to. Only inside a FilmWall's player. */
export function FilmWallToggle({
  material,
  className,
}: {
  material?: Material
  className?: string
}) {
  const toWall = React.useContext(WallContext)
  if (!toWall)
    throw new Error(
      "FilmWallToggle: place it in the player a FilmWall hands its box to",
    )
  const timebase = useTimebase()
  const glass = useMaterial(material) === "glass"
  const back = () => toWall(timebase.get().at)
  return (
    <Tip content="the wall">
      {glass ? (
        <Button
          data-slot="film-wall-toggle"
          material="glass"
          size="icon"
          aria-label="back to the wall"
          onClick={back}
          className={className}
        >
          <LayoutGrid aria-hidden />
        </Button>
      ) : (
        <button
          type="button"
          data-slot="film-wall-toggle"
          aria-label="back to the wall"
          onClick={back}
          className={cn(PLAYER_BUTTON, "[&_svg]:size-3.5", className)}
        >
          <LayoutGrid aria-hidden />
        </button>
      )}
    </Tip>
  )
}

/** A button over a tile in the player's face: a dark disc, or a glass circle. The
 *  hand's: out of the Tab order (the wall is one Tab stop, its keys say the same: Enter,
 *  +, M). */
function Disc({
  glass,
  size,
  label,
  onClick,
  disabled,
  pressed,
  className,
  children,
}: {
  glass: boolean
  size: "sm" | "md"
  label: string
  onClick: () => void
  disabled?: boolean
  pressed?: boolean
  className?: string
  children: React.ReactNode
}) {
  return (
    <Tip content={label}>
      {glass ? (
        <Button
          material="glass"
          scheme="dark"
          size={size === "md" ? "icon-lg" : "icon"}
          tabIndex={-1}
          aria-label={label}
          aria-pressed={pressed}
          disabled={disabled}
          onClick={onClick}
          className={cn("pointer-events-auto", className)}
        >
          {children}
        </Button>
      ) : (
        <button
          type="button"
          tabIndex={-1}
          aria-label={label}
          aria-pressed={pressed}
          disabled={disabled}
          onClick={onClick}
          className={cn(
            PLAYER_DISC,
            PLAYER_DISC_SIZE[size],
            schemeOf("dark"),
            "pointer-events-auto disabled:cursor-default disabled:opacity-40",
            className,
          )}
        >
          {children}
        </button>
      )}
    </Tip>
  )
}

/** What shows over a tile under the hand (or with focus inside). */
const REVEAL_TILE =
  "opacity-0 transition-opacity duration-150 group-hover/tile:opacity-100 group-focus-within/tile:opacity-100 motion-reduce:transition-none"
/** A time on a tile: small mono on a dark chip, readable on any frame. */
const TIME_CHIP =
  "rounded-[3px] bg-black/55 px-1 font-mono text-[10px] text-white/90 tabular-nums leading-4"

// ── a tile: a player's picture over its span ──────────────────────────────────────

interface TileProps {
  slot: number
  span: Span
  src: string
  label: string
  fit: "cover" | "contain"
  captions: readonly Captions[]
  crossOrigin: VideoPlaybackProps["crossOrigin"]
  stills: Stills<Still> | null
  /** The wall is on screen and motion is allowed (no reduced motion). */
  visible: boolean
  /** Every tile plays (the walls' motion), in turns under `budget`; else only the
   *  one under the hand. */
  motion: boolean
  budget: AmbientBudget | null
  /** The level's clock: when its loops began (null: not yet, the tiles still coming
   *  up) and how long one lasts (`loopOf`). */
  phase: { start: number | null; loopMs: number }
  /** Under the hand: it plays whatever the budget says, and it may be heard. */
  hovered: boolean
  /** The wall's last tile: its end is said too. */
  last: boolean
  /** The instant the player handed the wall back at, when this tile holds it: marked
   *  on its line. */
  seen: number | null
  /** The wall's one Tab stop: the tile the keys move from. */
  focused: boolean
  glass: boolean
  zoomable: boolean
  onHover: (slot: number | null) => void
  onPlay: (at: number) => void
  onZoom: (slot: number) => void
  /** Its new span is on screen. */
  onLanded: (slot: number) => void
  /** Its element, for the keys to move focus to; and its play, for Enter. */
  onMount: (slot: number, el: HTMLDivElement | null) => void
  onPlayer: (slot: number, play: (() => void) | null) => void
  onFocusTile: (slot: number) => void
}

function Tile(p: TileProps) {
  const mount = p.onMount
  const slot = p.slot
  const ref = React.useCallback(
    (el: HTMLDivElement | null) => mount(slot, el),
    [mount, slot],
  )
  return (
    // biome-ignore lint/a11y/useSemanticElements: a tile holds its own buttons; a fieldset groups fields
    <div
      ref={ref}
      data-slot="film-wall-tile"
      data-seen={p.seen !== null || undefined}
      // The wall is one Tab stop: its tile in hand; the arrows move between tiles
      // (the wall's keys), Enter plays it. A press plays (the tile's own layer).
      tabIndex={p.focused ? 0 : -1}
      role="group"
      aria-label={`${p.label}, ${spanWords(p.span)}`}
      aria-roledescription="tile"
      onFocus={() => p.onFocusTile(p.slot)}
      onPointerEnter={(e) => e.pointerType !== "touch" && p.onHover(p.slot)}
      onPointerLeave={() => p.onHover(null)}
      // Captions rise over the start, length and end while they show.
      style={{ "--ag-player-foot": "1.5rem" } as React.CSSProperties}
      className="group/tile @container/tile relative min-h-0 min-w-0 overflow-hidden outline-none"
    >
      <VideoPlayback
        src={p.src}
        label={`${p.label}, ${spanWords(p.span)}`}
        range={p.span}
        start={p.span.from}
        muted
        remember={false}
        captions={p.captions}
        crossOrigin={p.crossOrigin}
        className="absolute inset-0"
      >
        <VideoPicture
          clicks={false}
          className="size-full rounded-none"
          videoClassName={fit(p.fit)}
        >
          <TileRun {...p} />
          {/* A tile is a player of its own, whose popups would open in its small,
              clipped layer: the tile's tips open on the page instead. */}
          <PortalTarget element={null}>
            <TileFace {...p} />
          </PortalTarget>
        </VideoPicture>
      </VideoPlayback>
    </div>
  )
}

const fit = (f: "cover" | "contain") =>
  f === "cover" ? "object-cover" : "object-contain"

/** Whether the video has presented a frame of the span starting at `from` since it was
 *  given it (a seek landed, or its first data): once so, it stays so, the loop's own
 *  seek back to its start included. */
function useFramed(el: HTMLVideoElement | null, from: number): boolean {
  const [framed, setFramed] = React.useState(false)
  // biome-ignore lint/correctness/useExhaustiveDependencies: a new span is a new seek to wait for
  React.useEffect(() => {
    if (!el) return
    const read = () =>
      setFramed((was) => was || (el.readyState >= 2 && !el.seeking))
    setFramed(false)
    el.addEventListener("loadeddata", read)
    el.addEventListener("seeking", read)
    el.addEventListener("seeked", read)
    return () => {
      el.removeEventListener("loadeddata", read)
      el.removeEventListener("seeking", read)
      el.removeEventListener("seeked", read)
    }
  }, [el, from])
  return framed
}

/** The tile's clock as the wall asks. With the walls' motion on, it plays while it has
 *  a turn (lib/ambient-budget: one pass a turn, as many at once as the machine keeps up
 *  with) or the hand is on it, IN STEP with its level: its span lasts the level's loop
 *  (its speed is the span over the loop), it starts when the level's clock does, and a
 *  tile that joins late, or drifts, is put where the clock says. Off, only under the
 *  hand, on its own. A new span: sent to its start, and said once a frame of it is
 *  up. */
function TileRun({
  slot,
  span,
  visible,
  motion,
  budget,
  phase,
  hovered,
  onLanded,
}: TileProps) {
  const timebase = useTimebase()
  const { controls } = timebase
  const el = useVideoElement()
  const ready = useTimebaseState((s) => s.ready)
  const framed = useFramed(el, span.from)
  const showing = useTimebaseState((s) => s.at >= span.from && s.at < span.to)
  const [granted, setGranted] = React.useState(false)
  const id = React.useRef<symbol | null>(null)
  const wants = visible && motion && budget !== null
  React.useEffect(() => {
    if (!wants || !budget) return
    const me = Symbol(`tile ${slot}`)
    id.current = me
    budget.request(me, setGranted)
    return () => {
      budget.release(me)
      id.current = null
      setGranted(false)
    }
  }, [budget, wants, slot])
  const length = span.to - span.from
  const { start, loopMs } = phase
  const plays = motion
    ? visible && start !== null && (granted || hovered)
    : visible && hovered
  React.useEffect(() => {
    if (ready) controls.seek(span.from)
  }, [ready, controls, span.from])
  // Its speed: its span over the level's loop, so every tile comes round together;
  // never the person's remembered player speed.
  const rate = Math.max(SLOWEST, length / loopMs)
  React.useEffect(() => {
    if (ready) controls.setRate?.(rate, { remember: false })
  }, [ready, controls, rate])
  React.useEffect(() => {
    if (!ready) return
    if (plays) controls.play()
    else controls.pause()
  }, [ready, plays, controls])
  // In step: where the level's clock says it should be, now and every second (a tile
  // joining late, a decoder that slipped); the distance is read round the loop, so a
  // tile just past its wrap is not sent back to its end.
  React.useEffect(() => {
    if (!motion || !plays || start === null) return
    const align = () => {
      const share = ((performance.now() - start) % loopMs) / loopMs
      const due = span.from + share * length
      const off = Math.abs(timebase.get().at - due) % length
      if (Math.min(off, length - off) > DRIFT_MS) controls.seek(due)
    }
    align()
    const timer = window.setInterval(align, 1000)
    return () => window.clearInterval(timer)
  }, [motion, plays, start, loopMs, span.from, length, timebase, controls])
  // A pass: the span came round once, the clock going from inside it back to its
  // start. The seek that brings a tile to a new span is no pass (its clock comes from
  // outside the span), or every zoom would hand every turn away.
  React.useEffect(() => {
    if (!granted || !budget) return
    const length = span.to - span.from
    const inside = (at: number) => at >= span.from && at <= span.to
    let last = timebase.get().at
    return timebase.subscribe(() => {
      const at = timebase.get().at
      const me = id.current
      const wrapped = inside(last) && inside(at) && at < last - length / 2
      if (me && wrapped) budget.passed(me)
      last = at
    })
  }, [granted, budget, timebase, span.from, span.to])
  // Its frame counts, which size the budget.
  React.useEffect(() => {
    if (!granted || !budget || !el) return
    const timer = window.setInterval(() => {
      const me = id.current
      if (!me || document.hidden) return
      const q = el.getVideoPlaybackQuality()
      budget.measure(me, performance.now(), {
        total: q.totalVideoFrames,
        dropped: q.droppedVideoFrames,
      })
    }, AMBIENT_SAMPLE_MS)
    return () => window.clearInterval(timer)
  }, [granted, budget, el])
  const landed = React.useEffectEvent(() => onLanded(slot))
  // Landed: a frame of its new span is up.
  // biome-ignore lint/correctness/useExhaustiveDependencies: a new span that already holds the instant shown has landed too
  React.useEffect(() => {
    if (showing && framed) landed()
  }, [showing, framed, span.from])
  return null
}

/** Over a tile: the hand's controls, its line and times along its foot (where the
 *  player left off marked on the line), the ring of keyboard focus. */
function TileFace({
  span,
  hovered,
  seen,
  last,
  glass,
  zoomable,
  slot,
  stills,
  fit: shape,
  onPlay,
  onZoom,
  onPlayer,
}: TileProps) {
  const timebase = useTimebase()
  const at = useTimebaseState((s) => s.at)
  const el = useVideoElement()
  // Until a frame of its span is up, its still stands in (or black, with none: never
  // the frame of the span it had before).
  const framed = useFramed(el, span.from)
  const [sound] = useRemembered(FILM_WALL_SOUND)
  // Heard only under the hand, and only while the walls' sound is on.
  React.useEffect(() => {
    if (!el) return
    if (!hovered) {
      el.muted = true
      return
    }
    voice.take(el)
    return () => voice.leave(el)
  }, [el, hovered])
  const length = span.to - span.from
  // Where its play continues: the instant its picture is at, in its span.
  const play = () =>
    onPlay(Math.min(span.to - 1, Math.max(span.from, timebase.get().at)))
  // Enter on the tile in hand plays it, from the instant it shows.
  const playNow = React.useEffectEvent(play)
  React.useEffect(() => {
    onPlayer(slot, () => playNow())
    return () => onPlayer(slot, null)
  }, [onPlayer, slot])
  return (
    <>
      <div
        aria-hidden
        data-slot="film-wall-tile-still"
        className={cn(
          "pointer-events-none absolute inset-0 bg-black transition-opacity duration-200 motion-reduce:transition-none",
          framed && "opacity-0",
        )}
      >
        {stills && (
          <StillPicture stills={stills} at={span.from} className={fit(shape)} />
        )}
      </div>
      {/* The tile itself plays from here, a pointer's shortcut for its ▶. */}
      <span
        aria-hidden
        onClick={play}
        className="absolute inset-0 cursor-pointer"
      />
      {/* Keyboard focus: the tile in hand, ringed only when the keys moved it. */}
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 z-10 hidden ring-2 ring-white ring-inset group-focus-visible/tile:block"
      />
      {/* Under the hand: the sound in the top-left corner (where a gallery cell keeps
          it), the zoom in the top-right, play alone in the middle. */}
      <div className={cn(REVEAL_TILE, "pointer-events-none absolute inset-0")}>
        <Disc
          glass={glass}
          size="sm"
          label={sound ? "sound off (M)" : "sound on (M)"}
          pressed={sound}
          onClick={() => voice.set(!sound)}
          className="absolute top-2 left-2"
        >
          <PlayerIcon.sound level={sound ? 2 : 0} />
        </Disc>
        <span className="absolute inset-0 grid place-items-center">
          <Disc glass={glass} size="md" label="play from here" onClick={play}>
            <PlayerIcon.play />
          </Disc>
        </span>
        {zoomable && (
          <Disc
            glass={glass}
            size="sm"
            label="zoom in (+, or spread)"
            onClick={() => onZoom(slot)}
            className="absolute top-2 right-2"
          >
            <ZoomIn aria-hidden />
          </Disc>
        )}
      </div>
      <div
        data-slot="film-wall-tile-foot"
        className="pointer-events-none absolute inset-x-0 bottom-0"
      >
        {/* Its start on the left, always; its end on the right only on the wall's
            last tile (every other tile's end is the next one's start, said there);
            its length in the middle under the hand. */}
        <div className="flex items-end justify-between gap-1 px-1 pb-1">
          <span data-slot="film-wall-tile-start" className={TIME_CHIP}>
            {startLabel(span.from, length)}
          </span>
          <span className={cn(TIME_CHIP, REVEAL_TILE, "text-white/70")}>
            {lengthWords(length)}
          </span>
          <span
            data-slot="film-wall-tile-end"
            className={cn(TIME_CHIP, !last && "invisible")}
          >
            {startLabel(span.to, length)}
          </span>
        </div>
        <div className="h-1 bg-black/40 transition-[height] duration-150 group-hover/tile:h-1.5 motion-reduce:transition-none">
          <MediaLine
            duration={length}
            at={Math.max(0, at - span.from)}
            className="size-full text-white"
          >
            {/* Where the player left off, when it was in this tile. */}
            {seen !== null && (
              <span
                data-slot="film-wall-tile-seen"
                className="absolute -inset-y-0.5 left-(--tile-seen) w-0.5 -translate-x-1/2 bg-white shadow-[0_0_2px_rgb(0_0_0/0.8)]"
                style={
                  {
                    "--tile-seen": lineShare(seen - span.from, length),
                  } as React.CSSProperties
                }
              />
            )}
          </MediaLine>
        </div>
      </div>
    </>
  )
}

/** Plays while on screen, never under reduced motion. */
function useRunning(root: React.RefObject<HTMLElement | null>): boolean {
  const [visible, setVisible] = React.useState(false)
  const [still, setStill] = React.useState(false)
  React.useEffect(() => {
    const m = matchMedia("(prefers-reduced-motion: reduce)")
    const read = () => setStill(m.matches)
    read()
    m.addEventListener("change", read)
    return () => m.removeEventListener("change", read)
  }, [])
  React.useEffect(() => {
    const el = root.current
    if (!el) return
    const seen = new IntersectionObserver(([entry]) =>
      setVisible(!!entry?.isIntersecting),
    )
    seen.observe(el)
    return () => seen.disconnect()
  }, [root])
  return visible && !still
}

// ── the wall ──────────────────────────────────────────────────────────────────────

export function FilmWall({
  src,
  label,
  duration,
  width,
  height,
  grid = SQUARE,
  fit = "cover",
  finest = 1000,
  captions = NO_CAPTIONS,
  crossOrigin,
  stills = null,
  player,
  material,
  children,
  className,
}: FilmWallProps) {
  assert(duration > 0, `a film ${duration} ms long`)
  assert(width > 0 && height > 0, `a frame of ${width} × ${height}`)
  const glass = useMaterial(material) === "glass"
  const [z, setZ] = React.useState(() =>
    filmZoom({ duration, grain: finest, place: WHOLE_MS, grid }),
  )
  const { cols: askedCols, rows: askedRows } = grid
  React.useEffect(() => {
    setZ((was) => regrid(was, { cols: askedCols, rows: askedRows }))
  }, [askedCols, askedRows])
  const [start, setStart] = React.useState<number | null>(null)
  // Where the player was when it handed the box back: its tile is ringed.
  const [seen, setSeen] = React.useState<number | null>(null)
  const [hovered, setHovered] = React.useState<number | null>(null)
  const root = React.useRef<HTMLElement>(null)
  const sheet = React.useRef<HTMLDivElement>(null)
  const visible = useRunning(root)
  const [motion] = useRemembered(FILM_WALL_MOTION)
  const [speed, setSpeed] = useRemembered(FILM_WALL_SPEED)
  // The wall's decoders, as many as the machine keeps up with and its load allows
  // (ui/machine-pressure); made in the browser: its cores are where it starts.
  const [budget, setBudget] = React.useState<AmbientBudget | null>(null)
  React.useEffect(() => {
    const made = ambientBudget(
      Number.POSITIVE_INFINITY,
      ambientStart(navigator.hardwareConcurrency),
    )
    setBudget(made)
    return watchPressure(made)
  }, [])
  // The stills: their grain and placement are the tiles' edges; the ones shown asked
  // for first.
  React.useEffect(() => {
    if (stills)
      setZ((was) => restill(was, { grain: stills.grain, place: stills.place }))
  }, [stills])

  // The captions choice every player shares: the tiles show the one it picks.
  const [choice, choose] = useRemembered(CAPTIONS)
  const shownCaptions = shownOf(captions, choice)
  const offered = React.useMemo(
    () => captions.map((c) => ({ ...c, default: c === shownCaptions })),
    [captions, shownCaptions],
  )
  const toggleCaptions = () => choose(toggledChoice(captions, choice))
  const [sound] = useRemembered(FILM_WALL_SOUND)

  const { cols, rows } = z
  assert(Number.isInteger(z.top), `a sheet scrolled part of a row (${z.top})`)
  const count = cols * rows
  // The wall's places, each with the tile it shows (none past the film's end).
  const slots: (Span | null)[] = Array.from({ length: count }, () => null)
  for (const t of shownTiles(z)) slots[t.row * cols + t.column] = t.span
  const wanted = slots.map((s) => s?.from ?? -1).join(" ")
  // biome-ignore lint/correctness/useExhaustiveDependencies: the tiles shown, by their starts
  React.useEffect(() => {
    stills?.want(slots.flatMap((s) => (s ? [s.from] : [])))
  }, [stills, wanted])
  const view = viewOf(z)
  const zoomed = canZoomOut(z)

  // ── the tile in hand: the wall's one Tab stop, the arrows' and Enter's ──
  const [focus, setFocus] = React.useState(0)
  const lastTile = slots.findLastIndex((s) => s !== null)
  const inHand = Math.max(0, Math.min(focus, lastTile))
  const tileEls = React.useRef(new Map<number, HTMLDivElement>())
  const players = React.useRef(new Map<number, () => void>())
  const onMount = React.useCallback(
    (slot: number, el: HTMLDivElement | null) => {
      if (el) tileEls.current.set(slot, el)
      else tileEls.current.delete(slot)
    },
    [],
  )
  const onPlayer = React.useCallback(
    (slot: number, play: (() => void) | null) => {
      if (play) players.current.set(slot, play)
      else players.current.delete(slot)
    },
    [],
  )
  /** The tile at `slot` in hand; focused too when the focus is in the wall (the keys
   *  moved it), never pulled in from elsewhere on the page. */
  const focusOn = (slot: number) => {
    setFocus(slot)
    if (root.current?.contains(document.activeElement))
      tileEls.current.get(slot)?.focus({ preventScroll: true })
  }

  // ── motion: what the handlers (which outlive a render) read ──
  const live = React.useRef(z)
  live.current = z
  const pose = React.useRef<Pose>(REST)
  const busy = React.useRef(false)
  // The press that came while a change was under way, run once it lands.
  const next = React.useRef<(() => void) | null>(null)
  const landing = React.useRef({
    waiting: new Set<number>(),
    done: null as (() => void) | null,
    timer: 0,
  })
  const finishLanding = () => {
    const l = landing.current
    window.clearTimeout(l.timer)
    const done = l.done
    l.done = null
    l.waiting.clear()
    done?.()
  }
  // The level's clock: every tile of a level loops in step, its loop lasting
  // `loopMs`, from the moment the last of them has its first frame up (or
  // `CLOCK_WAIT_MS`, whichever comes first: one slow tile never holds the rest).
  const slotsNow = React.useRef(slots)
  slotsNow.current = slots
  const levelKey = React.useRef(wanted)
  levelKey.current = wanted
  const arrived = React.useRef(new Set<string>())
  const [clock, setClock] = React.useState<{
    key: string
    start: number
  } | null>(null)
  const startClock = React.useCallback(
    (key: string) =>
      setClock((was) =>
        was?.key === key ? was : { key, start: performance.now() },
      ),
    [],
  )
  React.useEffect(() => {
    const key = wanted
    const timer = window.setTimeout(() => startClock(key), CLOCK_WAIT_MS)
    return () => window.clearTimeout(timer)
  }, [wanted, startClock])
  const longest = Math.max(...slots.map((s) => (s ? s.to - s.from : 0)), 1)
  const loopMs = loopOf(longest, speed)
  const phase = {
    start: clock?.key === wanted ? clock.start : null,
    loopMs,
  }

  const landed = (slot: number) => {
    const span = slotsNow.current[slot]
    if (span) arrived.current.add(`${slot}:${span.from}`)
    if (
      slotsNow.current.every(
        (s, i) => !s || arrived.current.has(`${i}:${s.from}`),
      )
    )
      startClock(levelKey.current)
    const l = landing.current
    if (!l.done) return
    l.waiting.delete(slot)
    if (l.waiting.size === 0) finishLanding()
  }
  /** The wall shows `next` now; resolves once its tiles present their new spans (or
   *  `LAND_MS`). */
  const commit = (next: FilmZoom) =>
    new Promise<void>((resolve) => {
      const l = landing.current
      window.clearTimeout(l.timer)
      l.waiting = new Set(
        shownTiles(next).map((t) => t.row * next.cols + t.column),
      )
      l.done = resolve
      l.timer = window.setTimeout(finishLanding, LAND_MS)
      flushSync(() => setZ(next))
    })
  const box = (): Rect => {
    const r = root.current?.getBoundingClientRect()
    return { x: 0, y: 0, w: r?.width ?? 0, h: r?.height ?? 0 }
  }
  /** Where the place `slot` of a wall of `g` stands, px of the wall, at rest. */
  const cellOf = (slot: number, g: Grid): Rect => {
    const { w, h } = box()
    return {
      x: (slot % g.cols) * (w / g.cols),
      y: Math.floor(slot / g.cols) * (h / g.rows),
      w: w / g.cols,
      h: h / g.rows,
    }
  }
  /** The veil: the wall as it stands on screen now, drawn once, over the sheet; the
   *  motion moves it while the sheet moves the new level under it. */
  const veil = React.useRef<HTMLCanvasElement>(null)
  const lift = () => {
    const section = root.current
    const v = veil.current
    if (!section || !v) return null
    snapshot(section, v, fit)
    v.style.display = "block"
    v.style.opacity = "1"
    return v
  }
  const drop = (v: HTMLCanvasElement | null) => {
    if (!v) return
    v.style.display = "none"
    v.style.transform = ""
    v.style.opacity = "1"
  }
  /** Runs one change of view. A press while one is under way waits for it to land
   *  and runs then (the latest press only: a key held or pressed twice moves once
   *  more, never queues a run of them); a gesture under way is never interrupted.
   *  Every move reads the wall as it is when it runs (`live`), never as it was. */
  const change = async (move: (sheet: HTMLDivElement) => Promise<void>) => {
    const el = sheet.current
    if (!el) return
    if (busy.current) {
      next.current = () => change(move)
      return
    }
    busy.current = true
    try {
      await move(el)
    } finally {
      pose.current = REST
      busy.current = false
    }
    const then = next.current
    next.current = null
    then?.()
  }
  // ── the motions: one eased share drives the veil (the wall as it was) and the sheet
  // (the wall it becomes) together, every frame, so the two never come apart ──

  /** Into the tile at `slot`: the level it opens grows out of the tile while the tile,
   *  on the veil, grows past the wall's edges and fades. From where the hand left the
   *  sheet (a pinch), or from rest (a press). */
  const zoomInto = (slot: number) =>
    change(async (el) => {
      const now = live.current
      const index = shownTiles(now).find(
        (t) => t.row * now.cols + t.column === slot,
      )?.index
      if (!canZoomIn(now) || index === undefined) {
        await animate(el, pose.current, REST)
        return
      }
      const handed = pose.current !== REST
      const from = posed(pose.current, cellOf(slot, now))
      const v = lift()
      const landing = commit(zoomIn(now, index))
      // The tile zoomed into is the new level's first: the hand goes on from there.
      focusOn(0)
      const whole = box()
      await run(ZOOM_MS, handed, (e) => {
        const at = between(from, whole, e)
        place(el, whole, at)
        if (v) {
          place(v, from, at)
          v.style.opacity = String(1 - fade(e, 0.3))
        }
      })
      drop(v)
      rest(el)
      await landing
    })
  /** Out to the level above: the wall shrinks into the tile it is there, fading into
   *  it, while the level above closes in round it. */
  const zoomOutward = () =>
    change(async (el) => {
      const now = live.current
      const step = now.trail[now.trail.length - 1]
      if (!step) {
        await animate(el, pose.current, REST)
        return
      }
      const up = zoomOut(now)
      const handed = pose.current !== REST
      const whole = box()
      const from = posed(pose.current, whole)
      const back =
        (rowOf(up, step.index) - up.top) * up.cols + columnOf(up, step.index)
      const cell = cellOf(back, up)
      const v = lift()
      const landing = commit(up)
      // The tile it came out of is in hand again.
      focusOn(back)
      await run(ZOOM_MS, handed, (e) => {
        const at = between(from, cell, e)
        place(el, cell, at)
        if (v) {
          place(v, from, at)
          v.style.opacity = String(1 - fade(e, 0.35))
        }
      })
      drop(v)
      rest(el)
      await landing
    })
  /** The next (previous) stretch: the wall slides out on the veil as the next slides
   *  in beside it, from where a swipe left it. */
  const pageBy = (by: 1 | -1) =>
    change(async (el) => {
      const now = live.current
      if (!canPage(now, by)) {
        await animate(el, pose.current, REST)
        return
      }
      const handed = pose.current !== REST
      const { w } = box()
      const slid = pose.current.x
      const v = lift()
      const landing = commit(page(now, by))
      // The veil holds the wall where the swipe left it; it goes the rest of the way
      // out while the new stretch comes in from beside it.
      await run(PAGE_MS, handed, (e) => {
        el.style.transformOrigin = "0 0"
        el.style.transform = `translateX(${(slid + by * w) * (1 - e)}px)`
        if (v) v.style.transform = `translateX(${(-by * w - slid) * e}px)`
      })
      drop(v)
      rest(el)
      await landing
    })
  /** A jump (the whole film, a press on the head's line): the new view fades in over
   *  the old one. */
  const jumpTo = (next: FilmZoom) =>
    change(async (el) => {
      if (next === live.current) return
      rest(el)
      const v = lift()
      const landing = commit(next)
      await run(FADE_MS, false, (e) => {
        if (v) v.style.opacity = String(1 - e)
      })
      drop(v)
      await landing
    })
  const home = () => jumpTo(zoomHome(live.current))
  const revealAt = (at: number) => jumpTo(reveal(live.current, at))

  // ── the hand ──
  const act = React.useRef({ zoomInto, zoomOutward, pageBy })
  act.current = { zoomInto, zoomOutward, pageBy }
  React.useEffect(() => {
    const el = root.current
    if (!el || start !== null) return
    const local = (at: Client) => {
      const r = el.getBoundingClientRect()
      return { x: at.x - r.left, y: at.y - r.top, w: r.width, h: r.height }
    }
    const show = (p: Pose) => {
      const s = sheet.current
      if (!s) return
      pose.current = p
      paint(s, p)
    }
    const settleBack = () => {
      const s = sheet.current
      if (!s || busy.current) return
      const from = pose.current
      pose.current = REST
      animate(s, from, REST).then(() => !busy.current && rest(s))
    }
    // One pinch: where it grows from, and the level it may take ("spent" once taken).
    let rung: "in" | "out" | "none" | "spent" = "none"
    let origin = { x: 0, y: 0 }
    let slotUnder = 0
    // One swipe: how far it has slid.
    let slid = 0
    const stopReading = pinchInput(el, {
      begin: (out, at) => {
        const z0 = live.current
        if (busy.current) {
          rung = "spent"
          return
        }
        const p = local(at)
        origin = { x: p.x, y: p.y }
        const col = Math.min(
          z0.cols - 1,
          Math.max(0, Math.floor((p.x / p.w) * z0.cols)),
        )
        const row = Math.min(
          z0.rows - 1,
          Math.max(0, Math.floor((p.y / p.h) * z0.rows)),
        )
        slotUnder = row * z0.cols + col
        rung = out
          ? canZoomIn(z0)
            ? "in"
            : "none"
          : canZoomOut(z0)
            ? "out"
            : "none"
      },
      pinch: (stretch) => {
        if (rung === "spent" || busy.current) return
        if (rung === "in" && stretch >= 1 / PINCH_CLOSE) {
          rung = "spent"
          act.current.zoomInto(slotUnder)
          return
        }
        if (rung === "out" && stretch <= PINCH_CLOSE) {
          rung = "spent"
          act.current.zoomOutward()
          return
        }
        const k = rung === "none" ? 1 + (stretch - 1) * RESIST : stretch
        show({ s: k, x: origin.x * (1 - k), y: origin.y * (1 - k) })
      },
      end: () => {
        if (rung !== "spent") settleBack()
        rung = "none"
      },
      wheel: (e) => {
        const z0 = live.current
        const sideways = Math.abs(e.deltaX) > Math.abs(e.deltaY)
        if (!sideways || busy.current) return false
        if (!canPage(z0, 1) && !canPage(z0, -1)) return false
        slid -= e.deltaX
        const by = slid < 0 ? 1 : -1
        show({ s: 1, x: canPage(z0, by) ? slid : slid * RESIST, y: 0 })
        return true
      },
      wheelEnd: () => swipeEnd(),
      press: (e) => {
        const z0 = live.current
        slid = 0
        return (
          e.pointerType === "touch" &&
          !busy.current &&
          (canPage(z0, 1) || canPage(z0, -1))
        )
      },
      drag: (dx) => {
        if (busy.current) return
        const z0 = live.current
        slid += dx
        const by = slid < 0 ? 1 : -1
        show({ s: 1, x: canPage(z0, by) ? slid : slid * RESIST, y: 0 })
      },
      release: () => swipeEnd(),
    })
    const swipeEnd = () => {
      const { w } = local({ x: 0, y: 0 })
      const by = slid < 0 ? 1 : -1
      const far = Math.abs(slid) > w * PAGE_AT
      slid = 0
      if (far && canPage(live.current, by)) act.current.pageBy(by)
      else settleBack()
    }
    return stopReading
  }, [start])

  // ── the keys: the pointer's or the focus's wall. With focus in it, the arrows move
  // the tile in hand through time (← → a tile, ↑ ↓ a row), paging past the view's
  // edge onto the tile next to it in the film; Enter plays it, + zooms into it. With
  // only the pointer on it, ← → page. ──
  const centre = Math.floor(rows / 2) * cols + Math.floor(cols / 2)
  const focusIn = () => !!root.current?.contains(document.activeElement)
  useKeys(
    (event) => {
      if (start !== null || event.metaKey || event.ctrlKey || event.altKey)
        return false
      const k = event.key
      const step = {
        ArrowLeft: -1,
        ArrowRight: 1,
        ArrowUp: -cols,
        ArrowDown: cols,
      }[k]
      if (step !== undefined && focusIn()) {
        const to = inHand + step
        if (to >= 0 && to <= lastTile) {
          focusOn(to)
          return true
        }
        const by = to < 0 ? -1 : 1
        if (!canPage(z, by)) return true
        pageBy(by)
        // The tile next to it in the film: across a row, the far end of the next
        // stretch; down or up, the same column there.
        focusOn(
          Math.abs(step) === 1
            ? by > 0
              ? 0
              : count - 1
            : (inHand % cols) + (by > 0 ? 0 : (rows - 1) * cols),
        )
        return true
      }
      if (k === "ArrowRight" || k === "ArrowLeft") {
        const by = k === "ArrowRight" ? 1 : -1
        if (!canPage(z, by)) return false
        pageBy(by)
        return true
      }
      if (k === "Enter" && focusIn()) {
        const play = players.current.get(inHand)
        if (!play) return false
        play()
        return true
      }
      if (k === "+" || k === "=") {
        if (!canZoomIn(z)) return false
        zoomInto(focusIn() ? inHand : (hovered ?? centre))
        return true
      }
      if (k === "-" || k === "Backspace") {
        if (!zoomed) return false
        zoomOutward()
        return true
      }
      if (k === "0") {
        if (!zoomed) return false
        home()
        return true
      }
      if (k === "c" || k === "C") {
        if (captions.length === 0) return false
        toggleCaptions()
        return true
      }
      if (k === "m" || k === "M") {
        voice.set(!sound)
        return true
      }
      return false
    },
    { root },
  )
  useEscape(zoomOutward, { active: start === null && zoomed, root })

  /** The player hands the box back at `at`: the wall at the zoom it was, paged to it,
   *  the instant marked on its tile's line, that tile in hand. */
  const toWall = React.useCallback((at: number) => {
    setZ((was) => hold(was, at))
    setSeen(at)
    setStart(null)
    handBack.current = true
  }, [])
  // Back from the player: the keys go on from the tile it left off in.
  const handBack = React.useRef(false)
  // biome-ignore lint/correctness/useExhaustiveDependencies: once the wall is back
  React.useEffect(() => {
    if (start !== null || !handBack.current || seen === null) return
    handBack.current = false
    const slot = slots.findIndex((s) => s && seen >= s.from && seen < s.to)
    if (slot < 0) return
    setFocus(slot)
    tileEls.current.get(slot)?.focus({ preventScroll: true })
  }, [start, seen])

  const frame = { aspectRatio: `${width} / ${height}` }
  const length = view.to - view.from
  const walled = start === null
  // The wall's own bar, above its box: where it is in the film and the ways to move,
  // always there (disabled where they lead nowhere), the same while the player has the
  // box, so neither moves the page.
  const bar = (
    <div data-slot="film-wall-bar" className="flex min-h-8 items-center gap-1">
      <BarButton
        material={material}
        label="zoom out (Esc, -, or close the fingers)"
        disabled={!walled || !zoomed}
        onClick={zoomOutward}
      >
        <ZoomOut aria-hidden />
      </BarButton>
      <BarButton
        material={material}
        label="the stretch before (←, or swipe)"
        disabled={!walled || !canPage(z, -1)}
        onClick={() => pageBy(-1)}
      >
        <ChevronLeft aria-hidden />
      </BarButton>
      <BarButton
        material={material}
        label="the stretch after (→, or swipe)"
        disabled={!walled || !canPage(z, 1)}
        onClick={() => pageBy(1)}
      >
        <ChevronRight aria-hidden />
      </BarButton>
      <span className="min-w-0 truncate px-1.5 font-mono text-[11px] text-muted-foreground tabular-nums">
        {zoomed
          ? `${spanWords(view)} · ${lengthWords(length)} of ${clockTime(duration)}`
          : `the whole film, ${clockTime(duration)} · ${count} tiles of ${lengthWords(length / count)}`}
      </span>
      <span className="ml-auto flex items-center gap-2">
        <WallSpeed
          material={material}
          speed={speed}
          tileRate={Math.round((longest / loopMs) * 100) / 100}
          onSpeed={setSpeed}
        />
        {captions.length > 0 && (
          <BarButton
            material={material}
            label={shownCaptions ? "captions off (C)" : "captions on (C)"}
            pressed={shownCaptions !== null}
            onClick={toggleCaptions}
          >
            <PlayerIcon.captions on={shownCaptions !== null} />
          </BarButton>
        )}
        {children}
      </span>
    </div>
  )

  if (!walled)
    return (
      <div className={cn("flex w-full flex-col gap-2", className)}>
        {bar}
        <div
          data-slot="film-wall"
          data-showing="player"
          style={frame}
          className="relative w-full"
        >
          <WallContext.Provider value={toWall}>
            {player(start)}
          </WallContext.Provider>
        </div>
      </div>
    )

  return (
    <div className={cn("flex w-full flex-col gap-2", className)}>
      {bar}
      <section
        ref={root}
        data-slot="film-wall"
        data-showing="wall"
        aria-label={`${label}: ${count} tiles${zoomed ? `, ${spanWords(view)}` : ""}`}
        style={frame}
        className="group/wall relative isolate w-full touch-pan-y select-none overflow-hidden rounded-xl bg-black"
      >
        <div
          ref={sheet}
          style={{
            gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
            gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
          }}
          className="absolute inset-0 grid will-change-transform"
        >
          {slots.map((span, slot) =>
            span ? (
              <Tile
                // Kept by its place: a new span seeks the video already loaded.
                // biome-ignore lint/suspicious/noArrayIndexKey: the place on the wall is the identity
                key={slot}
                slot={slot}
                span={span}
                src={src}
                label={label}
                fit={fit}
                captions={offered}
                crossOrigin={crossOrigin}
                stills={stills}
                visible={visible}
                motion={motion}
                budget={budget}
                phase={phase}
                hovered={hovered === slot}
                seen={
                  seen !== null && seen >= span.from && seen < span.to
                    ? seen
                    : null
                }
                focused={slot === inHand}
                onMount={onMount}
                onPlayer={onPlayer}
                onFocusTile={setFocus}
                last={slot === slots.findLastIndex((s) => s !== null)}
                glass={glass}
                zoomable={canZoomIn(z)}
                onHover={setHovered}
                onPlay={setStart}
                onZoom={zoomInto}
                onLanded={landed}
              />
            ) : (
              // Past the film's end: the first place says so, the rest are black.
              <div
                // biome-ignore lint/suspicious/noArrayIndexKey: past the film's end, a place
                key={slot}
                aria-hidden
                className="grid place-items-center bg-black font-mono text-[11px] text-white/40"
              >
                {slot === slots.findIndex((s) => s === null) ? "the end" : null}
              </div>
            ),
          )}
        </div>
        {/* The veil: the wall as it was, over the one it becomes, while a change moves. */}
        <canvas
          ref={veil}
          aria-hidden
          style={{ display: "none" }}
          className="pointer-events-none absolute inset-0 z-[5] size-full"
        />

        {/* Along the top edge, zoomed: the view's window on the whole film (a press pages
          there), clear of every tile's own line along its foot. */}
        <div
          data-slot="film-wall-head"
          className="pointer-events-none absolute inset-x-0 top-0 z-10"
        >
          {zoomed && (
            <button
              type="button"
              tabIndex={-1}
              aria-hidden
              onPointerDown={(e) => {
                const r = e.currentTarget.getBoundingClientRect()
                revealAt(((e.clientX - r.left) / r.width) * duration)
              }}
              className="pointer-events-auto block h-2.5 w-full cursor-pointer"
            >
              <MediaLine
                duration={duration}
                at={null}
                className="h-1 w-full text-white"
              >
                <span
                  className="absolute inset-y-0 left-(--wall-from) w-(--wall-span) min-w-0.5 bg-current"
                  style={
                    {
                      "--wall-from": lineShare(view.from, duration),
                      "--wall-span": lineShare(length, duration),
                    } as React.CSSProperties
                  }
                />
                {seen !== null && (
                  <span
                    className="absolute inset-y-0 left-(--wall-seen) w-0.5 bg-white"
                    style={
                      {
                        "--wall-seen": lineShare(seen, duration),
                      } as React.CSSProperties
                    }
                  />
                )}
              </MediaLine>
            </button>
          )}
        </div>
        <span role="status" className="sr-only">
          {zoomed
            ? `${spanWords(view)}, tiles of ${lengthWords(length / count)}`
            : ""}
        </span>
      </section>
    </div>
  )
}

/** The tiles' speed, a menu in the bar: "auto" (said with the speed it gives the tiles
 *  shown, when that is slower than 1×) or one of the player's speeds. */
function WallSpeed({
  material,
  speed,
  tileRate,
  onSpeed,
}: {
  material?: Material
  speed: FilmWallSpeed
  /** What "auto" plays the tiles shown at. */
  tileRate: number
  onSpeed: (speed: FilmWallSpeed) => void
}) {
  const said =
    speed === "auto"
      ? tileRate < 1
        ? `auto ${speedLabel(tileRate)}`
        : "auto"
      : speedLabel(speed)
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        aria-label={`tiles' speed: ${said}`}
        render={
          <Button
            variant="ghost"
            size="sm"
            material={material}
            className="font-mono text-xs tabular-nums"
          />
        }
      >
        {said}
      </DropdownMenuTrigger>
      <DropdownMenuContent material={material} align="end">
        <DropdownMenuRadioGroup
          value={String(speed)}
          onValueChange={(v: string) =>
            onSpeed(v === "auto" ? "auto" : (Number(v) as FilmWallSpeed))
          }
        >
          <DropdownMenuRadioItem value="auto" className="text-xs">
            auto: slower when a tile is short
          </DropdownMenuRadioItem>
          {[...SPEEDS].reverse().map((r) => (
            <DropdownMenuRadioItem
              key={r}
              value={String(r)}
              className="font-mono text-xs tabular-nums"
            >
              {speedLabel(r)}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

/** A button of the wall's bar, on the page beside the box: the registry's Button,
 *  ghost and small, in the wall's material. */
function BarButton({
  material,
  label,
  disabled,
  pressed,
  onClick,
  children,
}: {
  material?: Material
  label: string
  disabled?: boolean
  pressed?: boolean
  onClick: () => void
  children: React.ReactNode
}) {
  return (
    <Tip content={label}>
      <Button
        variant="ghost"
        size="icon-sm"
        material={material}
        aria-label={label}
        aria-pressed={pressed}
        disabled={disabled}
        onClick={onClick}
      >
        {children}
      </Button>
    </Tip>
  )
}
film-tiles.ts
// A FILM CUT INTO TILES: how many a box holds and which span of the film each one is.
// Framework-free; ui/film-mosaic draws it, ui/film-wall labels its tiles with it.
//
// THE COUNT follows the box, never a number: as many tiles as fit its measured width
// at the narrowest a still is still read (`tile`, px), and never more than the stills
// can tell apart, since below the source's grain (lib/film-stills-core) two tiles would
// show one picture. A FACE is a square of tiles in a box of the film's own shape, so
// every tile wears that shape too and a tile grown by the side fills the whole box: n
// across and n down. A STRIP is one row.
//
// THE SPANS are equal shares of the view, each start placed on a still the source has
// (`place`: at or before), so the picture a tile shows is the first frame its span
// plays and a hover that plays the span never jumps from it. The last ends where the
// view does.

import type { Span } from "@/registry/base-nova/lib/clip"
import { clockTime } from "@/registry/base-nova/lib/timebase"

function assert(ok: unknown, why: string): asserts ok {
  if (!ok) throw new Error(`film-tiles: ${why}`)
}

/** How many tiles of `tile` px (or wider) fit `width` px with `gap` px between. */
export function fitting(width: number, tile: number, gap: number): number {
  assert(tile > 0, `a tile ${tile} px wide`)
  return Math.max(1, Math.floor((width + gap) / (tile + gap)))
}

/** How many tiles `span` ms can be cut into before two show one still. */
export function distinct(span: number, grain: number): number {
  assert(grain > 0, `a grain of ${grain} ms`)
  return Math.max(1, Math.floor(span / grain + 1e-9))
}

/** Tiles across (and down) a face `width` px wide over `span` ms. */
export function faceSide(
  width: number,
  tile: number,
  gap: number,
  span: number,
  grain: number,
): number {
  return Math.max(
    1,
    Math.min(
      fitting(width, tile, gap),
      Math.floor(Math.sqrt(distinct(span, grain))),
    ),
  )
}

/** Tiles in a strip `width` px wide over `span` ms. */
export function stripCount(
  width: number,
  tile: number,
  gap: number,
  span: number,
  grain: number,
): number {
  return Math.min(fitting(width, tile, gap), distinct(span, grain))
}

/** The spans `count` tiles cut `view` into (see the header). Throws when the source
 *  cannot tell that many apart: the count is decided by `distinct` first. */
export function tilesOf(
  view: Span,
  count: number,
  place: (at: number) => number,
): Span[] {
  assert(view.to > view.from, `a view of ${view.from}..${view.to}`)
  assert(Number.isInteger(count) && count >= 1, `${count} tiles`)
  const share = (view.to - view.from) / count
  const edges: number[] = []
  for (let k = 0; k < count; k++) edges.push(place(view.from + k * share))
  edges.push(view.to)
  for (let k = 1; k < edges.length; k++)
    assert(
      (edges[k] as number) > (edges[k - 1] as number),
      `${count} tiles over ${view.from}..${view.to}: tiles ${k - 1} and ${k} start on one still (fewer tiles: distinct())`,
    )
  return edges
    .slice(0, -1)
    .map((from, k) => ({ from, to: edges[k + 1] as number }))
}

/** The tile holding `at`, or -1 outside them. */
export function tileAt(tiles: readonly Span[], at: number): number {
  let lo = 0
  let hi = tiles.length - 1
  while (lo <= hi) {
    const mid = (lo + hi) >> 1
    const t = tiles[mid] as Span
    if (at < t.from) hi = mid - 1
    else if (at >= t.to) lo = mid + 1
    else return mid
  }
  return -1
}

/** A tile's start as a person reads it: the clock (`1:04`) when tiles are a second or
 *  longer, with the tenths or hundredths a shorter tile needs to tell it from its
 *  neighbour (`1:04.25`). */
export function startLabel(at: number, span: number): string {
  if (span >= 1000) return clockTime(at)
  const digits = span >= 100 ? 1 : span >= 10 ? 2 : 3
  // Rounded to the digits shown first, so 18.998 s reads 0:19.0, never 0:18.0.
  const unit = 10 ** (3 - digits)
  const shown = Math.round(at / unit) * unit
  const whole = Math.floor(shown / 1000) * 1000
  return `${clockTime(whole)}${((shown - whole) / 1000).toFixed(digits).slice(1)}`
}

/** A span as a person reads it, for a tile's name: "1:04 to 1:10". */
export function spanWords(span: Span): string {
  const length = span.to - span.from
  return `${startLabel(span.from, length)} to ${startLabel(span.to, length)}`
}
film-zoom-core.ts
// A FILM YOU ZOOM INTO, as state: the tiles a wall shows, which span each one is, and
// how a zoom, a page and a playhead move them. Framework-free; ui/film-wall draws it.
//
// A LEVEL is a contact sheet of the whole film at one zoom: tiles of one length
// (`span`, never under the grain), tile 0 at `origin`, laid in rows of `cols` from
// there, before and after, from the film's first instant to its last. The wall shows
// `rows` rows of it, `cols` × `rows` tiles (a square grid in a box of the film's own
// shape keeps every tile that shape); `top` says which rows, fractional while the sheet
// is scrolled part of a row. What the wall shows is the VIEW (lib/timeline-view's
// `View`, CLAUDE.md's TimeAxis): the window of time those rows hold.
//
// A tile's edges are PLACED on the stills the film has (`place`: the still at or
// before), so each tile starts on a picture of its own first instant and no two tiles
// start on one still; which tile holds an instant is read off the placed edges, never
// the raw arithmetic (a tile of 12.112 s placed on seconds starts at 96 s, not 96.9).
// With no stills, `place` is the identity and the grain the shortest a tile may be.
//
//   zoom in   on a tile: its span becomes the next level's whole wall, its tiles each
//             that share of it, the first starting where the tile did (so the picture
//             zoomed into is the first one of the level it opens); the level zoomed out
//             of is kept on the TRAIL. At the grain (a tile is one still) there is
//             nothing finer and the zoom refuses; a tile shorter than a wall of stills
//             opens the grain's level around it instead.
//   zoom out  back up the trail: the level above, scrolled so the part of the film in
//             hand is where the tile it came from was, the focus on the tile holding it.
//   scroll    the sheet slides by any length of time (a page is the view's length),
//             whole rows or part of one; it stops at the film's ends: the first row
//             holding the first instant at the top, the last row holding the last at the
//             bottom.
//   follow    a playhead that leaves the wall pages the sheet to it.
//   move      the tile in hand by tiles and rows; past the wall's edge the sheet scrolls
//             the row in.
//
// The grid is the caller's (`cols`, `rows`), never more tiles than the whole film tells
// apart at the grain (`gridFit`). Whole ms, as every time here.

import type { Span } from "@/registry/base-nova/lib/clip"
import type { View } from "@/registry/base-nova/lib/timeline-view"

export interface Level {
  /** Where tile 0 starts, before placing, ms. */
  origin: number
  /** One tile's length, ms: never under the grain. */
  span: number
}

/** A level zoomed out of: the level, where its sheet was scrolled, and the tile the
 *  zoom went into. */
export interface Step {
  level: Level
  top: number
  index: number
}

/** Tiles across the wall, and rows down it. */
export interface Grid {
  cols: number
  rows: number
}

export interface FilmZoom extends Grid {
  /** The film's length, ms. */
  duration: number
  /** The finest step the stills tell apart, ms: no tile is shorter. */
  grain: number
  /** The stills' placement: the instant of the still at or before an instant. */
  place: (at: number) => number
  level: Level
  /** The first row shown, in rows from the origin's (fractional: part scrolled). */
  top: number
  /** The way out, outermost first. */
  trail: readonly Step[]
  /** The tile in hand, by its index on the level. */
  focus: number
}

/** A tile the wall shows: its index on the level, its place on the wall (`row` may be
 *  negative or fractional: the sheet is scrolled), and its span, placed. */
export interface ShownTile {
  index: number
  row: number
  column: number
  span: Span
}

function assert(ok: unknown, why: string): asserts ok {
  if (!ok) throw new Error(`film-zoom: ${why}`)
}

/** Floors that survive a float's last bit (12.000000001 rows is row 12). */
const floorOf = (x: number) => Math.floor(x + 1e-9)

const assertGrid = ({ cols, rows }: Grid) =>
  assert(
    Number.isInteger(cols) && Number.isInteger(rows) && cols >= 1 && rows >= 1,
    `a grid of ${cols} × ${rows}`,
  )

/** The grid asked for, held to what the whole film fills with tiles at least a grain
 *  long: shrunk in its own proportions when the film is too short for it. */
export function gridFit(duration: number, grain: number, grid: Grid): Grid {
  assertGrid(grid)
  const most = duration / grain + 1e-9
  const count = grid.cols * grid.rows
  if (count <= most) return grid
  const k = Math.sqrt(most / count)
  return {
    cols: Math.max(1, Math.floor(grid.cols * k)),
    rows: Math.max(1, Math.floor(grid.rows * k)),
  }
}

/** The tiles a wall shows at once. */
const countOf = (g: Grid) => g.cols * g.rows

/** The whole film on one wall: every tile from its first instant. */
function whole(duration: number, grid: Grid): Level {
  return { origin: 0, span: duration / countOf(grid) }
}

/** The film at the top level, on a wall of `grid` (held to what the film tells apart
 *  at the stills' grain). */
export function filmZoom({
  duration,
  grain,
  place,
  grid,
}: {
  duration: number
  grain: number
  place: (at: number) => number
  grid: Grid
}): FilmZoom {
  assert(duration > 0, `a film of ${duration} ms`)
  assert(grain > 0 && Number.isFinite(grain), `a grain of ${grain} ms`)
  const fit = gridFit(duration, grain, grid)
  return {
    duration,
    grain,
    place,
    ...fit,
    level: whole(duration, fit),
    top: 0,
    trail: [],
    focus: 0,
  }
}

/** One row's length on the level, ms. */
const rowLength = (z: FilmZoom) => z.cols * z.level.span

/** A raw edge of the level: where tile `index` starts before placing. */
const rawEdge = (level: Level, index: number) =>
  level.origin + index * level.span

/** The first and last tile on the level: the ones holding a grain of the film past its
 *  first instant and before its last. A sliver before (after) them, under a grain of
 *  film, would start and end on one still; the first (last) tile takes it in, from
 *  the film's first instant (to its last). */
export function tileRange(z: FilmZoom): { first: number; last: number } {
  const within = z.grain * (1 - 1e-6)
  return {
    first: floorOf((within - z.level.origin) / z.level.span),
    last: floorOf((z.duration - within - z.level.origin) / z.level.span),
  }
}

/** Tile `index`'s span, placed (see the header), the film's first and last instants
 *  closing the first and the last tile. Null outside the film. */
export function tileOf(z: FilmZoom, index: number): Span | null {
  const { first, last } = tileRange(z)
  if (index < first || index > last) return null
  const from = index === first ? 0 : z.place(rawEdge(z.level, index))
  const to = index === last ? z.duration : z.place(rawEdge(z.level, index + 1))
  assert(
    to > from,
    `tile ${index} of ${z.level.span} ms tiles starts and ends on one still: the level is finer than the stills`,
  )
  return { from, to }
}

/** The tile holding `at`, by its placed edges, held to the film's tiles. */
export function tileIndexAt(z: FilmZoom, at: number): number {
  const { first, last } = tileRange(z)
  const raw = floorOf((at - z.level.origin) / z.level.span)
  // A placed edge lies within a still of its raw one, before it (a still at or before)
  // or a hair after (a frame's name is the first whole ms inside it): an instant on
  // the far side of a placed edge from its raw one is the neighbour's.
  const i =
    at < z.place(rawEdge(z.level, raw))
      ? raw - 1
      : at >= z.place(rawEdge(z.level, raw + 1))
        ? raw + 1
        : raw
  return Math.min(last, Math.max(first, i))
}

/** The row a tile lies in, rows from the origin's (negative before it). */
export const rowOf = (z: FilmZoom, index: number) => Math.floor(index / z.cols)

/** The column a tile lies in. */
export const columnOf = (z: FilmZoom, index: number) =>
  ((index % z.cols) + z.cols) % z.cols

/** How far the sheet scrolls: the first row holding the film's first instant at the
 *  top, the last row holding its last at the bottom (or the first at the top, for a
 *  film shorter than the wall at this level). */
export function topLimits(z: FilmZoom): { min: number; max: number } {
  const { first, last } = tileRange(z)
  const min = rowOf(z, first)
  return { min, max: Math.max(min, rowOf(z, last) - z.rows + 1) }
}

const clampTop = (z: FilmZoom, top: number) => {
  const { min, max } = topLimits(z)
  return Math.min(max, Math.max(min, top))
}

/** The rows the wall shows whole. */
const wholeRows = (z: FilmZoom) => ({
  from: Math.ceil(z.top - 1e-9),
  to: Math.floor(z.top + z.rows + 1e-9) - 1,
})

/** The window of time the wall shows: its rows, held to the film (a view showing the
 *  film's first or last tile starts or ends exactly there, whatever a float's last bit
 *  says). */
export function viewOf(z: FilmZoom): View {
  const row = rowLength(z)
  const { first, last } = tileRange(z)
  return {
    from:
      z.top <= rowOf(z, first) + 1e-9
        ? 0
        : Math.max(0, z.level.origin + z.top * row),
    to:
      z.top + z.rows >= rowOf(z, last) + 1 - 1e-9
        ? z.duration
        : Math.min(z.duration, z.level.origin + (z.top + z.rows) * row),
  }
}

/** Every tile the wall shows (the rows it shows, a part-scrolled one at each edge),
 *  in reading order. */
export function shownTiles(z: FilmZoom): ShownTile[] {
  const out: ShownTile[] = []
  const firstRow = Math.floor(z.top)
  const lastRow = Math.ceil(z.top + z.rows) - 1
  for (let r = firstRow; r <= lastRow; r++)
    for (let c = 0; c < z.cols; c++) {
      const index = r * z.cols + c
      const span = tileOf(z, index)
      if (span) out.push({ index, row: r - z.top, column: c, span })
    }
  return out
}

/** Whether a zoom into a tile can show anything finer: its tiles are longer than a
 *  grain, and the wall is more than one tile. */
export const canZoomIn = (z: FilmZoom) =>
  countOf(z) > 1 && z.level.span > z.grain * (1 + 1e-9)

export const canZoomOut = (z: FilmZoom) => z.trail.length > 0

/** Whether the sheet can page by `by` views (later when positive): it is not at that
 *  end of the film. */
export function canPage(z: FilmZoom, by: 1 | -1): boolean {
  const { min, max } = topLimits(z)
  return by > 0 ? z.top < max - 1e-9 : z.top > min + 1e-9
}

/** Into tile `index`: its span the next level's wall (see the header). Unchanged when
 *  nothing finer can be shown, or the tile holds none of the film. */
export function zoomIn(z: FilmZoom, index: number): FilmZoom {
  if (!canZoomIn(z)) return z
  const tile = tileOf(z, index)
  if (!tile) return z
  const next: FilmZoom = {
    ...z,
    level: {
      origin: tile.from,
      span: Math.max(z.grain, (tile.to - tile.from) / countOf(z)),
    },
    top: 0,
    trail: [...z.trail, { level: z.level, top: z.top, index }],
    focus: 0,
  }
  return {
    ...next,
    top: clampTop(next, 0),
    focus: tileIndexAt(next, tile.from),
  }
}

/** Out to the level above (see the header). Unchanged at the top. */
export function zoomOut(z: FilmZoom): FilmZoom {
  const step = z.trail[z.trail.length - 1]
  if (!step) return z
  const trail = z.trail.slice(0, -1)
  // The top level is the whole film on one wall, whatever the wall's grid is now.
  const level = trail.length === 0 ? whole(z.duration, z) : step.level
  const held = tileOf(z, z.focus)?.from ?? viewOf(z).from
  const up: FilmZoom = { ...z, level, trail, top: 0, focus: 0 }
  const index = tileIndexAt(up, held)
  // Where the tile zoomed into stood on the wall, the tile in hand stands now.
  const stood = rowOf(up, step.index) - step.top
  return {
    ...up,
    top: clampTop(up, rowOf(up, index) - stood),
    focus: index,
  }
}

/** Out to the whole film. */
export function zoomHome(z: FilmZoom): FilmZoom {
  let out = z
  while (canZoomOut(out)) out = zoomOut(out)
  return out
}

/** The tile in hand kept on the wall: moved to its column in the nearest row the wall
 *  shows whole. */
function keepFocus(z: FilmZoom): FilmZoom {
  const r = rowOf(z, z.focus)
  const rows = wholeRows(z)
  if (r >= rows.from && r <= rows.to) return z
  const row = Math.min(rows.to, Math.max(rows.from, r))
  const { first, last } = tileRange(z)
  const focus = Math.min(
    last,
    Math.max(first, row * z.cols + columnOf(z, z.focus)),
  )
  return { ...z, focus }
}

/** The sheet slid by `ms` (later when positive), stopping at the film's ends; the tile
 *  in hand stays on the wall. */
export function scroll(z: FilmZoom, ms: number): FilmZoom {
  const top = clampTop(z, z.top + ms / rowLength(z))
  return top === z.top ? z : keepFocus({ ...z, top })
}

/** The sheet slid by `by` views: the next (previous) stretch at the same zoom, stopping
 *  at the film's ends. */
export const page = (z: FilmZoom, by: 1 | -1): FilmZoom =>
  scroll(z, by * z.rows * rowLength(z))

/** Whether `at` is on the wall, in a row it shows whole. */
export function onStage(z: FilmZoom, at: number): boolean {
  const r = rowOf(z, tileIndexAt(z, at))
  const rows = wholeRows(z)
  return r >= rows.from && r <= rows.to
}

/** A playhead at `at` kept on the wall: past the last row the sheet pages forward to
 *  its row at the top, before the first back to its row at the bottom. */
export function follow(z: FilmZoom, at: number): FilmZoom {
  if (onStage(z, at)) return z
  const r = rowOf(z, tileIndexAt(z, at))
  const top = clampTop(z, r < z.top ? r - z.rows + 1 : r)
  return keepFocus({ ...z, top })
}

/** `at` kept on the wall at this zoom (paged to, as `follow` does), the focus on the tile
 *  holding it: where a player handing the wall back had got to. */
export function hold(z: FilmZoom, at: number): FilmZoom {
  const held = follow(z, at)
  return { ...held, focus: tileIndexAt(held, at) }
}

/** `at` brought onto the wall, its row in the middle, the focus on its tile (a press
 *  on the overview). */
export function reveal(z: FilmZoom, at: number): FilmZoom {
  const index = tileIndexAt(z, Math.min(z.duration - 1, Math.max(0, at)))
  return {
    ...z,
    top: clampTop(z, rowOf(z, index) - Math.floor((z.rows - 1) / 2)),
    focus: index,
  }
}

/** The tile in hand moved by `dx` tiles and `dy` rows, held to the film; past the
 *  wall's edge the sheet scrolls the least that shows its row whole. */
export function move(z: FilmZoom, dx: number, dy: number): FilmZoom {
  const { first, last } = tileRange(z)
  const focus = Math.min(last, Math.max(first, z.focus + dx + dy * z.cols))
  const r = rowOf(z, focus)
  const rows = wholeRows(z)
  const top = r < rows.from ? r : r > rows.to ? r - z.rows + 1 : z.top
  return { ...z, focus, top: clampTop(z, top) }
}

/** The tile in hand moved to the film's first or last tile. */
export function moveTo(z: FilmZoom, end: "first" | "last"): FilmZoom {
  const { first, last } = tileRange(z)
  return move(z, (end === "first" ? first : last) - z.focus, 0)
}

/** The wall now `grid`: the top level stays the whole film; a deeper one keeps its
 *  tiles and shows the same instant first. */
export function regrid(z: FilmZoom, grid: Grid): FilmZoom {
  const fit = gridFit(z.duration, z.grain, grid)
  if (fit.cols === z.cols && fit.rows === z.rows) return z
  const held = tileOf(z, z.focus)?.from ?? 0
  if (z.trail.length === 0) {
    const sized: FilmZoom = { ...z, ...fit, level: whole(z.duration, fit) }
    return { ...sized, top: 0, focus: tileIndexAt(sized, held) }
  }
  const from = viewOf(z).from
  const sized: FilmZoom = { ...z, ...fit }
  const top = clampTop(
    sized,
    (from - z.level.origin) / (fit.cols * z.level.span),
  )
  return keepFocus({ ...sized, top, focus: tileIndexAt(sized, held) })
}

/** Stills of another source (a finer one arrived: a decoder done timing the film):
 *  nothing moves; a tile now shorter than the grain grows to it. */
export function restill(
  z: FilmZoom,
  { grain, place }: { grain: number; place: (at: number) => number },
): FilmZoom {
  assert(grain > 0 && Number.isFinite(grain), `a grain of ${grain} ms`)
  if (grain === z.grain && place === z.place) return z
  return {
    ...z,
    grain,
    place,
    level: { ...z.level, span: Math.max(grain, z.level.span) },
  }
}