@effect-motion/react 0.2.0 → 0.3.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/dist/Player.d.ts +14 -11
- package/dist/Player.js +554 -124
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/package.json +11 -5
- package/dist/usePlayer.d.ts +0 -46
- package/dist/usePlayer.js +0 -218
package/dist/Player.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import type * as Scope from "effect/Scope";
|
|
2
|
+
import type * as Runner from "effect-motion/Runner";
|
|
3
|
+
import * as Scene from "effect-motion/Scene";
|
|
4
|
+
export type PlayerProps = {
|
|
5
|
+
prebufferedFrames?: number;
|
|
6
|
+
autoPlay?: boolean;
|
|
7
|
+
fps?: number;
|
|
8
|
+
isInfinite?: boolean;
|
|
9
|
+
defaultRepeatMode?: boolean;
|
|
10
|
+
bufferCapacity?: number;
|
|
11
|
+
settings?: Partial<Runner.Settings>;
|
|
12
|
+
scene: Scene.Scene<never, Runner.Runner | Scope.Scope>;
|
|
13
|
+
};
|
|
14
|
+
export declare const Player: ({ scene, fps: fpsProp, prebufferedFrames, autoPlay, isInfinite, defaultRepeatMode, bufferCapacity, settings, }: PlayerProps) => import("react").JSX.Element;
|
package/dist/Player.js
CHANGED
|
@@ -1,136 +1,566 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as Engine from "@effect-motion/thorvg/Engine";
|
|
4
|
+
import * as Session from "@effect-motion/thorvg/Session";
|
|
5
|
+
import { Cause, Context, Data, Effect, ManagedRuntime, Schedule, Semaphore, } from "effect";
|
|
6
|
+
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
|
+
import * as Scene from "effect-motion/Scene";
|
|
12
|
+
import * as Time from "effect-motion/Time";
|
|
13
|
+
import { useEffect, useEffectEvent, useRef, useState, } from "react";
|
|
14
|
+
const thorLayer = Engine.browserLayer("https://unpkg.com/@thorvg/webcanvas@1.0.8/dist/thorvg.wasm");
|
|
15
|
+
class PlayerError extends Data.TaggedError("PlayerError") {
|
|
16
|
+
static of(message) {
|
|
17
|
+
return (cause) => new PlayerError({ message, cause });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
2
20
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* is scoped to the focused player root
|
|
21
|
+
* Softened device-pixel-ratio, thorvg.web's formula: interpolate 75% of the
|
|
22
|
+
* way from 1 to the native dpr — visually indistinguishable from full dpr at
|
|
23
|
+
* a fraction of the rasterized pixels. Read per render call so moving the
|
|
24
|
+
* window across monitors picks up the new ratio.
|
|
8
25
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
};
|
|
26
|
+
const calculateDpr = () => typeof window === "undefined" ? 1 : 1 + (window.devicePixelRatio - 1) * 0.75;
|
|
27
|
+
// const layer = Layer.con
|
|
28
|
+
// const runtime = ManagedRuntime.make()
|
|
29
|
+
const PlayerScene = Context.Service("PlayerScene");
|
|
48
30
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
31
|
+
* Frame-data buffer keyed by absolute frame index, retaining at most
|
|
32
|
+
* `capacity` of the most-recently-pulled frames. Frames are pulled from the
|
|
33
|
+
* scene monotonically forward (never re-derivable at a random index — the
|
|
34
|
+
* scene is a forward-only stream), so the ring drops the oldest frames once
|
|
35
|
+
* the window is full. A finite scene that fits within `capacity` behaves like
|
|
36
|
+
* a plain array (nothing is ever evicted). Seeking below the retained window
|
|
37
|
+
* clamps to `oldest` — the earliest frame still in memory.
|
|
53
38
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
39
|
+
class FrameRing {
|
|
40
|
+
capacity;
|
|
41
|
+
// ponytail: fixed-capacity ring keyed by absolute index. capacity=Infinity
|
|
42
|
+
// keeps everything (finite scenes that fit); a finite cap bounds memory for
|
|
43
|
+
// long/infinite scenes at the cost of losing far-back seek. Bump the cap if
|
|
44
|
+
// deep backward seeking on huge scenes ever matters.
|
|
45
|
+
slots;
|
|
46
|
+
/** total frames pulled from the scene so far (monotonic) */
|
|
47
|
+
pulled = 0;
|
|
48
|
+
constructor(capacity) {
|
|
49
|
+
this.capacity = capacity;
|
|
50
|
+
this.slots = Number.isFinite(capacity) ? new Array(capacity) : [];
|
|
51
|
+
}
|
|
52
|
+
/** oldest absolute index still retained */
|
|
53
|
+
get oldest() {
|
|
54
|
+
return Number.isFinite(this.capacity)
|
|
55
|
+
? Math.max(0, this.pulled - this.capacity)
|
|
56
|
+
: 0;
|
|
57
|
+
}
|
|
58
|
+
has(index) {
|
|
59
|
+
return index >= this.oldest && index < this.pulled;
|
|
60
|
+
}
|
|
61
|
+
get(index) {
|
|
62
|
+
if (!this.has(index)) {
|
|
63
|
+
return undefined;
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
svg.style.height = "100%";
|
|
74
|
-
svg.style.display = "block";
|
|
65
|
+
return Number.isFinite(this.capacity)
|
|
66
|
+
? this.slots[index % this.capacity]
|
|
67
|
+
: this.slots[index];
|
|
68
|
+
}
|
|
69
|
+
/** append the next frame at the edge, evicting the oldest if full */
|
|
70
|
+
push(frame) {
|
|
71
|
+
if (Number.isFinite(this.capacity)) {
|
|
72
|
+
this.slots[this.pulled % this.capacity] = frame;
|
|
75
73
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// buttons and the scrubber already handle these keys natively
|
|
79
|
-
const tag = event.target.tagName;
|
|
80
|
-
if (tag === "BUTTON" || tag === "INPUT") {
|
|
81
|
-
return;
|
|
74
|
+
else {
|
|
75
|
+
this.slots[this.pulled] = frame;
|
|
82
76
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
77
|
+
this.pulled++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const useScene = (scene, options) => {
|
|
81
|
+
// loop is read live from optsRef inside the play loop, not destructured here
|
|
82
|
+
const { fps, prebufferedFrames, autoPlay, isInfinite, settings } = options;
|
|
83
|
+
const canvasRef = useRef(null);
|
|
84
|
+
const [currentFrame, setCurrentFrame] = useState(0);
|
|
85
|
+
const [bufferedFrames, setBufferedFrames] = useState(0);
|
|
86
|
+
// null until the scene stream ends (never, for infinite scenes)
|
|
87
|
+
const [totalFrames, setTotalFrames] = useState(null);
|
|
88
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
89
|
+
// repeat mode is player state seeded from the prop (an initial value); the
|
|
90
|
+
// user toggles it live via the repeat button
|
|
91
|
+
const [loop, setLoop] = useState(options.loop);
|
|
92
|
+
// latest option values (incl. live loop), read inside the long-lived Effect
|
|
93
|
+
// service without re-creating the runtime (which would re-run the scene)
|
|
94
|
+
const optsRef = useRef({ ...options, loop });
|
|
95
|
+
optsRef.current = { ...options, loop };
|
|
96
|
+
// One runtime per mount, DISPOSED on unmount: disposal closes the session
|
|
97
|
+
// (canvas deleted, fonts released — refcounted, so siblings keep theirs)
|
|
98
|
+
// and releases the engine (a browser no-op: the wasm module is a page
|
|
99
|
+
// singleton, design D2; two players share one module via the idempotent
|
|
100
|
+
// acquire). Held in a ref, not state, so a strict-mode remount can
|
|
101
|
+
// recreate it after the first cleanup disposed it.
|
|
102
|
+
const makeRuntime = () => ManagedRuntime.make(Layer.effectContext(Effect.gen(function* () {
|
|
103
|
+
const runningScene = yield* Scene.run(scene, {
|
|
104
|
+
...settings,
|
|
105
|
+
frameRate: fps,
|
|
106
|
+
});
|
|
107
|
+
let currentFrame = 0;
|
|
108
|
+
let isPlaying = false;
|
|
109
|
+
// total frame count once the stream ends; null while unknown
|
|
110
|
+
let totalFramesValue = null;
|
|
111
|
+
const setTotal = (n) => {
|
|
112
|
+
totalFramesValue = n;
|
|
113
|
+
setTotalFrames(n);
|
|
114
|
+
};
|
|
115
|
+
const updateCurrentFrame = (frameIndex) => {
|
|
116
|
+
currentFrame = frameIndex;
|
|
117
|
+
setCurrentFrame(frameIndex);
|
|
118
|
+
};
|
|
119
|
+
const updateIsPlaying = (newIsPlaying) => {
|
|
120
|
+
isPlaying = newIsPlaying;
|
|
121
|
+
setIsPlaying(isPlaying);
|
|
122
|
+
};
|
|
123
|
+
const ring = new FrameRing(options.bufferCapacity);
|
|
124
|
+
const loadFrameBuffer = (requested = ring.pulled) => Effect.gen(function* () {
|
|
125
|
+
// clamp a seek that fell off the back of the ring to the
|
|
126
|
+
// oldest retained frame (can't replay a forward-only stream)
|
|
127
|
+
const index = Math.max(requested, ring.oldest);
|
|
128
|
+
const existing = ring.get(index);
|
|
129
|
+
if (existing) {
|
|
130
|
+
return { frame: existing, index };
|
|
131
|
+
}
|
|
132
|
+
if (runningScene.done && index >= ring.pulled) {
|
|
133
|
+
// finite scene fully buffered — publish the total once
|
|
134
|
+
setTotal(ring.pulled);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
// pull forward until the requested index is at the edge
|
|
138
|
+
while (true) {
|
|
139
|
+
const frame = yield* Scene.step(runningScene);
|
|
140
|
+
if (!frame) {
|
|
141
|
+
// stream ended: this is the total frame count
|
|
142
|
+
setTotal(ring.pulled);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
ring.push(frame);
|
|
146
|
+
setBufferedFrames(ring.pulled);
|
|
147
|
+
if (index <= ring.pulled - 1) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const edge = Math.min(index, ring.pulled - 1);
|
|
152
|
+
return {
|
|
153
|
+
frame: ring.get(edge),
|
|
154
|
+
index: edge,
|
|
155
|
+
};
|
|
156
|
+
}).pipe(Effect.mapError(PlayerError.of("Error getting frame buffer")));
|
|
157
|
+
const renderSemaphore = yield* Semaphore.make(1);
|
|
158
|
+
const render = (frameIndex) => renderSemaphore.withPermitsIfAvailable(1)(Effect.gen(function* () {
|
|
159
|
+
if (!canvasRef.current) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const framebuffer = yield* loadFrameBuffer(frameIndex);
|
|
163
|
+
if (!framebuffer) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// use the resolved index, not the requested one: a seek/advance
|
|
167
|
+
// past the buffered edge resolves to the edge (clamped in
|
|
168
|
+
// loadFrameBuffer), and the playhead must reflect what's shown
|
|
169
|
+
updateCurrentFrame(framebuffer.index);
|
|
170
|
+
const renderBuffer = yield* Renderer.render(framebuffer.frame, {
|
|
171
|
+
dpr: calculateDpr(),
|
|
172
|
+
});
|
|
173
|
+
yield* CanvasExporter.toCanvas(renderBuffer, canvasRef.current);
|
|
174
|
+
}).pipe(Effect.mapError(PlayerError.of("Error exporting canvas"))));
|
|
175
|
+
const play = Effect.suspend(() => {
|
|
176
|
+
if (isPlaying)
|
|
177
|
+
return Effect.void;
|
|
178
|
+
// replaying from the end of a finished finite scene: rewind
|
|
179
|
+
const total = totalFramesValue;
|
|
180
|
+
if (total !== null && currentFrame >= total - 1) {
|
|
181
|
+
updateCurrentFrame(0);
|
|
182
|
+
}
|
|
183
|
+
isPlaying = true;
|
|
184
|
+
updateIsPlaying(true);
|
|
185
|
+
// Real-time playback clock. Each tick advances by however many
|
|
186
|
+
// whole frames of wall-clock have elapsed since the last tick
|
|
187
|
+
// (accumulator), so playback keeps real time even when a render
|
|
188
|
+
// is slow — intermediate frames are dropped, only the latest is
|
|
189
|
+
// rendered. The driver ticks a bit faster than the frame period
|
|
190
|
+
// so we never systematically miss a frame boundary; the
|
|
191
|
+
// accumulator decides whether a frame is actually due.
|
|
192
|
+
const frameMs = 1000 / fps;
|
|
193
|
+
let lastTick = null;
|
|
194
|
+
let acc = 0;
|
|
195
|
+
const tick = Effect.gen(function* () {
|
|
196
|
+
// wall-clock is intentional here: it drives real playback
|
|
197
|
+
// speed — not scene time, so the determinism rule that bans
|
|
198
|
+
// wall-clock in scenes doesn't apply.
|
|
199
|
+
const now = performance.now();
|
|
200
|
+
if (lastTick === null) {
|
|
201
|
+
lastTick = now;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const elapsed = now - lastTick;
|
|
205
|
+
lastTick = now;
|
|
206
|
+
acc += elapsed;
|
|
207
|
+
const advance = Math.floor(acc / frameMs);
|
|
208
|
+
if (advance <= 0) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
acc -= advance * frameMs;
|
|
212
|
+
let next = currentFrame + advance;
|
|
213
|
+
const total = totalFramesValue;
|
|
214
|
+
if (total !== null && next > total - 1) {
|
|
215
|
+
// past the end of a finite scene: wrap when looping,
|
|
216
|
+
// else clamp to the last frame (a big `advance` from a
|
|
217
|
+
// slow tick must not overshoot into the unbuffered void,
|
|
218
|
+
// which would render nothing and wedge the playhead)
|
|
219
|
+
next =
|
|
220
|
+
optsRef.current.loop && !optsRef.current.isInfinite
|
|
221
|
+
? next % total
|
|
222
|
+
: total - 1;
|
|
223
|
+
}
|
|
224
|
+
yield* render(next);
|
|
225
|
+
});
|
|
226
|
+
// small fixed spacing (quarter-frame) so the accumulator sees
|
|
227
|
+
// each frame boundary; the tick itself no-ops until a frame is due
|
|
228
|
+
const loop = Effect.repeat({
|
|
229
|
+
schedule: Schedule.spaced(Math.max(1, frameMs / 4)),
|
|
230
|
+
// keep playing until we reach the last buffered frame of a
|
|
231
|
+
// finished scene — unless looping (then wrap forever). A scene
|
|
232
|
+
// that is `done` but not yet at its last frame must keep going.
|
|
233
|
+
while: () => {
|
|
234
|
+
if (!isPlaying)
|
|
235
|
+
return false;
|
|
236
|
+
const total = totalFramesValue;
|
|
237
|
+
if (total === null)
|
|
238
|
+
return true; // still buffering
|
|
239
|
+
if (optsRef.current.loop && !optsRef.current.isInfinite) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return currentFrame < total - 1;
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
return loop(tick).pipe(Effect.ensuring(Effect.sync(() => updateIsPlaying(false))));
|
|
246
|
+
});
|
|
247
|
+
const pause = Effect.sync(() => {
|
|
248
|
+
isPlaying = false;
|
|
249
|
+
updateIsPlaying(false);
|
|
250
|
+
});
|
|
251
|
+
return Context.make(PlayerScene, {
|
|
252
|
+
play,
|
|
253
|
+
pause,
|
|
254
|
+
frameIndex: Effect.sync(() => currentFrame),
|
|
255
|
+
render,
|
|
256
|
+
load: (frameIndex) => loadFrameBuffer(frameIndex),
|
|
257
|
+
});
|
|
258
|
+
})).pipe(
|
|
259
|
+
// per-mount session: the canvas (sized by the render path) plus the
|
|
260
|
+
// scene's declared fonts and images, held until the runtime is
|
|
261
|
+
// disposed. Session.make awaits font/image settlement, so nothing
|
|
262
|
+
// renders (and the player can't report ready) before assets settle.
|
|
263
|
+
Layer.provideMerge(Session.layer({
|
|
264
|
+
width: 1,
|
|
265
|
+
height: 1,
|
|
266
|
+
fonts: Fonts.urlMap(scene),
|
|
267
|
+
images: Images.urlMap(scene),
|
|
268
|
+
})), Layer.provideMerge(thorLayer)));
|
|
269
|
+
const runtimeRef = useRef(null);
|
|
270
|
+
const getRuntime = () => {
|
|
271
|
+
if (runtimeRef.current === null) {
|
|
272
|
+
runtimeRef.current = makeRuntime();
|
|
86
273
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
274
|
+
return runtimeRef.current;
|
|
275
|
+
};
|
|
276
|
+
useEffect(() => () => {
|
|
277
|
+
// dispose interrupts our own in-flight fibers (play loop, prebuffer);
|
|
278
|
+
// its promise then rejects with that interruption — expected teardown,
|
|
279
|
+
// never actionable from a cleanup callback
|
|
280
|
+
runtimeRef.current?.dispose().catch(() => undefined);
|
|
281
|
+
runtimeRef.current = null;
|
|
282
|
+
}, []);
|
|
283
|
+
// Disposing the runtime on unmount INTERRUPTS in-flight fibers (the play
|
|
284
|
+
// loop, prebuffering). These handlers are fire-and-forget, so a rejecting
|
|
285
|
+
// runPromise would surface as an unhandled rejection on every navigation
|
|
286
|
+
// away from a playing scene. runPromiseExit never rejects — failures come
|
|
287
|
+
// back as Exit values. Two teardown shapes are silenced: interruption-only
|
|
288
|
+
// causes (dispose cancelling our own fibers — under strict mode this
|
|
289
|
+
// happens on EVERY mount, whose first-generation runtime is disposed with
|
|
290
|
+
// render/prebuffer still in flight), and failures from a runtime that is
|
|
291
|
+
// no longer current (a stale generation's teardown noise). Everything else
|
|
292
|
+
// is a real player error and is reported.
|
|
293
|
+
const runReported = (effect) => {
|
|
294
|
+
const runtime = getRuntime();
|
|
295
|
+
return runtime.runPromiseExit(effect).then((exit) => {
|
|
296
|
+
if (exit._tag !== "Failure") {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (Cause.hasInterruptsOnly(exit.cause)) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (runtimeRef.current !== runtime) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
console.error("effect-motion player:", String(exit.cause), exit.cause);
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
const render = useEffectEvent((frameIndex) => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.render(frameIndex)), Effect.scoped)));
|
|
309
|
+
const load = useEffectEvent((frameIndex) => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.load(frameIndex)), Effect.scoped)));
|
|
310
|
+
const play = useEffectEvent(() => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.play), Effect.scoped)));
|
|
311
|
+
const pause = useEffectEvent(() => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.pause), Effect.scoped)));
|
|
312
|
+
// prebuffer + autoplay once the runtime exists. For an infinite scene we
|
|
313
|
+
// can't buffer to the end, so cap at the requested prebuffer count.
|
|
314
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount; render/load/play are stable useEffectEvents and the options only matter at startup
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
render(0);
|
|
317
|
+
load(isInfinite ? prebufferedFrames : Math.max(prebufferedFrames, 0));
|
|
318
|
+
if (autoPlay) {
|
|
319
|
+
play();
|
|
91
320
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
321
|
+
}, []);
|
|
322
|
+
// scene time in seconds of the current frame, and total when known
|
|
323
|
+
const currentTime = Time.frameToMillis(currentFrame, fps) / 1000;
|
|
324
|
+
const totalTime = totalFrames !== null
|
|
325
|
+
? Time.frameToMillis(totalFrames - 1, fps) / 1000
|
|
326
|
+
: null;
|
|
327
|
+
return {
|
|
328
|
+
ref: canvasRef,
|
|
329
|
+
currentFrame,
|
|
330
|
+
bufferedFrames,
|
|
331
|
+
totalFrames,
|
|
332
|
+
currentTime,
|
|
333
|
+
totalTime,
|
|
334
|
+
play,
|
|
335
|
+
pause,
|
|
336
|
+
seek: render,
|
|
337
|
+
isPlaying,
|
|
338
|
+
loop,
|
|
339
|
+
setLoop,
|
|
340
|
+
load,
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
// keep-everything cap for an infinite scene: enough for ~30s of backward seek
|
|
344
|
+
// at 60fps, ~1MB of frame data. Bump via bufferCapacity for deeper scrubbing.
|
|
345
|
+
const INFINITE_BUFFER_CAP = 1800;
|
|
346
|
+
export const Player = ({ scene, fps: fpsProp = 60,
|
|
347
|
+
// default: prebuffer everything (Infinity) so the total time / progress bar
|
|
348
|
+
// are known up front; an infinite scene falls back to a finite window below.
|
|
349
|
+
prebufferedFrames = Number.POSITIVE_INFINITY, autoPlay = false, isInfinite = false,
|
|
350
|
+
// initial repeat mode; the player owns it after mount (toggle button)
|
|
351
|
+
defaultRepeatMode = false, bufferCapacity, settings, }) => {
|
|
352
|
+
// one effective rate for both the scene run and the playback clock
|
|
353
|
+
const fps = settings?.frameRate ?? fpsProp;
|
|
354
|
+
const { ref, currentFrame, bufferedFrames, totalFrames, currentTime, totalTime, play, pause, seek, isPlaying, loop, setLoop, } = useScene(scene, {
|
|
355
|
+
fps,
|
|
356
|
+
settings,
|
|
357
|
+
// an infinite scene can never buffer to the end — window it (60 frames
|
|
358
|
+
// if the caller left prebufferedFrames unbounded)
|
|
359
|
+
prebufferedFrames: isInfinite && !Number.isFinite(prebufferedFrames)
|
|
360
|
+
? 60
|
|
361
|
+
: prebufferedFrames,
|
|
362
|
+
autoPlay,
|
|
363
|
+
isInfinite,
|
|
364
|
+
loop: defaultRepeatMode,
|
|
365
|
+
// finite scenes keep everything (so far-back seek works); infinite
|
|
366
|
+
// scenes are windowed so memory can't grow forever
|
|
367
|
+
bufferCapacity: bufferCapacity ??
|
|
368
|
+
(isInfinite ? INFINITE_BUFFER_CAP : Number.POSITIVE_INFINITY),
|
|
369
|
+
});
|
|
370
|
+
// progress denominator: the total when known, else the buffered edge
|
|
371
|
+
const denominator = (totalFrames ?? bufferedFrames) - 1;
|
|
372
|
+
// ---- chrome state (hover/scrub only — playback state lives in useScene) ----
|
|
373
|
+
const [hovering, setHovering] = useState(false);
|
|
374
|
+
const [scrubbing, setScrubbing] = useState(false);
|
|
375
|
+
const [barHover, setBarHover] = useState(false);
|
|
376
|
+
const [bigPlayHover, setBigPlayHover] = useState(false);
|
|
377
|
+
// frame under the cursor while hovering the scrubber (for the time chip)
|
|
378
|
+
const [hoverFrame, setHoverFrame] = useState(0);
|
|
379
|
+
const barRef = useRef(null);
|
|
380
|
+
const controlsVisible = hovering || scrubbing || !isPlaying;
|
|
381
|
+
const frameAtPointer = (clientX) => {
|
|
382
|
+
const bar = barRef.current;
|
|
383
|
+
if (!bar || denominator <= 0) {
|
|
384
|
+
return 0;
|
|
96
385
|
}
|
|
386
|
+
const rect = bar.getBoundingClientRect();
|
|
387
|
+
const frac = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
|
388
|
+
return Math.round(frac * denominator);
|
|
97
389
|
};
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
390
|
+
const playedFrac = denominator > 0 ? currentFrame / denominator : 0;
|
|
391
|
+
const bufferedFrac = denominator > 0 ? Math.min(1, (bufferedFrames - 1) / denominator) : 0;
|
|
392
|
+
// the chip tracks the cursor while hovering, the playhead while dragging
|
|
393
|
+
const chipFrame = scrubbing ? currentFrame : hoverFrame;
|
|
394
|
+
const chipFrac = denominator > 0 ? chipFrame / denominator : 0;
|
|
395
|
+
const barActive = barHover || scrubbing;
|
|
396
|
+
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
|
+
...S.bigPlay,
|
|
398
|
+
background: bigPlayHover ? ACCENT : "rgba(0, 0, 0, 0.65)",
|
|
399
|
+
}, onMouseEnter: () => setBigPlayHover(true), onMouseLeave: () => setBigPlayHover(false), onClick: () => play(), children: _jsx(PlayIcon, { size: 26 }) })), _jsx("div", { style: { ...S.gradient, opacity: controlsVisible ? 1 : 0 } }), _jsxs("div", { style: {
|
|
400
|
+
...S.controls,
|
|
401
|
+
opacity: controlsVisible ? 1 : 0,
|
|
402
|
+
pointerEvents: controlsVisible ? "auto" : "none",
|
|
403
|
+
}, children: [_jsx("button", { type: "button", "aria-label": isPlaying ? "Pause" : "Play", style: S.iconButton, onClick: () => (isPlaying ? pause() : play()), children: isPlaying ? _jsx(PauseIcon, {}) : _jsx(PlayIcon, {}) }), !isInfinite && (_jsxs("div", { ref: barRef, role: "slider", "aria-label": "Seek", "aria-valuemin": 0, "aria-valuemax": Math.max(0, denominator), "aria-valuenow": currentFrame, tabIndex: 0, style: S.scrub, onPointerDown: (e) => {
|
|
404
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
405
|
+
setScrubbing(true);
|
|
406
|
+
seek(frameAtPointer(e.clientX));
|
|
407
|
+
}, onPointerMove: (e) => {
|
|
408
|
+
setHoverFrame(frameAtPointer(e.clientX));
|
|
409
|
+
if (scrubbing) {
|
|
410
|
+
seek(frameAtPointer(e.clientX));
|
|
411
|
+
}
|
|
412
|
+
}, onPointerUp: () => setScrubbing(false), onMouseEnter: () => setBarHover(true), onMouseLeave: () => setBarHover(false), onKeyDown: (e) => {
|
|
413
|
+
// arrow keys step one second of frames
|
|
414
|
+
if (e.key === "ArrowRight") {
|
|
415
|
+
seek(Math.min(denominator, currentFrame + fps));
|
|
416
|
+
}
|
|
417
|
+
else if (e.key === "ArrowLeft") {
|
|
418
|
+
seek(Math.max(0, currentFrame - fps));
|
|
419
|
+
}
|
|
420
|
+
}, children: [_jsxs("div", { style: { ...S.track, height: barActive ? 6 : 4 }, children: [_jsx("div", { style: {
|
|
421
|
+
...S.trackFill,
|
|
422
|
+
width: `${bufferedFrac * 100}%`,
|
|
423
|
+
background: "rgba(255, 255, 255, 0.35)",
|
|
424
|
+
} }), _jsx("div", { style: {
|
|
425
|
+
...S.trackFill,
|
|
426
|
+
width: `${playedFrac * 100}%`,
|
|
427
|
+
background: ACCENT,
|
|
428
|
+
} })] }), barActive && (_jsxs(_Fragment, { children: [_jsx("div", { style: { ...S.thumb, left: `${playedFrac * 100}%` } }), _jsx("div", { style: { ...S.chip, left: `${chipFrac * 100}%` }, children: formatTime(Time.frameToMillis(chipFrame, fps) / 1000) })] }))] })), _jsxs("span", { style: S.time, children: [formatTime(currentTime), totalTime !== null ? ` / ${formatTime(totalTime)}` : ""] }), !isInfinite && (_jsx("button", { type: "button", "aria-label": "Repeat", "aria-pressed": loop, title: loop ? "Repeat on" : "Repeat off", style: {
|
|
429
|
+
...S.iconButton,
|
|
430
|
+
color: loop ? ACCENT : "rgba(255, 255, 255, 0.7)",
|
|
431
|
+
}, onClick: () => setLoop((v) => !v), children: _jsx(RepeatIcon, {}) }))] })] }));
|
|
432
|
+
};
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// chrome: Vimeo-style player skin. Self-contained by design — inline style
|
|
435
|
+
// objects and inline SVG icons only, no stylesheet/tailwind/image deps.
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
const ACCENT = "#00adef"; // Vimeo blue
|
|
438
|
+
const formatTime = (seconds) => {
|
|
439
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
440
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
441
|
+
};
|
|
442
|
+
const PlayIcon = ({ size = 18 }) => (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M7 4.5v15l13-7.5z" }) }));
|
|
443
|
+
const PauseIcon = ({ size = 18 }) => (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M6.5 4.5h4v15h-4zM13.5 4.5h4v15h-4z" }) }));
|
|
444
|
+
// looping arrows (stroke icon — reads as "repeat" at small sizes)
|
|
445
|
+
const RepeatIcon = ({ size = 18 }) => (_jsxs("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M17 2l4 4-4 4" }), _jsx("path", { d: "M3 11V9a4 4 0 0 1 4-4h14" }), _jsx("path", { d: "M7 22l-4-4 4-4" }), _jsx("path", { d: "M21 13v2a4 4 0 0 1-4 4H3" })] }));
|
|
446
|
+
const S = {
|
|
447
|
+
player: {
|
|
448
|
+
position: "relative",
|
|
449
|
+
width: "100%",
|
|
450
|
+
background: "#000",
|
|
451
|
+
borderRadius: 8,
|
|
452
|
+
overflow: "hidden",
|
|
453
|
+
lineHeight: 1,
|
|
454
|
+
userSelect: "none",
|
|
455
|
+
WebkitUserSelect: "none",
|
|
456
|
+
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
457
|
+
},
|
|
458
|
+
canvas: {
|
|
459
|
+
display: "block",
|
|
460
|
+
width: "100%",
|
|
461
|
+
height: "auto",
|
|
462
|
+
cursor: "pointer",
|
|
463
|
+
},
|
|
464
|
+
bigPlay: {
|
|
465
|
+
position: "absolute",
|
|
466
|
+
top: "50%",
|
|
467
|
+
left: "50%",
|
|
468
|
+
transform: "translate(-50%, -50%)",
|
|
469
|
+
width: 66,
|
|
470
|
+
height: 46,
|
|
471
|
+
border: "none",
|
|
472
|
+
borderRadius: 8,
|
|
473
|
+
color: "#fff",
|
|
474
|
+
display: "flex",
|
|
475
|
+
alignItems: "center",
|
|
476
|
+
justifyContent: "center",
|
|
477
|
+
cursor: "pointer",
|
|
478
|
+
transition: "background 120ms ease",
|
|
479
|
+
},
|
|
480
|
+
gradient: {
|
|
481
|
+
position: "absolute",
|
|
482
|
+
left: 0,
|
|
483
|
+
right: 0,
|
|
484
|
+
bottom: 0,
|
|
485
|
+
height: 72,
|
|
486
|
+
background: "linear-gradient(transparent, rgba(0, 0, 0, 0.6))",
|
|
487
|
+
pointerEvents: "none",
|
|
488
|
+
transition: "opacity 200ms ease",
|
|
489
|
+
},
|
|
490
|
+
controls: {
|
|
491
|
+
position: "absolute",
|
|
492
|
+
left: 0,
|
|
493
|
+
right: 0,
|
|
494
|
+
bottom: 0,
|
|
495
|
+
display: "flex",
|
|
496
|
+
alignItems: "center",
|
|
497
|
+
gap: 10,
|
|
498
|
+
padding: "0 10px 8px 6px",
|
|
499
|
+
transition: "opacity 200ms ease",
|
|
500
|
+
},
|
|
501
|
+
iconButton: {
|
|
502
|
+
border: "none",
|
|
503
|
+
background: "transparent",
|
|
504
|
+
padding: 4,
|
|
505
|
+
margin: 0,
|
|
506
|
+
color: "#fff",
|
|
507
|
+
display: "flex",
|
|
508
|
+
alignItems: "center",
|
|
509
|
+
cursor: "pointer",
|
|
510
|
+
},
|
|
511
|
+
scrub: {
|
|
512
|
+
position: "relative",
|
|
513
|
+
flex: 1,
|
|
514
|
+
display: "flex",
|
|
515
|
+
alignItems: "center",
|
|
516
|
+
height: 16,
|
|
517
|
+
cursor: "pointer",
|
|
518
|
+
touchAction: "none",
|
|
519
|
+
},
|
|
520
|
+
track: {
|
|
521
|
+
position: "relative",
|
|
522
|
+
width: "100%",
|
|
523
|
+
borderRadius: 2,
|
|
524
|
+
background: "rgba(255, 255, 255, 0.2)",
|
|
525
|
+
overflow: "hidden",
|
|
526
|
+
transition: "height 100ms ease",
|
|
527
|
+
},
|
|
528
|
+
trackFill: {
|
|
529
|
+
position: "absolute",
|
|
530
|
+
left: 0,
|
|
531
|
+
top: 0,
|
|
532
|
+
bottom: 0,
|
|
533
|
+
borderRadius: 2,
|
|
534
|
+
},
|
|
535
|
+
thumb: {
|
|
536
|
+
position: "absolute",
|
|
537
|
+
top: "50%",
|
|
538
|
+
width: 12,
|
|
539
|
+
height: 12,
|
|
540
|
+
borderRadius: "50%",
|
|
541
|
+
background: "#fff",
|
|
542
|
+
transform: "translate(-50%, -50%)",
|
|
543
|
+
boxShadow: "0 0 4px rgba(0, 0, 0, 0.4)",
|
|
544
|
+
pointerEvents: "none",
|
|
545
|
+
},
|
|
546
|
+
chip: {
|
|
547
|
+
position: "absolute",
|
|
548
|
+
bottom: 20,
|
|
549
|
+
transform: "translateX(-50%)",
|
|
550
|
+
background: "rgba(0, 0, 0, 0.85)",
|
|
551
|
+
color: "#fff",
|
|
552
|
+
fontSize: 11,
|
|
553
|
+
fontVariantNumeric: "tabular-nums",
|
|
554
|
+
padding: "4px 6px",
|
|
555
|
+
borderRadius: 4,
|
|
556
|
+
whiteSpace: "nowrap",
|
|
557
|
+
pointerEvents: "none",
|
|
558
|
+
},
|
|
559
|
+
time: {
|
|
560
|
+
// pushes itself + the repeat button to the right of the scrubber
|
|
561
|
+
marginLeft: "auto",
|
|
562
|
+
color: "#fff",
|
|
563
|
+
fontSize: 12,
|
|
564
|
+
fontVariantNumeric: "tabular-nums",
|
|
565
|
+
},
|
|
136
566
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export { Player, type PlayerProps } from "./Player";
|
|
2
|
-
export { type AnyScene, type Player as PlayerHandle, type PlayerFrame, type PlayerStatus, type UsePlayerOptions, usePlayer, } from "./usePlayer";
|
|
1
|
+
export { Player, type PlayerProps } from "./Player.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export { Player } from "./Player";
|
|
2
|
-
export { usePlayer, } from "./usePlayer";
|
|
1
|
+
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.3.0",
|
|
4
4
|
"description": "React bindings for effect-motion: usePlayer hook and Player component",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
".": {
|
|
23
23
|
"types": "./dist/index.d.ts",
|
|
24
24
|
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./*": {
|
|
27
|
+
"types": "./dist/*.d.ts",
|
|
28
|
+
"default": "./dist/*.js"
|
|
25
29
|
}
|
|
26
30
|
},
|
|
27
31
|
"files": [
|
|
@@ -31,17 +35,19 @@
|
|
|
31
35
|
"access": "public"
|
|
32
36
|
},
|
|
33
37
|
"dependencies": {
|
|
34
|
-
"effect-motion": "^0.
|
|
38
|
+
"@effect-motion/thorvg": "^0.1.0",
|
|
39
|
+
"effect-motion": "^0.3.0"
|
|
35
40
|
},
|
|
36
41
|
"peerDependencies": {
|
|
37
|
-
"effect": ">=4.0.0-beta.
|
|
42
|
+
"effect": ">=4.0.0-beta.98",
|
|
38
43
|
"react": ">=18"
|
|
39
44
|
},
|
|
40
45
|
"devDependencies": {
|
|
41
46
|
"@testing-library/react": "^16.3.0",
|
|
47
|
+
"@types/node": "^26.1.1",
|
|
42
48
|
"@types/react": "^19.2.0",
|
|
43
49
|
"@types/react-dom": "^19.2.0",
|
|
44
|
-
"effect": "4.0.0-beta.
|
|
50
|
+
"effect": "4.0.0-beta.98",
|
|
45
51
|
"happy-dom": "^20.10.6",
|
|
46
52
|
"react": "^19.2.0",
|
|
47
53
|
"react-dom": "^19.2.0",
|
|
@@ -51,7 +57,7 @@
|
|
|
51
57
|
"scripts": {
|
|
52
58
|
"build": "tsc -p tsconfig.build.json",
|
|
53
59
|
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
|
54
|
-
"test": "vitest run",
|
|
60
|
+
"test": "vitest run --passWithNoTests",
|
|
55
61
|
"check": "tsc --noEmit"
|
|
56
62
|
}
|
|
57
63
|
}
|
package/dist/usePlayer.d.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
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;
|
package/dist/usePlayer.js
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
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
|
-
};
|