@effect-motion/react 0.3.2 → 0.5.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @effect-motion/react
2
2
 
3
- React bindings for [effect-motion](https://www.npmjs.com/package/effect-motion): a `<Player>` component and a `usePlayer` hook for playing scenes in the browser.
3
+ React bindings for [effect-motion](https://www.npmjs.com/package/effect-motion): a `<Player>` component for playing scenes in the browser.
4
4
 
5
- Because scenes are deterministic and finite, the player runs a scene once, collects every frame, and plays them back — so seeking in either direction is free.
5
+ Frames stream in as the scene runs rather than being rendered up front, so playback starts before the whole scene is computed. By default a finite scene keeps every frame it has pulled, which makes seeking backwards free; an endless scene keeps a bounded window instead.
6
6
 
7
7
  ## Install
8
8
 
@@ -14,18 +14,30 @@ pnpm add @effect-motion/react effect-motion effect
14
14
 
15
15
  ## Play a scene
16
16
 
17
- `<Player>` runs the scene and gives you transport controls — play/pause and a scrubbable progress bar:
17
+ `<Player>` runs the scene and gives you transport controls — play/pause, a scrubbable progress bar, and a repeat toggle:
18
18
 
19
19
  ```tsx
20
20
  import { Player } from "@effect-motion/react";
21
21
  import { scene } from "./my-scene";
22
22
 
23
23
  export function App() {
24
- return <Player scene={scene} width={500} height={300} autoPlay />;
24
+ return <Player scene={scene} autoPlay />;
25
25
  }
26
26
  ```
27
27
 
28
- Prefer your own UI? `usePlayer(scene, options)` exposes the same state and controls (`status`, `frame`, `progress`, `play`, `pause`, `seek`, …) without any chrome.
28
+ The player's size comes from its container — the canvas fills the available width at the scene's aspect ratio, and the scene's own resolution is set when you write it (`Scene.make(…, { width, height })`).
29
+
30
+ For a scene that never ends, pass `isInfinite` so memory stays bounded and the scrubber (which has no meaning without an end) is hidden:
31
+
32
+ ```tsx
33
+ <Player scene={ambientScene} isInfinite autoPlay />
34
+ ```
35
+
36
+ If the scene uses custom fonts or images, pass their loaders as `renderLayers`. It is required — and checked at compile time — whenever the scene declares resources:
37
+
38
+ ```tsx
39
+ <Player scene={scene} renderLayers={Font.layer(Inter, bytes)} />
40
+ ```
29
41
 
30
42
  ## Documentation
31
43
 
package/dist/Player.d.ts CHANGED
@@ -1,14 +1,155 @@
1
- import type * as Scope from "effect/Scope";
1
+ import * as Layer from "effect/Layer";
2
2
  import type * as Runner from "effect-motion/Runner";
3
3
  import * as Scene from "effect-motion/Scene";
4
- export type PlayerProps = {
4
+ /**
5
+ * Props for {@link Player}.
6
+ *
7
+ * @typeParam S - The scene's type, which decides whether `renderLayers` is
8
+ * required.
9
+ *
10
+ * @remarks
11
+ * Only `scene` is required — and `renderLayers`, if the scene declares
12
+ * resources. Everything else has a working default.
13
+ */
14
+ export type PlayerProps<S extends Scene.AnyScene = Scene.AnyScene> = {
15
+ /**
16
+ * How many frames to buffer ahead before playing.
17
+ *
18
+ * @remarks
19
+ * The default buffers the WHOLE scene, which is what makes the total
20
+ * duration and a complete progress bar available immediately. Lower it to
21
+ * start playing sooner on a long scene: the progress bar then tracks how
22
+ * much is buffered so far, and the total time stays hidden until the
23
+ * scene has been pulled to its end.
24
+ *
25
+ * An infinite scene can never be fully buffered, so it falls back to 60
26
+ * frames.
27
+ *
28
+ * @defaultValue `Infinity` (60 when `isInfinite`)
29
+ */
5
30
  prebufferedFrames?: number;
31
+ /**
32
+ * Start playing on mount instead of waiting for the play button.
33
+ *
34
+ * @defaultValue `false`
35
+ */
6
36
  autoPlay?: boolean;
37
+ /**
38
+ * Frames per second, for both the scene and the playback clock.
39
+ *
40
+ * @remarks
41
+ * Overridden by `settings.frameRate` when both are given, so the two can
42
+ * never disagree.
43
+ *
44
+ * @defaultValue `60`
45
+ */
7
46
  fps?: number;
47
+ /**
48
+ * Declare that the scene never ends.
49
+ *
50
+ * @remarks
51
+ * Set this for a scene built to run forever — an ambient loop, a
52
+ * `Schedule.forever` background. It changes three things: frames are
53
+ * buffered in a bounded window rather than kept forever, only a prefix is
54
+ * prebuffered, and the scrubber and repeat toggle are hidden, since
55
+ * neither means anything without an end. Play and pause remain.
56
+ *
57
+ * @defaultValue `false`
58
+ */
8
59
  isInfinite?: boolean;
60
+ /**
61
+ * Whether repeat starts switched on.
62
+ *
63
+ * @remarks
64
+ * An INITIAL value, not a controlled prop — the player owns repeat state
65
+ * once mounted, because the user can toggle it with the repeat button.
66
+ * Ignored for an infinite scene.
67
+ *
68
+ * @defaultValue `false`
69
+ */
9
70
  defaultRepeatMode?: boolean;
71
+ /**
72
+ * How many frames of scene DATA to keep in memory.
73
+ *
74
+ * @remarks
75
+ * Frame data, not rendered pixels. A finite scene keeps everything so
76
+ * seeking anywhere works; an infinite scene keeps a window, since
77
+ * retaining every frame of an endless scene would grow without bound.
78
+ *
79
+ * Seeking before the retained window clamps to its oldest frame — a scene
80
+ * is a forward-only stream, so frames that fell out cannot be recomputed.
81
+ * Raise this if deep backward scrubbing on a long scene matters.
82
+ *
83
+ * @defaultValue `Infinity` for a finite scene; `1800` (~30s at 60fps) when
84
+ * `isInfinite`
85
+ */
10
86
  bufferCapacity?: number;
87
+ /**
88
+ * Playback settings passed to the scene run — `seed`, `maxFrames`, and
89
+ * `frameRate`.
90
+ *
91
+ * @remarks
92
+ * Resolution and background are NOT here: those belong to the scene's own
93
+ * composition config, fixed when it was made. A `frameRate` given here
94
+ * wins over the `fps` prop.
95
+ */
11
96
  settings?: Partial<Runner.Settings>;
12
- scene: Scene.Scene<never, Runner.Runner | Scope.Scope>;
13
- };
14
- export declare const Player: ({ scene, fps: fpsProp, prebufferedFrames, autoPlay, isInfinite, defaultRepeatMode, bufferCapacity, settings, }: PlayerProps) => import("react").JSX.Element;
97
+ /** The scene to play. */
98
+ scene: S;
99
+ } & (Scene.Resources<S> extends never ? {
100
+ /**
101
+ * Not accepted: this scene declares no resources, so passing
102
+ * loaders is a compile error.
103
+ */
104
+ renderLayers?: never;
105
+ } : {
106
+ /**
107
+ * Loaders for the fonts and images the scene uses.
108
+ *
109
+ * @remarks
110
+ * REQUIRED when the scene declares resources, and it must cover
111
+ * every one of them — the types will not let you mount a player
112
+ * that is missing a loader, so a missing font is a compile error
113
+ * rather than blank text at runtime. Combine several with
114
+ * `Layer.mergeAll(...)`.
115
+ *
116
+ * Loads run once, eagerly, when the player mounts. A failed load
117
+ * shows in the player's error panel.
118
+ */
119
+ renderLayers: Layer.Layer<Scene.Resources<S>, unknown, never>;
120
+ });
121
+ /**
122
+ * A video-style player for an effect-motion scene.
123
+ *
124
+ * @remarks
125
+ * Self-contained: a canvas plus play/pause, a scrubber with buffered-range
126
+ * indicator, a time readout, and a repeat toggle. Controls fade out during
127
+ * playback and return on hover. There is no stylesheet to import — the skin
128
+ * is inline styles and inline SVG — and no way to restyle it short of
129
+ * wrapping it.
130
+ *
131
+ * The canvas is `width: 100%` with automatic height, so the player fills its
132
+ * container at the scene's aspect ratio. Size it by sizing the parent.
133
+ *
134
+ * Frames stream in rather than being pre-rendered, so playback starts before
135
+ * the whole scene is computed. Playback keeps real time: if a frame renders
136
+ * slowly the player drops intermediate frames rather than falling behind.
137
+ *
138
+ * Each player owns its own GPU renderer and scene run, released on unmount.
139
+ *
140
+ * Failures — no WebGPU, a font that would not load, a render error — are
141
+ * shown in the player's own frame as an alert panel, not just logged to the
142
+ * console.
143
+ *
144
+ * @example
145
+ * ```tsx
146
+ * <Player scene={scene} autoPlay />
147
+ * ```
148
+ *
149
+ * @example
150
+ * An endless scene: bounded memory, and no scrubber or repeat button.
151
+ * ```tsx
152
+ * <Player scene={ambientScene} isInfinite autoPlay />
153
+ * ```
154
+ */
155
+ export declare const Player: <S extends Scene.AnyScene>({ scene, fps: fpsProp, prebufferedFrames, autoPlay, isInfinite, defaultRepeatMode, bufferCapacity, settings, renderLayers, }: PlayerProps<S>) => import("react").JSX.Element;
package/dist/Player.js CHANGED
@@ -1,43 +1,54 @@
1
1
  "use client";
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import * as Engine from "@effect-motion/thorvg/Engine";
4
- import * as Session from "@effect-motion/thorvg/Session";
3
+ import * as FrameRenderer from "@effect-motion/renderer/Renderer";
5
4
  import { Cause, Context, Data, Effect, ManagedRuntime, Schedule, Semaphore, } from "effect";
6
5
  import * as Layer from "effect/Layer";
7
- import * as CanvasExporter from "effect-motion/CanvasExporter";
8
- import * as Fonts from "effect-motion/Fonts";
9
- import * as Images from "effect-motion/Images";
10
- import * as Renderer from "effect-motion/Renderer";
11
6
  import * as Scene from "effect-motion/Scene";
12
7
  import * as Time from "effect-motion/Time";
13
8
  import { useEffect, useEffectEvent, useRef, useState, } from "react";
14
- const thorLayer = Engine.browserLayer("https://unpkg.com/@thorvg/webcanvas@1.0.8/dist/thorvg.wasm");
9
+ /**
10
+ * Anything that went wrong inside the player: acquiring the renderer, a
11
+ * resource load, pulling a frame, or rendering one.
12
+ *
13
+ * @remarks
14
+ * Internal. The first failure is captured and shown in the player's error
15
+ * panel; `message` says which stage failed and `cause` carries the original.
16
+ */
15
17
  class PlayerError extends Data.TaggedError("PlayerError") {
16
18
  static of(message) {
17
19
  return (cause) => new PlayerError({ message, cause });
18
20
  }
19
21
  }
20
22
  /**
21
- * Softened device-pixel-ratio, thorvg.web's formula: interpolate 75% of the
22
- * way from 1 to the native dpr — visually indistinguishable from full dpr at
23
- * a fraction of the rasterized pixels. Read per render call so moving the
24
- * window across monitors picks up the new ratio.
23
+ * The device pixel ratio to render at — 75% of the way from 1 to the
24
+ * display's native ratio.
25
+ *
26
+ * @remarks
27
+ * Rendering at full native ratio on a high-DPI display costs several times
28
+ * the pixels for a difference nobody sees in motion. Softening it keeps text
29
+ * and edges sharp at a fraction of the fill cost.
30
+ *
31
+ * Read on every render, so dragging a window between monitors picks up the
32
+ * new ratio.
25
33
  */
26
34
  const calculateDpr = () => typeof window === "undefined" ? 1 : 1 + (window.devicePixelRatio - 1) * 0.75;
27
- // const layer = Layer.con
28
- // const runtime = ManagedRuntime.make()
29
35
  const PlayerScene = Context.Service("PlayerScene");
30
36
  /**
31
- * Frame-data buffer keyed by absolute frame index, retaining at most
32
- * `capacity` of the most-recently-pulled frames. Frames are pulled from the
33
- * scene monotonically forward (never re-derivable at a random index — the
34
- * scene is a forward-only stream), so the ring drops the oldest frames once
35
- * the window is full. A finite scene that fits within `capacity` behaves like
36
- * a plain array (nothing is ever evicted). Seeking below the retained window
37
- * clamps to `oldest` — the earliest frame still in memory.
37
+ * The frame buffer: keeps the most recent `capacity` frames, keyed by
38
+ * absolute frame index.
39
+ *
40
+ * @remarks
41
+ * A scene is a forward-only stream — frame 400 can only be reached by
42
+ * pulling frames 0 through 399 — so a frame that has been evicted cannot be
43
+ * recomputed on demand. That is why seeking below the retained window clamps
44
+ * to {@link FrameRing.oldest} rather than replaying.
45
+ *
46
+ * With an infinite `capacity` nothing is ever evicted and this behaves like
47
+ * a plain array, which is the finite-scene default.
38
48
  */
39
49
  class FrameRing {
40
50
  capacity;
51
+ /** slots indexed by `absoluteIndex % capacity`, or densely when unbounded */
41
52
  // ponytail: fixed-capacity ring keyed by absolute index. capacity=Infinity
42
53
  // keeps everything (finite scenes that fit); a finite cap bounds memory for
43
54
  // long/infinite scenes at the cost of losing far-back seek. Bump the cap if
@@ -49,7 +60,7 @@ class FrameRing {
49
60
  this.capacity = capacity;
50
61
  this.slots = Number.isFinite(capacity) ? new Array(capacity) : [];
51
62
  }
52
- /** oldest absolute index still retained */
63
+ /** The earliest frame index still in memory — the floor for any seek. */
53
64
  get oldest() {
54
65
  return Number.isFinite(this.capacity)
55
66
  ? Math.max(0, this.pulled - this.capacity)
@@ -66,7 +77,7 @@ class FrameRing {
66
77
  ? this.slots[index % this.capacity]
67
78
  : this.slots[index];
68
79
  }
69
- /** append the next frame at the edge, evicting the oldest if full */
80
+ /** Append the next frame, evicting the oldest once the window is full. */
70
81
  push(frame) {
71
82
  if (Number.isFinite(this.capacity)) {
72
83
  this.slots[this.pulled % this.capacity] = frame;
@@ -77,7 +88,25 @@ class FrameRing {
77
88
  this.pulled++;
78
89
  }
79
90
  }
80
- const useScene = (scene, options) => {
91
+ /**
92
+ * The player's engine: runs the scene, buffers frames, drives the playback
93
+ * clock, and renders to a canvas.
94
+ *
95
+ * @remarks
96
+ * Internal — {@link Player} is the public surface. Returns the canvas ref,
97
+ * playback state, and the controls the chrome is wired to.
98
+ *
99
+ * Two things worth knowing when reading this. All the Effect work runs in
100
+ * ONE `ManagedRuntime` created per mount and disposed on unmount, which is
101
+ * what releases the GPU renderer and interrupts in-flight fibers. And the
102
+ * playback clock is wall-clock on purpose: it drives real-time playback
103
+ * speed, not scene time, so the determinism rule banning clocks inside
104
+ * scenes does not apply to it.
105
+ */
106
+ const useScene = (sceneProp, options) => {
107
+ // internal seam mirroring makeScene's: the props boundary guarantees
108
+ // renderLayers covers Scene.Resources<S>, so frames render as loader-free
109
+ const scene = sceneProp;
81
110
  // loop is read live from optsRef inside the play loop, not destructured here
82
111
  const { fps, prebufferedFrames, autoPlay, isInfinite, settings } = options;
83
112
  const canvasRef = useRef(null);
@@ -89,21 +118,40 @@ const useScene = (scene, options) => {
89
118
  // repeat mode is player state seeded from the prop (an initial value); the
90
119
  // user toggles it live via the repeat button
91
120
  const [loop, setLoop] = useState(options.loop);
121
+ // first real failure (engine acquisition, loader load at runtime build,
122
+ // render) — rendered visibly by the Player, never only logged
123
+ const [error, setError] = useState(null);
92
124
  // latest option values (incl. live loop), read inside the long-lived Effect
93
125
  // service without re-creating the runtime (which would re-run the scene)
94
126
  const optsRef = useRef({ ...options, loop });
95
127
  optsRef.current = { ...options, loop };
96
- // One runtime per mount, DISPOSED on unmount: disposal closes the session
97
- // (canvas deleted, fonts released — refcounted, so siblings keep theirs)
98
- // and releases the engine (a browser no-op: the wasm module is a page
99
- // singleton, design D2; two players share one module via the idempotent
100
- // acquire). Held in a ref, not state, so a strict-mode remount can
101
- // recreate it after the first cleanup disposed it.
128
+ // One runtime per mount, DISPOSED on unmount: disposal closes the layer
129
+ // scope, which disposes the per-player three renderer (GPU resources,
130
+ // retained objects) — nothing is shared across players. Held in a ref,
131
+ // not state, so a strict-mode remount can recreate it after the first
132
+ // cleanup disposed it.
102
133
  const makeRuntime = () => ManagedRuntime.make(Layer.effectContext(Effect.gen(function* () {
103
134
  const runningScene = yield* Scene.run(scene, {
104
135
  ...settings,
105
136
  frameRate: fps,
106
137
  });
138
+ // the per-player renderer, bound to this mount's canvas and
139
+ // released with the runtime; init (incl. WebGPU device) happens
140
+ // here, so an acquisition failure surfaces as the error state
141
+ const canvas = canvasRef.current;
142
+ if (canvas === null) {
143
+ return yield* Effect.fail(PlayerError.of("Player canvas is not mounted")(null));
144
+ }
145
+ const sink = yield* FrameRenderer.make({
146
+ canvas,
147
+ width: 1,
148
+ height: 1,
149
+ }).pipe(Effect.mapError(PlayerError.of("Error acquiring the renderer")));
150
+ // viewport tracking: sized from frame metadata on first render
151
+ let sized = { width: 0, height: 0, dpr: 0 };
152
+ // pipeline pre-warm happens once, on the first rendered frame,
153
+ // before playback reveals motion — no first-frame compile jank
154
+ let prewarmed = false;
107
155
  let currentFrame = 0;
108
156
  let isPlaying = false;
109
157
  // total frame count once the stream ends; null while unknown
@@ -167,11 +215,24 @@ const useScene = (scene, options) => {
167
215
  // past the buffered edge resolves to the edge (clamped in
168
216
  // loadFrameBuffer), and the playhead must reflect what's shown
169
217
  updateCurrentFrame(framebuffer.index);
170
- const renderBuffer = yield* Renderer.render(framebuffer.frame, {
171
- dpr: calculateDpr(),
172
- });
173
- yield* CanvasExporter.toCanvas(renderBuffer, canvasRef.current);
174
- }).pipe(Effect.mapError(PlayerError.of("Error exporting canvas"))));
218
+ const frame = framebuffer.frame;
219
+ const dpr = calculateDpr();
220
+ if (sized.width !== frame.width ||
221
+ sized.height !== frame.height ||
222
+ sized.dpr !== dpr) {
223
+ sized = { width: frame.width, height: frame.height, dpr };
224
+ FrameRenderer.setViewport(sink, frame.width, frame.height, dpr);
225
+ }
226
+ // font loaders resolve from this runtime's context (the
227
+ // renderLayers merge); missing loaders defect loudly
228
+ yield* FrameRenderer.resolveResources(sink, frame);
229
+ yield* FrameRenderer.syncFrame(sink, frame);
230
+ if (!prewarmed) {
231
+ prewarmed = true;
232
+ yield* FrameRenderer.prewarm(sink);
233
+ }
234
+ yield* FrameRenderer.render(sink);
235
+ }).pipe(Effect.mapError(PlayerError.of("Error rendering frame"))));
175
236
  const play = Effect.suspend(() => {
176
237
  if (isPlaying)
177
238
  return Effect.void;
@@ -256,16 +317,10 @@ const useScene = (scene, options) => {
256
317
  load: (frameIndex) => loadFrameBuffer(frameIndex),
257
318
  });
258
319
  })).pipe(
259
- // per-mount session: the canvas (sized by the render path) plus the
260
- // scene's declared fonts and images, held until the runtime is
261
- // disposed. Session.make awaits font/image settlement, so nothing
262
- // renders (and the player can't report ready) before assets settle.
263
- Layer.provideMerge(Session.layer({
264
- width: 1,
265
- height: 1,
266
- fonts: Fonts.urlMap(scene),
267
- images: Images.urlMap(scene),
268
- })), Layer.provideMerge(thorLayer)));
320
+ // caller-provided loader layers: every provided load runs here, at
321
+ // runtime construction (eager, preload-all-provided) — a failed
322
+ // load fails the runtime build and surfaces as the error state
323
+ Layer.provideMerge(options.renderLayers ?? Layer.empty)));
269
324
  const runtimeRef = useRef(null);
270
325
  const getRuntime = () => {
271
326
  if (runtimeRef.current === null) {
@@ -303,6 +358,10 @@ const useScene = (scene, options) => {
303
358
  return;
304
359
  }
305
360
  console.error("effect-motion player:", String(exit.cause), exit.cause);
361
+ // surface the first failure visibly (loader/engine failures at
362
+ // runtime construction land here too — the runtime build is lazy,
363
+ // forced by the first render/load call)
364
+ setError((current) => current ?? exit.cause);
306
365
  });
307
366
  };
308
367
  const render = useEffectEvent((frameIndex) => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.render(frameIndex)), Effect.scoped)));
