{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-sequence",
  "type": "registry:component",
  "title": "Responsive Image Sequence",
  "description": "A responsive native-image sequence player with shared transfers, bounded decode readiness, and autoplay or scrub control.",
  "registryDependencies": [
    "@joyco/utils"
  ],
  "files": [
    {
      "path": "registry/components/image-sequence.tsx",
      "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { cn } from '@/lib/utils'\nimport {\n  resolveFrameSource,\n  SequenceDemandController,\n  type FrameSource,\n  type SequenceOptions,\n} from '@/registry/lib/image-sequence/runtime'\n\nexport type { FrameSource }\nexport type ImageSequenceProps = Omit<SequenceOptions, 'onFrame'> & {\n  sequenceId: string\n  poster: FrameSource\n  alt: string\n  className?: string\n  playing?: boolean\n  resetOnPause?: boolean\n  enabled?: boolean\n  target?: number\n  pixelated?: boolean\n  onFrame?: (index: number) => void\n}\n\n/** A standalone native-image player. Change sequenceId when replacing sources. */\nexport function ImageSequence({\n  sequenceId,\n  poster,\n  alt,\n  className,\n  playing = true,\n  resetOnPause = false,\n  enabled = true,\n  target,\n  pixelated = false,\n  onFrame,\n  onComplete,\n  frameCount,\n  source,\n  mode = 'autoplay',\n  frameDuration = 1000 / 24,\n  loop = true,\n  loopStart,\n  loopEnd,\n  initialFrame,\n  loadInitial = false,\n  priority = 'hybrid',\n  ahead = 7,\n  behind = 3,\n}: ImageSequenceProps) {\n  const rootRef = useRef<HTMLSpanElement>(null)\n  const imageRef = useRef<HTMLImageElement>(null)\n  const syncRef = useRef<(() => void) | null>(null)\n  const surfaceLease = useRef<(() => void) | undefined>(undefined)\n  const openingSurface = useRef<{\n    key: string\n    src: string\n    retain: () => () => void\n    release: () => void\n  } | null>(null)\n  // Opt-in hover previews start a fresh controller when resumed.\n  const restart = resetOnPause ? playing : null\n  const latest = useRef({\n    source,\n    poster,\n    playing,\n    resetOnPause,\n    enabled,\n    onFrame,\n    onComplete,\n    target,\n  })\n  // React owns the SSR poster once; the effect owns src/srcset afterwards. Parent\n  // renders must never overwrite a decoded surface with an undecoded candidate.\n  const [firstPoster] = useState(() =>\n    typeof poster === 'string' ? { src: poster } : poster\n  )\n\n  useEffect(() => {\n    latest.current = {\n      source,\n      poster,\n      playing,\n      resetOnPause,\n      enabled,\n      onFrame,\n      onComplete,\n      target,\n    }\n    syncRef.current?.()\n  })\n\n  useEffect(() => {\n    const root = rootRef.current\n    const image = imageRef.current\n    if (!root || !image) return\n    const runKey = JSON.stringify([\n      sequenceId,\n      initialFrame,\n      loopStart,\n      loopEnd,\n      frameCount,\n      resetOnPause,\n    ])\n    if (openingSurface.current?.key !== runKey) {\n      openingSurface.current?.release()\n      openingSurface.current = null\n    }\n    const motion = matchMedia('(prefers-reduced-motion: reduce)')\n    let nearby = false\n    let visible = false\n    let disposed = false\n    let raf: number | undefined\n    let lastTarget: number | undefined\n    let lastTime: number | undefined\n    let pixelWidth = Math.max(\n      1,\n      Math.ceil(image.getBoundingClientRect().width * devicePixelRatio)\n    )\n    const controller = new SequenceDemandController({\n      frameCount,\n      mode,\n      frameDuration,\n      loop,\n      loopStart,\n      loopEnd,\n      initialFrame,\n      loadInitial,\n      priority,\n      ahead,\n      behind,\n      source: (index) =>\n        resolveFrameSource(latest.current.source(index), pixelWidth),\n      onFrame: (index, frame) => {\n        if (\n          disposed ||\n          !image.isConnected ||\n          motion.matches ||\n          !latest.current.enabled ||\n          !latest.current.playing ||\n          document.hidden\n        )\n          return false\n        const releasePrevious = surfaceLease.current\n        const releaseNext = controller.retainSurface(frame)\n        image.removeAttribute('srcset')\n        image.src = frame.src\n        surfaceLease.current = releaseNext\n        releasePrevious?.()\n        if (\n          latest.current.resetOnPause &&\n          index === controller.initialFrame &&\n          !openingSurface.current\n        ) {\n          const retain = () => controller.retainSurface(frame)\n          openingSurface.current = {\n            key: runKey,\n            src: frame.src,\n            retain,\n            release: retain(),\n          }\n        }\n        latest.current.onFrame?.(index)\n        return true\n      },\n      onComplete: () => latest.current.onComplete?.(),\n    })\n\n    const stop = () => {\n      if (raf !== undefined) cancelAnimationFrame(raf)\n      raf = undefined\n      lastTime = undefined\n    }\n    const tick = (time: number) => {\n      raf = undefined\n      controller.tick(lastTime === undefined ? 0 : time - lastTime)\n      lastTime = time\n      if (!disposed && !controller.isComplete) raf = requestAnimationFrame(tick)\n    }\n    const assignPoster = () => {\n      const current = latest.current.poster\n      const value = typeof current === 'string' ? { src: current } : current\n      // Reassigning the same URL can restart the native poster request, notably\n      // with browser cache disabled. Observer notifications must be idempotent.\n      if ((image.getAttribute('sizes') ?? '') !== (value.sizes ?? ''))\n        image.sizes = value.sizes ?? ''\n      if ((image.getAttribute('srcset') ?? '') !== (value.srcSet ?? ''))\n        image.srcset = value.srcSet ?? ''\n      if (image.getAttribute('src') !== value.src) image.src = value.src\n    }\n    const restorePoster = (useOpeningFrame = false) => {\n      const opening = useOpeningFrame ? openingSurface.current : null\n      const releasePrevious = surfaceLease.current\n      surfaceLease.current = opening?.retain()\n      if (opening) {\n        image.removeAttribute('srcset')\n        if (image.src !== opening.src) image.src = opening.src\n      } else assignPoster()\n      releasePrevious?.()\n      controller.restorePoster()\n    }\n    const sync = () => {\n      const target = latest.current.target\n      if (target !== undefined && target !== lastTarget) {\n        lastTarget = target\n        controller.queueTarget(target)\n      }\n      const allowed =\n        !document.hidden &&\n        !motion.matches &&\n        latest.current.enabled &&\n        latest.current.playing\n      controller.setConditions(nearby && allowed, visible && allowed)\n      if (visible && allowed && mode !== 'scrub' && !controller.isComplete) {\n        if (raf === undefined) raf = requestAnimationFrame(tick)\n      } else stop()\n      if (\n        motion.matches ||\n        (latest.current.resetOnPause && !latest.current.playing)\n      )\n        restorePoster(!motion.matches)\n      else if (!surfaceLease.current) assignPoster()\n    }\n    const resize = () => {\n      const next = Math.max(\n        1,\n        Math.ceil(image.getBoundingClientRect().width * devicePixelRatio)\n      )\n      if (pixelWidth === next) return\n      pixelWidth = next\n      controller.resetWindow()\n    }\n    const visibilityObserver = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting\n      sync()\n    })\n    const nearbyObserver = new IntersectionObserver(\n      ([entry]) => {\n        nearby = entry.isIntersecting\n        sync()\n      },\n      { rootMargin: '200px' }\n    )\n    const sizeObserver = new ResizeObserver(resize)\n    visibilityObserver.observe(root)\n    nearbyObserver.observe(root)\n    sizeObserver.observe(image)\n    motion.addEventListener('change', sync)\n    document.addEventListener('visibilitychange', sync)\n    window.addEventListener('resize', resize)\n    window.addEventListener('orientationchange', resize)\n    syncRef.current = sync\n    sync()\n    return () => {\n      disposed = true\n      stop()\n      visibilityObserver.disconnect()\n      nearbyObserver.disconnect()\n      sizeObserver.disconnect()\n      motion.removeEventListener('change', sync)\n      document.removeEventListener('visibilitychange', sync)\n      window.removeEventListener('resize', resize)\n      window.removeEventListener('orientationchange', resize)\n      controller.dispose()\n      syncRef.current = null\n      // The separate surface lease survives replacement of this controller.\n    }\n  }, [\n    restart,\n    resetOnPause,\n    sequenceId,\n    frameCount,\n    mode,\n    frameDuration,\n    loop,\n    loopStart,\n    loopEnd,\n    initialFrame,\n    loadInitial,\n    priority,\n    ahead,\n    behind,\n  ])\n\n  useEffect(\n    () => () => {\n      surfaceLease.current?.()\n      surfaceLease.current = undefined\n      openingSurface.current?.release()\n      openingSurface.current = null\n    },\n    []\n  )\n\n  const dimensions = typeof poster === 'string' ? undefined : poster\n  return (\n    <span\n      ref={rootRef}\n      data-slot=\"image-sequence\"\n      className={cn(\n        'relative block **:data-[slot=sequence-image]:block **:data-[slot=sequence-image]:size-full **:data-[slot=sequence-image]:object-contain **:data-[slot=sequence-picture]:block **:data-[slot=sequence-picture]:size-full',\n        pixelated &&\n          '**:data-[slot=sequence-image]:[image-rendering:pixelated]',\n        className\n      )}\n    >\n      <picture data-slot=\"sequence-picture\">\n        <img\n          ref={imageRef}\n          data-slot=\"sequence-image\"\n          src={firstPoster.src}\n          srcSet={firstPoster.srcSet}\n          sizes={firstPoster.sizes}\n          width={dimensions?.width}\n          height={dimensions?.height}\n          alt={alt}\n          draggable={false}\n          decoding=\"async\"\n        />\n      </picture>\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/image-sequence.tsx"
    },
    {
      "path": "registry/lib/image-sequence/runtime.ts",
      "content": "import {\n  getSharedFrameTransfer,\n  sourceUrl,\n  type TransferDemand,\n} from './transfer'\n\nexport type FrameSource =\n  | string\n  | {\n      src: string\n      srcSet?: string\n      sizes?: string\n      width?: number\n      height?: number\n      accept?: string\n    }\ntype DecodedFrame = HTMLImageElement\ntype Demand = TransferDemand & { index: number }\nconst READY_AHEAD = 3\n\n/** Spread first, last, and midpoint transfer requests across a frame range. */\nfunction binaryFrameOrder(first: number, last: number): number[] {\n  if (first > last) return []\n  if (first === last) return [first]\n  const order = [first, last]\n  const ranges: [number, number][] = [[first, last]]\n  for (let position = 0; position < ranges.length; position++) {\n    const [start, end] = ranges[position]\n    if (end - start <= 1) continue\n    const middle = Math.floor((start + end) / 2)\n    order.push(middle)\n    ranges.push([start, middle], [middle, end])\n  }\n  return order\n}\n\n/** Pick the smallest responsive candidate that covers the visible image's physical pixels. */\nexport function resolveFrameSource(\n  source: FrameSource,\n  pixelWidth: number\n): FrameSource {\n  if (typeof source === 'string' || !source.srcSet) return source\n  const candidates = source.srcSet\n    .split(',')\n    .flatMap((candidate) => {\n      const [url, descriptor] = candidate.trim().split(/\\s+/)\n      const width = Number(descriptor?.match(/^(\\d+)w$/)?.[1])\n      return width > 0 ? [{ url, width }] : []\n    })\n    .sort((a, b) => a.width - b.width)\n  const selected =\n    candidates.find((candidate) => candidate.width >= pixelWidth) ??\n    candidates.at(-1)\n  return { ...source, src: selected?.url ?? source.src, srcSet: undefined }\n}\n\nfunction releaseFrame(frame: DecodedFrame) {\n  URL.revokeObjectURL(frame.src)\n  frame.removeAttribute('src')\n}\n\nasync function decodeFrame(\n  blob: Blob,\n  signal: AbortSignal\n): Promise<DecodedFrame> {\n  const image = new Image()\n  image.decoding = 'async'\n  const src = URL.createObjectURL(blob)\n  image.src = src\n  let timeout: ReturnType<typeof setTimeout> | undefined\n  let onAbort: (() => void) | undefined\n  try {\n    if (signal.aborted) throw signal.reason\n    const cancelled = new Promise<never>((_, reject) => {\n      onAbort = () =>\n        reject(signal.reason ?? new DOMException('Cancelled', 'AbortError'))\n      signal.addEventListener('abort', onAbort, { once: true })\n    })\n    const timedOut = new Promise<never>((_, reject) => {\n      timeout = setTimeout(\n        () => reject(new DOMException('Decode timeout', 'TimeoutError')),\n        15_000\n      )\n    })\n    await Promise.race([image.decode(), cancelled, timedOut])\n    if (signal.aborted) throw signal.reason\n    return image\n  } catch (error) {\n    image.removeAttribute('src')\n    URL.revokeObjectURL(src)\n    throw error\n  } finally {\n    if (timeout) clearTimeout(timeout)\n    if (onAbort) signal.removeEventListener('abort', onAbort)\n  }\n}\n\n/** A sequence's decoded ring and bounded decode pump. */\nclass LocalFrames {\n  private transport = getSharedFrameTransfer()\n  private frames = new Map<string, DecodedFrame>()\n  private retained = new Map<DecodedFrame, number>()\n  private wanted: FrameSource[] = []\n  private visible?: string\n  private pending = new Map<string, AbortController>()\n  private readonly decodeConcurrency = 3\n  private disposed = false\n  private decodeFailures = new Map<string, number>()\n  private retryTimers = new Map<string, ReturnType<typeof setTimeout>>()\n  constructor(\n    private owner: symbol,\n    private source: (index: number) => FrameSource,\n    private notify: (reason: 'transfer' | 'decode') => void\n  ) {}\n  get(source: FrameSource) {\n    return this.frames.get(sourceUrl(source))\n  }\n  isReady(source: FrameSource) {\n    return this.frames.has(sourceUrl(source))\n  }\n  hasBytes(source: FrameSource) {\n    return Boolean(this.transport.get(source))\n  }\n  retain(frame: DecodedFrame) {\n    this.retained.set(frame, (this.retained.get(frame) ?? 0) + 1)\n    let released = false\n    return () => {\n      if (released) return\n      released = true\n      const count = (this.retained.get(frame) ?? 1) - 1\n      if (count) this.retained.set(frame, count)\n      else {\n        this.retained.delete(frame)\n        if (![...this.frames.values()].includes(frame)) releaseFrame(frame)\n      }\n    }\n  }\n  setVisible(source: FrameSource) {\n    this.visible = sourceUrl(source)\n    this.trim()\n  }\n  update(wanted: Demand[], decode: number[], active: boolean) {\n    if (this.disposed) return\n    this.wanted = decode.map(this.source)\n    const demandUrls = new Set(this.wanted.map(sourceUrl))\n    // This bounds relevant decode promises. Browser codec work abandoned after\n    // cancellation may overlap briefly because HTMLImageElement.decode() has no\n    // cancellable API. Current demand must not wait behind obsolete frames.\n    for (const [url, controller] of this.pending)\n      if (!demandUrls.has(url)) {\n        controller.abort()\n        this.pending.delete(url)\n      }\n    this.transport.update(\n      this.owner,\n      wanted,\n      () => {\n        this.pump()\n        this.notify('transfer')\n      },\n      active\n    )\n    this.trim()\n    this.pump()\n  }\n  private trim() {\n    const keep = new Set(this.wanted.map(sourceUrl))\n    if (this.visible) keep.add(this.visible)\n    for (const url of this.decodeFailures.keys())\n      if (!keep.has(url)) {\n        this.decodeFailures.delete(url)\n        const timer = this.retryTimers.get(url)\n        if (timer) clearTimeout(timer)\n        this.retryTimers.delete(url)\n      }\n    for (const [url, frame] of this.frames) {\n      if (keep.has(url)) continue\n      this.frames.delete(url)\n      if (!this.retained.has(frame)) releaseFrame(frame)\n    }\n  }\n  private pump() {\n    if (this.disposed) return\n    for (const next of this.wanted) {\n      if (this.pending.size >= this.decodeConcurrency) break\n      const url = sourceUrl(next)\n      if (\n        this.frames.has(url) ||\n        this.retryTimers.has(url) ||\n        this.pending.has(url)\n      )\n        continue\n      const blob = this.transport.get(next)\n      if (!blob) continue\n      this.startDecode(url, blob)\n    }\n  }\n  private startDecode(url: string, blob: Blob) {\n    const controller = new AbortController()\n    this.pending.set(url, controller)\n    void decodeFrame(blob, controller.signal)\n      .then(\n        (frame) => {\n          if (\n            this.disposed ||\n            controller.signal.aborted ||\n            this.pending.get(url) !== controller ||\n            !this.wanted.some((source) => sourceUrl(source) === url)\n          ) {\n            releaseFrame(frame)\n            return\n          }\n          this.frames.set(url, frame)\n          this.notify('decode')\n        },\n        () => {\n          if (controller.signal.aborted || this.disposed) return\n          const failures = (this.decodeFailures.get(url) ?? 0) + 1\n          this.decodeFailures.set(url, failures)\n          const delay = Math.min(500 * 2 ** Math.min(failures - 1, 6), 30_000)\n          this.retryTimers.set(\n            url,\n            setTimeout(() => {\n              this.retryTimers.delete(url)\n              if (!this.disposed) this.pump()\n            }, delay)\n          )\n        }\n      )\n      .finally(() => {\n        if (this.pending.get(url) === controller) this.pending.delete(url)\n        this.trim()\n        this.pump()\n      })\n  }\n  resize() {\n    for (const controller of this.pending.values()) controller.abort()\n    this.pending.clear()\n    this.decodeFailures.clear()\n    for (const timer of this.retryTimers.values()) clearTimeout(timer)\n    this.retryTimers.clear()\n    for (const frame of this.frames.values())\n      if (!this.retained.has(frame)) releaseFrame(frame)\n    this.frames.clear()\n  }\n  suspend() {\n    this.wanted = []\n    for (const controller of this.pending.values()) controller.abort()\n    this.pending.clear()\n    this.visible = undefined\n    this.trim()\n    this.transport.release(this.owner)\n  }\n  release() {\n    if (this.disposed) return\n    this.disposed = true\n    this.resize()\n    this.transport.release(this.owner)\n  }\n}\n\nexport type SequenceOptions = {\n  frameCount: number\n  source: (index: number) => FrameSource\n  onFrame: (index: number, frame: DecodedFrame) => boolean | void\n  onComplete?: () => void\n  mode?: 'autoplay' | 'scrub'\n  frameDuration?: number\n  loop?: boolean\n  loopStart?: number\n  loopEnd?: number\n  initialFrame?: number\n  loadInitial?: boolean\n  priority?: 'sequential' | 'hybrid'\n  ahead?: number\n  behind?: number\n}\n\nconst integer = (value: number | undefined, fallback: number) =>\n  value !== undefined && Number.isFinite(value) ? Math.floor(value) : fallback\n\n/** Owns demand and playback; React never receives the per-frame working set. */\nexport class SequenceDemandController {\n  private owner = Symbol('sequence')\n  private frames: LocalFrames\n  private current: number\n  private playhead: number\n  private target: number\n  private direction = 1\n  private elapsed = 0\n  private nearby = false\n  private playing = false\n  private disposed = false\n  private hasPresented = false\n  private presentedUrl?: string\n  private cycle = 0\n  private presentedCycle = 0\n  private queuedTarget?: number\n  private targetRaf?: number\n  private readonly count: number\n  private readonly start: number\n  private readonly end: number\n  private readonly initial: number\n  private readonly duration: number\n  private readonly order: number[]\n  private readonly hybrid: boolean\n  private completed = false\n\n  constructor(private options: SequenceOptions) {\n    this.count = Math.max(0, integer(options.frameCount, 0))\n    this.end = Math.max(\n      0,\n      Math.min(this.count - 1, integer(options.loopEnd, this.count - 1))\n    )\n    this.start = Math.max(0, Math.min(this.end, integer(options.loopStart, 0)))\n    this.initial = Math.max(\n      this.start,\n      Math.min(this.end, integer(options.initialFrame, this.start))\n    )\n    this.current = this.playhead = this.target = this.initial\n    this.duration = Math.max(\n      1,\n      Number.isFinite(options.frameDuration)\n        ? options.frameDuration!\n        : 1000 / 24\n    )\n    this.hybrid = options.priority !== 'sequential'\n    this.order = binaryFrameOrder(this.start, this.end)\n    this.frames = new LocalFrames(this.owner, options.source, (reason) => {\n      if (this.disposed || !this.nearby || this.completed) return\n      if (reason === 'transfer') this.refresh()\n      if (options.mode === 'scrub') this.presentTarget()\n      else if (!this.hasPresented && (this.playing || options.loadInitial))\n        this.present(this.initial)\n    })\n  }\n\n  get isComplete() {\n    return this.completed\n  }\n\n  get initialFrame() {\n    return this.initial\n  }\n\n  setConditions(nearby: boolean, playing: boolean) {\n    if (this.disposed) return\n    this.nearby = nearby\n    this.playing = nearby && playing\n    if (!this.playing) this.elapsed = 0\n    this.refresh()\n    if (nearby && this.options.mode === 'scrub') this.presentTarget()\n    else if (\n      nearby &&\n      !this.hasPresented &&\n      (this.playing || this.options.loadInitial)\n    )\n      this.present(this.initial)\n  }\n\n  setTarget(index: number) {\n    if (this.disposed || !Number.isFinite(index)) return\n    const next = Math.max(this.start, Math.min(this.end, Math.round(index)))\n    if (next !== this.target) this.direction = next > this.target ? 1 : -1\n    this.target = next\n    this.refresh()\n    this.presentTarget()\n  }\n\n  /** Coalesce scroll input and suppress presentation against an outdated target. */\n  queueTarget(index: number) {\n    if (this.disposed) return\n    this.queuedTarget = index\n    if (this.targetRaf !== undefined) return\n    this.targetRaf = requestAnimationFrame(() => {\n      this.targetRaf = undefined\n      const target = this.queuedTarget\n      this.queuedTarget = undefined\n      if (target !== undefined) this.setTarget(target)\n    })\n  }\n\n  tick(delta: number) {\n    if (\n      !this.playing ||\n      this.disposed ||\n      this.completed ||\n      !this.count ||\n      this.options.mode === 'scrub'\n    )\n      return\n    if (!this.hasPresented) {\n      this.present(this.initial)\n      return\n    }\n    this.elapsed += Math.max(0, Number.isFinite(delta) ? delta : 0)\n    if (this.hybrid) {\n      const steps = Math.floor(this.elapsed / this.duration)\n      if (steps) {\n        this.elapsed %= this.duration\n        if (this.options.loop !== false && steps > this.end - this.playhead) {\n          this.cycle +=\n            1 +\n            Math.floor(\n              (steps - (this.end - this.playhead + 1)) /\n                (this.end - this.start + 1)\n            )\n        }\n        this.playhead = this.advance(this.playhead, steps)\n        this.refresh()\n      }\n      const candidate = this.closestReady()\n      if (candidate !== undefined) this.present(candidate)\n    } else if (this.elapsed >= this.duration) {\n      // Hold authored time until an allowed next frame is ready.\n      this.elapsed = Math.min(this.elapsed, this.duration * 2)\n      this.refresh()\n      const next = this.advance(this.current, 1)\n      if (this.frames.isReady(this.options.source(next))) {\n        this.elapsed -= this.duration\n        this.playhead = next\n        this.present(next)\n      }\n    }\n  }\n\n  resetWindow() {\n    if (this.disposed) return\n    this.frames.resize()\n    this.refresh()\n  }\n\n  /** Poster restoration invalidates the presentation identity, not the run. */\n  restorePoster() {\n    this.hasPresented = false\n    this.presentedUrl = undefined\n  }\n\n  retainSurface(frame: DecodedFrame) {\n    return this.frames.retain(frame)\n  }\n\n  dispose() {\n    this.disposed = true\n    if (this.targetRaf !== undefined) cancelAnimationFrame(this.targetRaf)\n    this.frames.release()\n  }\n\n  private advance(index: number, offset: number) {\n    if (this.options.loop === false) return Math.min(this.end, index + offset)\n    return (\n      this.start + ((index - this.start + offset) % (this.end - this.start + 1))\n    )\n  }\n\n  private canPresent(index: number) {\n    return this.cycle > this.presentedCycle || index >= this.current\n  }\n\n  private closestReady() {\n    let candidate: number | undefined\n    for (const index of this.order) {\n      if (\n        !this.canPresent(index) ||\n        !this.frames.isReady(this.options.source(index))\n      )\n        continue\n      // One-shot completion must follow authored time all the way to the end.\n      if (\n        this.options.loop === false &&\n        index === this.end &&\n        this.playhead !== this.end\n      )\n        continue\n      if (\n        candidate === undefined ||\n        Math.abs(index - this.playhead) < Math.abs(candidate - this.playhead) ||\n        (Math.abs(index - this.playhead) ===\n          Math.abs(candidate - this.playhead) &&\n          index < candidate)\n      )\n        candidate = index\n    }\n    return candidate\n  }\n\n  private presentTarget() {\n    if (\n      this.options.mode !== 'scrub' ||\n      !this.nearby ||\n      this.queuedTarget !== undefined\n    )\n      return\n    const direction = this.target >= this.current ? -1 : 1\n    for (\n      let index = this.target;\n      direction === -1 ? index >= this.current : index <= this.current;\n      index += direction\n    ) {\n      if (this.frames.isReady(this.options.source(index))) {\n        this.present(index)\n        return\n      }\n    }\n  }\n\n  private present(index: number) {\n    if (this.disposed || !this.nearby || !this.count || this.completed) return\n    const source = this.options.source(index)\n    const url = sourceUrl(source)\n    const frame = this.frames.get(source)\n    if (\n      !frame ||\n      (this.hasPresented && index === this.current && this.presentedUrl === url)\n    )\n      return\n    if (this.options.onFrame(index, frame) === false) return\n    if (this.disposed) return\n    this.current = index\n    this.hasPresented = true\n    this.presentedUrl = url\n    this.presentedCycle = this.cycle\n    this.frames.setVisible(source)\n    if (\n      this.options.mode !== 'scrub' &&\n      this.options.loop === false &&\n      index === this.end &&\n      this.playhead === this.end\n    ) {\n      this.completed = true\n      this.frames.suspend()\n      this.options.onComplete?.()\n      return\n    }\n    this.refresh()\n  }\n\n  private refresh() {\n    if (this.disposed) return\n    if (!this.nearby || !this.count || this.completed) {\n      this.frames.suspend()\n      return\n    }\n    const wanted: Demand[] = []\n    const added = new Set<number>()\n    const add = (index: number, priority: number) => {\n      if (added.has(index)) return\n      added.add(index)\n      wanted.push({ index, source: this.options.source(index), priority })\n    }\n    let decode: number[]\n    if (this.options.mode === 'scrub') {\n      add(this.target, 0)\n      if (this.playing) {\n        const ahead = Math.min(\n          this.count,\n          Math.max(0, integer(this.options.ahead, 7))\n        )\n        const behind = Math.min(\n          this.count,\n          Math.max(0, integer(this.options.behind, 3))\n        )\n        for (let i = 1; i <= ahead; i++) {\n          const index = this.target + i * this.direction\n          if (index >= this.start && index <= this.end) add(index, i)\n        }\n        for (let i = 1; i <= behind; i++) {\n          const index = this.target - i * this.direction\n          if (index >= this.start && index <= this.end) add(index, ahead + i)\n        }\n      }\n      decode = wanted\n        .filter((item) => item.index !== this.current)\n        .slice(0, 3)\n        .map((item) => item.index)\n      if (added.has(this.current)) decode.unshift(this.current)\n    } else if (!this.playing) {\n      add(this.hasPresented ? this.current : this.initial, 0)\n      add(this.advance(this.playhead, 1), 1)\n      decode =\n        this.options.loadInitial && !this.hasPresented ? [this.initial] : []\n    } else if (!this.hasPresented) {\n      add(this.initial, 0)\n      add(this.end, 1)\n      if (this.hybrid) this.order.forEach((index, rank) => add(index, rank + 2))\n      decode = [this.initial]\n    } else if (this.hybrid) {\n      add(this.playhead, 0)\n      for (let offset = 1; offset <= READY_AHEAD; offset++)\n        add(this.advance(this.playhead, offset), 1)\n      this.order.forEach((index, rank) => add(index, rank + 2))\n      decode = wanted\n        .filter((item) => item.index !== this.current)\n        .slice(0, READY_AHEAD)\n        .map((item) => item.index)\n      if (\n        !decode.some((index) =>\n          this.frames.hasBytes(this.options.source(index))\n        )\n      ) {\n        const fallback = wanted\n          .filter(\n            (item) =>\n              item.index !== this.current &&\n              this.canPresent(item.index) &&\n              this.frames.hasBytes(item.source)\n          )\n          .sort(\n            (a, b) =>\n              Math.abs(a.index - this.playhead) -\n              Math.abs(b.index - this.playhead)\n          )[0]\n        if (fallback)\n          decode = [\n            fallback.index,\n            ...decode.filter((index) => index !== fallback.index),\n          ].slice(0, READY_AHEAD)\n      }\n      if (this.playhead === this.current) decode.unshift(this.current)\n    } else {\n      for (let offset = 1; offset <= READY_AHEAD; offset++)\n        add(this.advance(this.current, offset), offset)\n      decode = wanted.map((item) => item.index)\n    }\n    this.frames.update(wanted, decode, this.playing)\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/image-sequence/runtime.ts"
    },
    {
      "path": "registry/lib/image-sequence/transfer.ts",
      "content": "import type { FrameSource } from './runtime'\n\nexport type TransferDemand = { source: FrameSource; priority: number }\ntype Owner = { wanted: TransferDemand[]; notify: () => void; active: boolean }\ntype Entry = {\n  url: string\n  accept?: string\n  state: 'queued' | 'pending' | 'failed' | 'ready'\n  controller?: AbortController\n  failures: number\n  retryTimer?: ReturnType<typeof setTimeout>\n  retireTimer?: ReturnType<typeof setTimeout>\n  contentionTimer?: ReturnType<typeof setTimeout>\n  timedOut?: boolean\n  preempted?: boolean\n  reclaiming?: boolean\n  preemptTimer?: ReturnType<typeof setTimeout>\n}\nconst DEFAULT_ENCODED_FRAME_BUDGET = 16 * 1024 * 1024\nconst PENDING_FRAME_GRACE_MS = 1_000\nconst CONTENDED_FRAME_IDLE_MS = 15_000\nexport const sourceUrl = (source: FrameSource) =>\n  typeof source === 'string' ? source : source.src\n\nclass OversizedFrameError extends Error {\n  constructor() {\n    super('Frame exceeds encoded byte budget')\n    this.name = 'OversizedFrameError'\n  }\n}\n\nclass FrameHttpError extends Error {\n  readonly retryable: boolean\n  constructor(status: number) {\n    super(`Frame request failed: ${status}`)\n    this.retryable = status === 408 || status === 429 || status >= 500\n  }\n}\n\n/** Compressed responses survive local decoded-frame eviction. */\nclass EncodedFrameCache {\n  private entries = new Map<string, Blob>()\n  private bytes = 0\n  has(url: string) {\n    return this.entries.has(url)\n  }\n  get(url: string) {\n    const blob = this.entries.get(url)\n    if (!blob) return undefined\n    this.entries.delete(url)\n    this.entries.set(url, blob)\n    return blob\n  }\n  put(url: string, blob: Blob) {\n    const previous = this.entries.get(url)\n    if (previous) {\n      this.bytes -= previous.size\n      this.entries.delete(url)\n    }\n    if (blob.size > DEFAULT_ENCODED_FRAME_BUDGET) return\n    this.entries.set(url, blob)\n    this.bytes += blob.size\n    while (this.bytes > DEFAULT_ENCODED_FRAME_BUDGET) {\n      const oldest = this.entries.keys().next().value\n      if (oldest === undefined) break\n      this.bytes -= this.entries.get(oldest)!.size\n      this.entries.delete(oldest)\n    }\n  }\n}\n\n/** Shared admission and deduplication. Owns encoded bytes, never decoded images. */\nclass FrameTransfer {\n  private entries = new Map<string, Entry>()\n  private owners = new Map<symbol, Owner>()\n  private active = 0\n  private reclaiming = 0\n  private lastOwner?: symbol\n  private encoded = new EncodedFrameCache()\n  private concurrency = 8\n  private async load(\n    url: string,\n    signal: AbortSignal,\n    accept?: string,\n    onBytes?: () => void\n  ) {\n    const response = await fetch(url, {\n      signal,\n      priority: 'low',\n      ...(accept ? { headers: { Accept: accept } } : {}),\n    })\n    if (!response.ok) {\n      void response.body?.cancel().catch(() => {})\n      throw new FrameHttpError(response.status)\n    }\n    const type = response.headers.get('content-type') ?? ''\n    if (!response.body) return new Blob([], { type })\n    const reader = response.body.getReader()\n    const chunks: Uint8Array<ArrayBuffer>[] = []\n    let bytes = 0\n    try {\n      while (true) {\n        const { done, value } = await reader.read()\n        if (done) break\n        if (value.byteLength === 0) continue\n        if (value.byteLength > DEFAULT_ENCODED_FRAME_BUDGET - bytes) {\n          void reader.cancel().catch(() => {})\n          throw new OversizedFrameError()\n        }\n        bytes += value.byteLength\n        // Stream producers may reuse the buffer before the Blob is assembled.\n        chunks.push(new Uint8Array(value))\n        onBytes?.()\n      }\n    } finally {\n      reader.releaseLock()\n    }\n    return new Blob(chunks, { type })\n  }\n  get(source: FrameSource) {\n    return this.encoded.get(sourceUrl(source))\n  }\n  update(\n    owner: symbol,\n    wanted: TransferDemand[],\n    notify: () => void,\n    active = true\n  ) {\n    this.owners.set(owner, { wanted, notify, active })\n    for (const item of wanted) {\n      const entry = this.entries.get(sourceUrl(item.source))\n      if (\n        entry?.state === 'ready' &&\n        !this.encoded.has(entry.url) &&\n        item.priority <= 1\n      )\n        entry.state = 'queued'\n    }\n    this.reconcile()\n  }\n  release(owner: symbol) {\n    this.owners.delete(owner)\n    this.reconcile()\n  }\n  private desired() {\n    const wanted = new Map<string, string | undefined>()\n    for (const owner of this.owners.values()) {\n      for (const item of owner.wanted) {\n        const url = sourceUrl(item.source)\n        const accept =\n          typeof item.source === 'string' ? undefined : item.source.accept\n        if (wanted.has(url) && wanted.get(url) !== accept)\n          throw new Error(`Conflicting Accept headers for frame ${url}`)\n        wanted.set(url, accept)\n      }\n    }\n    return wanted\n  }\n  private visibleUrls() {\n    const wanted = new Set<string>()\n    for (const owner of this.owners.values())\n      if (owner.active)\n        for (const item of owner.wanted) wanted.add(sourceUrl(item.source))\n    return wanted\n  }\n  private queue() {\n    const seen = new Set<string>()\n    const owners = [...this.owners.entries()]\n    const previous = owners.findIndex(([id]) => id === this.lastOwner)\n    const rotated = [\n      ...owners.slice(previous + 1),\n      ...owners.slice(0, previous + 1),\n    ]\n    const queue: { url: string; owner: symbol }[] = []\n    // Rotate after every admission, including when only one slot becomes free.\n    // Rebuilding a fixed owner order would starve later owners on rolling requests.\n    for (const active of [true, false]) {\n      const lanes = rotated\n        .filter(([, owner]) => owner.active === active)\n        .map(([id, owner]) => ({\n          id,\n          urls: [...owner.wanted]\n            .sort((a, b) => a.priority - b.priority)\n            .map((item) => sourceUrl(item.source)),\n        }))\n      while (lanes.some((lane) => lane.urls.length)) {\n        for (const lane of lanes) {\n          let url = lane.urls.shift()\n          while (\n            url &&\n            (seen.has(url) || this.entries.get(url)?.state !== 'queued')\n          )\n            url = lane.urls.shift()\n          if (url) {\n            seen.add(url)\n            queue.push({ url, owner: lane.id })\n          }\n        }\n      }\n    }\n    return queue\n  }\n  private reconcile() {\n    const wanted = this.desired()\n    for (const [url, entry] of this.entries) {\n      if (!wanted.has(url)) {\n        if (entry.state === 'pending') this.retire(entry)\n        else {\n          if (entry.retryTimer) clearTimeout(entry.retryTimer)\n          this.clearContentionTimer(entry)\n          this.entries.delete(url)\n        }\n      } else if (entry.retireTimer) {\n        clearTimeout(entry.retireTimer)\n        entry.retireTimer = undefined\n      }\n    }\n    for (const [url, accept] of wanted)\n      if (!this.encoded.has(url) && !this.entries.has(url))\n        this.entries.set(url, { url, accept, state: 'queued', failures: 0 })\n    const queue = this.queue()\n    let slotsNeeded = Math.max(\n      0,\n      Math.min(\n        queue.length,\n        this.active - this.reclaiming + queue.length - this.concurrency\n      )\n    )\n    for (const [url, entry] of this.entries) {\n      if (!slotsNeeded || !entry.retireTimer) continue\n      clearTimeout(entry.retireTimer)\n      entry.retireTimer = undefined\n      entry.controller?.abort()\n      entry.reclaiming = true\n      this.reclaiming++\n\n      this.entries.delete(url)\n      slotsNeeded--\n    }\n    // A newly visible owner must not wait behind already admitted nearby work.\n    const visibleUrls = this.visibleUrls()\n    const visibleQueued = queue.filter((item) => visibleUrls.has(item.url))\n    let visibleSlotsNeeded = Math.max(\n      0,\n      Math.min(\n        visibleQueued.length,\n        this.active - this.reclaiming + visibleQueued.length - this.concurrency\n      )\n    )\n    for (const entry of this.entries.values()) {\n      if (!visibleSlotsNeeded) break\n      if (\n        entry.state !== 'pending' ||\n        !wanted.has(entry.url) ||\n        visibleUrls.has(entry.url) ||\n        entry.controller?.signal.aborted\n      )\n        continue\n      // Keep the entry pending until its aborted fetch settles, then requeue it.\n      // This preserves deduplication and prevents it from racing the visible URL.\n      if (!entry.preemptTimer)\n        entry.preemptTimer = setTimeout(() => {\n          entry.preemptTimer = undefined\n          if (\n            entry.state !== 'pending' ||\n            entry.controller?.signal.aborted ||\n            this.visibleUrls().has(entry.url) ||\n            !this.queue().some((item) => this.visibleUrls().has(item.url))\n          )\n            return\n          entry.preempted = true\n          entry.controller?.abort()\n          entry.reclaiming = true\n          this.reclaiming++\n        }, PENDING_FRAME_GRACE_MS)\n      visibleSlotsNeeded--\n    }\n    for (const { url, owner } of queue) {\n      if (this.active >= this.concurrency) break\n      const entry = this.entries.get(url)\n      if (!entry || entry.state !== 'queued') continue\n      const controller = new AbortController()\n      entry.controller = controller\n      entry.state = 'pending'\n      entry.timedOut = false\n      this.lastOwner = owner\n      this.active++\n      void this.load(url, controller.signal, entry.accept, () => {\n        if (entry.controller !== controller || controller.signal.aborted) return\n        if (entry.contentionTimer) this.armContentionTimer(entry)\n      })\n        .then(\n          (blob) => {\n            if (controller.signal.aborted || this.entries.get(url) !== entry)\n              return\n            this.encoded.put(url, blob)\n            entry.state = 'ready'\n\n            for (const owner of this.owners.values()) owner.notify()\n          },\n          (error) => {\n            if (\n              (controller.signal.aborted && !entry.timedOut) ||\n              this.entries.get(url) !== entry\n            )\n              return\n            this.fail(entry, error)\n          }\n        )\n        .finally(() => {\n          this.clearContentionTimer(entry)\n          if (entry.preemptTimer) clearTimeout(entry.preemptTimer)\n          entry.preemptTimer = undefined\n          this.active--\n          if (entry.reclaiming) {\n            this.reclaiming--\n            entry.reclaiming = false\n          }\n          if (\n            entry.timedOut &&\n            entry.state === 'pending' &&\n            this.entries.get(url) === entry\n          )\n            this.fail(entry, controller.signal.reason)\n          entry.controller = undefined\n          if (entry.retireTimer) clearTimeout(entry.retireTimer)\n          entry.retireTimer = undefined\n          if (entry.preempted) {\n            entry.preempted = false\n            if (this.entries.get(url) === entry) {\n              if (this.desired().has(url)) entry.state = 'queued'\n              else this.entries.delete(url)\n            }\n          }\n          this.reconcile()\n        })\n    }\n    this.syncContentionTimers()\n  }\n  private clearContentionTimer(entry: Entry) {\n    if (entry.contentionTimer) clearTimeout(entry.contentionTimer)\n    entry.contentionTimer = undefined\n  }\n  private hasQueuedFrames() {\n    for (const entry of this.entries.values())\n      if (entry.state === 'queued') return true\n    return false\n  }\n  private armContentionTimer(entry: Entry) {\n    this.clearContentionTimer(entry)\n    const controller = entry.controller\n    if (!controller) return\n    entry.contentionTimer = setTimeout(() => {\n      entry.contentionTimer = undefined\n      if (\n        this.entries.get(entry.url) !== entry ||\n        entry.state !== 'pending' ||\n        entry.controller !== controller ||\n        controller.signal.aborted ||\n        this.active - this.reclaiming < this.concurrency ||\n        !this.hasQueuedFrames()\n      )\n        return\n      entry.timedOut = true\n      entry.reclaiming = true\n      this.reclaiming++\n      controller.abort(\n        new DOMException('Contended frame stalled', 'TimeoutError')\n      )\n      this.syncContentionTimers()\n    }, CONTENDED_FRAME_IDLE_MS)\n  }\n  private syncContentionTimers() {\n    const queued = this.hasQueuedFrames()\n    const contended =\n      queued && this.active - this.reclaiming >= this.concurrency\n    for (const entry of this.entries.values()) {\n      if (\n        !queued ||\n        entry.state !== 'pending' ||\n        entry.retireTimer ||\n        !entry.controller ||\n        entry.controller.signal.aborted\n      )\n        this.clearContentionTimer(entry)\n      else if (contended) {\n        if (!entry.contentionTimer) this.armContentionTimer(entry)\n      } else this.clearContentionTimer(entry)\n    }\n  }\n  private retire(entry: Entry) {\n    if (entry.retireTimer) return\n    this.clearContentionTimer(entry)\n    if (!entry.controller) {\n      this.entries.delete(entry.url)\n      return\n    }\n    entry.retireTimer = setTimeout(() => {\n      entry.retireTimer = undefined\n      if (\n        this.entries.get(entry.url) !== entry ||\n        this.desired().has(entry.url) ||\n        entry.state !== 'pending'\n      )\n        return\n      entry.controller?.abort()\n      entry.reclaiming = true\n      this.reclaiming++\n\n      this.entries.delete(entry.url)\n    }, PENDING_FRAME_GRACE_MS)\n  }\n  private fail(entry: Entry, error: unknown) {\n    entry.state = 'failed'\n    entry.failures++\n\n    if (\n      !(error instanceof OversizedFrameError) &&\n      (!(error instanceof FrameHttpError) || error.retryable)\n    )\n      this.retry(entry)\n    for (const owner of this.owners.values()) owner.notify()\n  }\n  private retry(entry: Entry) {\n    const delay = Math.min(500 * 2 ** Math.min(entry.failures - 1, 6), 30_000)\n    entry.retryTimer = setTimeout(() => {\n      entry.retryTimer = undefined\n      if (\n        this.entries.get(entry.url) !== entry ||\n        !this.desired().has(entry.url)\n      )\n        return\n      entry.state = 'queued'\n\n      this.reconcile()\n    }, delay)\n  }\n}\nlet shared: FrameTransfer | undefined\nexport const getSharedFrameTransfer = () => (shared ??= new FrameTransfer())\n",
      "type": "registry:lib",
      "target": "lib/image-sequence/transfer.ts"
    }
  ]
}