tempest-react-sdk 0.54.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -73
- package/dist/components/AudioPlayer/AudioPlayer.cjs +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.js +6 -6
- package/dist/components/VideoPlayer/VideoPlayer.cjs +2 -0
- package/dist/components/VideoPlayer/VideoPlayer.cjs.map +1 -0
- package/dist/components/VideoPlayer/VideoPlayer.js +229 -0
- package/dist/components/VideoPlayer/VideoPlayer.js.map +1 -0
- package/dist/components/VideoPlayer/VideoPlayer.module.cjs +2 -0
- package/dist/components/VideoPlayer/VideoPlayer.module.cjs.map +1 -0
- package/dist/components/VideoPlayer/VideoPlayer.module.js +19 -0
- package/dist/components/VideoPlayer/VideoPlayer.module.js.map +1 -0
- package/dist/components/VideoPlayer/playback-rates.cjs +2 -0
- package/dist/components/VideoPlayer/playback-rates.cjs.map +1 -0
- package/dist/components/VideoPlayer/playback-rates.js +11 -0
- package/dist/components/VideoPlayer/playback-rates.js.map +1 -0
- package/dist/imaging/exceptions.cjs +1 -1
- package/dist/imaging/exceptions.cjs.map +1 -1
- package/dist/imaging/exceptions.js +5 -1
- package/dist/imaging/exceptions.js.map +1 -1
- package/dist/imaging/frame.cjs +2 -0
- package/dist/imaging/frame.cjs.map +1 -0
- package/dist/imaging/frame.js +82 -0
- package/dist/imaging/frame.js.map +1 -0
- package/dist/imaging.cjs +1 -1
- package/dist/imaging.d.ts +611 -494
- package/dist/imaging.js +10 -9
- package/dist/styles/VideoPlayer.css +23 -0
- package/dist/styles/media.css +22 -0
- package/dist/styles.css +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +389 -23
- package/dist/tempest-react-sdk.js +213 -209
- package/dist/webrtc/mesh-quality.cjs +2 -0
- package/dist/webrtc/mesh-quality.cjs.map +1 -0
- package/dist/webrtc/mesh-quality.js +51 -0
- package/dist/webrtc/mesh-quality.js.map +1 -0
- package/dist/webrtc/peer-link.cjs +2 -0
- package/dist/webrtc/peer-link.cjs.map +1 -0
- package/dist/webrtc/peer-link.js +45 -0
- package/dist/webrtc/peer-link.js.map +1 -0
- package/dist/webrtc/peer-mesh.cjs +2 -0
- package/dist/webrtc/peer-mesh.cjs.map +1 -0
- package/dist/webrtc/peer-mesh.js +155 -0
- package/dist/webrtc/peer-mesh.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoPlayer.cjs","names":[],"sources":["../../../src/components/VideoPlayer/VideoPlayer.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count, function-lines — `src` takes a URL or a Blob, and\n * every other prop is a facet of playing it: durationMs (a MediaRecorder WebM may\n * carry none), poster, captions, aspect ratio, the rate control and its presets,\n * pitch, fullscreen, autoPlay/loop/muted, onEnded/onError, plus the actions slot and\n * locale. The body is the transport state machine — object-URL lifecycle, seek, rate,\n * volume — around one video element ref.\n */\nimport { Maximize, Minimize, Pause, Play, Volume2, VolumeX } from \"lucide-react\";\nimport { useEffect, useRef, useState, type HTMLAttributes, type ReactNode } from \"react\";\n\nimport { formatDuration } from \"@/audio/duration\";\nimport { useFullscreen } from \"@/hooks/use-fullscreen\";\nimport { useObjectUrl } from \"@/hooks/use-object-url\";\nimport { cn } from \"@/utils/cn\";\n\nimport { DEFAULT_PLAYBACK_RATES } from \"./playback-rates\";\nimport styles from \"./VideoPlayer.module.css\";\n\n/**\n * DOM attributes this component redefines.\n *\n * `onRateChange` is a real collision, not tidiness: React types it on every\n * element as the media `ratechange` handler, `ReactEventHandler`. Ours carries\n * the rate the viewer picked, and a `(rate: number) => void` is not assignable\n * to a handler that receives an event — same shape of clash as `Input`'s `size`\n * against `HTMLInputElement.size`. The DOM one is useless on a `<div>` anyway:\n * the event fires on the media element inside, which this component owns.\n */\ntype OverriddenDomProps = \"children\" | \"onRateChange\";\n\n/** One caption or subtitle file, rendered as a `<track>`. */\nexport interface VideoTextTrack {\n /** URL of the WebVTT file. */\n src: string;\n /** BCP 47 language tag, e.g. `\"pt-BR\"`. */\n srcLang: string;\n /** Name shown in the browser's own track menu. */\n label: string;\n /** Track kind. Default `\"captions\"`. */\n kind?: \"subtitles\" | \"captions\" | \"descriptions\" | \"chapters\" | \"metadata\";\n /** Whether this track is on unless the viewer picks another. */\n default?: boolean;\n}\n\nexport interface VideoPlayerProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /**\n * What to play — a URL, or a `Blob`/`File` straight from a recorder.\n *\n * A `Blob` is wrapped in an object URL that is revoked when it changes or the\n * component unmounts, so a session that records twenty clips does not leak\n * twenty URLs for the lifetime of the tab.\n */\n src: string | Blob | null;\n /**\n * Known length in milliseconds.\n *\n * Pass it whenever you have it — a recording from `useVideoRecorder` always\n * does. Without it the element's own `duration` is used, and a fresh\n * `MediaRecorder` blob may report `Infinity` until it is probed for one.\n */\n durationMs?: number;\n /** Still shown before playback. Pair it with `captureFrame` from `/imaging`. */\n poster?: string;\n /** Caption and subtitle files. */\n tracks?: readonly VideoTextTrack[];\n /** Frame shape while the video has no intrinsic size yet. Default `16 / 9`. */\n aspectRatio?: number;\n /**\n * Playback rate. Controlled when passed with `onRateChange`.\n *\n * Written to both `playbackRate` and `defaultPlaybackRate`, because the\n * element resets the first to the second when a source loads — a player that\n * only writes `playbackRate` silently drops back to 1× on every clip change.\n */\n rate?: number;\n /** Presets the control offers. `[]` hides it. Default {@link DEFAULT_PLAYBACK_RATES}. */\n rates?: readonly number[];\n /** Called with the rate the viewer picked. */\n onRateChange?: (rate: number) => void;\n /** Let the pitch rise with the rate (`preservesPitch = false`). Default `false`. */\n shiftPitch?: boolean;\n /** Offer a fullscreen button where the browser supports it. Default `true`. */\n fullscreen?: boolean;\n /** Start playing as soon as `src` is ready. Default `false`. */\n autoPlay?: boolean;\n /** Loop. Default `false`. */\n loop?: boolean;\n /** Start muted. Autoplay without a gesture needs this. Default `false`. */\n muted?: boolean;\n /** Locale for the labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Rendered at the end of the control row — a download button, a delete button. */\n actions?: ReactNode;\n /** Fired when playback reaches the end. */\n onEnded?: () => void;\n /** Fired when the element reports a decode/network error. */\n onError?: (error: unknown) => void;\n /** No `src` yet, or playback not allowed. */\n disabled?: boolean;\n}\n\nconst STRINGS = {\n \"pt-BR\": {\n play: \"Tocar\",\n pause: \"Pausar\",\n seek: \"Posição\",\n mute: \"Silenciar\",\n unmute: \"Ativar som\",\n volume: \"Volume\",\n rate: \"Velocidade\",\n enterFullscreen: \"Tela cheia\",\n exitFullscreen: \"Sair da tela cheia\",\n },\n en: {\n play: \"Play\",\n pause: \"Pause\",\n seek: \"Seek\",\n mute: \"Mute\",\n unmute: \"Unmute\",\n volume: \"Volume\",\n rate: \"Speed\",\n enterFullscreen: \"Full screen\",\n exitFullscreen: \"Exit full screen\",\n },\n} as const;\n\n/**\n * Label a rate the way a viewer reads it.\n *\n * @param rate The multiplier.\n * @param locale Which decimal separator to use.\n * @returns e.g. `\"1,5×\"` in pt-BR, `\"1.5×\"` in English.\n */\nfunction rateLabel(rate: number, locale: \"pt-BR\" | \"en\"): string {\n return `${rate.toLocaleString(locale === \"en\" ? \"en-US\" : \"pt-BR\")}×`;\n}\n\n/**\n * Playback transport for one video: play/pause, seek, volume, speed, fullscreen.\n *\n * Built on a real `<video>` element, and deliberately **not** an overlay: the\n * controls sit in a bar under the frame rather than on top of it. Controls drawn\n * over video have to earn their contrast against arbitrary pixels, which no\n * `--tempest-*` token can promise — every text token in this SDK is resolved\n * against a known surface, and the two contrast defects this repo shipped were\n * both a text token used over a surface it was never checked against. A bar with\n * its own surface inherits the theme and is legible by construction.\n *\n * The seek bar, the volume slider and the speed picker are bare `<input>` and\n * `<select>` elements rather than the SDK's `Slider` and `Select`: those are form\n * fields, with a label row and a value badge, and a transport wants neither. The\n * native elements keep the keyboard and screen-reader behaviour that matters here\n * for free.\n *\n * @example\n * const rec = useVideoRecorder(screen.stream);\n * {rec.recording && (\n * <VideoPlayer src={rec.recording.blob} durationMs={rec.recording.durationMs} />\n * )}\n */\nexport function VideoPlayer({\n src,\n durationMs,\n poster,\n tracks,\n aspectRatio = 16 / 9,\n rate,\n rates = DEFAULT_PLAYBACK_RATES,\n onRateChange,\n shiftPitch = false,\n fullscreen = true,\n autoPlay = false,\n loop = false,\n muted = false,\n locale = \"pt-BR\",\n actions,\n onEnded,\n onError,\n disabled = false,\n className,\n ...rest\n}: VideoPlayerProps) {\n const strings = STRINGS[locale];\n const shell = useRef<HTMLDivElement | null>(null);\n const video = useRef<HTMLVideoElement | null>(null);\n const probed = useRef(false);\n\n const [playing, setPlaying] = useState(false);\n const [currentMs, setCurrentMs] = useState(0);\n const [elementMs, setElementMs] = useState(Number.POSITIVE_INFINITY);\n const [ownRate, setOwnRate] = useState(rate ?? 1);\n const [volume, setVolume] = useState(1);\n const [silent, setSilent] = useState(muted);\n\n const immersive = useFullscreen(shell);\n const blobUrl = useObjectUrl(typeof src === \"string\" ? null : src);\n const url = typeof src === \"string\" ? src : blobUrl;\n\n const effectiveRate = rate ?? ownRate;\n const totalMs = Number.isFinite(durationMs) ? (durationMs as number) : elementMs;\n const seekable = Number.isFinite(totalMs) && totalMs > 0;\n\n useEffect(() => {\n setCurrentMs(0);\n setPlaying(false);\n setElementMs(Number.POSITIVE_INFINITY);\n probed.current = false;\n }, [url]);\n\n /**\n * Keep the rate on the element, and keep it across sources.\n *\n * `defaultPlaybackRate` is written alongside `playbackRate` because the load\n * algorithm copies the former onto the latter: writing only `playbackRate`\n * means the rate the viewer chose is silently lost on the next clip.\n */\n useEffect(() => {\n const node = video.current;\n if (!node) return;\n node.defaultPlaybackRate = effectiveRate;\n node.playbackRate = effectiveRate;\n node.preservesPitch = !shiftPitch;\n }, [effectiveRate, shiftPitch, url]);\n\n useEffect(() => {\n const node = video.current;\n if (!node) return;\n node.volume = volume;\n node.muted = silent;\n }, [volume, silent, url]);\n\n /**\n * Coax a duration out of an element that reports `Infinity`.\n *\n * Seeking past the end forces the browser to demux to the last frame, after\n * which it knows the real length. It runs at most once per source, and only\n * when the caller gave us no `durationMs`.\n */\n const handleMetadata = (): void => {\n const node = video.current;\n if (!node) return;\n if (Number.isFinite(node.duration)) {\n setElementMs(node.duration * 1000);\n return;\n }\n if (probed.current || Number.isFinite(durationMs)) return;\n probed.current = true;\n const onProbed = (): void => {\n node.removeEventListener(\"timeupdate\", onProbed);\n if (Number.isFinite(node.duration)) setElementMs(node.duration * 1000);\n node.currentTime = 0;\n };\n node.addEventListener(\"timeupdate\", onProbed);\n node.currentTime = 1e101;\n };\n\n const toggle = (): void => {\n const node = video.current;\n if (!node || !url) return;\n if (node.paused) {\n void Promise.resolve(node.play())\n .then(() => setPlaying(true))\n .catch((error: unknown) => onError?.(error));\n return;\n }\n node.pause();\n setPlaying(false);\n };\n\n const seek = (ms: number): void => {\n const node = video.current;\n if (!node || !seekable) return;\n node.currentTime = ms / 1000;\n setCurrentMs(ms);\n };\n\n const changeRate = (next: number): void => {\n if (rate === undefined) setOwnRate(next);\n onRateChange?.(next);\n };\n\n return (\n <div\n ref={shell}\n className={cn(styles.player, immersive.isFullscreen && styles.immersive, className)}\n {...rest}\n >\n <div className={styles.frame} style={{ aspectRatio }}>\n <video\n ref={video}\n className={styles.video}\n src={url ?? undefined}\n poster={poster}\n loop={loop}\n autoPlay={autoPlay}\n playsInline\n preload=\"metadata\"\n onLoadedMetadata={handleMetadata}\n onDurationChange={handleMetadata}\n onTimeUpdate={() => {\n const node = video.current;\n if (node) setCurrentMs(node.currentTime * 1000);\n }}\n onPlay={() => setPlaying(true)}\n onPause={() => setPlaying(false)}\n onVolumeChange={() => {\n const node = video.current;\n if (!node) return;\n setVolume(node.volume);\n setSilent(node.muted);\n }}\n onEnded={() => {\n setPlaying(false);\n onEnded?.();\n }}\n onError={(event) => onError?.(event)}\n >\n {tracks?.map((track) => (\n <track\n key={`${track.kind ?? \"captions\"}-${track.srcLang}-${track.src}`}\n kind={track.kind ?? \"captions\"}\n src={track.src}\n srcLang={track.srcLang}\n label={track.label}\n default={track.default}\n />\n ))}\n </video>\n </div>\n\n <div className={styles.chrome}>\n <button\n type=\"button\"\n className={styles.transport}\n onClick={toggle}\n disabled={disabled || !url}\n aria-label={playing ? strings.pause : strings.play}\n title={playing ? strings.pause : strings.play}\n >\n {playing ? <Pause size={16} aria-hidden /> : <Play size={16} aria-hidden />}\n </button>\n\n <input\n type=\"range\"\n className={styles.seek}\n min={0}\n max={seekable ? Math.round(totalMs) : 0}\n step={100}\n value={Math.min(Math.round(currentMs), seekable ? Math.round(totalMs) : 0)}\n onChange={(event) => seek(Number(event.target.value))}\n disabled={disabled || !seekable}\n aria-label={strings.seek}\n aria-valuetext={`${formatDuration(currentMs)} / ${formatDuration(totalMs)}`}\n />\n\n <span className={styles.time}>\n <span>{formatDuration(currentMs)}</span>\n <span aria-hidden=\"true\">/</span>\n <span>{formatDuration(totalMs)}</span>\n </span>\n\n <button\n type=\"button\"\n className={styles.icon}\n onClick={() => setSilent((was) => !was)}\n disabled={disabled}\n aria-label={silent ? strings.unmute : strings.mute}\n title={silent ? strings.unmute : strings.mute}\n >\n {silent ? <VolumeX size={16} aria-hidden /> : <Volume2 size={16} aria-hidden />}\n </button>\n\n <input\n type=\"range\"\n className={styles.volume}\n min={0}\n max={1}\n step={0.05}\n value={silent ? 0 : volume}\n onChange={(event) => {\n const next = Number(event.target.value);\n setVolume(next);\n setSilent(next === 0);\n }}\n disabled={disabled}\n aria-label={strings.volume}\n />\n\n {rates.length > 0 && (\n <select\n className={styles.rate}\n value={effectiveRate}\n onChange={(event) => changeRate(Number(event.target.value))}\n disabled={disabled}\n aria-label={strings.rate}\n title={strings.rate}\n >\n {rates.map((option) => (\n <option key={option} value={option}>\n {rateLabel(option, locale)}\n </option>\n ))}\n </select>\n )}\n\n {fullscreen && immersive.supported && (\n <button\n type=\"button\"\n className={styles.icon}\n onClick={() => {\n void immersive.toggle().catch((error: unknown) => onError?.(error));\n }}\n disabled={disabled}\n aria-label={\n immersive.isFullscreen\n ? strings.exitFullscreen\n : strings.enterFullscreen\n }\n title={\n immersive.isFullscreen\n ? strings.exitFullscreen\n : strings.enterFullscreen\n }\n >\n {immersive.isFullscreen ? (\n <Minimize size={16} aria-hidden />\n ) : (\n <Maximize size={16} aria-hidden />\n )}\n </button>\n )}\n\n {actions && <span className={styles.actions}>{actions}</span>}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { cn as e } from "../../utils/cn.js";
|
|
2
|
+
import { useFullscreen as t } from "../../hooks/use-fullscreen.js";
|
|
3
|
+
import { useObjectUrl as n } from "../../hooks/use-object-url.js";
|
|
4
|
+
import { DEFAULT_PLAYBACK_RATES as r } from "./playback-rates.js";
|
|
5
|
+
import { formatDuration as i } from "../../audio/duration.js";
|
|
6
|
+
import a from "./VideoPlayer.module.js";
|
|
7
|
+
import { useEffect as o, useRef as s, useState as c } from "react";
|
|
8
|
+
import { jsx as l, jsxs as u } from "react/jsx-runtime";
|
|
9
|
+
import { Maximize as d, Minimize as ee, Pause as te, Play as ne, Volume2 as f, VolumeX as p } from "lucide-react";
|
|
10
|
+
//#region src/components/VideoPlayer/VideoPlayer.tsx
|
|
11
|
+
var re = {
|
|
12
|
+
"pt-BR": {
|
|
13
|
+
play: "Tocar",
|
|
14
|
+
pause: "Pausar",
|
|
15
|
+
seek: "Posição",
|
|
16
|
+
mute: "Silenciar",
|
|
17
|
+
unmute: "Ativar som",
|
|
18
|
+
volume: "Volume",
|
|
19
|
+
rate: "Velocidade",
|
|
20
|
+
enterFullscreen: "Tela cheia",
|
|
21
|
+
exitFullscreen: "Sair da tela cheia"
|
|
22
|
+
},
|
|
23
|
+
en: {
|
|
24
|
+
play: "Play",
|
|
25
|
+
pause: "Pause",
|
|
26
|
+
seek: "Seek",
|
|
27
|
+
mute: "Mute",
|
|
28
|
+
unmute: "Unmute",
|
|
29
|
+
volume: "Volume",
|
|
30
|
+
rate: "Speed",
|
|
31
|
+
enterFullscreen: "Full screen",
|
|
32
|
+
exitFullscreen: "Exit full screen"
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
function ie(e, t) {
|
|
36
|
+
return `${e.toLocaleString(t === "en" ? "en-US" : "pt-BR")}×`;
|
|
37
|
+
}
|
|
38
|
+
function m({ src: m, durationMs: h, poster: g, tracks: _, aspectRatio: v = 16 / 9, rate: y, rates: b = r, onRateChange: x, shiftPitch: S = !1, fullscreen: C = !0, autoPlay: w = !1, loop: T = !1, muted: E = !1, locale: D = "pt-BR", actions: O, onEnded: k, onError: A, disabled: j = !1, className: ae, ...oe }) {
|
|
39
|
+
let M = re[D], N = s(null), P = s(null), F = s(!1), [I, L] = c(!1), [R, z] = c(0), [B, V] = c(Infinity), [H, U] = c(y ?? 1), [W, G] = c(1), [K, q] = c(E), J = t(N), se = n(typeof m == "string" ? null : m), Y = typeof m == "string" ? m : se, X = y ?? H, Z = Number.isFinite(h) ? h : B, Q = Number.isFinite(Z) && Z > 0;
|
|
40
|
+
o(() => {
|
|
41
|
+
z(0), L(!1), V(Infinity), F.current = !1;
|
|
42
|
+
}, [Y]), o(() => {
|
|
43
|
+
let e = P.current;
|
|
44
|
+
e && (e.defaultPlaybackRate = X, e.playbackRate = X, e.preservesPitch = !S);
|
|
45
|
+
}, [
|
|
46
|
+
X,
|
|
47
|
+
S,
|
|
48
|
+
Y
|
|
49
|
+
]), o(() => {
|
|
50
|
+
let e = P.current;
|
|
51
|
+
e && (e.volume = W, e.muted = K);
|
|
52
|
+
}, [
|
|
53
|
+
W,
|
|
54
|
+
K,
|
|
55
|
+
Y
|
|
56
|
+
]);
|
|
57
|
+
let $ = () => {
|
|
58
|
+
let e = P.current;
|
|
59
|
+
if (!e) return;
|
|
60
|
+
if (Number.isFinite(e.duration)) {
|
|
61
|
+
V(e.duration * 1e3);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (F.current || Number.isFinite(h)) return;
|
|
65
|
+
F.current = !0;
|
|
66
|
+
let t = () => {
|
|
67
|
+
e.removeEventListener("timeupdate", t), Number.isFinite(e.duration) && V(e.duration * 1e3), e.currentTime = 0;
|
|
68
|
+
};
|
|
69
|
+
e.addEventListener("timeupdate", t), e.currentTime = 1e101;
|
|
70
|
+
}, ce = () => {
|
|
71
|
+
let e = P.current;
|
|
72
|
+
if (!(!e || !Y)) {
|
|
73
|
+
if (e.paused) {
|
|
74
|
+
Promise.resolve(e.play()).then(() => L(!0)).catch((e) => A?.(e));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
e.pause(), L(!1);
|
|
78
|
+
}
|
|
79
|
+
}, le = (e) => {
|
|
80
|
+
let t = P.current;
|
|
81
|
+
!t || !Q || (t.currentTime = e / 1e3, z(e));
|
|
82
|
+
}, ue = (e) => {
|
|
83
|
+
y === void 0 && U(e), x?.(e);
|
|
84
|
+
};
|
|
85
|
+
return /* @__PURE__ */ u("div", {
|
|
86
|
+
ref: N,
|
|
87
|
+
className: e(a.player, J.isFullscreen && a.immersive, ae),
|
|
88
|
+
...oe,
|
|
89
|
+
children: [/* @__PURE__ */ l("div", {
|
|
90
|
+
className: a.frame,
|
|
91
|
+
style: { aspectRatio: v },
|
|
92
|
+
children: /* @__PURE__ */ l("video", {
|
|
93
|
+
ref: P,
|
|
94
|
+
className: a.video,
|
|
95
|
+
src: Y ?? void 0,
|
|
96
|
+
poster: g,
|
|
97
|
+
loop: T,
|
|
98
|
+
autoPlay: w,
|
|
99
|
+
playsInline: !0,
|
|
100
|
+
preload: "metadata",
|
|
101
|
+
onLoadedMetadata: $,
|
|
102
|
+
onDurationChange: $,
|
|
103
|
+
onTimeUpdate: () => {
|
|
104
|
+
let e = P.current;
|
|
105
|
+
e && z(e.currentTime * 1e3);
|
|
106
|
+
},
|
|
107
|
+
onPlay: () => L(!0),
|
|
108
|
+
onPause: () => L(!1),
|
|
109
|
+
onVolumeChange: () => {
|
|
110
|
+
let e = P.current;
|
|
111
|
+
e && (G(e.volume), q(e.muted));
|
|
112
|
+
},
|
|
113
|
+
onEnded: () => {
|
|
114
|
+
L(!1), k?.();
|
|
115
|
+
},
|
|
116
|
+
onError: (e) => A?.(e),
|
|
117
|
+
children: _?.map((e) => /* @__PURE__ */ l("track", {
|
|
118
|
+
kind: e.kind ?? "captions",
|
|
119
|
+
src: e.src,
|
|
120
|
+
srcLang: e.srcLang,
|
|
121
|
+
label: e.label,
|
|
122
|
+
default: e.default
|
|
123
|
+
}, `${e.kind ?? "captions"}-${e.srcLang}-${e.src}`))
|
|
124
|
+
})
|
|
125
|
+
}), /* @__PURE__ */ u("div", {
|
|
126
|
+
className: a.chrome,
|
|
127
|
+
children: [
|
|
128
|
+
/* @__PURE__ */ l("button", {
|
|
129
|
+
type: "button",
|
|
130
|
+
className: a.transport,
|
|
131
|
+
onClick: ce,
|
|
132
|
+
disabled: j || !Y,
|
|
133
|
+
"aria-label": I ? M.pause : M.play,
|
|
134
|
+
title: I ? M.pause : M.play,
|
|
135
|
+
children: l(I ? te : ne, {
|
|
136
|
+
size: 16,
|
|
137
|
+
"aria-hidden": !0
|
|
138
|
+
})
|
|
139
|
+
}),
|
|
140
|
+
/* @__PURE__ */ l("input", {
|
|
141
|
+
type: "range",
|
|
142
|
+
className: a.seek,
|
|
143
|
+
min: 0,
|
|
144
|
+
max: Q ? Math.round(Z) : 0,
|
|
145
|
+
step: 100,
|
|
146
|
+
value: Math.min(Math.round(R), Q ? Math.round(Z) : 0),
|
|
147
|
+
onChange: (e) => le(Number(e.target.value)),
|
|
148
|
+
disabled: j || !Q,
|
|
149
|
+
"aria-label": M.seek,
|
|
150
|
+
"aria-valuetext": `${i(R)} / ${i(Z)}`
|
|
151
|
+
}),
|
|
152
|
+
/* @__PURE__ */ u("span", {
|
|
153
|
+
className: a.time,
|
|
154
|
+
children: [
|
|
155
|
+
/* @__PURE__ */ l("span", { children: i(R) }),
|
|
156
|
+
/* @__PURE__ */ l("span", {
|
|
157
|
+
"aria-hidden": "true",
|
|
158
|
+
children: "/"
|
|
159
|
+
}),
|
|
160
|
+
/* @__PURE__ */ l("span", { children: i(Z) })
|
|
161
|
+
]
|
|
162
|
+
}),
|
|
163
|
+
/* @__PURE__ */ l("button", {
|
|
164
|
+
type: "button",
|
|
165
|
+
className: a.icon,
|
|
166
|
+
onClick: () => q((e) => !e),
|
|
167
|
+
disabled: j,
|
|
168
|
+
"aria-label": K ? M.unmute : M.mute,
|
|
169
|
+
title: K ? M.unmute : M.mute,
|
|
170
|
+
children: l(K ? p : f, {
|
|
171
|
+
size: 16,
|
|
172
|
+
"aria-hidden": !0
|
|
173
|
+
})
|
|
174
|
+
}),
|
|
175
|
+
/* @__PURE__ */ l("input", {
|
|
176
|
+
type: "range",
|
|
177
|
+
className: a.volume,
|
|
178
|
+
min: 0,
|
|
179
|
+
max: 1,
|
|
180
|
+
step: .05,
|
|
181
|
+
value: K ? 0 : W,
|
|
182
|
+
onChange: (e) => {
|
|
183
|
+
let t = Number(e.target.value);
|
|
184
|
+
G(t), q(t === 0);
|
|
185
|
+
},
|
|
186
|
+
disabled: j,
|
|
187
|
+
"aria-label": M.volume
|
|
188
|
+
}),
|
|
189
|
+
b.length > 0 && /* @__PURE__ */ l("select", {
|
|
190
|
+
className: a.rate,
|
|
191
|
+
value: X,
|
|
192
|
+
onChange: (e) => ue(Number(e.target.value)),
|
|
193
|
+
disabled: j,
|
|
194
|
+
"aria-label": M.rate,
|
|
195
|
+
title: M.rate,
|
|
196
|
+
children: b.map((e) => /* @__PURE__ */ l("option", {
|
|
197
|
+
value: e,
|
|
198
|
+
children: ie(e, D)
|
|
199
|
+
}, e))
|
|
200
|
+
}),
|
|
201
|
+
C && J.supported && /* @__PURE__ */ l("button", {
|
|
202
|
+
type: "button",
|
|
203
|
+
className: a.icon,
|
|
204
|
+
onClick: () => {
|
|
205
|
+
J.toggle().catch((e) => A?.(e));
|
|
206
|
+
},
|
|
207
|
+
disabled: j,
|
|
208
|
+
"aria-label": J.isFullscreen ? M.exitFullscreen : M.enterFullscreen,
|
|
209
|
+
title: J.isFullscreen ? M.exitFullscreen : M.enterFullscreen,
|
|
210
|
+
children: J.isFullscreen ? /* @__PURE__ */ l(ee, {
|
|
211
|
+
size: 16,
|
|
212
|
+
"aria-hidden": !0
|
|
213
|
+
}) : /* @__PURE__ */ l(d, {
|
|
214
|
+
size: 16,
|
|
215
|
+
"aria-hidden": !0
|
|
216
|
+
})
|
|
217
|
+
}),
|
|
218
|
+
O && /* @__PURE__ */ l("span", {
|
|
219
|
+
className: a.actions,
|
|
220
|
+
children: O
|
|
221
|
+
})
|
|
222
|
+
]
|
|
223
|
+
})]
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
export { m as VideoPlayer };
|
|
228
|
+
|
|
229
|
+
//# sourceMappingURL=VideoPlayer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoPlayer.js","names":[],"sources":["../../../src/components/VideoPlayer/VideoPlayer.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count, function-lines — `src` takes a URL or a Blob, and\n * every other prop is a facet of playing it: durationMs (a MediaRecorder WebM may\n * carry none), poster, captions, aspect ratio, the rate control and its presets,\n * pitch, fullscreen, autoPlay/loop/muted, onEnded/onError, plus the actions slot and\n * locale. The body is the transport state machine — object-URL lifecycle, seek, rate,\n * volume — around one video element ref.\n */\nimport { Maximize, Minimize, Pause, Play, Volume2, VolumeX } from \"lucide-react\";\nimport { useEffect, useRef, useState, type HTMLAttributes, type ReactNode } from \"react\";\n\nimport { formatDuration } from \"@/audio/duration\";\nimport { useFullscreen } from \"@/hooks/use-fullscreen\";\nimport { useObjectUrl } from \"@/hooks/use-object-url\";\nimport { cn } from \"@/utils/cn\";\n\nimport { DEFAULT_PLAYBACK_RATES } from \"./playback-rates\";\nimport styles from \"./VideoPlayer.module.css\";\n\n/**\n * DOM attributes this component redefines.\n *\n * `onRateChange` is a real collision, not tidiness: React types it on every\n * element as the media `ratechange` handler, `ReactEventHandler`. Ours carries\n * the rate the viewer picked, and a `(rate: number) => void` is not assignable\n * to a handler that receives an event — same shape of clash as `Input`'s `size`\n * against `HTMLInputElement.size`. The DOM one is useless on a `<div>` anyway:\n * the event fires on the media element inside, which this component owns.\n */\ntype OverriddenDomProps = \"children\" | \"onRateChange\";\n\n/** One caption or subtitle file, rendered as a `<track>`. */\nexport interface VideoTextTrack {\n /** URL of the WebVTT file. */\n src: string;\n /** BCP 47 language tag, e.g. `\"pt-BR\"`. */\n srcLang: string;\n /** Name shown in the browser's own track menu. */\n label: string;\n /** Track kind. Default `\"captions\"`. */\n kind?: \"subtitles\" | \"captions\" | \"descriptions\" | \"chapters\" | \"metadata\";\n /** Whether this track is on unless the viewer picks another. */\n default?: boolean;\n}\n\nexport interface VideoPlayerProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /**\n * What to play — a URL, or a `Blob`/`File` straight from a recorder.\n *\n * A `Blob` is wrapped in an object URL that is revoked when it changes or the\n * component unmounts, so a session that records twenty clips does not leak\n * twenty URLs for the lifetime of the tab.\n */\n src: string | Blob | null;\n /**\n * Known length in milliseconds.\n *\n * Pass it whenever you have it — a recording from `useVideoRecorder` always\n * does. Without it the element's own `duration` is used, and a fresh\n * `MediaRecorder` blob may report `Infinity` until it is probed for one.\n */\n durationMs?: number;\n /** Still shown before playback. Pair it with `captureFrame` from `/imaging`. */\n poster?: string;\n /** Caption and subtitle files. */\n tracks?: readonly VideoTextTrack[];\n /** Frame shape while the video has no intrinsic size yet. Default `16 / 9`. */\n aspectRatio?: number;\n /**\n * Playback rate. Controlled when passed with `onRateChange`.\n *\n * Written to both `playbackRate` and `defaultPlaybackRate`, because the\n * element resets the first to the second when a source loads — a player that\n * only writes `playbackRate` silently drops back to 1× on every clip change.\n */\n rate?: number;\n /** Presets the control offers. `[]` hides it. Default {@link DEFAULT_PLAYBACK_RATES}. */\n rates?: readonly number[];\n /** Called with the rate the viewer picked. */\n onRateChange?: (rate: number) => void;\n /** Let the pitch rise with the rate (`preservesPitch = false`). Default `false`. */\n shiftPitch?: boolean;\n /** Offer a fullscreen button where the browser supports it. Default `true`. */\n fullscreen?: boolean;\n /** Start playing as soon as `src` is ready. Default `false`. */\n autoPlay?: boolean;\n /** Loop. Default `false`. */\n loop?: boolean;\n /** Start muted. Autoplay without a gesture needs this. Default `false`. */\n muted?: boolean;\n /** Locale for the labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Rendered at the end of the control row — a download button, a delete button. */\n actions?: ReactNode;\n /** Fired when playback reaches the end. */\n onEnded?: () => void;\n /** Fired when the element reports a decode/network error. */\n onError?: (error: unknown) => void;\n /** No `src` yet, or playback not allowed. */\n disabled?: boolean;\n}\n\nconst STRINGS = {\n \"pt-BR\": {\n play: \"Tocar\",\n pause: \"Pausar\",\n seek: \"Posição\",\n mute: \"Silenciar\",\n unmute: \"Ativar som\",\n volume: \"Volume\",\n rate: \"Velocidade\",\n enterFullscreen: \"Tela cheia\",\n exitFullscreen: \"Sair da tela cheia\",\n },\n en: {\n play: \"Play\",\n pause: \"Pause\",\n seek: \"Seek\",\n mute: \"Mute\",\n unmute: \"Unmute\",\n volume: \"Volume\",\n rate: \"Speed\",\n enterFullscreen: \"Full screen\",\n exitFullscreen: \"Exit full screen\",\n },\n} as const;\n\n/**\n * Label a rate the way a viewer reads it.\n *\n * @param rate The multiplier.\n * @param locale Which decimal separator to use.\n * @returns e.g. `\"1,5×\"` in pt-BR, `\"1.5×\"` in English.\n */\nfunction rateLabel(rate: number, locale: \"pt-BR\" | \"en\"): string {\n return `${rate.toLocaleString(locale === \"en\" ? \"en-US\" : \"pt-BR\")}×`;\n}\n\n/**\n * Playback transport for one video: play/pause, seek, volume, speed, fullscreen.\n *\n * Built on a real `<video>` element, and deliberately **not** an overlay: the\n * controls sit in a bar under the frame rather than on top of it. Controls drawn\n * over video have to earn their contrast against arbitrary pixels, which no\n * `--tempest-*` token can promise — every text token in this SDK is resolved\n * against a known surface, and the two contrast defects this repo shipped were\n * both a text token used over a surface it was never checked against. A bar with\n * its own surface inherits the theme and is legible by construction.\n *\n * The seek bar, the volume slider and the speed picker are bare `<input>` and\n * `<select>` elements rather than the SDK's `Slider` and `Select`: those are form\n * fields, with a label row and a value badge, and a transport wants neither. The\n * native elements keep the keyboard and screen-reader behaviour that matters here\n * for free.\n *\n * @example\n * const rec = useVideoRecorder(screen.stream);\n * {rec.recording && (\n * <VideoPlayer src={rec.recording.blob} durationMs={rec.recording.durationMs} />\n * )}\n */\nexport function VideoPlayer({\n src,\n durationMs,\n poster,\n tracks,\n aspectRatio = 16 / 9,\n rate,\n rates = DEFAULT_PLAYBACK_RATES,\n onRateChange,\n shiftPitch = false,\n fullscreen = true,\n autoPlay = false,\n loop = false,\n muted = false,\n locale = \"pt-BR\",\n actions,\n onEnded,\n onError,\n disabled = false,\n className,\n ...rest\n}: VideoPlayerProps) {\n const strings = STRINGS[locale];\n const shell = useRef<HTMLDivElement | null>(null);\n const video = useRef<HTMLVideoElement | null>(null);\n const probed = useRef(false);\n\n const [playing, setPlaying] = useState(false);\n const [currentMs, setCurrentMs] = useState(0);\n const [elementMs, setElementMs] = useState(Number.POSITIVE_INFINITY);\n const [ownRate, setOwnRate] = useState(rate ?? 1);\n const [volume, setVolume] = useState(1);\n const [silent, setSilent] = useState(muted);\n\n const immersive = useFullscreen(shell);\n const blobUrl = useObjectUrl(typeof src === \"string\" ? null : src);\n const url = typeof src === \"string\" ? src : blobUrl;\n\n const effectiveRate = rate ?? ownRate;\n const totalMs = Number.isFinite(durationMs) ? (durationMs as number) : elementMs;\n const seekable = Number.isFinite(totalMs) && totalMs > 0;\n\n useEffect(() => {\n setCurrentMs(0);\n setPlaying(false);\n setElementMs(Number.POSITIVE_INFINITY);\n probed.current = false;\n }, [url]);\n\n /**\n * Keep the rate on the element, and keep it across sources.\n *\n * `defaultPlaybackRate` is written alongside `playbackRate` because the load\n * algorithm copies the former onto the latter: writing only `playbackRate`\n * means the rate the viewer chose is silently lost on the next clip.\n */\n useEffect(() => {\n const node = video.current;\n if (!node) return;\n node.defaultPlaybackRate = effectiveRate;\n node.playbackRate = effectiveRate;\n node.preservesPitch = !shiftPitch;\n }, [effectiveRate, shiftPitch, url]);\n\n useEffect(() => {\n const node = video.current;\n if (!node) return;\n node.volume = volume;\n node.muted = silent;\n }, [volume, silent, url]);\n\n /**\n * Coax a duration out of an element that reports `Infinity`.\n *\n * Seeking past the end forces the browser to demux to the last frame, after\n * which it knows the real length. It runs at most once per source, and only\n * when the caller gave us no `durationMs`.\n */\n const handleMetadata = (): void => {\n const node = video.current;\n if (!node) return;\n if (Number.isFinite(node.duration)) {\n setElementMs(node.duration * 1000);\n return;\n }\n if (probed.current || Number.isFinite(durationMs)) return;\n probed.current = true;\n const onProbed = (): void => {\n node.removeEventListener(\"timeupdate\", onProbed);\n if (Number.isFinite(node.duration)) setElementMs(node.duration * 1000);\n node.currentTime = 0;\n };\n node.addEventListener(\"timeupdate\", onProbed);\n node.currentTime = 1e101;\n };\n\n const toggle = (): void => {\n const node = video.current;\n if (!node || !url) return;\n if (node.paused) {\n void Promise.resolve(node.play())\n .then(() => setPlaying(true))\n .catch((error: unknown) => onError?.(error));\n return;\n }\n node.pause();\n setPlaying(false);\n };\n\n const seek = (ms: number): void => {\n const node = video.current;\n if (!node || !seekable) return;\n node.currentTime = ms / 1000;\n setCurrentMs(ms);\n };\n\n const changeRate = (next: number): void => {\n if (rate === undefined) setOwnRate(next);\n onRateChange?.(next);\n };\n\n return (\n <div\n ref={shell}\n className={cn(styles.player, immersive.isFullscreen && styles.immersive, className)}\n {...rest}\n >\n <div className={styles.frame} style={{ aspectRatio }}>\n <video\n ref={video}\n className={styles.video}\n src={url ?? undefined}\n poster={poster}\n loop={loop}\n autoPlay={autoPlay}\n playsInline\n preload=\"metadata\"\n onLoadedMetadata={handleMetadata}\n onDurationChange={handleMetadata}\n onTimeUpdate={() => {\n const node = video.current;\n if (node) setCurrentMs(node.currentTime * 1000);\n }}\n onPlay={() => setPlaying(true)}\n onPause={() => setPlaying(false)}\n onVolumeChange={() => {\n const node = video.current;\n if (!node) return;\n setVolume(node.volume);\n setSilent(node.muted);\n }}\n onEnded={() => {\n setPlaying(false);\n onEnded?.();\n }}\n onError={(event) => onError?.(event)}\n >\n {tracks?.map((track) => (\n <track\n key={`${track.kind ?? \"captions\"}-${track.srcLang}-${track.src}`}\n kind={track.kind ?? \"captions\"}\n src={track.src}\n srcLang={track.srcLang}\n label={track.label}\n default={track.default}\n />\n ))}\n </video>\n </div>\n\n <div className={styles.chrome}>\n <button\n type=\"button\"\n className={styles.transport}\n onClick={toggle}\n disabled={disabled || !url}\n aria-label={playing ? strings.pause : strings.play}\n title={playing ? strings.pause : strings.play}\n >\n {playing ? <Pause size={16} aria-hidden /> : <Play size={16} aria-hidden />}\n </button>\n\n <input\n type=\"range\"\n className={styles.seek}\n min={0}\n max={seekable ? Math.round(totalMs) : 0}\n step={100}\n value={Math.min(Math.round(currentMs), seekable ? Math.round(totalMs) : 0)}\n onChange={(event) => seek(Number(event.target.value))}\n disabled={disabled || !seekable}\n aria-label={strings.seek}\n aria-valuetext={`${formatDuration(currentMs)} / ${formatDuration(totalMs)}`}\n />\n\n <span className={styles.time}>\n <span>{formatDuration(currentMs)}</span>\n <span aria-hidden=\"true\">/</span>\n <span>{formatDuration(totalMs)}</span>\n </span>\n\n <button\n type=\"button\"\n className={styles.icon}\n onClick={() => setSilent((was) => !was)}\n disabled={disabled}\n aria-label={silent ? strings.unmute : strings.mute}\n title={silent ? strings.unmute : strings.mute}\n >\n {silent ? <VolumeX size={16} aria-hidden /> : <Volume2 size={16} aria-hidden />}\n </button>\n\n <input\n type=\"range\"\n className={styles.volume}\n min={0}\n max={1}\n step={0.05}\n value={silent ? 0 : volume}\n onChange={(event) => {\n const next = Number(event.target.value);\n setVolume(next);\n setSilent(next === 0);\n }}\n disabled={disabled}\n aria-label={strings.volume}\n />\n\n {rates.length > 0 && (\n <select\n className={styles.rate}\n value={effectiveRate}\n onChange={(event) => changeRate(Number(event.target.value))}\n disabled={disabled}\n aria-label={strings.rate}\n title={strings.rate}\n >\n {rates.map((option) => (\n <option key={option} value={option}>\n {rateLabel(option, locale)}\n </option>\n ))}\n </select>\n )}\n\n {fullscreen && immersive.supported && (\n <button\n type=\"button\"\n className={styles.icon}\n onClick={() => {\n void immersive.toggle().catch((error: unknown) => onError?.(error));\n }}\n disabled={disabled}\n aria-label={\n immersive.isFullscreen\n ? strings.exitFullscreen\n : strings.enterFullscreen\n }\n title={\n immersive.isFullscreen\n ? strings.exitFullscreen\n : strings.enterFullscreen\n }\n >\n {immersive.isFullscreen ? (\n <Minimize size={16} aria-hidden />\n ) : (\n <Maximize size={16} aria-hidden />\n )}\n </button>\n )}\n\n {actions && <span className={styles.actions}>{actions}</span>}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=`tempest_player_tP0ul`,t=`tempest_frame_ds-xN`,n=`tempest_video_Ejoer`,r=`tempest_chrome_LI2ZI`,i=`tempest_transport_waV9q`,a=`tempest_icon_jIwyC`,o=`tempest_seek_qB6l-`,s=`tempest_volume_R0Fr0`,c=`tempest_rate_Dluev`,l=`tempest_time_z9Tg1`,u=`tempest_actions_UxFoM`,d=`tempest_immersive_eSsR6`,f={player:e,frame:t,video:n,chrome:r,transport:i,icon:a,seek:o,volume:s,rate:c,time:l,actions:u,immersive:d};exports.actions=u,exports.chrome=r,exports.default=f,exports.frame=t,exports.icon=a,exports.immersive=d,exports.player=e,exports.rate=c,exports.seek=o,exports.time=l,exports.transport=i,exports.video=n,exports.volume=s;
|
|
2
|
+
//# sourceMappingURL=VideoPlayer.module.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoPlayer.module.cjs","names":[],"sources":["../../../src/components/VideoPlayer/VideoPlayer.module.css"],"sourcesContent":["/*\n * The controls are a bar under the frame, not an overlay.\n *\n * An overlay has to earn its contrast against arbitrary video pixels, and no\n * `--tempest-*` token can promise that: every text token here is resolved\n * against a known surface, and the two contrast defects this repo shipped were\n * both a text token used over a surface it was never checked against. A bar with\n * its own surface inherits the theme and is legible by construction. An app that\n * wants a cinema overlay can position its own chrome over the frame; what ships\n * is the one that is right in both themes.\n */\n.player {\n display: flex;\n flex-direction: column;\n min-width: 0;\n overflow: hidden;\n border: 1px solid var(--tempest-border);\n border-radius: var(--tempest-radius-lg);\n background-color: var(--tempest-surface);\n color: var(--tempest-text);\n font-family: var(--tempest-font-sans);\n}\n\n/*\n * The frame keeps its shape before the video reports one, so the control bar\n * does not jump down the page when metadata lands. Black rather than a surface\n * token: letterboxing is part of the picture, and a light bar around a dark\n * video reads as a rendering bug.\n */\n.frame {\n position: relative;\n width: 100%;\n background-color: #000000;\n}\n\n.video {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n\n.chrome {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: var(--tempest-space-3);\n padding: var(--tempest-space-2) var(--tempest-space-3);\n border-top: 1px solid var(--tempest-border);\n background-color: var(--tempest-surface);\n}\n\n.transport {\n position: relative;\n display: inline-flex;\n flex: 0 0 auto;\n align-items: center;\n justify-content: center;\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n border: none;\n border-radius: var(--tempest-radius-full);\n background-color: var(--tempest-primary);\n color: var(--tempest-primary-foreground);\n cursor: pointer;\n transition: var(--tempest-transition-color);\n}\n\n.transport:disabled {\n background-color: var(--tempest-surface-3);\n color: var(--tempest-text-subtle);\n cursor: not-allowed;\n}\n\n.icon {\n position: relative;\n display: inline-flex;\n flex: 0 0 auto;\n align-items: center;\n justify-content: center;\n width: 2rem;\n height: 2rem;\n padding: 0;\n border: none;\n border-radius: var(--tempest-radius-md);\n background-color: transparent;\n color: var(--tempest-text-muted);\n cursor: pointer;\n transition: var(--tempest-transition-color);\n}\n\n.icon:hover:not(:disabled) {\n background-color: var(--tempest-surface-2);\n color: var(--tempest-text);\n}\n\n.icon:disabled {\n color: var(--tempest-text-subtle);\n cursor: not-allowed;\n}\n\n/*\n * The track is drawn on the input itself rather than with a wrapper and an\n * overlay: the native element already carries the keyboard behaviour and the\n * value semantics, and the only thing missing is the look.\n */\n.seek {\n flex: 1 1 8rem;\n min-width: 3rem;\n height: 1.25rem;\n margin: 0;\n background: none;\n accent-color: var(--tempest-primary);\n cursor: pointer;\n}\n\n.volume {\n flex: 0 1 4.5rem;\n min-width: 2.5rem;\n height: 1.25rem;\n margin: 0;\n background: none;\n accent-color: var(--tempest-primary);\n cursor: pointer;\n}\n\n.seek:disabled,\n.volume:disabled {\n accent-color: var(--tempest-surface-3);\n cursor: not-allowed;\n}\n\n.rate {\n flex: 0 0 auto;\n padding: var(--tempest-space-1) var(--tempest-space-2);\n border: 1px solid var(--tempest-border);\n border-radius: var(--tempest-radius-md);\n background-color: var(--tempest-surface);\n color: var(--tempest-text);\n font-family: inherit;\n font-size: var(--tempest-text-xs);\n font-variant-numeric: tabular-nums;\n cursor: pointer;\n}\n\n.rate:disabled {\n color: var(--tempest-text-subtle);\n cursor: not-allowed;\n}\n\n.transport:focus-visible,\n.icon:focus-visible,\n.seek:focus-visible,\n.volume:focus-visible,\n.rate:focus-visible {\n outline: var(--tempest-focus-ring-width) solid var(--tempest-focus-ring-color);\n outline-offset: 2px;\n border-radius: var(--tempest-radius-sm);\n}\n\n/*\n * Tabular figures, so the elapsed time does not shift the seek bar sideways as\n * the digits change — a proportional `1` is narrower than a `0`, and at ten\n * updates a second the whole row visibly jitters.\n */\n.time {\n display: inline-flex;\n flex: 0 0 auto;\n gap: var(--tempest-space-1);\n color: var(--tempest-text-muted);\n font-size: var(--tempest-text-xs);\n font-variant-numeric: tabular-nums;\n}\n\n.actions {\n display: inline-flex;\n flex: 0 0 auto;\n align-items: center;\n gap: var(--tempest-space-1);\n}\n\n/*\n * Fullscreen presents the shell, so the frame has to give up its aspect box and\n * take the height that is left over — otherwise a 16/9 box inside a 16/10\n * display leaves the bar floating in the middle of the screen.\n */\n.immersive {\n width: 100%;\n height: 100%;\n border: none;\n border-radius: 0;\n}\n\n.immersive .frame {\n flex: 1 1 auto;\n aspect-ratio: auto !important;\n min-height: 0;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .transport,\n .icon {\n transition: none;\n }\n}\n\n/* Touch hit-slop — 44×44 without changing the visual footprint, as `Button` does. */\n@media (pointer: coarse) {\n .transport::after,\n .icon::after {\n content: \"\";\n position: absolute;\n inset: -6px;\n }\n}\n\n/*\n * On a phone the row cannot hold seven controls. The seek bar takes a line of its\n * own and the rest wraps under it, which keeps every target at full size instead\n * of shrinking them all below the touch minimum.\n */\n@media (max-width: 480px) {\n .chrome {\n gap: var(--tempest-space-2);\n padding: var(--tempest-space-2);\n }\n\n .seek {\n flex: 1 1 100%;\n order: -1;\n }\n\n /*\n * No volume slider on a phone. Measured at 390px in Chromium: the second row\n * came to exactly the container width, which pushed the fullscreen button\n * onto a third row — spent on a slider nobody drags on a phone, where the\n * hardware keys do that job. The mute button stays, because silencing fast\n * is the thing people actually want from a video in a feed.\n */\n .volume {\n display: none;\n }\n}\n"],"mappings":""}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/components/VideoPlayer/VideoPlayer.module.css
|
|
2
|
+
var e = "tempest_player_tP0ul", t = "tempest_frame_ds-xN", n = "tempest_video_Ejoer", r = "tempest_chrome_LI2ZI", i = "tempest_transport_waV9q", a = "tempest_icon_jIwyC", o = "tempest_seek_qB6l-", s = "tempest_volume_R0Fr0", c = "tempest_rate_Dluev", l = "tempest_time_z9Tg1", u = "tempest_actions_UxFoM", d = "tempest_immersive_eSsR6", f = {
|
|
3
|
+
player: e,
|
|
4
|
+
frame: t,
|
|
5
|
+
video: n,
|
|
6
|
+
chrome: r,
|
|
7
|
+
transport: i,
|
|
8
|
+
icon: a,
|
|
9
|
+
seek: o,
|
|
10
|
+
volume: s,
|
|
11
|
+
rate: c,
|
|
12
|
+
time: l,
|
|
13
|
+
actions: u,
|
|
14
|
+
immersive: d
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { u as actions, r as chrome, f as default, t as frame, a as icon, d as immersive, e as player, c as rate, o as seek, l as time, i as transport, n as video, s as volume };
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=VideoPlayer.module.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoPlayer.module.js","names":[],"sources":["../../../src/components/VideoPlayer/VideoPlayer.module.css"],"sourcesContent":["/*\n * The controls are a bar under the frame, not an overlay.\n *\n * An overlay has to earn its contrast against arbitrary video pixels, and no\n * `--tempest-*` token can promise that: every text token here is resolved\n * against a known surface, and the two contrast defects this repo shipped were\n * both a text token used over a surface it was never checked against. A bar with\n * its own surface inherits the theme and is legible by construction. An app that\n * wants a cinema overlay can position its own chrome over the frame; what ships\n * is the one that is right in both themes.\n */\n.player {\n display: flex;\n flex-direction: column;\n min-width: 0;\n overflow: hidden;\n border: 1px solid var(--tempest-border);\n border-radius: var(--tempest-radius-lg);\n background-color: var(--tempest-surface);\n color: var(--tempest-text);\n font-family: var(--tempest-font-sans);\n}\n\n/*\n * The frame keeps its shape before the video reports one, so the control bar\n * does not jump down the page when metadata lands. Black rather than a surface\n * token: letterboxing is part of the picture, and a light bar around a dark\n * video reads as a rendering bug.\n */\n.frame {\n position: relative;\n width: 100%;\n background-color: #000000;\n}\n\n.video {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n\n.chrome {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: var(--tempest-space-3);\n padding: var(--tempest-space-2) var(--tempest-space-3);\n border-top: 1px solid var(--tempest-border);\n background-color: var(--tempest-surface);\n}\n\n.transport {\n position: relative;\n display: inline-flex;\n flex: 0 0 auto;\n align-items: center;\n justify-content: center;\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n border: none;\n border-radius: var(--tempest-radius-full);\n background-color: var(--tempest-primary);\n color: var(--tempest-primary-foreground);\n cursor: pointer;\n transition: var(--tempest-transition-color);\n}\n\n.transport:disabled {\n background-color: var(--tempest-surface-3);\n color: var(--tempest-text-subtle);\n cursor: not-allowed;\n}\n\n.icon {\n position: relative;\n display: inline-flex;\n flex: 0 0 auto;\n align-items: center;\n justify-content: center;\n width: 2rem;\n height: 2rem;\n padding: 0;\n border: none;\n border-radius: var(--tempest-radius-md);\n background-color: transparent;\n color: var(--tempest-text-muted);\n cursor: pointer;\n transition: var(--tempest-transition-color);\n}\n\n.icon:hover:not(:disabled) {\n background-color: var(--tempest-surface-2);\n color: var(--tempest-text);\n}\n\n.icon:disabled {\n color: var(--tempest-text-subtle);\n cursor: not-allowed;\n}\n\n/*\n * The track is drawn on the input itself rather than with a wrapper and an\n * overlay: the native element already carries the keyboard behaviour and the\n * value semantics, and the only thing missing is the look.\n */\n.seek {\n flex: 1 1 8rem;\n min-width: 3rem;\n height: 1.25rem;\n margin: 0;\n background: none;\n accent-color: var(--tempest-primary);\n cursor: pointer;\n}\n\n.volume {\n flex: 0 1 4.5rem;\n min-width: 2.5rem;\n height: 1.25rem;\n margin: 0;\n background: none;\n accent-color: var(--tempest-primary);\n cursor: pointer;\n}\n\n.seek:disabled,\n.volume:disabled {\n accent-color: var(--tempest-surface-3);\n cursor: not-allowed;\n}\n\n.rate {\n flex: 0 0 auto;\n padding: var(--tempest-space-1) var(--tempest-space-2);\n border: 1px solid var(--tempest-border);\n border-radius: var(--tempest-radius-md);\n background-color: var(--tempest-surface);\n color: var(--tempest-text);\n font-family: inherit;\n font-size: var(--tempest-text-xs);\n font-variant-numeric: tabular-nums;\n cursor: pointer;\n}\n\n.rate:disabled {\n color: var(--tempest-text-subtle);\n cursor: not-allowed;\n}\n\n.transport:focus-visible,\n.icon:focus-visible,\n.seek:focus-visible,\n.volume:focus-visible,\n.rate:focus-visible {\n outline: var(--tempest-focus-ring-width) solid var(--tempest-focus-ring-color);\n outline-offset: 2px;\n border-radius: var(--tempest-radius-sm);\n}\n\n/*\n * Tabular figures, so the elapsed time does not shift the seek bar sideways as\n * the digits change — a proportional `1` is narrower than a `0`, and at ten\n * updates a second the whole row visibly jitters.\n */\n.time {\n display: inline-flex;\n flex: 0 0 auto;\n gap: var(--tempest-space-1);\n color: var(--tempest-text-muted);\n font-size: var(--tempest-text-xs);\n font-variant-numeric: tabular-nums;\n}\n\n.actions {\n display: inline-flex;\n flex: 0 0 auto;\n align-items: center;\n gap: var(--tempest-space-1);\n}\n\n/*\n * Fullscreen presents the shell, so the frame has to give up its aspect box and\n * take the height that is left over — otherwise a 16/9 box inside a 16/10\n * display leaves the bar floating in the middle of the screen.\n */\n.immersive {\n width: 100%;\n height: 100%;\n border: none;\n border-radius: 0;\n}\n\n.immersive .frame {\n flex: 1 1 auto;\n aspect-ratio: auto !important;\n min-height: 0;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .transport,\n .icon {\n transition: none;\n }\n}\n\n/* Touch hit-slop — 44×44 without changing the visual footprint, as `Button` does. */\n@media (pointer: coarse) {\n .transport::after,\n .icon::after {\n content: \"\";\n position: absolute;\n inset: -6px;\n }\n}\n\n/*\n * On a phone the row cannot hold seven controls. The seek bar takes a line of its\n * own and the rest wraps under it, which keeps every target at full size instead\n * of shrinking them all below the touch minimum.\n */\n@media (max-width: 480px) {\n .chrome {\n gap: var(--tempest-space-2);\n padding: var(--tempest-space-2);\n }\n\n .seek {\n flex: 1 1 100%;\n order: -1;\n }\n\n /*\n * No volume slider on a phone. Measured at 390px in Chromium: the second row\n * came to exactly the container width, which pushed the fullscreen button\n * onto a third row — spent on a slider nobody drags on a phone, where the\n * hardware keys do that job. The mute button stays, because silencing fast\n * is the thing people actually want from a video in a feed.\n */\n .volume {\n display: none;\n }\n}\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"playback-rates.cjs","names":[],"sources":["../../../src/components/VideoPlayer/playback-rates.ts"],"sourcesContent":["/**\n * The speed presets the player offers, and why they stop at 2×.\n *\n * The spec lets a browser drop the audio once `playbackRate` leaves a range it\n * considers useful, and that limit varies by engine. These four sit inside every\n * range measured, so the default control never silences a clip by accident. A\n * caller who wants 4× can pass it — and should test it with sound on the\n * browsers they support before promising it.\n */\nexport const DEFAULT_PLAYBACK_RATES: readonly number[] = [0.5, 1, 1.5, 2];\n"],"mappings":"AASA,IAAa,EAA4C,CAAC,GAAK,EAAG,IAAK,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"playback-rates.js","names":[],"sources":["../../../src/components/VideoPlayer/playback-rates.ts"],"sourcesContent":["/**\n * The speed presets the player offers, and why they stop at 2×.\n *\n * The spec lets a browser drop the audio once `playbackRate` leaves a range it\n * considers useful, and that limit varies by engine. These four sit inside every\n * range measured, so the default control never silences a clip by accident. A\n * caller who wants 4× can pass it — and should test it with sound on the\n * browsers they support before promising it.\n */\nexport const DEFAULT_PLAYBACK_RATES: readonly number[] = [0.5, 1, 1.5, 2];\n"],"mappings":";AASA,IAAa,IAA4C;CAAC;CAAK;CAAG;CAAK;AAAC"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var e=class extends Error{constructor(e,t){super(e,t),this.name=`ImagingError`}},t=class extends e{constructor(e,t){super(e,t),this.name=`ImageDecodeError`}},n=class extends e{constructor(e,t){super(e,t),this.name=`ImageEncodeError`}},r=class extends e{constructor(e,t){super(e,t),this.name=`UnsupportedImageTypeError`}},i=class extends e{constructor(e,t){super(e,t),this.name=`ImagingUnavailableError`}};exports.ImageDecodeError=t,exports.ImageEncodeError=n,exports.ImagingError=e,exports.ImagingUnavailableError=
|
|
1
|
+
var e=class extends Error{constructor(e,t){super(e,t),this.name=`ImagingError`}},t=class extends e{constructor(e,t){super(e,t),this.name=`ImageDecodeError`}},n=class extends e{constructor(e,t){super(e,t),this.name=`ImageEncodeError`}},r=class extends e{constructor(e,t){super(e,t),this.name=`UnsupportedImageTypeError`}},i=class extends e{constructor(e,t){super(e,t),this.name=`FrameSeekError`}},a=class extends e{constructor(e,t){super(e,t),this.name=`ImagingUnavailableError`}};exports.FrameSeekError=i,exports.ImageDecodeError=t,exports.ImageEncodeError=n,exports.ImagingError=e,exports.ImagingUnavailableError=a,exports.UnsupportedImageTypeError=r;
|
|
2
2
|
//# sourceMappingURL=exceptions.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"exceptions.cjs","names":[],"sources":["../../src/imaging/exceptions.ts"],"sourcesContent":["/**\n * Errors thrown by the imaging module.\n *\n * `name` is a literal string on every subclass: minifiers rename classes,\n * and a derived `new.target.name` ships as `error.name === \"t\"`.\n */\n\n/** Base class for every error this module throws. */\nexport class ImagingError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImagingError\";\n }\n}\n\n/** The source could not be decoded into pixels. */\nexport class ImageDecodeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImageDecodeError\";\n }\n}\n\n/** The canvas could not produce encoded bytes. */\nexport class ImageEncodeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImageEncodeError\";\n }\n}\n\n/**\n * The browser cannot encode the requested format.\n *\n * Worth its own class because the failure is otherwise silent: asking a\n * canvas for an unsupported type does not throw — it hands back a PNG,\n * and an app that trusted the request ships 4 MB where it expected 300 KB.\n */\nexport class UnsupportedImageTypeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"UnsupportedImageTypeError\";\n }\n}\n\n/** The environment has no canvas to draw on. */\nexport class ImagingUnavailableError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImagingUnavailableError\";\n }\n}\n"],"mappings":"AAQA,IAAa,EAAb,cAAkC,KAAM,CACpC,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,cAChB,CACJ,EAGa,EAAb,cAAsC,CAAa,CAC/C,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,kBAChB,CACJ,EAGa,EAAb,cAAsC,CAAa,CAC/C,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,kBAChB,CACJ,EASa,EAAb,cAA+C,CAAa,CACxD,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,2BAChB,CACJ,EAGa,EAAb,cAA6C,CAAa,CACtD,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,yBAChB,CACJ"}
|
|
1
|
+
{"version":3,"file":"exceptions.cjs","names":[],"sources":["../../src/imaging/exceptions.ts"],"sourcesContent":["/**\n * Errors thrown by the imaging module.\n *\n * `name` is a literal string on every subclass: minifiers rename classes,\n * and a derived `new.target.name` ships as `error.name === \"t\"`.\n */\n\n/** Base class for every error this module throws. */\nexport class ImagingError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImagingError\";\n }\n}\n\n/** The source could not be decoded into pixels. */\nexport class ImageDecodeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImageDecodeError\";\n }\n}\n\n/** The canvas could not produce encoded bytes. */\nexport class ImageEncodeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImageEncodeError\";\n }\n}\n\n/**\n * The browser cannot encode the requested format.\n *\n * Worth its own class because the failure is otherwise silent: asking a\n * canvas for an unsupported type does not throw — it hands back a PNG,\n * and an app that trusted the request ships 4 MB where it expected 300 KB.\n */\nexport class UnsupportedImageTypeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"UnsupportedImageTypeError\";\n }\n}\n\n/**\n * The frame asked for never arrived.\n *\n * Its own class because the alternative is the failure this module refuses to\n * produce: a frame from the wrong instant. `seeked` firing does not mean the\n * frame for the new position is composited and readable, so a capture that\n * gave up waiting has to say so — a thumbnail of the neighbouring frame looks\n * exactly like a correct one, and nothing downstream can tell.\n */\nexport class FrameSeekError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"FrameSeekError\";\n }\n}\n\n/** The environment has no canvas to draw on. */\nexport class ImagingUnavailableError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImagingUnavailableError\";\n }\n}\n"],"mappings":"AAQA,IAAa,EAAb,cAAkC,KAAM,CACpC,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,cAChB,CACJ,EAGa,EAAb,cAAsC,CAAa,CAC/C,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,kBAChB,CACJ,EAGa,EAAb,cAAsC,CAAa,CAC/C,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,kBAChB,CACJ,EASa,EAAb,cAA+C,CAAa,CACxD,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,2BAChB,CACJ,EAWa,EAAb,cAAoC,CAAa,CAC7C,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,gBAChB,CACJ,EAGa,EAAb,cAA6C,CAAa,CACtD,YAAY,EAAiB,EAAwB,CACjD,MAAM,EAAS,CAAO,EACtB,KAAK,KAAO,yBAChB,CACJ"}
|
|
@@ -16,11 +16,15 @@ var e = class extends Error {
|
|
|
16
16
|
super(e, t), this.name = "UnsupportedImageTypeError";
|
|
17
17
|
}
|
|
18
18
|
}, i = class extends e {
|
|
19
|
+
constructor(e, t) {
|
|
20
|
+
super(e, t), this.name = "FrameSeekError";
|
|
21
|
+
}
|
|
22
|
+
}, a = class extends e {
|
|
19
23
|
constructor(e, t) {
|
|
20
24
|
super(e, t), this.name = "ImagingUnavailableError";
|
|
21
25
|
}
|
|
22
26
|
};
|
|
23
27
|
//#endregion
|
|
24
|
-
export { t as ImageDecodeError, n as ImageEncodeError, e as ImagingError,
|
|
28
|
+
export { i as FrameSeekError, t as ImageDecodeError, n as ImageEncodeError, e as ImagingError, a as ImagingUnavailableError, r as UnsupportedImageTypeError };
|
|
25
29
|
|
|
26
30
|
//# sourceMappingURL=exceptions.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"exceptions.js","names":[],"sources":["../../src/imaging/exceptions.ts"],"sourcesContent":["/**\n * Errors thrown by the imaging module.\n *\n * `name` is a literal string on every subclass: minifiers rename classes,\n * and a derived `new.target.name` ships as `error.name === \"t\"`.\n */\n\n/** Base class for every error this module throws. */\nexport class ImagingError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImagingError\";\n }\n}\n\n/** The source could not be decoded into pixels. */\nexport class ImageDecodeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImageDecodeError\";\n }\n}\n\n/** The canvas could not produce encoded bytes. */\nexport class ImageEncodeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImageEncodeError\";\n }\n}\n\n/**\n * The browser cannot encode the requested format.\n *\n * Worth its own class because the failure is otherwise silent: asking a\n * canvas for an unsupported type does not throw — it hands back a PNG,\n * and an app that trusted the request ships 4 MB where it expected 300 KB.\n */\nexport class UnsupportedImageTypeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"UnsupportedImageTypeError\";\n }\n}\n\n/** The environment has no canvas to draw on. */\nexport class ImagingUnavailableError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImagingUnavailableError\";\n }\n}\n"],"mappings":";AAQA,IAAa,IAAb,cAAkC,MAAM;CACpC,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ,GAGa,IAAb,cAAsC,EAAa;CAC/C,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ,GAGa,IAAb,cAAsC,EAAa;CAC/C,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ,GASa,IAAb,cAA+C,EAAa;CACxD,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ,GAGa,IAAb,cAA6C,EAAa;CACtD,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ"}
|
|
1
|
+
{"version":3,"file":"exceptions.js","names":[],"sources":["../../src/imaging/exceptions.ts"],"sourcesContent":["/**\n * Errors thrown by the imaging module.\n *\n * `name` is a literal string on every subclass: minifiers rename classes,\n * and a derived `new.target.name` ships as `error.name === \"t\"`.\n */\n\n/** Base class for every error this module throws. */\nexport class ImagingError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImagingError\";\n }\n}\n\n/** The source could not be decoded into pixels. */\nexport class ImageDecodeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImageDecodeError\";\n }\n}\n\n/** The canvas could not produce encoded bytes. */\nexport class ImageEncodeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImageEncodeError\";\n }\n}\n\n/**\n * The browser cannot encode the requested format.\n *\n * Worth its own class because the failure is otherwise silent: asking a\n * canvas for an unsupported type does not throw — it hands back a PNG,\n * and an app that trusted the request ships 4 MB where it expected 300 KB.\n */\nexport class UnsupportedImageTypeError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"UnsupportedImageTypeError\";\n }\n}\n\n/**\n * The frame asked for never arrived.\n *\n * Its own class because the alternative is the failure this module refuses to\n * produce: a frame from the wrong instant. `seeked` firing does not mean the\n * frame for the new position is composited and readable, so a capture that\n * gave up waiting has to say so — a thumbnail of the neighbouring frame looks\n * exactly like a correct one, and nothing downstream can tell.\n */\nexport class FrameSeekError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"FrameSeekError\";\n }\n}\n\n/** The environment has no canvas to draw on. */\nexport class ImagingUnavailableError extends ImagingError {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"ImagingUnavailableError\";\n }\n}\n"],"mappings":";AAQA,IAAa,IAAb,cAAkC,MAAM;CACpC,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ,GAGa,IAAb,cAAsC,EAAa;CAC/C,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ,GAGa,IAAb,cAAsC,EAAa;CAC/C,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ,GASa,IAAb,cAA+C,EAAa;CACxD,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ,GAWa,IAAb,cAAoC,EAAa;CAC7C,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ,GAGa,IAAb,cAA6C,EAAa;CACtD,YAAY,GAAiB,GAAwB;EAEjD,AADA,MAAM,GAAS,CAAO,GACtB,KAAK,OAAO;CAChB;AACJ"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("./exceptions.cjs"),t=require("./transform.cjs");var n=3e3,r=2,i=.001,a=200;function o(e){return typeof e.requestVideoFrameCallback==`function`}var s=1e101;function c(){return new DOMException(`The frame capture was aborted.`,`AbortError`)}function l(t,n,r,i,a){return new Promise((o,s)=>{let l=e=>{clearTimeout(f),t.removeEventListener(n,u),i?.removeEventListener(`abort`,d),e()},u=()=>l(o),d=()=>l(()=>s(c())),f=setTimeout(()=>l(()=>s(new e.FrameSeekError(a))),r);t.addEventListener(n,u),i?.addEventListener(`abort`,d)})}function u(e){return new Promise(t=>{let n=setTimeout(()=>{e.cancelVideoFrameCallback(r),t(!1)},a),r=e.requestVideoFrameCallback(()=>{clearTimeout(n),t(!0)})})}function d(){return typeof requestAnimationFrame==`function`?new Promise(e=>{requestAnimationFrame(()=>requestAnimationFrame(()=>e()))}):new Promise(e=>setTimeout(e,0))}async function f(e,t){return!e.paused&&o(e)?await u(e):(t&&await d(),!1)}function p(e){return!!e.srcObject}async function m(t,n,r){if(Number.isFinite(t.duration))return t.duration;if(p(t))throw new e.FrameSeekError("This element is playing a live MediaStream, which has no timeline to seek: there is no instant but now. Leave `atMs` out to capture the frame on screen.");let i=l(t,`timeupdate`,n,r,`The video reported no duration and the probe for it did not answer within ${n}ms, so there is no timeline to seek on.`);if(t.currentTime=s,await i,!Number.isFinite(t.duration))throw new e.FrameSeekError("This video still reports no duration after seeking past its end, so it has no seekable timeline — a live HLS or DASH source behaves this way. Leave `atMs` out to capture the frame on screen.");return t.duration}async function h(e,t,n,r){let a=await m(e,n,r),o=Math.min(Math.max(t/1e3,0),a);if(Math.abs(e.currentTime-o)<i)return await f(e,!1);let s=l(e,`seeked`,n,r,`The video did not finish seeking to ${o.toFixed(3)}s within ${n}ms. Nothing was captured: a frame from the wrong instant is indistinguishable from a correct one downstream.`);return e.currentTime=o,await s,await f(e,!0)}function g(t){return[t,t?.cause].map(e=>typeof e==`object`&&e&&`name`in e?String(e.name):``).includes(`SecurityError`)?new e.ImageDecodeError(`The video is cross-origin, so reading its pixels is not allowed. Set crossOrigin="anonymous" on the element before the source loads, and serve the video with a permissive Access-Control-Allow-Origin.`,{cause:t}):t}function _(e,t,n){e.currentTime!==t&&(e.currentTime=t),n&&e.paused&&Promise.resolve(e.play()).catch(()=>void 0)}async function v(e,n={}){let i=n.timeoutMs??3e3;if(n.signal?.aborted===!0)throw c();e.readyState<r&&await l(e,`loadeddata`,i,n.signal,`The video had no frame to read within ${i}ms (readyState ${e.readyState}). Give it a source, or wait for it yourself.`);let{atMs:a}=n,o=e.currentTime,s=!e.paused;try{let r;a===void 0?r=await f(e,!1):(s&&e.pause(),r=await h(e,a,i,n.signal));let o=e.currentTime*1e3;return{...await t.resizeImage(e,n),atMs:o,confirmed:r}}catch(e){throw g(e)}finally{n.restore!==!1&&_(e,o,s)}}exports.DEFAULT_FRAME_TIMEOUT_MS=n,exports.captureFrame=v;
|
|
2
|
+
//# sourceMappingURL=frame.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"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":"iEAyDA,IAAa,EAA2B,IAGlC,EAAoB,EAGpB,EAAqB,KAUrB,EAAkB,IAyBxB,SAAS,EAAc,EAAsD,CACzE,OAAO,OAAQ,EAAsC,2BAA8B,UACvF,CAGA,IAAM,EAAuB,MAG7B,SAAS,GAA2B,CAChC,OAAO,IAAI,aAAa,iCAAkC,YAAY,CAC1E,CAaA,SAAS,EACL,EACA,EACA,EACA,EACA,EACa,CACb,OAAO,IAAI,SAAe,EAAS,IAAW,CAC1C,IAAM,EAAU,GAA6B,CACzC,aAAa,CAAK,EAClB,EAAM,oBAAoB,EAAM,CAAO,EACvC,GAAQ,oBAAoB,QAAS,CAAO,EAC5C,EAAO,CACX,EACM,MAAsB,EAAO,CAAO,EACpC,MAAsB,MAAa,EAAO,EAAW,CAAC,CAAC,EAEvD,EAAQ,eACJ,MAAa,EAAO,IAAI,EAAA,eAAe,CAAQ,CAAC,CAAC,EACvD,CACJ,EACA,EAAM,iBAAiB,EAAM,CAAO,EACpC,GAAQ,iBAAiB,QAAS,CAAO,CAC7C,CAAC,CACL,CAYA,SAAS,EAAmB,EAA6C,CACrE,OAAO,IAAI,QAAkB,GAAY,CACrC,IAAM,EAAQ,eAAiB,CAC3B,EAAM,yBAAyB,CAAM,EACrC,EAAQ,EAAK,CACjB,EAAG,CAAe,EACZ,EAAS,EAAM,8BAAgC,CACjD,aAAa,CAAK,EAClB,EAAQ,EAAI,CAChB,CAAC,CACL,CAAC,CACL,CAYA,SAAS,GAAiC,CAMtC,OALI,OAAO,uBAA0B,WAC1B,IAAI,QAAe,GAAY,CAClC,0BAA4B,0BAA4B,EAAQ,CAAC,CAAC,CACtE,CAAC,EAEE,IAAI,QAAe,GAAY,WAAW,EAAS,CAAC,CAAC,CAChE,CASA,eAAe,EAAmB,EAAyB,EAAmC,CAG1F,MAFI,CAAC,EAAM,QAAU,EAAc,CAAK,EAAU,MAAM,EAAmB,CAAK,GAC5E,GAAQ,MAAM,EAAgB,EAC3B,GACX,CAYA,SAAS,EAAa,EAAkC,CACpD,MAAO,EAAQ,EAAM,SACzB,CAiBA,eAAe,EACX,EACA,EACA,EACe,CACf,GAAI,OAAO,SAAS,EAAM,QAAQ,EAAG,OAAO,EAAM,SAElD,GAAI,EAAa,CAAK,EAClB,MAAM,IAAI,EAAA,eACN,0JAEJ,EAGJ,IAAM,EAAS,EACX,EACA,aACA,EACA,EACA,6EACO,EAAU,wCACrB,EAIA,GAHA,EAAM,YAAc,EACpB,MAAM,EAEF,CAAC,OAAO,SAAS,EAAM,QAAQ,EAC/B,MAAM,IAAI,EAAA,eACN,gMAGJ,EAEJ,OAAO,EAAM,QACjB,CAaA,eAAe,EACX,EACA,EACA,EACA,EACgB,CAChB,IAAM,EAAW,MAAM,EAAgB,EAAO,EAAW,CAAM,EAEzD,EAAS,KAAK,IAAI,KAAK,IAAI,EAAO,IAAM,CAAC,EAAG,CAAQ,EAC1D,GAAI,KAAK,IAAI,EAAM,YAAc,CAAM,EAAI,EACvC,OAAO,MAAM,EAAmB,EAAO,EAAK,EAGhD,IAAM,EAAS,EACX,EACA,SACA,EACA,EACA,uCAAuC,EAAO,QAAQ,CAAC,EAAE,WAAW,EAAU,6GAGlF,EAGA,MAFA,GAAM,YAAc,EACpB,MAAM,EACC,MAAM,EAAmB,EAAO,EAAI,CAC/C,CAYA,SAAS,EAAU,EAAyB,CAOxC,MANc,CAAC,EAAQ,GAAsC,KAAK,CAAC,CAAC,IAAK,GACrE,OAAO,GAAc,UAAY,GAAsB,SAAU,EAC3D,OAAQ,EAAgC,IAAI,EAC5C,EAEL,CAAA,CAAM,SAAS,eAAe,EAC5B,IAAI,EAAA,iBACP,0MAGA,CAAE,MAAO,CAAM,CACnB,EAN6C,CAOjD,CAeA,SAAS,EAAgB,EAAyB,EAAc,EAA2B,CACnF,EAAM,cAAgB,IAAM,EAAM,YAAc,GAChD,GAAc,EAAM,QAAQ,QAAa,QAAQ,EAAM,KAAK,CAAC,CAAC,CAAC,UAAY,IAAA,EAAS,CAC5F,CAqCA,eAAsB,EAClB,EACA,EAA+B,CAAC,EACV,CACtB,IAAM,EAAY,EAAQ,WAAA,IAC1B,GAAI,EAAQ,QAAQ,UAAY,GAAM,MAAM,EAAW,EAEnD,EAAM,WAAa,GACnB,MAAM,EACF,EACA,aACA,EACA,EAAQ,OACR,yCAAyC,EAAU,iBAC5C,EAAM,WAAW,8CAC5B,EAGJ,GAAM,CAAE,KAAM,GAAgB,EACxB,EAAe,EAAM,YACrB,EAAa,CAAC,EAAM,OAE1B,GAAI,CACA,IAAI,EACA,IAAgB,IAAA,GAChB,EAAY,MAAM,EAAmB,EAAO,EAAK,GAE7C,GAAY,EAAM,MAAM,EAC5B,EAAY,MAAM,EAAO,EAAO,EAAa,EAAW,EAAQ,MAAM,GAG1E,IAAM,EAAO,EAAM,YAAc,IAEjC,MAAO,CAAE,GAAG,MADU,EAAA,YAAY,EAAO,CAAO,EAC3B,OAAM,WAAU,CACzC,OAAS,EAAO,CACZ,MAAM,EAAU,CAAK,CACzB,QAAU,CACF,EAAQ,UAAY,IAAO,EAAgB,EAAO,EAAc,CAAU,CAClF,CACJ"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { FrameSeekError as e, ImageDecodeError as t } from "./exceptions.js";
|
|
2
|
+
import { resizeImage as n } from "./transform.js";
|
|
3
|
+
//#region src/imaging/frame.ts
|
|
4
|
+
var r = 3e3, i = 2, a = .001, o = 200;
|
|
5
|
+
function s(e) {
|
|
6
|
+
return typeof e.requestVideoFrameCallback == "function";
|
|
7
|
+
}
|
|
8
|
+
var c = 1e101;
|
|
9
|
+
function l() {
|
|
10
|
+
return new DOMException("The frame capture was aborted.", "AbortError");
|
|
11
|
+
}
|
|
12
|
+
function u(t, n, r, i, a) {
|
|
13
|
+
return new Promise((o, s) => {
|
|
14
|
+
let c = (e) => {
|
|
15
|
+
clearTimeout(f), t.removeEventListener(n, u), i?.removeEventListener("abort", d), e();
|
|
16
|
+
}, u = () => c(o), d = () => c(() => s(l())), f = setTimeout(() => c(() => s(new e(a))), r);
|
|
17
|
+
t.addEventListener(n, u), i?.addEventListener("abort", d);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function d(e) {
|
|
21
|
+
return new Promise((t) => {
|
|
22
|
+
let n = setTimeout(() => {
|
|
23
|
+
e.cancelVideoFrameCallback(r), t(!1);
|
|
24
|
+
}, o), r = e.requestVideoFrameCallback(() => {
|
|
25
|
+
clearTimeout(n), t(!0);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function f() {
|
|
30
|
+
return typeof requestAnimationFrame == "function" ? new Promise((e) => {
|
|
31
|
+
requestAnimationFrame(() => requestAnimationFrame(() => e()));
|
|
32
|
+
}) : new Promise((e) => setTimeout(e, 0));
|
|
33
|
+
}
|
|
34
|
+
async function p(e, t) {
|
|
35
|
+
return !e.paused && s(e) ? await d(e) : (t && await f(), !1);
|
|
36
|
+
}
|
|
37
|
+
function m(e) {
|
|
38
|
+
return !!e.srcObject;
|
|
39
|
+
}
|
|
40
|
+
async function h(t, n, r) {
|
|
41
|
+
if (Number.isFinite(t.duration)) return t.duration;
|
|
42
|
+
if (m(t)) throw new e("This element is playing a live MediaStream, which has no timeline to seek: there is no instant but now. Leave `atMs` out to capture the frame on screen.");
|
|
43
|
+
let i = u(t, "timeupdate", n, r, `The video reported no duration and the probe for it did not answer within ${n}ms, so there is no timeline to seek on.`);
|
|
44
|
+
if (t.currentTime = c, await i, !Number.isFinite(t.duration)) throw new e("This video still reports no duration after seeking past its end, so it has no seekable timeline — a live HLS or DASH source behaves this way. Leave `atMs` out to capture the frame on screen.");
|
|
45
|
+
return t.duration;
|
|
46
|
+
}
|
|
47
|
+
async function g(e, t, n, r) {
|
|
48
|
+
let i = await h(e, n, r), o = Math.min(Math.max(t / 1e3, 0), i);
|
|
49
|
+
if (Math.abs(e.currentTime - o) < a) return await p(e, !1);
|
|
50
|
+
let s = u(e, "seeked", n, r, `The video did not finish seeking to ${o.toFixed(3)}s within ${n}ms. Nothing was captured: a frame from the wrong instant is indistinguishable from a correct one downstream.`);
|
|
51
|
+
return e.currentTime = o, await s, await p(e, !0);
|
|
52
|
+
}
|
|
53
|
+
function _(e) {
|
|
54
|
+
return [e, e?.cause].map((e) => typeof e == "object" && e && "name" in e ? String(e.name) : "").includes("SecurityError") ? new t("The video is cross-origin, so reading its pixels is not allowed. Set crossOrigin=\"anonymous\" on the element before the source loads, and serve the video with a permissive Access-Control-Allow-Origin.", { cause: e }) : e;
|
|
55
|
+
}
|
|
56
|
+
function v(e, t, n) {
|
|
57
|
+
e.currentTime !== t && (e.currentTime = t), n && e.paused && Promise.resolve(e.play()).catch(() => void 0);
|
|
58
|
+
}
|
|
59
|
+
async function y(e, t = {}) {
|
|
60
|
+
let r = t.timeoutMs ?? 3e3;
|
|
61
|
+
if (t.signal?.aborted === !0) throw l();
|
|
62
|
+
e.readyState < i && await u(e, "loadeddata", r, t.signal, `The video had no frame to read within ${r}ms (readyState ${e.readyState}). Give it a source, or wait for it yourself.`);
|
|
63
|
+
let { atMs: a } = t, o = e.currentTime, s = !e.paused;
|
|
64
|
+
try {
|
|
65
|
+
let i;
|
|
66
|
+
a === void 0 ? i = await p(e, !1) : (s && e.pause(), i = await g(e, a, r, t.signal));
|
|
67
|
+
let o = e.currentTime * 1e3;
|
|
68
|
+
return {
|
|
69
|
+
...await n(e, t),
|
|
70
|
+
atMs: o,
|
|
71
|
+
confirmed: i
|
|
72
|
+
};
|
|
73
|
+
} catch (e) {
|
|
74
|
+
throw _(e);
|
|
75
|
+
} finally {
|
|
76
|
+
t.restore !== !1 && v(e, o, s);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
export { r as DEFAULT_FRAME_TIMEOUT_MS, y as captureFrame };
|
|
81
|
+
|
|
82
|
+
//# sourceMappingURL=frame.js.map
|