@@ -325,35 +384,87 @@ const useScene = (scene, options) => {
325
384
  ? Time.frameToMillis(totalFrames - 1, fps) / 1000
326
385
  : null;
327
386
  return {
387
+ /** Attach to the canvas the player draws into. */
328
388
  ref: canvasRef,
389
+ /** The frame currently shown. */
329
390
  currentFrame,
391
+ /** How many frames have been pulled from the scene so far. */
330
392
  bufferedFrames,
393
+ /** Total frames, or `null` until the scene has been pulled to its end. */
331
394
  totalFrames,
395
+ /** Scene time of the current frame, in seconds. */
332
396
  currentTime,
397
+ /** Total duration in seconds, or `null` while unknown. */
333
398
  totalTime,
334
399
  play,
335
400
  pause,
401
+ /** Jump to a frame; clamps to the buffered window. */
336
402
  seek: render,
337
403
  isPlaying,
404
+ /** Whether repeat is on (player-owned after mount). */
338
405
  loop,
339
406
  setLoop,
407
+ /** Buffer ahead to a frame without displaying it. */
340
408
  load,
409
+ /** The first failure, or `null`. Rendered as the error panel. */
410
+ error,
341
411
  };
342
412
  };
343
- // keep-everything cap for an infinite scene: enough for ~30s of backward seek
344
- // at 60fps, ~1MB of frame data. Bump via bufferCapacity for deeper scrubbing.
413
+ /**
414
+ * Frames retained for an infinite scene — about 30 seconds of backward seek
415
+ * at 60fps, roughly 1MB of frame data.
416
+ *
417
+ * @remarks
418
+ * An endless scene cannot keep every frame, so this bounds the window.
419
+ * Callers who need deeper scrubbing raise it with `bufferCapacity`.
420
+ */
345
421
  const INFINITE_BUFFER_CAP = 1800;
