@effect-motion/react 0.5.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/Player.js +83 -18
  3. package/package.json +62 -62
package/README.md CHANGED
@@ -9,7 +9,7 @@ Frames stream in as the scene runs rather than being rendered up front, so playb
9
9
  `effect` and `effect-motion` are peer dependencies — install them alongside:
10
10
 
11
11
  ```bash
12
- pnpm add @effect-motion/react effect-motion effect
12
+ bun add @effect-motion/react effect-motion effect
13
13
  ```
14
14
 
15
15
  ## Play a scene
package/dist/Player.js CHANGED
@@ -20,18 +20,32 @@ class PlayerError extends Data.TaggedError("PlayerError") {
20
20
  }
21
21
  }
22
22
  /**
23
- * The device pixel ratio to render at — 75% of the way from 1 to the
24
- * display's native ratio.
23
+ * The viewport for a `width` × `height` frame shown in `canvas`: logical size
24
+ * is the frame's, pixel density follows how big the canvas is displayed.
25
25
  *
26
26
  * @remarks
27
- * Rendering at full native ratio on a high-DPI display costs several times
28
- * the pixels for a difference nobody sees in motion. Softening it keeps text
29
- * and edges sharp at a fraction of the fill cost.
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.
30
33
  *
31
- * Read on every render, so dragging a window between monitors picks up the
32
- * new ratio.
34
+ * `pw`/`ph` are the backing-store size three will set (it floors), used to
35
+ * skip resizes that would not change a pixel.
33
36
  */
34
- const calculateDpr = () => typeof window === "undefined" ? 1 : 1 + (window.devicePixelRatio - 1) * 0.75;
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
+ };
35
49
  const PlayerScene = Context.Service("PlayerScene");
