@meta-sam/react 0.1.5

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/LICENSE ADDED
@@ -0,0 +1,61 @@
1
+ SAM License
2
+ Last Updated: November 19, 2025
3
+
4
+ “Agreement” means the terms and conditions for use, reproduction, distribution and modification of the SAM Materials set forth herein.
5
+
6
+
7
+ “SAM Materials” means, collectively, Documentation and the models, software and algorithms, including machine-learning model code, trained model weights, inference-enabling code, training-enabling code, fine-tuning enabling code, and other elements of the foregoing distributed by Meta and made available under this Agreement.
8
+
9
+ “Documentation” means the specifications, manuals and documentation accompanying
10
+ SAM Materials distributed by Meta.
11
+
12
+
13
+ “Licensee” or “you” means you, or your employer or any other person or entity (if you are entering into this Agreement on such person or entity’s behalf), of the age required under applicable laws, rules or regulations to provide legal consent and that has legal authority to bind your employer or such other person or entity if you are entering in this Agreement on their behalf.
14
+
15
+
16
+ “Meta” or “we” means Meta Platforms Ireland Limited (if you are located in or, if you are an entity, your principal place of business is in the EEA or Switzerland) or Meta Platforms, Inc. (if you are located outside of the EEA or Switzerland).
17
+
18
+
19
+ “Sanctions” means any economic or trade sanctions or restrictions administered or enforced by the United States (including the Office of Foreign Assets Control of the U.S. Department of the Treasury (“OFAC”), the U.S. Department of State and the U.S. Department of Commerce), the United Nations, the European Union, or the United Kingdom.
20
+
21
+
22
+ “Trade Controls” means any of the following: Sanctions and applicable export and import controls.
23
+
24
+ By using or distributing any portion or element of the SAM Materials, you agree to be bound by this Agreement.
25
+
26
+
27
+ 1. License Rights and Redistribution.
28
+
29
+
30
+ a. Grant of Rights. You are granted a non-exclusive, worldwide, non-transferable and royalty-free limited license under Meta’s intellectual property or other rights owned by Meta embodied in the SAM Materials to use, reproduce, distribute, copy, create derivative works of, and make modifications to the SAM Materials.
31
+
32
+ b. Redistribution and Use.
33
+ i. Distribution of SAM Materials, and any derivative works thereof, are subject to the terms of this Agreement. If you distribute or make the SAM Materials, or any derivative works thereof, available to a third party, you may only do so under the terms of this Agreement and you shall provide a copy of this Agreement with any such SAM Materials.
34
+
35
+
36
+ ii. If you submit for publication the results of research you perform on, using, or otherwise in connection with SAM Materials, you must acknowledge the use of SAM Materials in your publication.
37
+
38
+
39
+ iii. Your use of the SAM Materials must comply with applicable laws and regulations, including Trade Control Laws and applicable privacy and data protection laws.
40
+ iv. Your use of the SAM Materials will not involve or encourage others to reverse engineer, decompile or discover the underlying components of the SAM Materials.
41
+ v. You are not the target of Trade Controls and your use of SAM Materials must comply with Trade Controls. You agree not to use, or permit others to use, SAM Materials for any activities subject to the International Traffic in Arms Regulations (ITAR) or end uses prohibited by Trade Controls, including those related to military or warfare purposes, nuclear industries or applications, espionage, or the development or use of guns or illegal weapons.
42
+ 2. User Support. Your use of the SAM Materials is done at your own discretion; Meta does not process any information nor provide any service in relation to such use. Meta is under no obligation to provide any support services for the SAM Materials. Any support provided is “as is”, “with all faults”, and without warranty of any kind.
43
+
44
+
45
+ 3. Disclaimer of Warranty. UNLESS REQUIRED BY APPLICABLE LAW, THE SAM MATERIALS AND ANY OUTPUT AND RESULTS THEREFROM ARE PROVIDED ON AN “AS IS” BASIS, WITHOUT WARRANTIES OF ANY KIND, AND META DISCLAIMS ALL WARRANTIES OF ANY KIND, BOTH EXPRESS AND IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. YOU ARE SOLELY RESPONSIBLE FOR DETERMINING THE APPROPRIATENESS OF USING OR REDISTRIBUTING THE SAM MATERIALS AND ASSUME ANY RISKS ASSOCIATED WITH YOUR USE OF THE SAM MATERIALS AND ANY OUTPUT AND RESULTS.
46
+
47
+ 4. Limitation of Liability. IN NO EVENT WILL META OR ITS AFFILIATES BE LIABLE UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, TORT, NEGLIGENCE, PRODUCTS LIABILITY, OR OTHERWISE, ARISING OUT OF THIS AGREEMENT, FOR ANY LOST PROFITS OR ANY DIRECT OR INDIRECT, SPECIAL, CONSEQUENTIAL, INCIDENTAL, EXEMPLARY OR PUNITIVE DAMAGES, EVEN IF META OR ITS AFFILIATES HAVE BEEN ADVISED OF THE POSSIBILITY OF ANY OF THE FOREGOING.
48
+
49
+ 5. Intellectual Property.
50
+
51
+
52
+ a. Subject to Meta’s ownership of SAM Materials and derivatives made by or for Meta, with respect to any derivative works and modifications of the SAM Materials that are made by you, as between you and Meta, you are and will be the owner of such derivative works and modifications.
53
+
54
+ b. If you institute litigation or other proceedings against Meta or any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the SAM Materials, outputs or results, or any portion of any of the foregoing, constitutes infringement of intellectual property or other rights owned or licensable by you, then any licenses granted to you under this Agreement shall terminate as of the date such litigation or claim is filed or instituted. You will indemnify and hold harmless Meta from and against any claim by any third party arising out of or related to your use or distribution of the SAM Materials.
55
+
56
+ 6. Term and Termination. The term of this Agreement will commence upon your acceptance of this Agreement or access to the SAM Materials and will continue in full force and effect until terminated in accordance with the terms and conditions herein. Meta may terminate this Agreement if you are in breach of any term or condition of this Agreement. Upon termination of this Agreement, you shall delete and cease use of the SAM Materials. Sections 3, 4 and 7 shall survive the termination of this Agreement.
57
+
58
+ 7. Governing Law and Jurisdiction. This Agreement will be governed and construed under the laws of the State of California without regard to choice of law principles, and the UN Convention on Contracts for the International Sale of Goods does not apply to this Agreement. The courts of California shall have exclusive jurisdiction of any dispute arising out of this Agreement.
59
+
60
+
61
+ 8. Modifications and Amendments. Meta may modify this Agreement from time to time; provided that they are similar in spirit to the current version of the Agreement, but may differ in detail to address new problems or concerns. All such changes will be effective immediately. Your continued use of the SAM Materials after any modification to this Agreement constitutes your agreement to such modification. Except as provided in this Agreement, no modification or addition to any provision of this Agreement will be binding unless it is in writing and signed by an authorized representative of both you and Meta.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # @meta-sam/react
2
+
3
+ `@meta-sam/react` provides Canvas-only React bindings for Mediabunny playback and
4
+ SAM 3 video segmentation. `Video` owns a media player and segmentation renderer;
5
+ `useMediaPlayer` connects the same player to a caller-owned canvas. The package
6
+ ships no global CSS.
7
+
8
+ ## Installation
9
+
10
+ ```sh
11
+ npm install @meta-sam/react @meta-sam/parser react react-dom
12
+ ```
13
+
14
+ React and React DOM `>=18.2 <20` are peer dependencies. Applications that import
15
+ parser or video types directly should also declare those packages as direct
16
+ dependencies.
17
+
18
+ ## `Video` quick start
19
+
20
+ `Video` renders one canvas inside a wrapper. It accepts a URL string, `URL`, or
21
+ `Blob`, discovers exact packet metadata through Mediabunny, and composites the
22
+ decoded frame and segmentation into that canvas.
23
+
24
+ <!-- readme-example -->
25
+
26
+ ```tsx
27
+ 'use client';
28
+
29
+ import { useRef } from 'react';
30
+ import { Video, type VideoRef } from '@meta-sam/react';
31
+ import type { VideoSegmentationResult } from '@meta-sam/parser';
32
+
33
+ declare const result: VideoSegmentationResult;
34
+
35
+ export function SegmentedVideo() {
36
+ const videoRef = useRef<VideoRef>(null);
37
+
38
+ return (
39
+ <div>
40
+ <button type="button" onClick={() => void videoRef.current?.play()}>
41
+ Play
42
+ </button>
43
+ <button type="button" onClick={() => videoRef.current?.pause()}>
44
+ Pause
45
+ </button>
46
+ <button type="button" onClick={() => void videoRef.current?.seekToFrame(10)}>
47
+ Go to frame 10
48
+ </button>
49
+
50
+ <Video
51
+ ref={videoRef}
52
+ src="/media/clip.webm"
53
+ result={result}
54
+ objectFit="contain"
55
+ devicePixelRatio={() => window.devicePixelRatio || 1}
56
+ volume={0.8}
57
+ style={{ width: 640 }}
58
+ canvasProps={{ 'aria-label': 'Segmented video' }}
59
+ onTimeChange={(time) => console.log('time', time)}
60
+ onError={(error) => console.error('Video failed', error)}
61
+ />
62
+ </div>
63
+ );
64
+ }
65
+ ```
66
+
67
+ `result` accepts either a cumulative `VideoSegmentationSnapshot` while parsing is
68
+ in progress or the final `VideoSegmentationResult`. Updating `result`,
69
+ `hiddenIds`, `objectFit`, or `devicePixelRatio` recomposes the retained decoded
70
+ frame without replacing the player or reopening the source.
71
+
72
+ ## Props
73
+
74
+ | Prop | Behavior |
75
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
76
+ | `src` | Required media source: string, `URL`, or `Blob`. A new value replaces the source on the existing player. |
77
+ | `result` | Cumulative video segmentation snapshot or final result. Image results are rejected at runtime. |
78
+ | `renderer` | Optional caller-owned `SegmentationRenderer`. When omitted, `Video` creates and disposes one. |
79
+ | `hiddenIds` | Object IDs omitted from the composition without changing retained segmentation state. |
80
+ | `objectFit` | `contain` (default), `cover`, or `fill` for both frame and overlay geometry. |
81
+ | `devicePixelRatio` | Positive number or function read while sizing and rendering the canvas. |
82
+ | `loop`, `playbackRate`, `volume`, `muted` | Controlled playback and audio settings. |
83
+ | `initialSeekTime` | Initial time used when each new source opens. Changing it alone does not reopen or seek the current source. |
84
+ | `autoPlay` | Starts playback after a source opens. Browser user-activation policy still applies. |
85
+ | `playerOptions` | Construction-only `MediaPlayerOptions` for advanced audio configuration. |
86
+ | `className`, `style` | Configure the wrapper. The default wrapper is positioned, clipped, and full width. |
87
+ | `canvasProps` | Canvas attributes and styles except `ref`, children, and backing width/height, which the component owns. |
88
+
89
+ The wrapper adopts the decoded video's aspect ratio after metadata loads unless
90
+ `style.aspectRatio` overrides it. The canvas backing dimensions track its CSS
91
+ size and DPR. A `ResizeObserver` is used when available.
92
+
93
+ `autoPlay` runs after asynchronous source initialization and therefore cannot preserve
94
+ a caller's user-activation token. Browsers may block Web Audio startup and the player
95
+ will report the fallback through `onAudioWarning`. For reliable audible playback, call
96
+ `videoRef.current?.play()` directly from a trusted click or key handler.
97
+
98
+ ## Callbacks
99
+
100
+ Callbacks always use the newest committed prop without reconstructing the player:
101
+
102
+ - `onTimeChange(time)`
103
+ - `onPlayingChange(playing)`
104
+ - `onDurationChange(duration)`
105
+ - `onLoadedMetadata(metadata)`
106
+ - `onFrame({time, frameIndex})`
107
+ - `onAudioStatusChange(status)`
108
+ - `onAudioWarning(warning)`
109
+ - `onError(error)`
110
+ - `onPlayerReady(player)`
111
+
112
+ `onPlayerReady` reports construction, not source readiness. Use
113
+ `onLoadedMetadata` for a successfully opened source. In development StrictMode,
114
+ more than one short-lived player may be reported; each is disposed by its
115
+ matching effect cleanup.
116
+
117
+ ## `VideoRef`
118
+
119
+ The imperative ref exposes the Canvas player without exposing a media-element
120
+ reference:
121
+
122
+ ```ts
123
+ interface VideoRef {
124
+ play(): Promise<void>;
125
+ pause(): void;
126
+ seek(time: number): Promise<void>;
127
+ seekToFrame(frameIndex: number): Promise<void>;
128
+ nextFrame(): Promise<void>;
129
+ previousFrame(): Promise<void>;
130
+ getFrameIndexAtTimeExact(time: number): number;
131
+ getTimeAtFrameIndexExact(frameIndex: number): number;
132
+ getPacketAtTimeExact(time: number): VideoPacketMetadata;
133
+ getVideoPackets(): VideoPacketTimeline;
134
+ setPlaybackRate(playbackRate: number): void;
135
+ setVolume(volume: number): void;
136
+ setMuted(muted: boolean): void;
137
+ setLoop(loop: boolean): void;
138
+ forceRender(): Promise<void>;
139
+ captureFrame(source?: 'composited' | 'raw'): HTMLCanvasElement;
140
+ getStats(): VideoStats;
141
+ resetStats(): void;
142
+ }
143
+ ```
144
+
145
+ `captureFrame()` returns a detached canvas owned by the caller. The default
146
+ captures the visible composited frame; `raw` captures the retained decoded frame.
147
+ Canvas security rules still apply if callers later export pixels from that
148
+ snapshot. `getStats()` forwards the player's frozen `PlaybackStats` snapshot,
149
+ including bounded decode/overlay timings, scheduler counters, queue depths,
150
+ signed A/V presentation-window error, and audio gap/underrun/drop diagnostics.
151
+ `resetStats()` clears that measurement interval without changing playback.
152
+
153
+ Async methods (`play`, both seek forms, frame stepping, and `forceRender`) return
154
+ rejected promises when called through a retained handle after unmount; they never throw
155
+ before returning. Synchronous controls, lookups, capture, and stats throw when no player
156
+ is mounted. React clears a normally attached ref on unmount, so prefer checking
157
+ `ref.current` at the call site.
158
+
159
+ ## Caller-owned canvas with `useMediaPlayer`
160
+
161
+ Use the hook for custom composition or controls while retaining React lifecycle
162
+ ownership:
163
+
164
+ <!-- readme-example -->
165
+
166
+ ```tsx
167
+ 'use client';
168
+
169
+ import { useRef } from 'react';
170
+ import { useMediaPlayer } from '@meta-sam/react';
171
+
172
+ export function CanvasPlayer({ src }: { src: string | URL | Blob }) {
173
+ const canvasRef = useRef<HTMLCanvasElement>(null);
174
+ const playerRef = useMediaPlayer({
175
+ canvasRef,
176
+ src,
177
+ volume: 0.5,
178
+ renderFrame({ ctx, canvas, frame }) {
179
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
180
+ ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
181
+ },
182
+ onError(error) {
183
+ console.error('Playback failed', error);
184
+ },
185
+ });
186
+
187
+ return (
188
+ <div>
189
+ <button type="button" onClick={() => void playerRef.current?.play()}>
190
+ Play
191
+ </button>
192
+ <canvas ref={canvasRef} width={640} height={360} />
193
+ </div>
194
+ );
195
+ }
196
+ ```
197
+
198
+ The returned `RefObject<IMediaPlayer | null>` is stable. The hook creates a player after
199
+ the canvas is attached, replaces it only when that canvas node changes, opens a changed
200
+ `src` on the existing player, and disposes owned players on replacement and unmount.
201
+ Failed opens are retryable when the component renders that source again. `renderFrame`
202
+ and all callbacks are read through current refs, so callback changes do not recreate the
203
+ player.
204
+
205
+ The hook owns the player. Do not dispose a player received through
206
+ `onPlayerReady`; use the component lifecycle instead.
207
+
208
+ ## Renderer ownership and update ordering
209
+
210
+ When `renderer` is omitted, every effect setup owns one renderer and disposes it
211
+ exactly once. A supplied renderer always remains caller-owned and is never
212
+ disposed by `Video`.
213
+
214
+ Accepted segmentation values are applied with `{reset: true}` because parser
215
+ snapshots are cumulative. Result work is generation-fenced: a late update from a
216
+ replaced result or renderer cannot trigger a stale redraw. Source replacement is
217
+ independently generation-fenced by `@meta-sam/video`.
218
+
219
+ ## Migration from the previous React surface
220
+
221
+ | Previous API | Canvas-only API |
222
+ | -------------------------------------------- | ------------------------------------------------------ |
223
+ | `source` | `src` accepting a string, `URL`, or `Blob` |
224
+ | caller-provided `timeline` | Removed; exact packet metadata is read from the source |
225
+ | overlaid native video plus canvas | One composited canvas |
226
+ | `fit` | `objectFit` |
227
+ | `videoProps` | Removed |
228
+ | `VideoHandle` | `VideoRef` |
229
+ | `seek({timestamp})` | `seek(time)` |
230
+ | `seek({frameIndex})` | `seekToFrame(frameIndex)` |
231
+ | `requestRender()` | `forceRender()` |
232
+ | `useVideoPlayer({videoRef, canvasRef, ...})` | `useMediaPlayer({canvasRef, src, ...})` |
233
+
234
+ The legacy element-backed player, frame timeline, and related errors are not
235
+ exported by `@meta-sam/video` after this migration.
236
+
237
+ ## Runtime and package map
238
+
239
+ The package is ESM-only and side-effect-free. Importing it does not construct
240
+ browser objects. Mounted players require Canvas 2D, Fetch for URL sources,
241
+ WebCodecs for video decoding, and Web Audio for supported audio playback.
242
+ Missing or unsupported audio degrades to explicit silent playback as described
243
+ by `onAudioStatusChange` and `onAudioWarning`.
244
+
245
+ Only the package root is public; deep imports are unsupported.
246
+
247
+ ## License
248
+
249
+ The source is licensed under the SAM License. See `LICENSE` in this package or
250
+ the repository root for the license text.
@@ -0,0 +1,4 @@
1
+ export { Video } from './video.js';
2
+ export type { VideoCaptureSource, VideoProps, VideoRef, VideoStats } from './video.js';
3
+ export { useMediaPlayer } from './use-media-player.js';
4
+ export type { MediaPlayerCallbacks, MediaPlayerFrame, MediaPlayerLoadedMetadata, UseMediaPlayerOptions, } from './use-media-player.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ /*
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates. All Rights Reserved.
3
+ */
4
+ export { Video } from './video.js';
5
+ export { useMediaPlayer } from './use-media-player.js';
@@ -0,0 +1,35 @@
1
+ import { type RefObject } from 'react';
2
+ import { type CustomRenderFunction, type IMediaPlayer, type MediaPlayerAudioError, type MediaPlayerAudioStatus, type MediaPlayerEventMap, type MediaPlayerOptions, type MediaResource } from '@meta-sam/video';
3
+ export type MediaPlayerLoadedMetadata = MediaPlayerEventMap['loadedmetadata'];
4
+ export type MediaPlayerFrame = MediaPlayerEventMap['frame'];
5
+ export interface MediaPlayerCallbacks {
6
+ readonly onTimeChange?: (time: number) => void;
7
+ readonly onPlayingChange?: (playing: boolean) => void;
8
+ readonly onDurationChange?: (duration: number) => void;
9
+ readonly onLoadedMetadata?: (metadata: MediaPlayerLoadedMetadata) => void;
10
+ readonly onFrame?: (frame: MediaPlayerFrame) => void;
11
+ readonly onAudioStatusChange?: (status: MediaPlayerAudioStatus) => void;
12
+ readonly onAudioWarning?: (warning: MediaPlayerAudioError) => void;
13
+ readonly onError?: (error: Error) => void;
14
+ readonly onPlayerReady?: (player: IMediaPlayer) => void;
15
+ }
16
+ export interface UseMediaPlayerOptions extends MediaPlayerCallbacks {
17
+ readonly canvasRef: RefObject<HTMLCanvasElement | null>;
18
+ readonly src: MediaResource;
19
+ /** Applied when this source is first opened. Later changes do not seek it again. */
20
+ readonly initialSeekTime?: number;
21
+ readonly loop?: boolean;
22
+ readonly playbackRate?: number;
23
+ readonly volume?: number;
24
+ readonly muted?: boolean;
25
+ readonly autoPlay?: boolean;
26
+ readonly renderFrame?: CustomRenderFunction;
27
+ /** Construction-only options, read when a canvas receives its player. */
28
+ readonly playerOptions?: MediaPlayerOptions;
29
+ }
30
+ /**
31
+ * Owns a Canvas-only @meta-sam/video player for a caller-owned canvas.
32
+ *
33
+ * The returned ref is stable. The hook owns and disposes every player it creates.
34
+ */
35
+ export declare function useMediaPlayer(options: UseMediaPlayerOptions): RefObject<IMediaPlayer | null>;
@@ -0,0 +1,206 @@
1
+ /*
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates. All Rights Reserved.
3
+ */
4
+ 'use client';
5
+ import { useEffect, useRef } from 'react';
6
+ import { createMediaPlayer, } from '@meta-sam/video';
7
+ const noSource = Symbol('no-source');
8
+ function isCancelled(error) {
9
+ return (typeof error === 'object' &&
10
+ error !== null &&
11
+ 'code' in error &&
12
+ (error.code === 'operation_cancelled' || error.code === 'player_disposed'));
13
+ }
14
+ /**
15
+ * Owns a Canvas-only @meta-sam/video player for a caller-owned canvas.
16
+ *
17
+ * The returned ref is stable. The hook owns and disposes every player it creates.
18
+ */
19
+ export function useMediaPlayer(options) {
20
+ const playerRef = useRef(null);
21
+ const ownedRef = useRef(null);
22
+ const optionsRef = useRef(options);
23
+ optionsRef.current = options;
24
+ useEffect(() => {
25
+ const report = (error) => {
26
+ const normalized = error instanceof Error
27
+ ? error
28
+ : new Error('The media player failed with a non-Error value.', {
29
+ cause: error,
30
+ });
31
+ try {
32
+ optionsRef.current.onError?.(normalized);
33
+ }
34
+ catch {
35
+ // Error callbacks are terminal observers and must not break lifecycle cleanup.
36
+ }
37
+ };
38
+ const dispose = (owned) => {
39
+ owned.openGeneration += 1;
40
+ if (ownedRef.current === owned)
41
+ ownedRef.current = null;
42
+ if (playerRef.current === owned.player)
43
+ playerRef.current = null;
44
+ owned.player.dispose();
45
+ };
46
+ const canvas = optionsRef.current.canvasRef.current;
47
+ let owned = ownedRef.current;
48
+ if (owned !== null && owned.canvas !== canvas) {
49
+ dispose(owned);
50
+ owned = null;
51
+ }
52
+ if (canvas === null)
53
+ return;
54
+ if (owned === null) {
55
+ try {
56
+ const player = createMediaPlayer(canvas, optionsRef.current.playerOptions);
57
+ let created;
58
+ const renderFrame = (context) => optionsRef.current.renderFrame?.(context);
59
+ created = {
60
+ player,
61
+ canvas,
62
+ renderFrame,
63
+ source: noSource,
64
+ openingSource: noSource,
65
+ openGeneration: 0,
66
+ customRendering: false,
67
+ lastRenderFrame: undefined,
68
+ };
69
+ ownedRef.current = created;
70
+ playerRef.current = player;
71
+ owned = created;
72
+ const current = () => ownedRef.current === created;
73
+ player.on('timeupdate', ({ time }) => {
74
+ if (current())
75
+ optionsRef.current.onTimeChange?.(time);
76
+ });
77
+ player.on('play', () => {
78
+ if (current())
79
+ optionsRef.current.onPlayingChange?.(true);
80
+ });
81
+ player.on('pause', () => {
82
+ if (current())
83
+ optionsRef.current.onPlayingChange?.(false);
84
+ });
85
+ player.on('ended', () => {
86
+ if (current())
87
+ optionsRef.current.onPlayingChange?.(false);
88
+ });
89
+ player.on('durationchange', ({ duration }) => {
90
+ if (current())
91
+ optionsRef.current.onDurationChange?.(duration);
92
+ });
93
+ player.on('loadedmetadata', (metadata) => {
94
+ if (current())
95
+ optionsRef.current.onLoadedMetadata?.(metadata);
96
+ });
97
+ player.on('frame', (frame) => {
98
+ if (current())
99
+ optionsRef.current.onFrame?.(frame);
100
+ });
101
+ player.on('audiostatuschange', (status) => {
102
+ if (current())
103
+ optionsRef.current.onAudioStatusChange?.(status);
104
+ });
105
+ player.on('audiowarning', ({ warning }) => {
106
+ if (current())
107
+ optionsRef.current.onAudioWarning?.(warning);
108
+ });
109
+ player.on('error', ({ error }) => {
110
+ if (!current())
111
+ return;
112
+ try {
113
+ optionsRef.current.onError?.(error);
114
+ }
115
+ catch {
116
+ // MediaPlayer treats error listeners as terminal observers.
117
+ }
118
+ });
119
+ try {
120
+ optionsRef.current.onPlayerReady?.(player);
121
+ }
122
+ catch (error) {
123
+ report(error);
124
+ }
125
+ }
126
+ catch (error) {
127
+ report(error);
128
+ return;
129
+ }
130
+ }
131
+ const currentOptions = optionsRef.current;
132
+ const useCustomRendering = currentOptions.renderFrame !== undefined;
133
+ if (owned.customRendering !== useCustomRendering) {
134
+ owned.customRendering = useCustomRendering;
135
+ owned.lastRenderFrame = currentOptions.renderFrame;
136
+ owned.player.setCustomRender(useCustomRendering ? owned.renderFrame : null);
137
+ }
138
+ else if (useCustomRendering &&
139
+ owned.lastRenderFrame !== currentOptions.renderFrame) {
140
+ owned.lastRenderFrame = currentOptions.renderFrame;
141
+ void owned.player.forceRender().catch(() => undefined);
142
+ }
143
+ if (currentOptions.loop !== undefined &&
144
+ owned.player.loop !== currentOptions.loop) {
145
+ owned.player.loop = currentOptions.loop;
146
+ }
147
+ if (currentOptions.playbackRate !== undefined &&
148
+ owned.player.playbackRate !== currentOptions.playbackRate) {
149
+ owned.player.playbackRate = currentOptions.playbackRate;
150
+ }
151
+ if (currentOptions.volume !== undefined &&
152
+ owned.player.volume !== currentOptions.volume) {
153
+ owned.player.volume = currentOptions.volume;
154
+ }
155
+ if (currentOptions.muted !== undefined &&
156
+ owned.player.muted !== currentOptions.muted) {
157
+ owned.player.muted = currentOptions.muted;
158
+ }
159
+ if (!Object.is(owned.source, currentOptions.src) &&
160
+ !Object.is(owned.openingSource, currentOptions.src)) {
161
+ owned.source = noSource;
162
+ owned.openingSource = currentOptions.src;
163
+ const generation = ++owned.openGeneration;
164
+ const player = owned.player;
165
+ const source = currentOptions.src;
166
+ const startTime = currentOptions.initialSeekTime;
167
+ void Promise.resolve()
168
+ .then(() => player.open(source, startTime))
169
+ .then(() => {
170
+ if (ownedRef.current !== owned || generation !== owned.openGeneration) {
171
+ return;
172
+ }
173
+ owned.openingSource = noSource;
174
+ owned.source = source;
175
+ if (optionsRef.current.autoPlay === true) {
176
+ void Promise.resolve()
177
+ .then(() => player.play())
178
+ .catch(() => {
179
+ // MediaPlayer reports fatal play failures through its error event.
180
+ });
181
+ }
182
+ })
183
+ .catch((error) => {
184
+ if (ownedRef.current !== owned || generation !== owned.openGeneration) {
185
+ return;
186
+ }
187
+ owned.openingSource = noSource;
188
+ owned.source = noSource;
189
+ if (isCancelled(error))
190
+ return;
191
+ // MediaPlayer emits fatal open and play failures through its error event.
192
+ });
193
+ }
194
+ });
195
+ useEffect(() => () => {
196
+ const owned = ownedRef.current;
197
+ ownedRef.current = null;
198
+ if (playerRef.current === owned?.player)
199
+ playerRef.current = null;
200
+ if (owned !== null) {
201
+ owned.openGeneration += 1;
202
+ owned.player.dispose();
203
+ }
204
+ }, []);
205
+ return playerRef;
206
+ }
@@ -0,0 +1,47 @@
1
+ import { type CSSProperties, type CanvasHTMLAttributes } from 'react';
2
+ import { SegmentationRenderer, type VideoFrameFit } from '@meta-sam/graphics';
3
+ import type { VideoSegmentationResult, VideoSegmentationSnapshot } from '@meta-sam/parser';
4
+ import { type MediaPlayerOptions, type MediaResource, type PlaybackStats, type VideoPacketMetadata, type VideoPacketTimeline } from '@meta-sam/video';
5
+ import { type MediaPlayerCallbacks } from './use-media-player.js';
6
+ export type VideoCaptureSource = 'composited' | 'raw';
7
+ export type VideoStats = PlaybackStats;
8
+ export interface VideoRef {
9
+ play(): Promise<void>;
10
+ pause(): void;
11
+ seek(time: number): Promise<void>;
12
+ seekToFrame(frameIndex: number): Promise<void>;
13
+ nextFrame(): Promise<void>;
14
+ previousFrame(): Promise<void>;
15
+ getFrameIndexAtTimeExact(time: number): number;
16
+ getTimeAtFrameIndexExact(frameIndex: number): number;
17
+ getPacketAtTimeExact(time: number): VideoPacketMetadata;
18
+ getVideoPackets(): VideoPacketTimeline;
19
+ setPlaybackRate(playbackRate: number): void;
20
+ setVolume(volume: number): void;
21
+ setMuted(muted: boolean): void;
22
+ setLoop(loop: boolean): void;
23
+ forceRender(): Promise<void>;
24
+ /** Returns a detached canvas snapshot that the caller owns. */
25
+ captureFrame(source?: VideoCaptureSource): HTMLCanvasElement;
26
+ getStats(): VideoStats;
27
+ resetStats(): void;
28
+ }
29
+ export interface VideoProps extends MediaPlayerCallbacks {
30
+ readonly src: MediaResource;
31
+ readonly result?: VideoSegmentationResult | VideoSegmentationSnapshot;
32
+ readonly renderer?: SegmentationRenderer;
33
+ readonly hiddenIds?: ReadonlySet<string> | readonly string[];
34
+ readonly objectFit?: VideoFrameFit;
35
+ readonly devicePixelRatio?: number | (() => number);
36
+ readonly loop?: boolean;
37
+ readonly playbackRate?: number;
38
+ readonly volume?: number;
39
+ readonly muted?: boolean;
40
+ readonly autoPlay?: boolean;
41
+ readonly initialSeekTime?: number;
42
+ readonly playerOptions?: MediaPlayerOptions;
43
+ readonly className?: string;
44
+ readonly style?: CSSProperties;
45
+ readonly canvasProps?: Omit<CanvasHTMLAttributes<HTMLCanvasElement>, 'children' | 'ref' | 'width' | 'height'>;
46
+ }
47
+ export declare const Video: import("react").ForwardRefExoticComponent<VideoProps & import("react").RefAttributes<VideoRef>>;
package/dist/video.js ADDED
@@ -0,0 +1,250 @@
1
+ /*
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates. All Rights Reserved.
3
+ */
4
+ 'use client';
5
+ import { jsx as _jsx } from "react/jsx-runtime";
6
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from 'react';
7
+ import { SegmentationRenderer } from '@meta-sam/graphics';
8
+ import { getPacketAtTimeExact, } from '@meta-sam/video';
9
+ import { useMediaPlayer, } from './use-media-player.js';
10
+ function isExpectedRenderMiss(error) {
11
+ return (typeof error === 'object' &&
12
+ error !== null &&
13
+ 'code' in error &&
14
+ (error.code === 'frame_metadata_unavailable' ||
15
+ error.code === 'operation_cancelled' ||
16
+ error.code === 'player_disposed'));
17
+ }
18
+ function resolvePixelRatio(value) {
19
+ const ratio = typeof value === 'function' ? value() : (value ?? globalThis.devicePixelRatio ?? 1);
20
+ if (!Number.isFinite(ratio) || ratio <= 0) {
21
+ throw new TypeError('devicePixelRatio must be finite and greater than zero.');
22
+ }
23
+ return ratio;
24
+ }
25
+ function unavailable() {
26
+ throw new Error('The video player is not mounted.');
27
+ }
28
+ function rejectUnavailable() {
29
+ return Promise.reject(new Error('The video player is not mounted.'));
30
+ }
31
+ function runPlayerOperation(player, operation) {
32
+ if (player === null)
33
+ return rejectUnavailable();
34
+ try {
35
+ return operation(player);
36
+ }
37
+ catch (error) {
38
+ return Promise.reject(error);
39
+ }
40
+ }
41
+ function requirePlayer(player) {
42
+ return player ?? unavailable();
43
+ }
44
+ function captureCanvas(visibleCanvas, player, source) {
45
+ const canvas = visibleCanvas ?? unavailable();
46
+ const media = source === 'composited'
47
+ ? canvas
48
+ : (requirePlayer(player).getCurrentFrame() ??
49
+ (() => {
50
+ throw new Error('No decoded video frame is available.');
51
+ })());
52
+ const snapshot = canvas.ownerDocument.createElement('canvas');
53
+ snapshot.width = media.width;
54
+ snapshot.height = media.height;
55
+ const context = snapshot.getContext('2d');
56
+ if (context === null)
57
+ throw new Error('A Canvas 2D context is required.');
58
+ context.drawImage(media, 0, 0, media.width, media.height);
59
+ return snapshot;
60
+ }
61
+ export const Video = forwardRef(function Video(props, ref) {
62
+ const { src, result, renderer: suppliedRenderer, hiddenIds, objectFit, devicePixelRatio, loop, playbackRate, volume, muted, autoPlay, initialSeekTime, playerOptions, className, style, canvasProps, } = props;
63
+ const canvasRef = useRef(null);
64
+ const rendererRef = useRef(null);
65
+ const propsRef = useRef(props);
66
+ const updateGenerationRef = useRef(0);
67
+ const metadataRef = useRef(null);
68
+ const [aspectRatio, setAspectRatio] = useState();
69
+ propsRef.current = props;
70
+ const reportError = useCallback((error) => {
71
+ const normalized = error instanceof Error
72
+ ? error
73
+ : new Error('Video failed with a non-Error value.', { cause: error });
74
+ propsRef.current.onError?.(normalized);
75
+ }, []);
76
+ useEffect(() => {
77
+ const renderer = suppliedRenderer ?? new SegmentationRenderer();
78
+ rendererRef.current = renderer;
79
+ return () => {
80
+ updateGenerationRef.current += 1;
81
+ if (rendererRef.current === renderer)
82
+ rendererRef.current = null;
83
+ if (suppliedRenderer === undefined)
84
+ renderer.dispose();
85
+ };
86
+ }, [suppliedRenderer]);
87
+ const renderFrame = useCallback((context) => {
88
+ const current = propsRef.current;
89
+ rendererRef.current?.renderVideoFrame(context, {
90
+ ...(current.objectFit === undefined ? {} : { fit: current.objectFit }),
91
+ devicePixelRatio: () => resolvePixelRatio(current.devicePixelRatio),
92
+ ...(current.hiddenIds === undefined ? {} : { hiddenIds: current.hiddenIds }),
93
+ });
94
+ }, []);
95
+ const onLoadedMetadata = useCallback((metadata) => {
96
+ metadataRef.current = metadata;
97
+ setAspectRatio(`${metadata.width} / ${metadata.height}`);
98
+ propsRef.current.onLoadedMetadata?.(metadata);
99
+ }, []);
100
+ const playerRef = useMediaPlayer({
101
+ canvasRef,
102
+ src,
103
+ ...(initialSeekTime === undefined ? {} : { initialSeekTime }),
104
+ ...(loop === undefined ? {} : { loop }),
105
+ ...(playbackRate === undefined ? {} : { playbackRate }),
106
+ ...(volume === undefined ? {} : { volume }),
107
+ ...(muted === undefined ? {} : { muted }),
108
+ ...(autoPlay === undefined ? {} : { autoPlay }),
109
+ ...(playerOptions === undefined ? {} : { playerOptions }),
110
+ renderFrame,
111
+ onLoadedMetadata,
112
+ onTimeChange: (time) => propsRef.current.onTimeChange?.(time),
113
+ onPlayingChange: (playing) => propsRef.current.onPlayingChange?.(playing),
114
+ onDurationChange: (duration) => propsRef.current.onDurationChange?.(duration),
115
+ onFrame: (frame) => propsRef.current.onFrame?.(frame),
116
+ onAudioStatusChange: (status) => propsRef.current.onAudioStatusChange?.(status),
117
+ onAudioWarning: (warning) => propsRef.current.onAudioWarning?.(warning),
118
+ onError: reportError,
119
+ onPlayerReady: (player) => propsRef.current.onPlayerReady?.(player),
120
+ });
121
+ useEffect(() => {
122
+ const renderer = rendererRef.current;
123
+ if (renderer === null)
124
+ return;
125
+ if (result !== undefined && result.media !== 'video') {
126
+ reportError(new TypeError('Video accepts only video segmentation results.'));
127
+ return;
128
+ }
129
+ let active = true;
130
+ const generation = ++updateGenerationRef.current;
131
+ const update = result === undefined
132
+ ? (renderer.clear(), Promise.resolve())
133
+ : renderer.update(result, { reset: true });
134
+ void update
135
+ .then(async () => {
136
+ if (!active ||
137
+ generation !== updateGenerationRef.current ||
138
+ rendererRef.current !== renderer) {
139
+ return;
140
+ }
141
+ try {
142
+ await playerRef.current?.forceRender();
143
+ }
144
+ catch (error) {
145
+ if (!isExpectedRenderMiss(error))
146
+ reportError(error);
147
+ }
148
+ })
149
+ .catch((error) => {
150
+ if (active &&
151
+ generation === updateGenerationRef.current &&
152
+ rendererRef.current === renderer) {
153
+ reportError(error);
154
+ }
155
+ });
156
+ return () => {
157
+ active = false;
158
+ };
159
+ }, [playerRef, reportError, result, suppliedRenderer]);
160
+ useEffect(() => {
161
+ const player = playerRef.current;
162
+ if (player === null)
163
+ return;
164
+ void player.forceRender().catch((error) => {
165
+ if (!isExpectedRenderMiss(error))
166
+ reportError(error);
167
+ });
168
+ }, [devicePixelRatio, hiddenIds, objectFit, playerRef, reportError]);
169
+ useEffect(() => {
170
+ const canvas = canvasRef.current;
171
+ if (canvas === null)
172
+ return;
173
+ const resize = () => {
174
+ try {
175
+ const metadata = metadataRef.current;
176
+ const logicalWidth = canvas.clientWidth || metadata?.width || 0;
177
+ const logicalHeight = canvas.clientHeight || metadata?.height || 0;
178
+ if (logicalWidth <= 0 || logicalHeight <= 0)
179
+ return;
180
+ const ratio = resolvePixelRatio(propsRef.current.devicePixelRatio);
181
+ const width = Math.round(logicalWidth * ratio);
182
+ const height = Math.round(logicalHeight * ratio);
183
+ if (canvas.width === width && canvas.height === height)
184
+ return;
185
+ canvas.width = width;
186
+ canvas.height = height;
187
+ void playerRef.current?.forceRender().catch((error) => {
188
+ if (!isExpectedRenderMiss(error))
189
+ reportError(error);
190
+ });
191
+ }
192
+ catch (error) {
193
+ reportError(error);
194
+ }
195
+ };
196
+ resize();
197
+ const Observer = globalThis.ResizeObserver;
198
+ if (Observer === undefined)
199
+ return;
200
+ const observer = new Observer(resize);
201
+ observer.observe(canvas);
202
+ return () => observer.disconnect();
203
+ }, [aspectRatio, devicePixelRatio, playerRef, reportError]);
204
+ useImperativeHandle(ref, () => ({
205
+ play: () => runPlayerOperation(playerRef.current, (player) => player.play()),
206
+ pause: () => requirePlayer(playerRef.current).pause(),
207
+ seek: (time) => runPlayerOperation(playerRef.current, (player) => player.seek(time)),
208
+ seekToFrame: (frameIndex) => runPlayerOperation(playerRef.current, (player) => player.seekToFrame(frameIndex)),
209
+ nextFrame: () => runPlayerOperation(playerRef.current, (player) => player.nextFrame()),
210
+ previousFrame: () => runPlayerOperation(playerRef.current, (player) => player.previousFrame()),
211
+ getFrameIndexAtTimeExact: (time) => requirePlayer(playerRef.current).getFrameIndexAtTimeExact(time),
212
+ getTimeAtFrameIndexExact: (frameIndex) => requirePlayer(playerRef.current).getTimeAtFrameIndexExact(frameIndex),
213
+ getPacketAtTimeExact: (time) => {
214
+ const player = requirePlayer(playerRef.current);
215
+ return getPacketAtTimeExact(player.getVideoPackets(), time);
216
+ },
217
+ getVideoPackets: () => requirePlayer(playerRef.current).getVideoPackets(),
218
+ setPlaybackRate: (value) => {
219
+ requirePlayer(playerRef.current).playbackRate = value;
220
+ },
221
+ setVolume: (value) => {
222
+ requirePlayer(playerRef.current).volume = value;
223
+ },
224
+ setMuted: (value) => {
225
+ requirePlayer(playerRef.current).muted = value;
226
+ },
227
+ setLoop: (value) => {
228
+ requirePlayer(playerRef.current).loop = value;
229
+ },
230
+ forceRender: () => runPlayerOperation(playerRef.current, (player) => player.forceRender()),
231
+ captureFrame: (source = 'composited') => captureCanvas(canvasRef.current, playerRef.current, source),
232
+ getStats: () => requirePlayer(playerRef.current).getStats(),
233
+ resetStats: () => requirePlayer(playerRef.current).resetStats(),
234
+ }), [playerRef]);
235
+ const rootStyle = {
236
+ position: 'relative',
237
+ display: 'inline-block',
238
+ overflow: 'hidden',
239
+ width: '100%',
240
+ ...(aspectRatio === undefined ? {} : { aspectRatio }),
241
+ ...style,
242
+ };
243
+ const canvasStyle = {
244
+ display: 'block',
245
+ width: '100%',
246
+ height: '100%',
247
+ ...canvasProps?.style,
248
+ };
249
+ return (_jsx("span", { className: className, style: rootStyle, children: _jsx("canvas", { ...canvasProps, ref: canvasRef, style: canvasStyle }) }));
250
+ });
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@meta-sam/react",
3
+ "version": "0.1.5",
4
+ "description": "Canvas-only React video player for SAM 3 overlays",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "homepage": "https://github.com/meta-models/meta-sam#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/meta-models/meta-sam/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/meta-models/meta-sam.git",
13
+ "directory": "typescript/packages/react"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "types": "./dist/index.d.ts",
32
+ "browser": "./dist/index.js",
33
+ "engines": {
34
+ "node": "^20.17.0 || >=22.9.0"
35
+ },
36
+ "dependencies": {
37
+ "@meta-sam/graphics": "0.1.5",
38
+ "@meta-sam/parser": "0.0.8",
39
+ "@meta-sam/video": "0.1.2"
40
+ },
41
+ "peerDependencies": {
42
+ "react": ">=18.2 <20",
43
+ "react-dom": ">=18.2 <20"
44
+ },
45
+ "devDependencies": {
46
+ "@types/react": "^19.1.12",
47
+ "@types/react-dom": "^19.1.9",
48
+ "react": "^19.1.1",
49
+ "react-dom": "^19.1.1"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -b",
53
+ "prepack": "tsc -b --force",
54
+ "typecheck": "tsc -p tsconfig.json --noEmit"
55
+ }
56
+ }