@effect-motion/react 0.4.0 → 0.6.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,31 +1,43 @@
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
 
9
9
  `effect` and `effect-motion` are peer dependencies — install them alongside:
10
10
 
11
11
  ```bash
12
- pnpm add @effect-motion/react effect-motion effect
12
+ bun add @effect-motion/react effect-motion effect
13
13
  ```
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,68 @@
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 viewport for a `width` × `height` frame shown in `canvas`: logical size
24
+ * is the frame's, pixel density follows how big the canvas is displayed.
25
+ *
26
+ * @remarks
27
+ * Scene coordinates, camera and HUD stay in frame units; only the pixel ratio
28
+ * changes, set so the backing store matches the displayed CSS size at device
29
+ * density — a 1920-wide scene shown 700 CSS px wide on a 2× display renders
30
+ * 1400 px wide, not 3840. Capped at the device ratio, so never more detail
31
+ * than the display can show. Before layout (width 0) it falls back to the
32
+ * device ratio.
33
+ *
34
+ * `pw`/`ph` are the backing-store size three will set (it floors), used to
35
+ * skip resizes that would not change a pixel.
25
36
  */
26
- const calculateDpr = () => typeof window === "undefined" ? 1 : 1 + (window.devicePixelRatio - 1) * 0.75;
27
- // const layer = Layer.con
28
- // const runtime = ManagedRuntime.make()
37
+ const viewportFor = (canvas, width, height) => {
38
+ const dpr = window.devicePixelRatio;
39
+ const displayed = canvas.clientWidth;
40
+ const ratio = displayed > 0 ? Math.min(dpr, (displayed * dpr) / width) : dpr;
41
+ return {
42
+ width,
43
+ height,
44
+ ratio,
45
+ pw: Math.floor(width * ratio),
46
+ ph: Math.floor(height * ratio),
47
+ };
48
+ };
29
49
  const PlayerScene = Context.Service("PlayerScene");
30
50
  /**
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.
51
+ * The frame buffer: keeps the most recent `capacity` frames, keyed by
52
+ * absolute frame index.
53
+ *
54
+ * @remarks
55
+ * A scene is a forward-only stream — frame 400 can only be reached by
56
+ * pulling frames 0 through 399 — so a frame that has been evicted cannot be
57
+ * recomputed on demand. That is why seeking below the retained window clamps
58
+ * to {@link FrameRing.oldest} rather than replaying.
59
+ *
60
+ * With an infinite `capacity` nothing is ever evicted and this behaves like
61
+ * a plain array, which is the finite-scene default.
38
62
  */