36
50
  /**
37
51
  * The frame buffer: keeps the most recent `capacity` frames, keyed by
@@ -147,8 +161,9 @@ const useScene = (sceneProp, options) => {
147
161
  width: 1,
148
162
  height: 1,
149
163
  }).pipe(Effect.mapError(PlayerError.of("Error acquiring the renderer")));
150
- // viewport tracking: sized from frame metadata on first render
151
- let sized = { width: 0, height: 0, dpr: 0 };
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 };
152
167
  // pipeline pre-warm happens once, on the first rendered frame,
153
168
  // before playback reveals motion — no first-frame compile jank
154
169
  let prewarmed = false;
@@ -203,7 +218,8 @@ const useScene = (sceneProp, options) => {
203
218
  };
204
219
  }).pipe(Effect.mapError(PlayerError.of("Error getting frame buffer")));
205
220
  const renderSemaphore = yield* Semaphore.make(1);
206
- const render = (frameIndex) => renderSemaphore.withPermitsIfAvailable(1)(Effect.gen(function* () {
221
+ // unlocked: callers hold renderSemaphore
222
+ const renderFrame = (frameIndex) => Effect.gen(function* () {
207
223
  if (!canvasRef.current) {
208
224
  return;
209
225
  }
@@ -216,12 +232,13 @@ const useScene = (sceneProp, options) => {
216
232
  // loadFrameBuffer), and the playhead must reflect what's shown
217
233
  updateCurrentFrame(framebuffer.index);
218
234
  const frame = framebuffer.frame;
219
- const dpr = calculateDpr();
220
- if (sized.width !== frame.width ||
221
- sized.height !== frame.height ||
222
- sized.dpr !== dpr) {
223
- sized = { width: frame.width, height: frame.height, dpr };
224
- FrameRenderer.setViewport(sink, frame.width, frame.height, dpr);
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);
225
242
  }
226
243
  // font loaders resolve from this runtime's context (the
227
244
  // renderLayers merge); missing loaders defect loudly
@@ -232,7 +249,30 @@ const useScene = (sceneProp, options) => {
232
249
  yield* FrameRenderer.prewarm(sink);
233
250
  }
234
251
  yield* FrameRenderer.render(sink);
235
- }).pipe(Effect.mapError(PlayerError.of("Error rendering frame"))));
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
+ });
236
276
  const play = Effect.suspend(() => {
237
277
  if (isPlaying)
238
278
  return Effect.void;
@@ -312,6 +352,7 @@ const useScene = (sceneProp, options) => {
312
352
  return Context.make(PlayerScene, {
313
353
  play,
314
354
  pause,
355
+ redraw,
315
356
  frameIndex: Effect.sync(() => currentFrame),
316
357
  render,
317
358
  load: (frameIndex) => loadFrameBuffer(frameIndex),
@@ -378,6 +419,30 @@ const useScene = (sceneProp, options) => {
378
419
  play();
379
420
  }
380
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
+ }, []);
381
446
  // scene time in seconds of the current frame, and total when known
382
447
  const currentTime = Time.frameToMillis(currentFrame, fps) / 1000;
383
448
  const totalTime = totalFrames !== null
package/package.json CHANGED
@@ -1,63 +1,63 @@
1
1
  {
2
- "name": "@effect-motion/react",
3
- "version": "0.5.0",
4
- "description": "React bindings for effect-motion: usePlayer hook and Player component",
5
- "type": "module",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/julia-script/effect-motion.git",
10
- "directory": "packages/react"
11
- },
12
- "homepage": "https://github.com/julia-script/effect-motion#readme",
13
- "bugs": "https://github.com/julia-script/effect-motion/issues",
14
- "keywords": [
15
- "effect",
16
- "motion",
17
- "react",
18
- "animation",
19
- "motion-graphics"
20
- ],
21
- "exports": {
22
- ".": {
23
- "types": "./dist/index.d.ts",
24
- "default": "./dist/index.js"
25
- },
26
- "./*": {
27
- "types": "./dist/*.d.ts",
28
- "default": "./dist/*.js"
29
- }
30
- },
31
- "files": [
32
- "dist"
33
- ],
34
- "publishConfig": {
35
- "access": "public"
36
- },
37
- "dependencies": {
38
- "@effect-motion/renderer": "^0.5.0",
39
- "effect-motion": "^0.5.0"
40
- },
41
- "peerDependencies": {
42
- "effect": ">=4.0.0-beta.98",
43
- "react": ">=18"
44
- },
45
- "devDependencies": {
46
- "@testing-library/react": "^16.3.0",
47
- "@types/node": "^26.1.1",
48
- "@types/react": "^19.2.0",
49
- "@types/react-dom": "^19.2.0",
50
- "effect": "4.0.0-beta.98",
51
- "happy-dom": "^20.10.6",
52
- "react": "^19.2.0",
53
- "react-dom": "^19.2.0",
54
- "typescript": "^7.0.2",
55
- "vitest": "^4.1.10"
56
- },
57
- "scripts": {
58
- "build": "tsc -p tsconfig.build.json",
59
- "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
60
- "test": "vitest run --passWithNoTests",
61
- "check": "tsc --noEmit"
62
- }
63
- }
2
+ "name": "@effect-motion/react",
3
+ "version": "0.6.0",
4
+ "description": "React bindings for effect-motion: usePlayer hook and Player component",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/julia-script/effect-motion.git",
10
+ "directory": "packages/react"
11
+ },
12
+ "homepage": "https://github.com/julia-script/effect-motion#readme",
13
+ "bugs": "https://github.com/julia-script/effect-motion/issues",
14
+ "keywords": [
15
+ "effect",
16
+ "motion",
17
+ "react",
18
+ "animation",
19
+ "motion-graphics"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./*": {
27
+ "types": "./dist/*.d.ts",
28
+ "default": "./dist/*.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.build.json",
36
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
37
+ "test": "vitest run --passWithNoTests",
38
+ "check": "tsc --noEmit"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@effect-motion/renderer": "workspace:^",
45
+ "effect-motion": "workspace:^"
46
+ },
47
+ "peerDependencies": {
48
+ "effect": ">=4.0.0-rc.115",
49
+ "react": ">=18"
50
+ },
51
+ "devDependencies": {
52
+ "@testing-library/react": "^16.3.0",
53
+ "@types/node": "^26.1.1",
54
+ "@types/react": "^19.2.0",
55
+ "@types/react-dom": "^19.2.0",
56
+ "effect": "4.0.0-rc.115",
57
+ "happy-dom": "^20.10.6",
58
+ "react": "^19.2.0",
59
+ "react-dom": "^19.2.0",
60
+ "typescript": "^7.0.2",
61
+ "vitest": "^4.1.10"
62
+ }
63
+ }