422
+ /**
423
+ * A video-style player for an effect-motion scene.
424
+ *
425
+ * @remarks
426
+ * Self-contained: a canvas plus play/pause, a scrubber with buffered-range
427
+ * indicator, a time readout, and a repeat toggle. Controls fade out during
428
+ * playback and return on hover. There is no stylesheet to import — the skin
429
+ * is inline styles and inline SVG — and no way to restyle it short of
430
+ * wrapping it.
431
+ *
432
+ * The canvas is `width: 100%` with automatic height, so the player fills its
433
+ * container at the scene's aspect ratio. Size it by sizing the parent.
434
+ *
435
+ * Frames stream in rather than being pre-rendered, so playback starts before
436
+ * the whole scene is computed. Playback keeps real time: if a frame renders
437
+ * slowly the player drops intermediate frames rather than falling behind.
438
+ *
439
+ * Each player owns its own GPU renderer and scene run, released on unmount.
440
+ *
441
+ * Failures — no WebGPU, a font that would not load, a render error — are
442
+ * shown in the player's own frame as an alert panel, not just logged to the
443
+ * console.
444
+ *
445
+ * @example
446
+ * ```tsx
447
+ * <Player scene={scene} autoPlay />
448
+ * ```
449
+ *
450
+ * @example
451
+ * An endless scene: bounded memory, and no scrubber or repeat button.
452
+ * ```tsx
453
+ * <Player scene={ambientScene} isInfinite autoPlay />
454
+ * ```
455
+ */
346
456
  export const Player = ({ scene, fps: fpsProp = 60,
347
457
  // default: prebuffer everything (Infinity) so the total time / progress bar
348
458
  // are known up front; an infinite scene falls back to a finite window below.
349
459
  prebufferedFrames = Number.POSITIVE_INFINITY, autoPlay = false, isInfinite = false,
350
460
  // initial repeat mode; the player owns it after mount (toggle button)
351
- defaultRepeatMode = false, bufferCapacity, settings, }) => {
461
+ defaultRepeatMode = false, bufferCapacity, settings, renderLayers, }) => {
352
462
  // one effective rate for both the scene run and the playback clock
353
463
  const fps = settings?.frameRate ?? fpsProp;
354
- const { ref, currentFrame, bufferedFrames, totalFrames, currentTime, totalTime, play, pause, seek, isPlaying, loop, setLoop, } = useScene(scene, {
464
+ const { ref, currentFrame, bufferedFrames, totalFrames, currentTime, totalTime, play, pause, seek, isPlaying, loop, setLoop, error, } = useScene(scene, {
355
465
  fps,
356
466
  settings,
467
+ renderLayers: renderLayers,
357
468
  // an infinite scene can never buffer to the end — window it (60 frames
358
469
  // if the caller left prebufferedFrames unbounded)
359
470
  prebufferedFrames: isInfinite && !Number.isFinite(prebufferedFrames)
@@ -393,6 +504,11 @@ defaultRepeatMode = false, bufferCapacity, settings, }) => {
393
504
  const chipFrame = scrubbing ? currentFrame : hoverFrame;
394
505
  const chipFrac = denominator > 0 ? chipFrame / denominator : 0;
395
506
  const barActive = barHover || scrubbing;
507
+ // a failed engine acquisition, loader load, or render: show the failure
508
+ // in the player's frame instead of a black box + console line
509
+ if (error !== null) {
510
+ return (_jsx("div", { style: S.player, children: _jsxs("div", { style: S.errorPanel, role: "alert", children: [_jsx("strong", { children: "effect-motion player failed" }), _jsx("pre", { style: S.errorDetail, children: String(error) })] }) }));
511
+ }
396
512
  return (_jsxs("div", { style: S.player, onMouseEnter: () => setHovering(true), onMouseLeave: () => setHovering(false), children: [_jsx("canvas", { ref: ref, style: S.canvas, onClick: () => (isPlaying ? pause() : play()) }), !isPlaying && (_jsx("button", { type: "button", "aria-label": "Play", style: {
397
513
  ...S.bigPlay,
398
514
  background: bigPlayHover ? ACCENT : "rgba(0, 0, 0, 0.65)",
@@ -431,10 +547,13 @@ defaultRepeatMode = false, bufferCapacity, settings, }) => {
431
547
  }, onClick: () => setLoop((v) => !v), children: _jsx(RepeatIcon, {}) }))] })] }));
