@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 +17 -5
- package/dist/Player.d.ts +146 -5
- package/dist/Player.js +184 -50
- package/dist/index.d.ts +44 -0
- package/dist/index.js +44 -0
- package/package.json +3 -3
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
|
|
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
|
-
|
|
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
|
|
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}
|
|
24
|
+
return <Player scene={scene} autoPlay />;
|
|
25
25
|
}
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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:
|
|
435
|
-
//
|
|
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
|
+
"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.
|
|
39
|
-
"
|
|
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",
|