@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 +18 -6
- package/dist/Player.d.ts +146 -5
- package/dist/Player.js +251 -52
- package/dist/index.d.ts +44 -0
- package/dist/index.js +44 -0
- package/package.json +62 -62
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
|
|
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
|
|
|
9
9
|
`effect` and `effect-motion` are peer dependencies — install them alongside:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
|
|
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
|
|
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,68 @@
|
|
|
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 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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
//
|
|
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)));
|
|
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
|
-
|
|
344
|
-
|
|
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:
|
|
435
|
-
//
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|