432
548
  };
433
549
  // ---------------------------------------------------------------------------
434
- // chrome: Vimeo-style player skin. Self-contained by design — inline style
435
- // objects and inline SVG icons only, no stylesheet/tailwind/image deps.
550
+ // chrome: the player skin. Self-contained by design — inline style objects
551
+ // and inline SVG icons only, so the package ships no stylesheet and pulls in
552
+ // no CSS framework or image assets. The tradeoff is that the look is fixed:
553
+ // restyling means wrapping the player, not overriding classes.
436
554
  // ---------------------------------------------------------------------------
437
555
  const ACCENT = "#00adef"; // Vimeo blue
556
+ /** Seconds as `m:ss`, for the time readout and the scrubber chip. */
438
557
  const formatTime = (seconds) => {
439
558
  const s = Math.max(0, Math.floor(seconds));
440
559
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
@@ -455,6 +574,21 @@ const S = {
455
574
  WebkitUserSelect: "none",
456
575
  fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
457
576
  },
577
+ errorPanel: {
578
+ padding: "24px 20px",
579
+ color: "#ffb4ab",
580
+ background: "#1a1113",
581
+ fontSize: 13,
582
+ lineHeight: 1.5,
583
+ },
584
+ errorDetail: {
585
+ margin: "8px 0 0",
586
+ whiteSpace: "pre-wrap",
587
+ wordBreak: "break-word",
588
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
589
+ fontSize: 12,
590
+ opacity: 0.85,
591
+ },
458
592
  canvas: {
459
593
  display: "block",
460
594
  width: "100%",
package/dist/index.d.ts CHANGED
@@ -1 +1,45 @@
1
+ /**
2
+ * `@effect-motion/react` — play effect-motion scenes in the browser.
3
+ *
4
+ * @remarks
5
+ * One component: {@link Player}, a self-contained video-style player with
6
+ * play/pause, a scrubber, a time readout, and a repeat toggle. Point it at
7
+ * a scene and it renders.
8
+ *
9
+ * ```tsx
10
+ * <Player scene={scene} />
11
+ * ```
12
+ *
13
+ * Playback is streamed rather than pre-rendered. Frames are pulled from the
14
+ * scene on demand and buffered, so a long scene starts playing without
15
+ * being computed to the end first, and an endless one plays without
16
+ * accumulating forever.
17
+ *
18
+ * Everything is per-mount: each `Player` owns its own GPU renderer and
19
+ * scene run, disposed on unmount. Several on a page do not interfere, and
20
+ * navigating away releases the GPU resources.
21
+ *
22
+ * The component needs a browser — it renders through WebGPU to a canvas.
23
+ * Under a framework that server-renders, it is already marked
24
+ * `"use client"`.
25
+ *
26
+ * @example
27
+ * A scene with a custom font. The `renderLayers` prop is REQUIRED when the
28
+ * scene declares resources, and the types enforce it.
29
+ * ```tsx
30
+ * import { Player } from "@effect-motion/react";
31
+ * import * as Font from "effect-motion/Font";
32
+ * import * as Resource from "effect-motion/Resource";
33
+ *
34
+ * const Inter = Font.Font("Inter");
35
+ *
36
+ * <Player
37
+ * scene={scene}
38
+ * renderLayers={Font.layer(Inter, Resource.fetchBytes("/fonts/inter.ttf"))}
39
+ * autoPlay
40
+ * />
41
+ * ```
42
+ *
43
+ * @packageDocumentation
44
+ */
1
45
  export { Player, type PlayerProps } from "./Player.js";
package/dist/index.js CHANGED
@@ -1 +1,45 @@
1
+ /**
2
+ * `@effect-motion/react` — play effect-motion scenes in the browser.
3
+ *
4
+ * @remarks
5
+ * One component: {@link Player}, a self-contained video-style player with
6
+ * play/pause, a scrubber, a time readout, and a repeat toggle. Point it at
7
+ * a scene and it renders.
8
+ *
9
+ * ```tsx
10
+ * <Player scene={scene} />
11
+ * ```
12
+ *
13
+ * Playback is streamed rather than pre-rendered. Frames are pulled from the
14
+ * scene on demand and buffered, so a long scene starts playing without
15
+ * being computed to the end first, and an endless one plays without
16
+ * accumulating forever.
17
+ *
18
+ * Everything is per-mount: each `Player` owns its own GPU renderer and
19
+ * scene run, disposed on unmount. Several on a page do not interfere, and
20
+ * navigating away releases the GPU resources.
21
+ *
22
+ * The component needs a browser — it renders through WebGPU to a canvas.
23
+ * Under a framework that server-renders, it is already marked
24
+ * `"use client"`.
25
+ *
26
+ * @example
27
+ * A scene with a custom font. The `renderLayers` prop is REQUIRED when the
28
+ * scene declares resources, and the types enforce it.
29
+ * ```tsx
30
+ * import { Player } from "@effect-motion/react";
31
+ * import * as Font from "effect-motion/Font";
32
+ * import * as Resource from "effect-motion/Resource";
33
+ *
34
+ * const Inter = Font.Font("Inter");
35
+ *
36
+ * <Player
37
+ * scene={scene}
38
+ * renderLayers={Font.layer(Inter, Resource.fetchBytes("/fonts/inter.ttf"))}
39
+ * autoPlay
40
+ * />
41
+ * ```
42
+ *
43
+ * @packageDocumentation
44
+ */
1
45
  export { Player } from "./Player.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-motion/react",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "React bindings for effect-motion: usePlayer hook and Player component",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,8 +35,8 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "effect-motion": "^0.3.2",
39
- "@effect-motion/thorvg": "^0.1.0"
38
+ "@effect-motion/renderer": "^0.5.0",
39
+ "effect-motion": "^0.5.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "effect": ">=4.0.0-beta.98",