{"version":3,"file":"frame.cjs","names":[],"sources":["../../src/imaging/frame.ts"],"sourcesContent":["/**\n * Reading one frame out of a `<video>`, at the instant you asked for.\n *\n * The current frame needs nothing from this file: `createImageBitmap` takes a\n * video element, so `resizeImage(video)` already works. A **chosen** instant is\n * the part every app writes by hand and writes wrong:\n *\n * ```ts\n * video.currentTime = 12.5;\n * await new Promise((r) => video.addEventListener(\"seeked\", r, { once: true }));\n * ctx.drawImage(video, 0, 0);   // may draw the PREVIOUS frame\n * ```\n *\n * `seeked` says the seek finished, not that the frame for the new position is\n * composited and readable by `drawImage`. The symptom is the worst kind: it\n * works on the machine it was written on and produces the neighbouring frame\n * elsewhere, with no error and no log.\n *\n * `requestVideoFrameCallback` is the only signal that reports a frame having\n * been **presented** — and measuring it, 2026-09-04 in Chromium, settled how it\n * can be used here: it fires while the video **plays** and does **not** fire\n * for a seek on a paused element. So blocking a seek on it would stall every\n * capture for the whole timeout and then proceed anyway. Instead:\n *\n * - Capturing from a **playing** video waits for the next presented frame, so\n *   the pixels are demonstrably fresh. That is the screen-recording print.\n * - Capturing at an **instant** waits for `seeked` and then two animation\n *   frames, which is what a browser offers for a paused element.\n * - Capturing the current frame of a **paused** video waits for nothing: the\n *   frame on screen already is the frame.\n *\n * `confirmed` in the result says which of those happened, so a caller can tell\n * a demonstrably fresh frame from a best-effort one instead of guessing. A\n * seek-based capture reporting `confirmed: false` is the normal case, not a\n * warning sign.\n *\n * Three more traps come along for the ride:\n *\n * - **A recording may arrive with no duration.** `MediaRecorder` does not\n *   guarantee a duration in the WebM header, so the blob `useVideoRecorder`\n *   just handed you may report `Infinity` — and the video an app most wants to\n *   grab a frame from is exactly that one. Seeking past the end forces the\n *   browser to demux to the last frame, after which it knows the length; that\n *   probe runs here rather than in every caller. Chromium was measured writing\n *   the duration for a one-shot recording, so this is for the paths that omit\n *   it — chunked `timeslice` recording, and other engines.\n * - **A cross-origin video taints the canvas**, and the failure surfaces far\n *   from its cause, so it is re-thrown saying what to set.\n * - **Moving `currentTime` moves the player** somebody is watching; `restore`\n *   puts it back, and touches only what actually moved.\n */\n\nimport { FrameSeekError, ImageDecodeError } from \"./exceptions\";\nimport { resizeImage } from \"./transform\";\nimport type { CaptureFrameOptions, CapturedFrame } from \"./types\";\n\n/** How long a seek and the frame after it may take, in milliseconds. */\nexport const DEFAULT_FRAME_TIMEOUT_MS = 3000;\n\n/** `HTMLMediaElement.HAVE_CURRENT_DATA` — there are pixels for the current position. */\nconst HAVE_CURRENT_DATA = 2;\n\n/** Below this, in seconds, a seek to `currentTime` is a seek to where we already are. */\nconst SAME_FRAME_EPSILON = 0.001;\n\n/**\n * How long to wait for a presented frame, in milliseconds.\n *\n * Its own budget, not the caller's `timeoutMs`: this wait is an optimisation on\n * freshness, and a video playing at 24 fps presents one every 42 ms. Spending\n * seconds here would stall the capture for a guarantee the platform may simply\n * not be able to give.\n */\nconst FRAME_SETTLE_MS = 200;\n\n/**\n * A video element that reports presented frames.\n *\n * Declared structurally rather than by augmenting `HTMLVideoElement`: this is a\n * published package, and widening a lib interface would widen it for every\n * consumer, in every file, whether they load a polyfill or not.\n *\n * The two members are one capability. The spec defines them together, so a\n * runtime with `requestVideoFrameCallback` and no `cancelVideoFrameCallback`\n * does not exist — guarding the second separately would add a branch no test\n * can honestly reach.\n */\ntype FrameCallbackVideo = HTMLVideoElement & {\n    requestVideoFrameCallback: (callback: (now: number) => void) => number;\n    cancelVideoFrameCallback: (handle: number) => void;\n};\n\n/**\n * Whether this element reports presented frames.\n *\n * @param video The element to test.\n * @returns `true` when the frame callback pair is available.\n */\nfunction reportsFrames(video: HTMLVideoElement): video is FrameCallbackVideo {\n    return typeof (video as Partial<FrameCallbackVideo>).requestVideoFrameCallback === \"function\";\n}\n\n/** Seek target that forces a demux to the end, to learn an unknown duration. */\nconst PAST_THE_END_SECONDS = 1e101;\n\n/** The `AbortError` a caller's `signal` produces. */\nfunction abortError(): DOMException {\n    return new DOMException(\"The frame capture was aborted.\", \"AbortError\");\n}\n\n/**\n * Wait for one media event, and clean up on every exit.\n *\n * @param video The element to listen on.\n * @param type The event to wait for.\n * @param timeoutMs How long to wait.\n * @param signal Optional abort signal.\n * @param whenLate Message for the {@link FrameSeekError} on timeout.\n * @returns Resolves when the event fires.\n * @throws {@link FrameSeekError} when the timeout is reached.\n */\nfunction onceEvent(\n    video: HTMLVideoElement,\n    type: string,\n    timeoutMs: number,\n    signal: AbortSignal | undefined,\n    whenLate: string,\n): Promise<void> {\n    return new Promise<void>((resolve, reject) => {\n        const finish = (settle: () => void): void => {\n            clearTimeout(timer);\n            video.removeEventListener(type, onEvent);\n            signal?.removeEventListener(\"abort\", onAbort);\n            settle();\n        };\n        const onEvent = (): void => finish(resolve);\n        const onAbort = (): void => finish(() => reject(abortError()));\n\n        const timer = setTimeout(\n            () => finish(() => reject(new FrameSeekError(whenLate))),\n            timeoutMs,\n        );\n        video.addEventListener(type, onEvent);\n        signal?.addEventListener(\"abort\", onAbort);\n    });\n}\n\n/**\n * Wait for the next frame the compositor presents.\n *\n * Only ever called for a **playing** element, because that is the only state in\n * which the callback fires — measured, not assumed. The timeout is the escape\n * for a hidden tab, which presents nothing at all.\n *\n * @param video A playing element that reports presented frames.\n * @returns `true` when a frame was presented, `false` when the wait expired.\n */\nfunction nextPresentedFrame(video: FrameCallbackVideo): Promise<boolean> {\n    return new Promise<boolean>((resolve) => {\n        const timer = setTimeout(() => {\n            video.cancelVideoFrameCallback(handle);\n            resolve(false);\n        }, FRAME_SETTLE_MS);\n        const handle = video.requestVideoFrameCallback(() => {\n            clearTimeout(timer);\n            resolve(true);\n        });\n    });\n}\n\n/**\n * Let the compositor catch up after a seek on a paused element.\n *\n * Two animation frames is what a browser offers here: the frame callback does\n * not fire for a paused seek, so there is nothing to confirm against. A runtime\n * with no animation frames at all gets a macrotask — enough for a test\n * environment, not a promise about pixels.\n *\n * @returns Resolves once the wait is over. Never a confirmation.\n */\nfunction settleAfterSeek(): Promise<void> {\n    if (typeof requestAnimationFrame === \"function\") {\n        return new Promise<void>((resolve) => {\n            requestAnimationFrame(() => requestAnimationFrame(() => resolve()));\n        });\n    }\n    return new Promise<void>((resolve) => setTimeout(resolve, 0));\n}\n\n/**\n * Wait for the freshest frame this element's state allows, and say which.\n *\n * @param video The element about to be read.\n * @param seeked Whether a seek just moved it.\n * @returns `true` when a presented frame was observed.\n */\nasync function awaitReadableFrame(video: HTMLVideoElement, seeked: boolean): Promise<boolean> {\n    if (!video.paused && reportsFrames(video)) return await nextPresentedFrame(video);\n    if (seeked) await settleAfterSeek();\n    return false;\n}\n\n/**\n * Whether the element is fed by a live stream rather than by a file.\n *\n * A truthiness check rather than `!== null`: the property is absent in\n * environments that model only part of `HTMLMediaElement`, where comparing\n * against `null` would read every element as live.\n *\n * @param video The element to test.\n * @returns `true` when a `MediaStream` is playing into it.\n */\nfunction isLiveStream(video: HTMLVideoElement): boolean {\n    return Boolean(video.srcObject);\n}\n\n/**\n * The video's length in seconds, probing for it when the header lacks one.\n *\n * `MediaRecorder` does not guarantee a duration in the WebM it produces, so a\n * recording can report `Infinity` until something forces the browser to demux\n * to the end. That is what the seek past the end does — the same hack the\n * `AudioPlayer` uses, for the same reason: the value is not in the file.\n *\n * @param video The element to measure.\n * @param timeoutMs How long the probe may take.\n * @param signal Optional abort signal.\n * @returns The duration in seconds.\n * @throws {@link FrameSeekError} when the element is a live stream, or the\n *   probe produced no duration.\n */\nasync function resolveDuration(\n    video: HTMLVideoElement,\n    timeoutMs: number,\n    signal: AbortSignal | undefined,\n): Promise<number> {\n    if (Number.isFinite(video.duration)) return video.duration;\n\n    if (isLiveStream(video)) {\n        throw new FrameSeekError(\n            \"This element is playing a live MediaStream, which has no timeline to seek: \" +\n                \"there is no instant but now. Leave `atMs` out to capture the frame on screen.\",\n        );\n    }\n\n    const probed = onceEvent(\n        video,\n        \"timeupdate\",\n        timeoutMs,\n        signal,\n        `The video reported no duration and the probe for it did not answer within ` +\n            `${timeoutMs}ms, so there is no timeline to seek on.`,\n    );\n    video.currentTime = PAST_THE_END_SECONDS;\n    await probed;\n\n    if (!Number.isFinite(video.duration)) {\n        throw new FrameSeekError(\n            \"This video still reports no duration after seeking past its end, so it has \" +\n                \"no seekable timeline — a live HLS or DASH source behaves this way. Leave \" +\n                \"`atMs` out to capture the frame on screen.\",\n        );\n    }\n    return video.duration;\n}\n\n/**\n * Move the video to an instant, and wait for that instant's frame.\n *\n * @param video The element to move.\n * @param atMs The instant, in milliseconds. Clamped to the duration.\n * @param timeoutMs How long the seek and the frame may take.\n * @param signal Optional abort signal.\n * @returns Whether a presented frame confirmed the new position.\n * @throws {@link FrameSeekError} when the video has no seekable timeline, or\n *   the seek did not land in time.\n */\nasync function seekTo(\n    video: HTMLVideoElement,\n    atMs: number,\n    timeoutMs: number,\n    signal: AbortSignal | undefined,\n): Promise<boolean> {\n    const duration = await resolveDuration(video, timeoutMs, signal);\n\n    const target = Math.min(Math.max(atMs / 1000, 0), duration);\n    if (Math.abs(video.currentTime - target) < SAME_FRAME_EPSILON) {\n        return await awaitReadableFrame(video, false);\n    }\n\n    const seeked = onceEvent(\n        video,\n        \"seeked\",\n        timeoutMs,\n        signal,\n        `The video did not finish seeking to ${target.toFixed(3)}s within ${timeoutMs}ms. ` +\n            \"Nothing was captured: a frame from the wrong instant is indistinguishable \" +\n            \"from a correct one downstream.\",\n    );\n    video.currentTime = target;\n    await seeked;\n    return await awaitReadableFrame(video, true);\n}\n\n/**\n * Re-throw a tainted-canvas failure saying what to fix.\n *\n * A cross-origin video without `crossOrigin` taints everything it is drawn\n * into, and the `SecurityError` then arrives from the encoder — one call away\n * from the element that caused it, wrapped in whatever the decode said.\n *\n * @param error What the imaging pipeline threw.\n * @returns The error to throw instead.\n */\nfunction explained(error: unknown): unknown {\n    const names = [error, (error as { cause?: unknown } | null)?.cause].map((candidate) =>\n        typeof candidate === \"object\" && candidate !== null && \"name\" in candidate\n            ? String((candidate as { name: unknown }).name)\n            : \"\",\n    );\n    if (!names.includes(\"SecurityError\")) return error;\n    return new ImageDecodeError(\n        \"The video is cross-origin, so reading its pixels is not allowed. Set \" +\n            'crossOrigin=\"anonymous\" on the element before the source loads, and serve the ' +\n            \"video with a permissive Access-Control-Allow-Origin.\",\n        { cause: error },\n    );\n}\n\n/**\n * Put playback back where the capture found it, touching only what moved.\n *\n * Each half is guarded because writing `currentTime` **is** a seek, even when\n * the value is unchanged: a capture that was refused before it moved anything\n * — a live stream, an abort — would otherwise perturb the player it never\n * touched. And a capture that paused without needing to move still has to let\n * go of the pause.\n *\n * @param video The element to restore.\n * @param time Where `currentTime` was.\n * @param wasPlaying Whether it was playing before the capture paused it.\n */\nfunction restorePlayback(video: HTMLVideoElement, time: number, wasPlaying: boolean): void {\n    if (video.currentTime !== time) video.currentTime = time;\n    if (wasPlaying && video.paused) void Promise.resolve(video.play()).catch(() => undefined);\n}\n\n/**\n * Capture a frame from a video as encoded image bytes.\n *\n * Without `atMs` it reads the frame on screen, which is what a screen or camera\n * recording wants — a print of what is being recorded. With `atMs` it seeks,\n * waits for that instant's frame to be presented, captures, and puts the player\n * back.\n *\n * @example Print of a screen recording in progress\n * ```ts\n * const shot = await captureFrame(videoRef.current!, {\n *     type: \"image/webp\",\n *     quality: 0.9,\n * });\n * await shareOrDownloadBlob(shot.blob, \"print.webp\");\n * ```\n *\n * @example A poster from ten seconds in, scaled down\n * ```ts\n * const poster = await captureFrame(video, { atMs: 10_000, width: 640 });\n * setPosterUrl(URL.createObjectURL(poster.blob));\n * console.log(`landed on ${poster.atMs}ms`);\n * ```\n *\n * @param video The element to read. It needs data — the capture waits for\n *   `loadeddata` when the element has none yet.\n * @param options Instant, output box, format, and how long to wait.\n * @returns The encoded frame, the instant it actually came from, and whether a\n *   presented frame confirmed that instant.\n * @throws {@link FrameSeekError} when the video has no data or no timeline in\n *   time, or the seek did not land.\n * @throws {@link ImageDecodeError} when the pixels cannot be read — a\n *   cross-origin video without `crossOrigin` is the common one.\n * @throws {@link ImageEncodeError} when the canvas produces no bytes.\n */\nexport async function captureFrame(\n    video: HTMLVideoElement,\n    options: CaptureFrameOptions = {},\n): Promise<CapturedFrame> {\n    const timeoutMs = options.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS;\n    if (options.signal?.aborted === true) throw abortError();\n\n    if (video.readyState < HAVE_CURRENT_DATA) {\n        await onceEvent(\n            video,\n            \"loadeddata\",\n            timeoutMs,\n            options.signal,\n            `The video had no frame to read within ${timeoutMs}ms (readyState ` +\n                `${video.readyState}). Give it a source, or wait for it yourself.`,\n        );\n    }\n\n    const { atMs: requestedMs } = options;\n    const previousTime = video.currentTime;\n    const wasPlaying = !video.paused;\n\n    try {\n        let confirmed: boolean;\n        if (requestedMs === undefined) {\n            confirmed = await awaitReadableFrame(video, false);\n        } else {\n            if (wasPlaying) video.pause();\n            confirmed = await seekTo(video, requestedMs, timeoutMs, options.signal);\n        }\n\n        const atMs = video.currentTime * 1000;\n        const encoded = await resizeImage(video, options);\n        return { ...encoded, atMs, confirmed };\n    } catch (error) {\n        throw explained(error);\n    } finally {\n        if (options.restore !== false) restorePlayback(video, previousTime, wasPlaying);\n    }\n}\n"],"mappings":"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"}