39
63
  class FrameRing {
40
64
  capacity;
65
+ /** slots indexed by `absoluteIndex % capacity`, or densely when unbounded */
41
66
  // ponytail: fixed-capacity ring keyed by absolute index. capacity=Infinity
42
67
  // keeps everything (finite scenes that fit); a finite cap bounds memory for
43
68
  // long/infinite scenes at the cost of losing far-back seek. Bump the cap if
@@ -49,7 +74,7 @@ class FrameRing {
49
74
  this.capacity = capacity;
50
75
  this.slots = Number.isFinite(capacity) ? new Array(capacity) : [];
51
76
  }
52
- /** oldest absolute index still retained */
77
+ /** The earliest frame index still in memory — the floor for any seek. */
53
78
  get oldest() {
54
79
  return Number.isFinite(this.capacity)
55
80
  ? Math.max(0, this.pulled - this.capacity)
@@ -66,7 +91,7 @@ class FrameRing {
66
91
  ? this.slots[index % this.capacity]
67
92
  : this.slots[index];
68
93
  }
69
- /** append the next frame at the edge, evicting the oldest if full */
94
+ /** Append the next frame, evicting the oldest once the window is full. */
70
95
  push(frame) {
71
96
  if (Number.isFinite(this.capacity)) {
72
97
  this.slots[this.pulled % this.capacity] = frame;
@@ -77,7 +102,25 @@ class FrameRing {
77
102
  this.pulled++;
78
103
  }
79
104
  }
80
- const useScene = (scene, options) => {
105
+ /**
106
+ * The player's engine: runs the scene, buffers frames, drives the playback
107
+ * clock, and renders to a canvas.
108
+ *
109
+ * @remarks
110
+ * Internal — {@link Player} is the public surface. Returns the canvas ref,
111
+ * playback state, and the controls the chrome is wired to.
112
+ *
113
+ * Two things worth knowing when reading this. All the Effect work runs in
114
+ * ONE `ManagedRuntime` created per mount and disposed on unmount, which is
115
+ * what releases the GPU renderer and interrupts in-flight fibers. And the
116
+ * playback clock is wall-clock on purpose: it drives real-time playback
117
+ * speed, not scene time, so the determinism rule banning clocks inside
118
+ * scenes does not apply to it.
119
+ */
120
+ const useScene = (sceneProp, options) => {
121
+ // internal seam mirroring makeScene's: the props boundary guarantees
122
+ // renderLayers covers Scene.Resources<S>, so frames render as loader-free
123
+ const scene = sceneProp;
81
124
  // loop is read live from optsRef inside the play loop, not destructured here
82
125
  const { fps, prebufferedFrames, autoPlay, isInfinite, settings } = options;
83
126
  const canvasRef = useRef(null);
@@ -89,21 +132,41 @@ const useScene = (scene, options) => {
89
132
  // repeat mode is player state seeded from the prop (an initial value); the
90
133
  // user toggles it live via the repeat button
91
134
  const [loop, setLoop] = useState(options.loop);
135
+ // first real failure (engine acquisition, loader load at runtime build,
136
+ // render) — rendered visibly by the Player, never only logged
137
+ const [error, setError] = useState(null);
92
138
  // latest option values (incl. live loop), read inside the long-lived Effect
93
139
  // service without re-creating the runtime (which would re-run the scene)
94
140
  const optsRef = useRef({ ...options, loop });
95
141
  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.
142
+ // One runtime per mount, DISPOSED on unmount: disposal closes the layer
143
+ // scope, which disposes the per-player three renderer (GPU resources,
144
+ // retained objects) — nothing is shared across players. Held in a ref,
145
+ // not state, so a strict-mode remount can recreate it after the first
146
+ // cleanup disposed it.
102
147
  const makeRuntime = () => ManagedRuntime.make(Layer.effectContext(Effect.gen(function* () {
103
148
  const runningScene = yield* Scene.run(scene, {
104
149
  ...settings,
105
150
  frameRate: fps,
106
151
  });
152
+ // the per-player renderer, bound to this mount's canvas and
153
+ // released with the runtime; init (incl. WebGPU device) happens
154
+ // here, so an acquisition failure surfaces as the error state
155
+ const canvas = canvasRef.current;
156
+ if (canvas === null) {
157
+ return yield* Effect.fail(PlayerError.of("Player canvas is not mounted")(null));
158
+ }
159
+ const sink = yield* FrameRenderer.make({
160
+ canvas,
161
+ width: 1,
162
+ height: 1,
163
+ }).pipe(Effect.mapError(PlayerError.of("Error acquiring the renderer")));
164
+ // viewport tracking: sized from frame metadata + displayed size
165
+ // on first render, then on every resize / DPR change
166
+ let sized = { width: 0, height: 0, ratio: 0, pw: 0, ph: 0 };
167
+ // pipeline pre-warm happens once, on the first rendered frame,
168
+ // before playback reveals motion — no first-frame compile jank
169
+ let prewarmed = false;
107
170
  let currentFrame = 0;
108
171
  let isPlaying = false;
109
172
  // total frame count once the stream ends; null while unknown
@@ -155,7 +218,8 @@ const useScene = (scene, options) => {
155
218
  };
156
219
  }).pipe(Effect.mapError(PlayerError.of("Error getting frame buffer")));
157
220
  const renderSemaphore = yield* Semaphore.make(1);
158
- const render = (frameIndex) => renderSemaphore.withPermitsIfAvailable(1)(Effect.gen(function* () {
221
+ // unlocked: callers hold renderSemaphore
222
+ const renderFrame = (frameIndex) => Effect.gen(function* () {
159
223
  if (!canvasRef.current) {
160
224
  return;
161
225
  }
@@ -167,11 +231,48 @@ const useScene = (scene, options) => {
167
231
  // past the buffered edge resolves to the edge (clamped in
168
232
  // loadFrameBuffer), and the playhead must reflect what's shown
169
233
  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"))));
234
+ const frame = framebuffer.frame;
235
+ const next = viewportFor(canvas, frame.width, frame.height);
236
+ if (next.width !== sized.width ||
237
+ next.height !== sized.height ||
238
+ next.pw !== sized.pw ||
239
+ next.ph !== sized.ph) {
240
+ sized = next;
241
+ FrameRenderer.setViewport(sink, next.width, next.height, next.ratio);
242
+ }
243
+ // font loaders resolve from this runtime's context (the
244
+ // renderLayers merge); missing loaders defect loudly
245
+ yield* FrameRenderer.resolveResources(sink, frame);
246
+ yield* FrameRenderer.syncFrame(sink, frame);
247
+ if (!prewarmed) {
248
+ prewarmed = true;
249
+ yield* FrameRenderer.prewarm(sink);
250
+ }
251
+ yield* FrameRenderer.render(sink);
252
+ }).pipe(Effect.mapError(PlayerError.of("Error rendering frame")));
253
+ const render = (frameIndex) => renderSemaphore.withPermitsIfAvailable(1)(renderFrame(frameIndex));
254
+ // resize / DPR change: resizing clears the canvas, so re-render
255
+ // the shown frame (a paused player would otherwise stay blank or
256
+ // scaled). Waits for the permit rather than dropping, so the
257
+ // final size of a drag is never lost; at most one waits.
258
+ let redrawQueued = false;
259
+ const redraw = Effect.suspend(() => {
260
+ if (redrawQueued) {
261
+ return Effect.void;
262
+ }
263
+ redrawQueued = true;
264
+ return renderSemaphore.withPermits(1)(Effect.suspend(() => {
265
+ redrawQueued = false;
266
+ // unsized: the first render has not run yet and will size
267
+ if (sized.width === 0) {
268
+ return Effect.void;
269
+ }
270
+ const next = viewportFor(canvas, sized.width, sized.height);
271
+ return next.pw === sized.pw && next.ph === sized.ph
272
+ ? Effect.void
273
+ : renderFrame(currentFrame);
274
+ }));
275
+ });
175
276
  const play = Effect.suspend(() => {
176
277
  if (isPlaying)
177
278
  return Effect.void;
@@ -251,21 +352,16 @@ const useScene = (scene, options) => {
251
352
  return Context.make(PlayerScene, {
252
353
  play,
253
354
  pause,
355
+ redraw,
254
356
  frameIndex: Effect.sync(() => currentFrame),
255
357
  render,
256
358
  load: (frameIndex) => loadFrameBuffer(frameIndex),
257
359
  });
258
360
  })).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)));
361
+ // caller-provided loader layers: every provided load runs here, at
362
+ // runtime construction (eager, preload-all-provided) — a failed
363
+ // load fails the runtime build and surfaces as the error state
364
+ Layer.provideMerge(options.renderLayers ?? Layer.empty)));
269
365
  const runtimeRef = useRef(null);
270
366
  const getRuntime = () => {
271
367
  if (runtimeRef.current === null) {
@@ -303,6 +399,10 @@ const useScene = (scene, options) => {
303
399
  return;
304
400
  }
305
401
  console.error("effect-motion player:", String(exit.cause), exit.cause);
402
+ // surface the first failure visibly (loader/engine failures at
403
+ // runtime construction land here too — the runtime build is lazy,
404
+ // forced by the first render/load call)
405
+ setError((current) => current ?? exit.cause);
306
406
  });
307
407
  };
308
408
  const render = useEffectEvent((frameIndex) => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.render(frameIndex)), Effect.scoped)));
@@ -319,41 +419,117 @@ const useScene = (scene, options) => {
319
419
  play();
320
420
  }
321
421
  }, []);
422
+ const redraw = useEffectEvent(() => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.redraw), Effect.scoped)));
423
+ // keep the backing store at displayed size × device pixel ratio: the
424
+ // observer covers container resize and fullscreen; the resolution query
425
+ // covers DPR changes that leave the CSS size alone (monitor move, zoom)
426
+ useEffect(() => {
427
+ const canvas = canvasRef.current;
428
+ if (canvas === null) {
429
+ return;
430
+ }
431
+ const observer = new ResizeObserver(() => redraw());
432
+ observer.observe(canvas);
433
+ let media = null;
434
+ const onDprChange = () => {
435
+ media?.removeEventListener("change", onDprChange);
436
+ media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
437
+ media.addEventListener("change", onDprChange);
438
+ redraw();
439
+ };
440
+ onDprChange();
441
+ return () => {
442
+ observer.disconnect();
443
+ media?.removeEventListener("change", onDprChange);
444
+ };
445
+ }, []);
322
446
  // scene time in seconds of the current frame, and total when known
