pictures of a film at any instant: frames prepared ahead, or decoded on the page
npx shadcn add @ag/film-stillsAlready installed? Preview an update
npx shadcn add @ag/film-stills --dry-runYour installed source stays unchanged until you update it. How updates work
- New: pictures of a film at any instant. A
StillSourcesays its grain (the finest step it tells apart, ms) and places an instant on the still it has at or before it:frameStills(asset.frames)names prepared frames at once (a media asset's, prepare-media's, a CDN's),addressStills(grain, last, address)any addressable tier,useSeekStills(src, { fps?, width })decodes on the page (one hidden video seeked to each instant, one at a time; given nofpsit times the film's frames).useStills([coarse, fine])is the cache over a ladder: the prepared still paints at once, the decoded frame replaces it; what is on screen is asked first, a still under way is never stopped, a still refused or broken is not asked again and its stand-in stays.StillPicturedraws the best one. The cache is framework-free (lib/film-stills-core).
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import {
frameStills,
type StillSource,
type Stills,
} from "@/registry/base-nova/lib/film-stills-core"
import { startLabel } from "@/registry/base-nova/lib/film-tiles"
import { frameAt, frameTime } from "@/registry/base-nova/lib/timebase"
import {
type Still,
StillPicture,
useSeekStills,
useStills,
} from "@/registry/base-nova/ui/film-stills"
// #region interview
// Walter Cronkite questions Buzz Aldrin, 5 July 1969 (NASA, public domain): 109 s at
// 29.97 fps, its stills prepared at every second (lib/prepare-media's `frames`).
const INTERVIEW = {
src: "/apollo-11/cronkite-aldrin.mp4",
duration: 109_009,
aspect: 626 / 476,
fps: 30000 / 1001,
frames: {
every: 1,
count: 109,
src: "/gallery-stills/cronkite-aldrin/{i}.jpg",
},
}
/** Made once: a new source each render would be a new cache each render. */
const SECONDS = frameStills(INTERVIEW.frames)
// #endregion
// #region row
/** Stills of `stills` at `ats`, in a row, each with its instant and what it shows. */
function Row({ stills, ats }: { stills: Stills<Still> | null; ats: number[] }) {
React.useEffect(() => stills?.want(ats), [stills, ats])
return (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{ats.map((at) => (
<figure key={at} className="m-0 flex flex-col gap-1">
<span className="relative block aspect-[626/476] overflow-hidden rounded-md bg-foreground/[0.07]">
{stills && <StillPicture stills={stills} at={at} />}
</span>
<figcaption className="flex justify-between font-mono text-[11px] text-foreground/60 tabular-nums">
<span>{startLabel(at, 100)}</span>
{stills && <Status stills={stills} at={at} />}
</figcaption>
</figure>
))}
</div>
)
}
/** What a still shows now: its own, a stand-in, nothing yet, or never. */
function Status({ stills, at }: { stills: Stills<Still>; at: number }) {
const status = React.useSyncExternalStore(
stills.subscribe,
() => stills.status(at),
() => stills.status(at),
)
return <span data-status={status}>{status}</span>
}
// #endregion
const ON_SECONDS = [0, 12_000, 33_000, 61_000, 77_000, 96_000]
const OFF_SECONDS = [12_412, 33_220, 60_800, 76_500, 96_934, 108_400]
function Prepared() {
const stills = useStills([SECONDS])
return <Row stills={stills} ats={ON_SECONDS} />
}
function Decoded() {
const decoded = useSeekStills(INTERVIEW.src, {
fps: INTERVIEW.fps,
width: 360,
})
const stills = useStills([decoded.source])
return <Row stills={stills} ats={OFF_SECONDS} />
}
function Ladder() {
const decoded = useSeekStills(INTERVIEW.src, {
fps: INTERVIEW.fps,
width: 360,
})
const stills = useStills([SECONDS, decoded.source])
return <Row stills={stills} ats={OFF_SECONDS} />
}
// #region file
/** A file from your disk: no frames prepared, no rate known. The decoder times its
* frames, then seeks to each instant. */
function YourFile() {
const [file, setFile] = React.useState<{
src: string
duration: number
} | null>(null)
React.useEffect(
() => () => {
if (file) URL.revokeObjectURL(file.src)
},
[file],
)
const decoded = useSeekStills(file?.src ?? null, { width: 360 })
const stills = useStills([decoded.source])
const source = decoded.source
const ats = React.useMemo(
() =>
source && file
? [0, 1, 2, 3, 4, 5].map((k) => source.place((file.duration * k) / 6))
: [],
[source, file],
)
return (
<div className="flex flex-col gap-3">
<label className="flex flex-wrap items-center gap-2 text-[13px]">
<span>a video file</span>
<input
type="file"
accept="video/*"
className="text-[12px]"
onChange={(e) => {
const picked = e.currentTarget.files?.[0]
if (!picked) return
const src = URL.createObjectURL(picked)
// Its length, read off its metadata before anything is decoded.
const probe = document.createElement("video")
probe.preload = "metadata"
probe.src = src
probe.onloadedmetadata = () =>
setFile({ src, duration: probe.duration * 1000 })
}}
/>
</label>
<p className="m-0 font-mono text-[11px] text-foreground/60">
{!file
? "nothing picked"
: decoded.failed
? decoded.failed
: source
? `${(1000 / source.grain).toFixed(3)} fps, measured: a still a frame`
: "timing its frames…"}
</p>
{source && <Row stills={stills} ats={ats} />}
</div>
)
}
// #endregion
// #region refused
/** A stand-in for a server's still tier, making a frame on request: slowly, and
* refusing the ones past the minute. The prepared second stands in for those, marked
* failed, and they are never asked again. */
const TIER: StillSource<string> = {
grain: 1000 / INTERVIEW.fps,
place: (at) =>
frameTime(frameAt(Math.max(0, at), INTERVIEW.fps), INTERVIEW.fps),
parallel: 2,
still: (at) =>
new Promise((made, refused) =>
setTimeout(
() =>
at > 60_000
? refused(new Error(`no frame at ${at} ms`))
: made(
`/gallery-stills/cronkite-aldrin/${Math.floor(at / 1000)}.jpg`,
),
600,
),
),
}
// #endregion
function Refused() {
const stills = useStills([SECONDS, TIER])
return <Row stills={stills} ats={OFF_SECONDS} />
}
export default function FilmStillsDemo() {
return (
<div className="space-y-8">
<Sample
name="prepared"
glance="wide"
title="prepared, a still a second"
description="Frames cut when the file was prepared (prepare-media's frames, a media asset's), named at once: the browser fetches only the ones on screen."
with="interview row"
>
<Prepared />
</Sample>
<Sample
name="decoded"
title="decoded on the page"
description="One hidden video seeked to each instant, one at a time, its frame kept: any instant, to the frame, with nothing prepared."
with="interview row"
>
<Decoded />
</Sample>
<Sample
name="ladder"
title="prepared, then exact"
description="Instants between the seconds: the prepared second stands in at once, the decoded frame replaces it when it lands."
with="interview row"
>
<Ladder />
</Sample>
<Sample
name="your-file"
title="a file from your disk"
description="No frames prepared and no rate known: the decoder times the frames the browser presents, then seeks. Nothing is uploaded."
with="row file"
>
<YourFile />
</Sample>
<Sample
name="refused"
title="a tier that refuses some"
description="A stand-in for a server making frames on request, slowly, refusing the ones past the minute: those keep their prepared second and say failed."
with="interview row refused"
>
<Refused />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// Stills of a film on a page: the cache of lib/film-stills-core held for a component's
// life, the DECODER that makes a still where none was prepared (one hidden video,
// seeked to each instant asked, its frame kept as a bitmap), and the picture a tile
// draws. Where stills come from, and why, is lib/film-stills-core's header and
// film-stills.notes.md.
//
// const decoded = useSeekStills("/film.mp4", { width: 480 })
// const stills = useStills([frameStills(asset.frames), decoded.source])
// <StillPicture stills={stills} at={12_000} />
//
// A decoder needs the film's frame rate to know its grain (a frame); given none it
// MEASURES it, playing the hidden video muted for a moment and timing the frames the
// browser presents, as a video timebase does (lib/timebase `measureRate`): a file
// picked from a disk says its own rate. A video on another origin decodes too: its
// frames are drawn, never read back, so no CORS is needed.
import * as React from "react"
import { cn } from "@/lib/utils"
import {
createStills,
type StillSource,
type Stills,
} from "@/registry/base-nova/lib/film-stills-core"
import {
frameAt,
frameTime,
measureRate,
msOf,
seekSeconds,
} from "@/registry/base-nova/lib/timebase"
import { Image } from "@/registry/base-nova/ui/image"
/** A still as a page holds it: an address for an <img>, or a bitmap decoded here. */
export type Still = string | ImageBitmap
/** How long the decoder plays a video to time its frames before it gives up, ms: long
* enough for `measureRate`'s eight gaps at the slowest rate it accepts. */
const MEASURE_MS = 3000
/** The cache over `sources` (coarse to fine; a null rung, a decoder still measuring,
* is left out until it is ready), made when the ladder changes and disposed with the
* component. `keep`: pictures each rung holds beyond the ones on screen. A stable
* list of sources (memoized, or each made once) keeps the cache and its pictures. */
export function useStills(
sources: readonly (StillSource<Still> | null)[],
{ keep = 64 }: { keep?: number } = {},
): Stills<Still> | null {
const ladder = sources.filter((s): s is StillSource<Still> => s !== null)
// The ladder as its sources' identities: a new cache only for new sources.
const key = ladder.map(identity).join(" ")
const current = React.useRef(ladder)
current.current = ladder
const [stills, setStills] = React.useState<Stills<Still> | null>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: the ladder is read through its key
React.useEffect(() => {
if (current.current.length === 0) return
const made = createStills(current.current, { keep })
setStills(made)
return () => {
setStills(null)
made.dispose()
}
}, [key, keep])
return stills
}
const identities = new WeakMap<object, number>()
let identified = 0
/** A source's identity for a dependency list: one number per object, for its life. */
function identity(source: object): number {
const known = identities.get(source)
if (known !== undefined) return known
identities.set(source, ++identified)
return identified
}
/** A decoder's state: measuring the film (`source` null), ready, or failed (the file
* would not load, or its frames could not be timed). */
export interface SeekStills {
source: StillSource<Still> | null
failed: string | null
}
/** The decoder: one hidden video seeked to each instant asked, one at a time, its
* frame kept as a bitmap as wide as `width` CSS px on this display (the widest a tile
* draws it). `fps` when the film's rate is known; else it is measured (see the
* header). Null `src` makes nothing. */
export function useSeekStills(
src: string | null,
{ fps, width }: { fps?: number; width: number },
): SeekStills {
const [state, setState] = React.useState<SeekStills>({
source: null,
failed: null,
})
React.useEffect(() => {
setState({ source: null, failed: null })
if (!src) return
const resize = Math.max(1, Math.round(width * window.devicePixelRatio))
const v = document.createElement("video")
v.muted = true
v.playsInline = true
v.preload = "auto"
v.src = src
let dead = false
const fail = (why: string) => {
if (!dead) setState({ source: null, failed: why })
}
const loaded = new Promise<void>((resolve, reject) => {
if (v.readyState >= 1) return resolve()
v.addEventListener("loadedmetadata", () => resolve(), { once: true })
v.addEventListener(
"error",
() => reject(new Error(`${src} did not load`)),
{
once: true,
},
)
})
/** The film's rate: given, or timed off the frames it presents playing at 1×. */
const rate = async (): Promise<number> => {
if (fps) return fps
const gaps: number[] = []
let last: number | null = null
let handle = 0
const timed = new Promise<number | null>((resolve) => {
const onFrame = (_: number, meta: VideoFrameCallbackMetadata) => {
const at = meta.mediaTime * 1000
if (last !== null && at > last) gaps.push(at - last)
last = at
const measured = measureRate(gaps)
if (measured) return resolve(measured)
handle = v.requestVideoFrameCallback(onFrame)
}
handle = v.requestVideoFrameCallback(onFrame)
window.setTimeout(() => resolve(null), MEASURE_MS)
})
await v.play()
const measured = await timed
v.cancelVideoFrameCallback(handle)
v.pause()
if (!measured)
throw new Error(`${src}: its frames could not be timed to a rate`)
return measured
}
loaded
.then(rate)
.then((measured) => {
if (dead) return
const duration = msOf(v.duration)
// The last frame is the one at or before its end.
const last = frameTime(
frameAt(Math.max(0, duration - 1), measured),
measured,
)
let busy: Promise<unknown> = Promise.resolve()
const source: StillSource<Still> = {
grain: 1000 / measured,
place: (at) =>
frameTime(
frameAt(Math.min(last, Math.max(0, at)), measured),
measured,
),
parallel: 1,
// One seek at a time, chained: the element is one decoder.
still: (at, signal) => {
const made = busy.then(async () => {
signal.throwIfAborted()
await new Promise<void>((resolve) => {
v.addEventListener("seeked", () => resolve(), { once: true })
v.currentTime = seekSeconds(at)
})
signal.throwIfAborted()
return createImageBitmap(v, {
resizeWidth: resize,
resizeQuality: "medium",
})
})
busy = made.catch(() => {})
return made
},
release: (picture) => {
if (typeof picture !== "string") picture.close()
},
}
setState({ source, failed: null })
})
.catch((e: unknown) => fail(e instanceof Error ? e.message : String(e)))
return () => {
dead = true
// A detached element keeps its media: release it (the spec's own procedure).
v.pause()
v.removeAttribute("src")
v.load()
}
}, [src, fps, width])
return state
}
/** The best picture the cache has of `at`, filling its box (object-cover): an address
* as an <img> that fades in once, a bitmap drawn on a canvas. Nothing yet: the box's
* own ground shows. Wears `data-status` (shown, standing-in, loading, failed). */
export function StillPicture({
stills,
at,
className,
}: {
stills: Stills<Still>
at: number
className?: string
}) {
const read = () => stills.shown(at)
const shown = React.useSyncExternalStore(stills.subscribe, read, read)
const status = React.useSyncExternalStore(
stills.subscribe,
() => stills.status(at),
() => stills.status(at),
)
const picture = shown?.picture
const box = cn(
"pointer-events-none absolute inset-0 size-full object-cover",
className,
)
if (picture === undefined)
return <span aria-hidden data-slot="still-picture" data-status={status} />
if (typeof picture === "string")
return (
<Image
data-slot="still-picture"
data-status={status}
src={picture}
alt=""
draggable={false}
decoding="async"
className={box}
onError={() => {
if (shown) stills.broken(shown)
}}
/>
)
return <BitmapPicture bitmap={picture} status={status} className={box} />
}
/** A decoded still on a canvas the bitmap's own size, scaled by CSS like an image. */
function BitmapPicture({
bitmap,
status,
className,
}: {
bitmap: ImageBitmap
status: string
className: string
}) {
const canvas = React.useRef<HTMLCanvasElement>(null)
React.useLayoutEffect(() => {
const c = canvas.current
if (!c) return
c.width = bitmap.width
c.height = bitmap.height
c.getContext("2d")?.drawImage(bitmap, 0, 0)
}, [bitmap])
return (
<canvas
ref={canvas}
aria-hidden
data-slot="still-picture"
data-status={status}
className={className}
/>
)
}// STILLS OF A FILM: a picture of it at an instant, from wherever pictures come from, and
// the one cache that asks for them in the order a page needs them. Framework-free;
// ui/film-stills adds the decoder that seeks a video on the page.
//
// WHERE A STILL COMES FROM is a `StillSource`, and it says three things about itself:
//
// grain the finest step between two stills it can tell apart, ms: a prepared
// frame every second is 1000, a decoder seeking a 25 fps video is 40. Below
// its grain two tiles would show the same picture, so a view is never cut
// finer than this (lib/film-tiles, lib/film-zoom-core).
// place the instant of the still it has at or before an instant, whole ms: the
// second a prepared frame was cut at, the name of a decoded frame. A tile
// starts on a placed instant, so the picture it shows is the first frame its
// span plays.
// still the picture at a placed instant: an address answered at once (a prepared
// frame, a CDN's frame), or a promise of a decoded one (`parallel` at a time,
// a decoder seeks one at a time).
//
// THE CACHE takes a LADDER of sources, coarse to fine: a prepared frame every second
// paints at once, the frame decoded for the exact instant replaces it when it lands
// (the ladder's promise: the content never swaps for another moment, it only sharpens).
// `want` says what is on screen, in the order it matters; what is not there yet is made
// in that order, the newest want replacing the queue. A still under way is never
// stopped for a newer want (a decoder stopped mid-seek has bought nothing, and a scrub
// that stops every seek never lands one): the staleness is one still, bounded. A still
// that fails is not asked again; its tile shows what stands in for it, or nothing.
/** Where pictures of a film come from (see the header). `P` is what a picture is: an
* address for an <img>, a decoded bitmap for a canvas. */
export interface StillSource<P = string> {
/** The finest step between two stills it tells apart, ms. */
grain: number
/** The instant of the still it has at or before `at`, whole ms. */
place: (at: number) => number
/** The still at a placed instant: at once, or a promise. `signal` aborts when the
* cache is disposed. */
still: (at: number, signal: AbortSignal) => P | Promise<P>
/** How many stills it makes at once. Default: every one asked (an address costs
* nothing to name; the browser fetches what it shows). */
parallel?: number
/** Gives back what a picture holds (a bitmap's memory) when it leaves the cache.
* (A method: an address source, which releases nothing, stands in a ladder of
* wider pictures.) */
release?(picture: P): void
}
/** A picture the cache holds: what it is, which rung of the ladder made it (0 the
* coarsest), and the placed instant it shows. One object per picture, so a reader
* comparing what it got with what it had sees a change only when there is one. */
export interface Shown<P> {
picture: P
rung: number
at: number
}
export type StillStatus = "shown" | "standing-in" | "loading" | "failed"
export interface Stills<P> {
/** The finest grain of the ladder, ms. */
grain: number
/** The finest rung's placement: where a tile starts. */
place: (at: number) => number
/** The best picture of `at` now: the finest rung's own once made, else what a
* coarser rung has at or before it, else null. */
shown: (at: number) => Shown<P> | null
/** Whether `at` shows its own still, a stand-in, nothing yet, or never will. */
status: (at: number) => StillStatus
/** The instants a page shows, the first first: what is missing is made in that order
* (each rung its own queue), a newer want replacing what is still queued. */
want: (ats: readonly number[]) => void
/** A picture that would not draw (a named still whose file is not there): it
* fails as a still that could not be made does, and what stands in shows. Said by
* whoever drew it, since only a drawing learns that an address answers nothing. */
broken: (shown: Shown<P>) => void
subscribe: (listener: () => void) => () => void
/** Moves on every change: a still landed, one failed. */
version: () => number
/** Stops what is under way and gives back every picture. */
dispose: () => void
}
function assert(ok: unknown, why: string): asserts ok {
if (!ok) throw new Error(`film-stills: ${why}`)
}
/** Floors that survive a float's last bit: 12 × (1000/30) / (1000/30) is 11.999…, and
* the still at or before it must still be the 12th. */
const floorOf = (x: number) => Math.floor(x + 1e-9)
/** A source that names its stills: `address(at)` for every placed instant on a grid of
* `grain` ms from 0, the last at `last` (inclusive). Prepared frames, an edge's frame
* at a second (media-cdn), a server's still tier. */
export function addressStills(
grain: number,
last: number,
address: (at: number) => string,
): StillSource<string> {
assert(grain > 0 && Number.isFinite(grain), `a grain of ${grain} ms`)
assert(last >= 0, `a last still at ${last} ms`)
const top = floorOf(last / grain)
return {
grain,
place: (at) =>
Math.round(Math.min(top, Math.max(0, floorOf(at / grain))) * grain),
still: (at) => address(at),
}
}
/** The frames a media asset was prepared with (lib/media-asset's `frames`: frame i the
* picture at i × every seconds, at `src` with `{i}` replaced): a still every `every`
* seconds, named at once. */
export function frameStills(frames: {
every: number
count: number
src: string
}): StillSource<string> {
assert(frames.src.includes("{i}"), `frames at ${frames.src} name no {i}`)
assert(frames.count >= 1, `${frames.count} frames`)
const grain = frames.every * 1000
return addressStills(grain, (frames.count - 1) * grain, (at) =>
frames.src.replace("{i}", String(Math.round(at / grain))),
)
}
/** A rung of the ladder: its source, the pictures it holds (in the order they were
* last asked for: a Map keeps insertion order), the ones it could not make, what is
* under way, and its queue. */
interface Rung<P> {
source: StillSource<P>
held: Map<number, Shown<P>>
failed: Set<number>
making: Set<number>
queue: number[]
wanted: Set<number>
}
/** The cache over a ladder of sources, coarse to fine (see the header). `keep` is how
* many pictures each rung holds beyond the ones wanted now; a wanted picture is never
* given back. */
export function createStills<P>(
sources: readonly StillSource<P>[],
{ keep }: { keep: number },
): Stills<P> {
assert(sources.length > 0, "a ladder of no sources")
assert(keep >= 0, `keep ${keep}`)
for (let i = 1; i < sources.length; i++)
assert(
(sources[i] as StillSource<P>).grain <=
(sources[i - 1] as StillSource<P>).grain,
"a ladder runs coarse to fine: each rung's grain at most the one before's",
)
const rungs: Rung<P>[] = sources.map((source) => ({
source,
held: new Map(),
failed: new Set(),
making: new Set(),
queue: [],
wanted: new Set(),
}))
const finest = rungs.length - 1
const fine = sources[finest] as StillSource<P>
const listeners = new Set<() => void>()
const abort = new AbortController()
let version = 0
const changed = () => {
version++
for (const l of listeners) l()
}
/** Over `keep` beyond what is wanted, the least recently asked leave. */
const trim = (rung: Rung<P>) => {
const room = keep + rung.wanted.size
for (const [at, shown] of rung.held) {
if (rung.held.size <= room) break
if (rung.wanted.has(at)) continue
rung.held.delete(at)
rung.source.release?.(shown.picture)
}
}
const land = (k: number, at: number, picture: P) => {
const rung = rungs[k] as Rung<P>
if (abort.signal.aborted) {
rung.source.release?.(picture)
return
}
rung.held.set(at, { picture, rung: k, at })
trim(rung)
}
const pump = (k: number) => {
const rung = rungs[k] as Rung<P>
const parallel = rung.source.parallel ?? Number.POSITIVE_INFINITY
let landed = false
while (rung.making.size < parallel && rung.queue.length > 0) {
const at = rung.queue.shift() as number
if (rung.held.has(at) || rung.failed.has(at) || rung.making.has(at))
continue
let made: P | Promise<P>
try {
made = rung.source.still(at, abort.signal)
} catch {
rung.failed.add(at)
landed = true
continue
}
if (!(made instanceof Promise)) {
land(k, at, made)
landed = true
continue
}
rung.making.add(at)
made.then(
(picture) => {
rung.making.delete(at)
land(k, at, picture)
if (abort.signal.aborted) return
changed()
pump(k)
},
() => {
rung.making.delete(at)
if (abort.signal.aborted) return
rung.failed.add(at)
changed()
pump(k)
},
)
}
if (landed) changed()
}
return {
grain: fine.grain,
place: fine.place,
shown: (at) => {
for (let k = finest; k >= 0; k--) {
const rung = rungs[k] as Rung<P>
const shown = rung.held.get(rung.source.place(at))
if (shown) return shown
}
return null
},
status: (at) => {
const own = fine.place(at)
const top = rungs[finest] as Rung<P>
if (top.held.has(own)) return "shown"
for (let k = finest - 1; k >= 0; k--) {
const rung = rungs[k] as Rung<P>
if (rung.held.has(rung.source.place(at)))
return top.failed.has(own) ? "failed" : "standing-in"
}
return top.failed.has(own) ? "failed" : "loading"
},
want: (ats) => {
assert(!abort.signal.aborted, "want after dispose")
for (let k = 0; k < rungs.length; k++) {
const rung = rungs[k] as Rung<P>
const placed = [...new Set(ats.map((at) => rung.source.place(at)))]
rung.wanted = new Set(placed)
// Asked again: to the back of the Map's order, the last to leave.
for (const at of placed) {
const shown = rung.held.get(at)
if (!shown) continue
rung.held.delete(at)
rung.held.set(at, shown)
}
rung.queue = placed.filter(
(at) =>
!rung.held.has(at) && !rung.failed.has(at) && !rung.making.has(at),
)
pump(k)
}
},
broken: (shown) => {
const rung = rungs[shown.rung]
if (!rung || rung.held.get(shown.at) !== shown) return
rung.held.delete(shown.at)
rung.failed.add(shown.at)
rung.source.release?.(shown.picture)
changed()
},
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
version: () => version,
dispose: () => {
abort.abort()
for (const rung of rungs) {
for (const shown of rung.held.values())
rung.source.release?.(shown.picture)
rung.held.clear()
rung.queue = []
}
listeners.clear()
},
}
}