@effect-motion/react 0.2.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 ADDED
@@ -0,0 +1,32 @@
1
+ # @effect-motion/react
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.
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.
6
+
7
+ ## Install
8
+
9
+ `effect` and `effect-motion` are peer dependencies — install them alongside:
10
+
11
+ ```bash
12
+ pnpm add @effect-motion/react effect-motion effect
13
+ ```
14
+
15
+ ## Play a scene
16
+
17
+ `<Player>` runs the scene and gives you transport controls — play/pause and a scrubbable progress bar:
18
+
19
+ ```tsx
20
+ import { Player } from "@effect-motion/react";
21
+ import { scene } from "./my-scene";
22
+
23
+ export function App() {
24
+ return <Player scene={scene} width={500} height={300} autoPlay />;
25
+ }
26
+ ```
27
+
28
+ Prefer your own UI? `usePlayer(scene, options)` exposes the same state and controls (`status`, `frame`, `progress`, `play`, `pause`, `seek`, …) without any chrome.
29
+
30
+ ## Documentation
31
+
32
+ Full docs, concepts, and live examples: **https://github.com/julia-script/effect-motion**
@@ -0,0 +1,11 @@
1
+ import { type AnyScene, type UsePlayerOptions } from "./usePlayer";
2
+ export interface PlayerProps extends UsePlayerOptions {
3
+ readonly scene: AnyScene;
4
+ }
5
+ /**
6
+ * A scene player: metadata-sized SVG viewport and a transport bar with
7
+ * play/pause, a scrubber clamped to the buffered range, a time readout,
8
+ * and a loop toggle. Focus the player for keyboard control: Space toggles
9
+ * playback, arrow keys step one frame.
10
+ */
11
+ export declare const Player: ({ scene, ...options }: PlayerProps) => import("react").JSX.Element;
package/dist/Player.js ADDED
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * biome-ignore-all lint/a11y/noNoninteractiveTabindex: the player root is a
4
+ * deliberate focus target for transport shortcuts (space/arrows), like the
5
+ * native <video> element
6
+ * biome-ignore-all lint/a11y/noStaticElementInteractions: keyboard transport
7
+ * is scoped to the focused player root
8
+ */
9
+ import { Layer } from "effect";
10
+ import * as Effect from "effect/Effect";
11
+ import { Svg } from "effect-motion";
12
+ import { useEffect, useRef } from "react";
13
+ import { usePlayer, } from "./usePlayer";
14
+ const layers = Svg.layer.pipe(Layer.provideMerge(Svg.shapesLayer));
15
+ // everything in the SVG DOM sink and its entity renderers is synchronous
16
+ const renderFrame = (frame, target) => Effect.runSync(Effect.gen(function* () {
17
+ const renderer = yield* Svg.SvgDomRenderer.Context;
18
+ // no size in the config: the sink falls back to frame metadata
19
+ yield* renderer.render(frame, { target });
20
+ }).pipe(Effect.provide(layers)));
21
+ const iconProps = {
22
+ width: 14,
23
+ height: 14,
24
+ viewBox: "0 0 16 16",
25
+ fill: "currentColor",
26
+ };
27
+ // icons are decorative: the owning buttons carry the accessible labels
28
+ const PlayIcon = () => (_jsx("svg", { ...iconProps, "aria-hidden": "true", children: _jsx("path", { d: "M4.5 2.5v11l9-5.5z" }) }));
29
+ const PauseIcon = () => (_jsx("svg", { ...iconProps, "aria-hidden": "true", children: _jsx("path", { d: "M4 2.5h3v11H4zM9 2.5h3v11H9z" }) }));
30
+ const LoopIcon = () => (_jsx("svg", { ...iconProps, "aria-hidden": "true", children: _jsx("path", { d: "M4 5h6V3l3.5 3L10 9V7H5v3H3V6a1 1 0 0 1 1-1zM12 11H6v2l-3.5-3L6 7v2h5V6h2v4a1 1 0 0 1-1 1z" }) }));
31
+ const buttonStyle = (active) => ({
32
+ display: "inline-flex",
33
+ alignItems: "center",
34
+ justifyContent: "center",
35
+ width: 28,
36
+ height: 28,
37
+ padding: 0,
38
+ border: "none",
39
+ borderRadius: 6,
40
+ background: "transparent",
41
+ color: active ? "#8b9cff" : "#d6d6de",
42
+ cursor: "pointer",
43
+ });
44
+ const formatTime = (frames, frameRate) => {
45
+ const seconds = Math.floor(frames / frameRate);
46
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
47
+ };
48
+ /**
49
+ * A scene player: metadata-sized SVG viewport and a transport bar with
50
+ * play/pause, a scrubber clamped to the buffered range, a time readout,
51
+ * and a loop toggle. Focus the player for keyboard control: Space toggles
52
+ * playback, arrow keys step one frame.
53
+ */
54
+ export const Player = ({ scene, ...options }) => {
55
+ const player = usePlayer(scene, options);
56
+ const viewportRef = useRef(null);
57
+ // scene resolution: frame metadata once available, else explicit props
58
+ const sceneWidth = player.currentFrame?.width ?? options.width;
59
+ const sceneHeight = player.currentFrame?.height ?? options.height;
60
+ useEffect(() => {
61
+ const target = viewportRef.current;
62
+ if (target === null || player.currentFrame === null) {
63
+ return;
64
+ }
65
+ renderFrame(player.currentFrame, target);
66
+ // ponytail: post-process the sink's root for responsive scaling —
67
+ // viewBox + CSS size lets the fixed-pixel SVG fill the viewport box;
68
+ // move viewBox into the sink if another consumer needs scaling
69
+ const svg = target.querySelector("svg");
70
+ if (svg !== null) {
71
+ svg.setAttribute("viewBox", `0 0 ${player.currentFrame.width} ${player.currentFrame.height}`);
72
+ svg.style.width = "100%";
73
+ svg.style.height = "100%";
74
+ svg.style.display = "block";
75
+ }
76
+ }, [player.currentFrame]);
77
+ const handleKeyDown = (event) => {
78
+ // buttons and the scrubber already handle these keys natively
79
+ const tag = event.target.tagName;
80
+ if (tag === "BUTTON" || tag === "INPUT") {
81
+ return;
82
+ }
83
+ if (event.key === " ") {
84
+ event.preventDefault();
85
+ player.toggle();
86
+ }
87
+ else if (event.key === "ArrowRight") {
88
+ event.preventDefault();
89
+ player.pause();
90
+ player.seek(player.frame + 1);
91
+ }
92
+ else if (event.key === "ArrowLeft") {
93
+ event.preventDefault();
94
+ player.pause();
95
+ player.seek(player.frame - 1);
96
+ }
97
+ };
98
+ return (_jsxs("div", { tabIndex: 0, onKeyDown: handleKeyDown, style: {
99
+ display: "flex",
100
+ flexDirection: "column",
101
+ // fill the container like a video element; the aspect ratio
102
+ // below keeps the scene's proportions at any width
103
+ width: "100%",
104
+ background: "#101014",
105
+ borderRadius: 10,
106
+ overflow: "hidden",
107
+ }, children: [_jsxs("div", { style: {
108
+ position: "relative",
109
+ width: "100%",
110
+ aspectRatio: sceneWidth !== undefined && sceneHeight !== undefined
111
+ ? `${sceneWidth} / ${sceneHeight}`
112
+ : undefined,
113
+ minHeight: sceneHeight === undefined ? 120 : undefined,
114
+ }, children: [_jsx("div", { ref: viewportRef, style: { width: "100%", height: "100%" } }), player.status === "loading" ? (_jsx("div", { style: {
115
+ position: "absolute",
116
+ inset: 0,
117
+ display: "flex",
118
+ alignItems: "center",
119
+ justifyContent: "center",
120
+ color: "#6b6b76",
121
+ fontSize: 13,
122
+ }, children: "Loading\u2026" })) : null] }), _jsxs("div", { style: {
123
+ display: "flex",
124
+ alignItems: "center",
125
+ gap: 8,
126
+ padding: "6px 10px",
127
+ background: "#1a1a20",
128
+ }, children: [_jsx("button", { type: "button", onClick: player.toggle, disabled: player.status !== "ready", "aria-label": player.playing ? "Pause" : "Play", style: buttonStyle(false), children: player.playing ? _jsx(PauseIcon, {}) : _jsx(PlayIcon, {}) }), _jsx("input", { type: "range", "aria-label": "Progress", min: 0, max: Math.max(0, (player.totalFrames ?? player.bufferedFrames) - 1), step: 1, value: player.frame, onChange: (event) => player.seek(Number(event.currentTarget.value)), disabled: player.status !== "ready", style: { flex: 1, accentColor: "#8b9cff", margin: 0 } }), _jsx("span", { style: {
129
+ color: "#b9b9c3",
130
+ fontSize: 12,
131
+ fontVariantNumeric: "tabular-nums",
132
+ whiteSpace: "nowrap",
133
+ }, children: player.totalFrames !== null
134
+ ? `${formatTime(player.frame, player.frameRate)} / ${formatTime(player.totalFrames, player.frameRate)}`
135
+ : formatTime(player.frame, player.frameRate) }), _jsx("button", { type: "button", onClick: () => player.setLoop(!player.loop), "aria-label": "Loop", "aria-pressed": player.loop, style: buttonStyle(player.loop), children: _jsx(LoopIcon, {}) })] }), player.status === "error" ? (_jsxs("div", { role: "alert", style: { color: "#ff8a8a", padding: "6px 10px" }, children: ["Scene failed: ", String(player.error)] })) : null] }));
136
+ };
@@ -0,0 +1,2 @@
1
+ export { Player, type PlayerProps } from "./Player";
2
+ export { type AnyScene, type Player as PlayerHandle, type PlayerFrame, type PlayerStatus, type UsePlayerOptions, usePlayer, } from "./usePlayer";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { Player } from "./Player";
2
+ export { usePlayer, } from "./usePlayer";
@@ -0,0 +1,46 @@
1
+ import { type Entity, Scene } from "effect-motion";
2
+ export type PlayerStatus = "loading" | "ready" | "error";
3
+ /** A scene as produced by `Scene.make`, requirements erased. */
4
+ export type AnyScene = Scene.Scene<unknown, unknown, Entity.AnyEntity>;
5
+ export type PlayerFrame = Scene.Frame<Entity.AnyEntity>;
6
+ export interface UsePlayerOptions {
7
+ readonly seed?: number | string | undefined;
8
+ /** frames per second of both the scene runner and the playback clock */
9
+ readonly frameRate?: number | undefined;
10
+ /** scene resolution, forwarded to the runner and stamped on frames */
11
+ readonly width?: number | undefined;
12
+ readonly height?: number | undefined;
13
+ /** start playing as soon as the first frame is buffered */
14
+ readonly autoPlay?: boolean | undefined;
15
+ }
16
+ export interface Player {
17
+ readonly status: PlayerStatus;
18
+ /** the failure value when `status` is "error" */
19
+ readonly error: unknown;
20
+ /** the frame to show right now (null while loading/error) */
21
+ readonly currentFrame: PlayerFrame | null;
22
+ /** current frame index */
23
+ readonly frame: number;
24
+ /** frames pulled from the scene so far */
25
+ readonly bufferedFrames: number;
26
+ /** null until the scene's stream completes (never, for infinite scenes) */
27
+ readonly totalFrames: number | null;
28
+ /** 0..1 — against totalFrames when known, else the buffered edge */
29
+ readonly progress: number;
30
+ readonly playing: boolean;
31
+ readonly loop: boolean;
32
+ readonly frameRate: number;
33
+ readonly play: () => void;
34
+ readonly pause: () => void;
35
+ readonly toggle: () => void;
36
+ readonly seek: (frame: number) => void;
37
+ readonly setLoop: (loop: boolean) => void;
38
+ }
39
+ /**
40
+ * Prepare a scene for playback: pull frames from the scene's stream with a
41
+ * read-ahead buffer and play them back on a rAF clock. Playback starts as
42
+ * soon as the first frame is buffered, so long and infinite scenes play
43
+ * without waiting for completion. Played frames are retained, so backward
44
+ * seeking is free; forward seeking clamps to the buffered edge.
45
+ */
46
+ export declare const usePlayer: (scene: AnyScene, options?: UsePlayerOptions) => Player;
@@ -0,0 +1,218 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Pull from "effect/Pull";
3
+ import * as Stream from "effect/Stream";
4
+ import { Fonts, Scene } from "effect-motion";
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+ /**
7
+ * Prepare a scene for playback: pull frames from the scene's stream with a
8
+ * read-ahead buffer and play them back on a rAF clock. Playback starts as
9
+ * soon as the first frame is buffered, so long and infinite scenes play
10
+ * without waiting for completion. Played frames are retained, so backward
11
+ * seeking is free; forward seeking clamps to the buffered edge.
12
+ */
13
+ export const usePlayer = (scene, options = {}) => {
14
+ const { seed, frameRate = 60, width, height, autoPlay = false } = options;
15
+ // ponytail: the buffer is append-only and unbounded — infinite scenes
16
+ // grow it forever; swap in a ring buffer with a re-run-from-0 story if
17
+ // memory ever matters. buffer[i] never changes once present, so reading
18
+ // it during render is safe.
19
+ const bufferRef = useRef([]);
20
+ const [bufferedFrames, setBufferedFrames] = useState(0);
21
+ const [totalFrames, setTotalFrames] = useState(null);
22
+ const [error, setError] = useState(null);
23
+ const [frame, setFrame] = useState(0);
24
+ const [playing, setPlaying] = useState(false);
25
+ const [loop, setLoop] = useState(false);
26
+ const [fontsReady, setFontsReady] = useState(false);
27
+ // font preload: load the scene's declared url fonts (Fonts annotation)
28
+ // alongside initial buffering. Fonts cannot affect frame data, so this
29
+ // only gates `status` — a failed load warns and proceeds with the
30
+ // browser's normal fallback.
31
+ useEffect(() => {
32
+ const entries = Fonts.get(scene).filter((f) => f.src.url !== undefined);
33
+ if (entries.length === 0 ||
34
+ typeof FontFace === "undefined" ||
35
+ typeof document === "undefined" ||
36
+ document.fonts === undefined) {
37
+ setFontsReady(true);
38
+ return;
39
+ }
40
+ setFontsReady(false);
41
+ let cancelled = false;
42
+ const loads = entries.map((f) => {
43
+ const face = new FontFace(f.family, `url(${f.src.url})`, {
44
+ ...(f.weight !== undefined && { weight: String(f.weight) }),
45
+ ...(f.style !== undefined && { style: f.style }),
46
+ });
47
+ document.fonts.add(face);
48
+ return face.load().then(undefined, (err) => {
49
+ console.warn(`effect-motion: font "${f.family}" failed to load`, err);
50
+ });
51
+ });
52
+ Promise.all(loads).then(() => {
53
+ if (!cancelled) {
54
+ setFontsReady(true);
55
+ }
56
+ });
57
+ return () => {
58
+ cancelled = true;
59
+ };
60
+ }, [scene]);
61
+ // latest-value refs keep the fill loop and the rAF clock out of effect
62
+ // deps: neither should restart on every frame or buffer growth
63
+ const autoPlayRef = useRef(autoPlay);
64
+ autoPlayRef.current = autoPlay;
65
+ const frameRef = useRef(frame);
66
+ frameRef.current = frame;
67
+ const totalRef = useRef(totalFrames);
68
+ totalRef.current = totalFrames;
69
+ const loopRef = useRef(loop);
70
+ loopRef.current = loop;
71
+ // fill loop: pull frames ahead of the playhead until the stream ends
72
+ useEffect(() => {
73
+ const controller = new AbortController();
74
+ bufferRef.current = [];
75
+ frameRef.current = 0;
76
+ setBufferedFrames(0);
77
+ setTotalFrames(null);
78
+ setError(null);
79
+ setFrame(0);
80
+ setPlaying(false);
81
+ const readAhead = 2 * frameRate;
82
+ const frames = Scene.stream(scene, {
83
+ frameRate,
84
+ ...(seed !== undefined && { seed }),
85
+ ...(width !== undefined && { width }),
86
+ ...(height !== undefined && { height }),
87
+ });
88
+ const fill = Effect.gen(function* () {
89
+ const pull = yield* Stream.toPull(frames);
90
+ let first = true;
91
+ while (true) {
92
+ if (bufferRef.current.length - frameRef.current >= readAhead) {
93
+ // ponytail: 50ms poll instead of demand signalling — the
94
+ // condition changes at most once per played frame; wire a
95
+ // real latch if the wakeups ever show up in a profile
96
+ yield* Effect.sleep(50);
97
+ continue;
98
+ }
99
+ const chunk = yield* pull;
100
+ bufferRef.current.push(...chunk);
101
+ setBufferedFrames(bufferRef.current.length);
102
+ if (first) {
103
+ first = false;
104
+ if (autoPlayRef.current) {
105
+ setPlaying(true);
106
+ }
107
+ }
108
+ }
109
+ }).pipe(
110
+ // the pull signals stream end as a Done failure: the scene is finite
111
+ Pull.catchDone(() => Effect.sync(() => setTotalFrames(bufferRef.current.length))), Effect.scoped);
112
+ Effect.runPromise(fill, { signal: controller.signal }).then(undefined, (err) => {
113
+ // aborted means unmount/re-run: no state updates after that
114
+ if (!controller.signal.aborted) {
115
+ setError(err);
116
+ }
117
+ });
118
+ return () => controller.abort();
119
+ }, [scene, seed, frameRate, width, height]);
120
+ // playback clock: advance the index at frameRate while playing, clamped
121
+ // to the buffered edge — playing at the live edge waits for frames
122
+ useEffect(() => {
123
+ if (!playing) {
124
+ return;
125
+ }
126
+ const frameMs = 1000 / frameRate;
127
+ let raf = 0;
128
+ let last = null;
129
+ let acc = 0;
130
+ const tick = (now) => {
131
+ if (last !== null) {
132
+ acc += now - last;
133
+ const advance = Math.floor(acc / frameMs);
134
+ if (advance > 0) {
135
+ acc -= advance * frameMs;
136
+ setFrame((f) => {
137
+ const buffered = bufferRef.current.length;
138
+ if (buffered === 0) {
139
+ return f;
140
+ }
141
+ let next = f + advance;
142
+ const total = totalRef.current;
143
+ if (total !== null && loopRef.current && next > total - 1) {
144
+ next = next % total;
145
+ }
146
+ return Math.min(next, buffered - 1);
147
+ });
148
+ }
149
+ }
150
+ last = now;
151
+ raf = requestAnimationFrame(tick);
152
+ };
153
+ raf = requestAnimationFrame(tick);
154
+ return () => cancelAnimationFrame(raf);
155
+ }, [playing, frameRate]);
156
+ // auto-pause on the last frame of a completed stream (loop wraps instead)
157
+ useEffect(() => {
158
+ if (playing && !loop && totalFrames !== null && frame >= totalFrames - 1) {
159
+ setPlaying(false);
160
+ }
161
+ }, [playing, loop, totalFrames, frame]);
162
+ const play = useCallback(() => {
163
+ if (bufferRef.current.length === 0) {
164
+ return;
165
+ }
166
+ // replay: sitting on the last frame of a finished scene means start over
167
+ setFrame((f) => {
168
+ const total = totalRef.current;
169
+ return total !== null && f >= total - 1 ? 0 : f;
170
+ });
171
+ setPlaying(true);
172
+ }, []);
173
+ const pause = useCallback(() => {
174
+ setPlaying(false);
175
+ }, []);
176
+ const toggle = useCallback(() => {
177
+ if (playing) {
178
+ pause();
179
+ }
180
+ else {
181
+ play();
182
+ }
183
+ }, [playing, play, pause]);
184
+ const seek = useCallback((target) => {
185
+ const buffered = bufferRef.current.length;
186
+ if (buffered === 0) {
187
+ return;
188
+ }
189
+ setFrame(Math.min(Math.max(0, Math.floor(target)), buffered - 1));
190
+ }, []);
191
+ const status = error !== null
192
+ ? "error"
193
+ : bufferedFrames > 0 && fontsReady
194
+ ? "ready"
195
+ : "loading";
196
+ const denominator = (totalFrames ?? bufferedFrames) - 1;
197
+ return {
198
+ status,
199
+ error,
200
+ currentFrame: bufferRef.current[frame] ?? null,
201
+ frame,
202
+ bufferedFrames,
203
+ totalFrames,
204
+ progress: denominator > 0
205
+ ? Math.min(frame / denominator, 1)
206
+ : totalFrames !== null
207
+ ? 1
208
+ : 0,
209
+ playing,
210
+ loop,
211
+ frameRate,
212
+ play,
213
+ pause,
214
+ toggle,
215
+ seek,
216
+ setLoop,
217
+ };
218
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@effect-motion/react",
3
+ "version": "0.2.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
+ "files": [
28
+ "dist"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "effect-motion": "^0.2.0"
35
+ },
36
+ "peerDependencies": {
37
+ "effect": ">=4.0.0-beta.94",
38
+ "react": ">=18"
39
+ },
40
+ "devDependencies": {
41
+ "@testing-library/react": "^16.3.0",
42
+ "@types/react": "^19.2.0",
43
+ "@types/react-dom": "^19.2.0",
44
+ "effect": "4.0.0-beta.94",
45
+ "happy-dom": "^20.10.6",
46
+ "react": "^19.2.0",
47
+ "react-dom": "^19.2.0",
48
+ "typescript": "^7.0.2",
49
+ "vitest": "^4.1.10"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -p tsconfig.build.json",
53
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
54
+ "test": "vitest run",
55
+ "check": "tsc --noEmit"
56
+ }
57
+ }