323
447
  const currentTime = Time.frameToMillis(currentFrame, fps) / 1000;
324
448
  const totalTime = totalFrames !== null
325
449
  ? Time.frameToMillis(totalFrames - 1, fps) / 1000
326
450
  : null;
327
451
  return {
452
+ /** Attach to the canvas the player draws into. */
328
453
  ref: canvasRef,
454
+ /** The frame currently shown. */
329
455
  currentFrame,
456
+ /** How many frames have been pulled from the scene so far. */
330
457
  bufferedFrames,
458
+ /** Total frames, or `null` until the scene has been pulled to its end. */
331
459
  totalFrames,
460
+ /** Scene time of the current frame, in seconds. */
332
461
  currentTime,
462
+ /** Total duration in seconds, or `null` while unknown. */
333
463
  totalTime,
334
464
  play,
335
465
  pause,
466
+ /** Jump to a frame; clamps to the buffered window. */
336
467
  seek: render,
337
468
  isPlaying,
469
+ /** Whether repeat is on (player-owned after mount). */
338
470
  loop,
339
471
  setLoop,
472
+ /** Buffer ahead to a frame without displaying it. */
340
473
  load,
474
+ /** The first failure, or `null`. Rendered as the error panel. */
475
+ error,
341
476
  };
342
477
  };
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.
478
+ /**
479
+ * Frames retained for an infinite scene — about 30 seconds of backward seek
480
+ * at 60fps, roughly 1MB of frame data.
481
+ *
482
+ * @remarks
483
+ * An endless scene cannot keep every frame, so this bounds the window.
484
+ * Callers who need deeper scrubbing raise it with `bufferCapacity`.
485
+ */
345
486
  const INFINITE_BUFFER_CAP = 1800;
487
+ /**
488
+ * A video-style player for an effect-motion scene.
489
+ *
490
+ * @remarks
491
+ * Self-contained: a canvas plus play/pause, a scrubber with buffered-range
492
+ * indicator, a time readout, and a repeat toggle. Controls fade out during
493
+ * playback and return on hover. There is no stylesheet to import — the skin
494
+ * is inline styles and inline SVG — and no way to restyle it short of
495
+ * wrapping it.
496
+ *
497
+ * The canvas is `width: 100%` with automatic height, so the player fills its
498
+ * container at the scene's aspect ratio. Size it by sizing the parent.
499
+ *
500
+ * Frames stream in rather than being pre-rendered, so playback starts before
501
+ * the whole scene is computed. Playback keeps real time: if a frame renders
502
+ * slowly the player drops intermediate frames rather than falling behind.
503
+ *
504
+ * Each player owns its own GPU renderer and scene run, released on unmount.
505
+ *
506
+ * Failures — no WebGPU, a font that would not load, a render error — are
507
+ * shown in the player's own frame as an alert panel, not just logged to the
508
+ * console.
509
+ *
510
+ * @example
511
+ * ```tsx
512
+ * <Player scene={scene} autoPlay />
513
+ * ```
514
+ *
515
+ * @example
516
+ * An endless scene: bounded memory, and no scrubber or repeat button.
517
+ * ```tsx
518
+ * <Player scene={ambientScene} isInfinite autoPlay />
519
+ * ```
520
+ */
346
521
  export const Player = ({ scene, fps: fpsProp = 60,
347
522
  // default: prebuffer everything (Infinity) so the total time / progress bar
348
523
  // are known up front; an infinite scene falls back to a finite window below.
349
524
  prebufferedFrames = Number.POSITIVE_INFINITY, autoPlay = false, isInfinite = false,
350
525
  // initial repeat mode; the player owns it after mount (toggle button)
351
- defaultRepeatMode = false, bufferCapacity, settings, }) => {
526
+ defaultRepeatMode = false, bufferCapacity, settings, renderLayers, }) => {
352
527
  // one effective rate for both the scene run and the playback clock
353
528
  const fps = settings?.frameRate ?? fpsProp;
354
- const { ref, currentFrame, bufferedFrames, totalFrames, currentTime, totalTime, play, pause, seek, isPlaying, loop, setLoop, } = useScene(scene, {
529
+ const { ref, currentFrame, bufferedFrames, totalFrames, currentTime, totalTime, play, pause, seek, isPlaying, loop, setLoop, error, } = useScene(scene, {
355
530
  fps,
356
531
  settings,
532
+ renderLayers: renderLayers,
357
533
  // an infinite scene can never buffer to the end — window it (60 frames
358
534
  // if the caller left prebufferedFrames unbounded)
359
535
  prebufferedFrames: isInfinite && !Number.isFinite(prebufferedFrames)
@@ -393,6 +569,11 @@ defaultRepeatMode = false, bufferCapacity, settings, }) => {
393
569
  const chipFrame = scrubbing ? currentFrame : hoverFrame;
394
570
  const chipFrac = denominator > 0 ? chipFrame / denominator : 0;
395
571
  const barActive = barHover || scrubbing;
572
+ // a failed engine acquisition, loader load, or render: show the failure
573
+ // in the player's frame instead of a black box + console line
574
+ if (error !== null) {
575
+ 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) })] }) }));
576
+ }
396
577
  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
578
  ...S.bigPlay,
398
579
  background: bigPlayHover ? ACCENT : "rgba(0, 0, 0, 0.65)",
@@ -431,10 +612,13 @@ defaultRepeatMode = false, bufferCapacity, settings, }) => {
431
612
  }, onClick: () => setLoop((v) => !v), children: _jsx(RepeatIcon, {}) }))] })] }));
432
613
  };
433
614
  // ---------------------------------------------------------------------------
434
- // chrome: Vimeo-style player skin. Self-contained by design — inline style
435
- // objects and inline SVG icons only, no stylesheet/tailwind/image deps.
615
+ // chrome: the player skin. Self-contained by design — inline style objects
616
+ // and inline SVG icons only, so the package ships no stylesheet and pulls in
617
+ // no CSS framework or image assets. The tradeoff is that the look is fixed:
618
+ // restyling means wrapping the player, not overriding classes.
436
619
  // ---------------------------------------------------------------------------
437
620
  const ACCENT = "#00adef"; // Vimeo blue
621
+ /** Seconds as `m:ss`, for the time readout and the scrubber chip. */
438
622
  const formatTime = (seconds) => {
439
623
  const s = Math.max(0, Math.floor(seconds));
440
624
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
@@ -455,6 +639,21 @@ const S = {
455
639
  WebkitUserSelect: "none",
456
640
  fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
457
641
  },
642
+ errorPanel: {
643
+ padding: "24px 20px",
644
+ color: "#ffb4ab",
645
+ background: "#1a1113",
646
+ fontSize: 13,
647
+ lineHeight: 1.5,
648
+ },
649
+ errorDetail: {
650
+ margin: "8px 0 0",
651
+ whiteSpace: "pre-wrap",
652
+ wordBreak: "break-word",
653
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
654
+ fontSize: 12,
655
+ opacity: 0.85,
656
+ },
458
657
  canvas: {
459
658
  display: "block",
460
659
  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,63 +1,63 @@
1
1
  {
2
- "name": "@effect-motion/react",
3
- "version": "0.4.0",
4
- "description": "React bindings for effect-motion: usePlayer hook and Player component",
5
- "type": "module",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/julia-script/effect-motion.git",
10
- "directory": "packages/react"
11
- },
12
- "homepage": "https://github.com/julia-script/effect-motion#readme",
13
- "bugs": "https://github.com/julia-script/effect-motion/issues",
14
- "keywords": [
15
- "effect",
16
- "motion",
17
- "react",
18
- "animation",
19
- "motion-graphics"
20
- ],
21
- "exports": {
22
- ".": {
23
- "types": "./dist/index.d.ts",
24
- "default": "./dist/index.js"
25
- },
26
- "./*": {
27
- "types": "./dist/*.d.ts",
28
- "default": "./dist/*.js"
29
- }
30
- },
31
- "files": [
32
- "dist"
33
- ],
34
- "publishConfig": {
35
- "access": "public"
36
- },
37
- "dependencies": {
38
- "@effect-motion/thorvg": "^0.2.0",
39
- "effect-motion": "^0.4.0"
40
- },
41
- "peerDependencies": {
42
- "effect": ">=4.0.0-beta.98",
43
- "react": ">=18"
44
- },
45
- "devDependencies": {
46
- "@testing-library/react": "^16.3.0",
47
- "@types/node": "^26.1.1",
48
- "@types/react": "^19.2.0",
49
- "@types/react-dom": "^19.2.0",
50
- "effect": "4.0.0-beta.98",
51
- "happy-dom": "^20.10.6",
52
- "react": "^19.2.0",
53
- "react-dom": "^19.2.0",
54
- "typescript": "^7.0.2",
55
- "vitest": "^4.1.10"
56
- },
57
- "scripts": {
58
- "build": "tsc -p tsconfig.build.json",
59
- "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
60
- "test": "vitest run --passWithNoTests",
61
- "check": "tsc --noEmit"
62
- }
63
- }
2
+ "name": "@effect-motion/react",
3
+ "version": "0.6.0",
4
+ "description": "React bindings for effect-motion: usePlayer hook and Player component",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/julia-script/effect-motion.git",
10
+ "directory": "packages/react"
11
+ },
12
+ "homepage": "https://github.com/julia-script/effect-motion#readme",
13
+ "bugs": "https://github.com/julia-script/effect-motion/issues",
14
+ "keywords": [
15
+ "effect",
16
+ "motion",
17
+ "react",
18
+ "animation",
19
+ "motion-graphics"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./*": {
27
+ "types": "./dist/*.d.ts",
28
+ "default": "./dist/*.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.build.json",
36
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
37
+ "test": "vitest run --passWithNoTests",
38
+ "check": "tsc --noEmit"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@effect-motion/renderer": "workspace:^",
45
+ "effect-motion": "workspace:^"
46
+ },
47
+ "peerDependencies": {
48
+ "effect": ">=4.0.0-rc.115",
49
+ "react": ">=18"
50
+ },
51
+ "devDependencies": {
52
+ "@testing-library/react": "^16.3.0",
53
+ "@types/node": "^26.1.1",
54
+ "@types/react": "^19.2.0",
55
+ "@types/react-dom": "^19.2.0",
56
+ "effect": "4.0.0-rc.115",
57
+ "happy-dom": "^20.10.6",
58
+ "react": "^19.2.0",
59
+ "react-dom": "^19.2.0",
60
+ "typescript": "^7.0.2",
61
+ "vitest": "^4.1.10"
62
+ }
63
+ }