effect-motion 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +2 -2
  2. package/dist/Camera.d.ts +186 -49
  3. package/dist/Camera.js +343 -76
  4. package/dist/Color.d.ts +101 -1
  5. package/dist/Color.js +101 -1
  6. package/dist/EffectMotionError.d.ts +17 -0
  7. package/dist/EffectMotionError.js +17 -0
  8. package/dist/Entity.d.ts +682 -38
  9. package/dist/Entity.js +271 -27
  10. package/dist/Font.d.ts +108 -0
  11. package/dist/Font.js +95 -0
  12. package/dist/Image.d.ts +71 -0
  13. package/dist/Image.js +50 -0
  14. package/dist/Instance.d.ts +73 -11
  15. package/dist/Instance.js +44 -11
  16. package/dist/Motion.d.ts +328 -53
  17. package/dist/Motion.js +278 -46
  18. package/dist/Physics.d.ts +154 -20
  19. package/dist/Physics.js +92 -11
  20. package/dist/Projection.d.ts +37 -132
  21. package/dist/Projection.js +33 -292
  22. package/dist/Resource.d.ts +26 -0
  23. package/dist/Resource.js +41 -0
  24. package/dist/Runner.d.ts +639 -350
  25. package/dist/Runner.js +197 -150
  26. package/dist/Scene.d.ts +603 -175
  27. package/dist/Scene.js +596 -112
  28. package/dist/Timing.d.ts +170 -13
  29. package/dist/Timing.js +124 -6
  30. package/dist/Tree.d.ts +39 -0
  31. package/dist/Tree.js +127 -0
  32. package/dist/index.d.ts +54 -5
  33. package/dist/index.js +56 -5
  34. package/dist/particles/Particle.d.ts +2 -2
  35. package/dist/particles/ParticleField.d.ts +8 -6
  36. package/dist/particles/ParticleField.js +8 -9
  37. package/dist/particles/constructors.d.ts +5 -6
  38. package/dist/particles/constructors.js +8 -3
  39. package/dist/particles/legacy.d.ts +58 -0
  40. package/dist/particles/legacy.js +46 -0
  41. package/dist/particles/simulate.js +11 -3
  42. package/dist/particles/step.js +14 -10
  43. package/dist/types.d.ts +5 -0
  44. package/dist/types.js +1 -0
  45. package/package.json +2 -4
  46. package/dist/CameraHelpers.d.ts +0 -70
  47. package/dist/CameraHelpers.js +0 -239
  48. package/dist/CanvasExporter.d.ts +0 -12
  49. package/dist/CanvasExporter.js +0 -40
  50. package/dist/Fonts.d.ts +0 -41
  51. package/dist/Fonts.js +0 -27
  52. package/dist/Images.d.ts +0 -33
  53. package/dist/Images.js +0 -24
  54. package/dist/PngExporter.d.ts +0 -6
  55. package/dist/PngExporter.js +0 -85
  56. package/dist/Renderer.d.ts +0 -118
  57. package/dist/Renderer.js +0 -508
  58. package/dist/Shapes.d.ts +0 -11
  59. package/dist/Shapes.js +0 -11
  60. package/dist/demo.d.ts +0 -3
  61. package/dist/demo.js +0 -71
  62. package/dist/render/dof.d.ts +0 -27
  63. package/dist/render/dof.js +0 -37
  64. package/dist/render/paint.d.ts +0 -30
  65. package/dist/render/paint.js +0 -36
  66. package/dist/render/shapes.d.ts +0 -42
  67. package/dist/render/shapes.js +0 -310
  68. package/dist/shapes/Circle.d.ts +0 -32
  69. package/dist/shapes/Circle.js +0 -9
  70. package/dist/shapes/Ellipse.d.ts +0 -35
  71. package/dist/shapes/Ellipse.js +0 -10
  72. package/dist/shapes/Group.d.ts +0 -126
  73. package/dist/shapes/Group.js +0 -89
  74. package/dist/shapes/Hud.d.ts +0 -38
  75. package/dist/shapes/Hud.js +0 -35
  76. package/dist/shapes/Image.d.ts +0 -45
  77. package/dist/shapes/Image.js +0 -28
  78. package/dist/shapes/Line.d.ts +0 -47
  79. package/dist/shapes/Line.js +0 -35
  80. package/dist/shapes/Path.d.ts +0 -107
  81. package/dist/shapes/Path.js +0 -32
  82. package/dist/shapes/Rect.d.ts +0 -51
  83. package/dist/shapes/Rect.js +0 -22
  84. package/dist/shapes/Shape2D.d.ts +0 -49
  85. package/dist/shapes/Shape2D.js +0 -52
  86. package/dist/shapes/Shapes.d.ts +0 -11
  87. package/dist/shapes/Shapes.js +0 -11
  88. package/dist/shapes/Square.d.ts +0 -32
  89. package/dist/shapes/Square.js +0 -11
  90. package/dist/shapes/Text.d.ts +0 -51
  91. package/dist/shapes/Text.js +0 -24
@@ -4,11 +4,13 @@
4
4
  * point twice is bit-for-bit identical, which is what keeps 2.5D scenes
5
5
  * deterministic.
6
6
  *
7
- * The model is the After Effects one: a camera with a world position, Euler
8
- * orientation, and a focal length, looking down world -z at rest. World
9
- * points are transformed into the camera's frame (the view transform, which
10
- * is the inverse of the camera's own world transform), then divided by their
11
- * depth in front of the camera to land on screen.
7
+ * The model: a camera with a world position, Euler orientation, and a focal
8
+ * length, looking down world -z at rest. World points are transformed into
9
+ * the camera's frame (the view transform, which is the inverse of the
10
+ * camera's own world transform), then divided by their depth in front of the
11
+ * camera to land on screen. Scene space is right-handed — x right, y up,
12
+ * origin at the viewport center, +z toward the viewer — so the camera's
13
+ * optical axis passes through world (0, 0) at rest.
12
14
  *
13
15
  * Identity invariant: the default camera (see `defaultFocalLength` and
14
16
  * `defaultCameraZ`) projects a world point at `z = 0` to screen `(x, y)` at
@@ -102,8 +104,7 @@ const rotate = (v, rx, ry, rz) => {
102
104
  };
103
105
  /**
104
106
  * The auto-orient Euler angles (yaw + pitch, no roll) aiming a camera at
105
- * `poi` from its WORLD position (viewport-center pan already composed in —
106
- * see `resolveCamera`). The view transform flips z before rotating
107
+ * `poi` from its WORLD position. The view transform flips z before rotating
107
108
  * (in-front is +z), which inverts rotation handedness vs. world space;
108
109
  * that subtlety is handled here, exactly once, pinned by tests that
109
110
  * project the POI and assert it lands on the viewport center.
@@ -125,13 +126,13 @@ export const lookAtOrientation = (position, poi) => {
125
126
  * camera's own frame (so a lone `rotZ` rolls about the view axis and the
126
127
  * POI stays centered); the exact composed rotation is extracted back to
127
128
  * the fixed Rz·Ry·Rx Euler convention the view transform consumes.
128
- * Camera `x`/`y` are pan-from-viewport-center, so the world position
129
- * composes `origin` in before aiming. Absent POI is a pass-through
130
- * (one-node camera, unchanged); a partial POI is a loud defect. The
131
- * user's rotation fields are never written back — derivation happens
132
- * here, at view-assembly time.
129
+ * Camera `x`/`y` are world coordinates (the center-origin frame makes a
130
+ * resting camera sit on the optical axis through world (0, 0)). Absent POI
131
+ * is a pass-through (one-node camera, unchanged); a partial POI is a loud
132
+ * defect. The user's rotation fields are never written back — derivation
133
+ * happens here, at view-assembly time.
133
134
  */
134
- export const resolveCamera = (camera, origin) => {
135
+ export const resolveCamera = (camera) => {
135
136
  const { poiX, poiY, poiZ } = camera;
136
137
  const present = [poiX, poiY, poiZ].filter((v) => v !== undefined).length;
137
138
  if (present === 0) {
@@ -141,8 +142,8 @@ export const resolveCamera = (camera, origin) => {
141
142
  throw new Error("Camera: a point of interest requires all of poiX, poiY, poiZ — got a partial POI");
142
143
  }
143
144
  const world = {
144
- x: origin.x + camera.x,
145
- y: origin.y + camera.y,
145
+ x: camera.x,
146
+ y: camera.y,
146
147
  z: camera.z,
147
148
  };
148
149
  const aim = lookAtOrientation(world, {
@@ -173,301 +174,41 @@ export const resolveCamera = (camera, origin) => {
173
174
  return { ...camera, rotX, rotY, rotZ };
174
175
  };
175
176
  /**
176
- * The four world-space corners of a flat rectangular plane. The rect spans
177
- * local `[x, x+width] × [y, y+height]` on the z=0 plane; each corner is
178
- * rotated about the rect's local origin `(x, y)` by the Euler orientation,
179
- * then translated to `world` (the plane's composed world anchor minus its own
180
- * local x/y, so rotation pivots on the anchor). Winding: TL, TR, BR, BL.
177
+ * A world point in the camera's frame. The camera's `x`/`y` are its world
178
+ * position (origin at the viewport center), so a resting camera keeps
179
+ * world-x/y = screen-x/y at unit scale. `+z` in the result is in front of
180
+ * the camera: the camera looks down world -z, so a resting camera at
181
+ * z=focalLength sees the z=0 plane at view-z = focalLength.
181
182
  */
182
- export const planeCorners = (rect, orientation, world) => {
183
- const { rotX, rotY, rotZ } = orientation;
184
- // local corners relative to the rect's own origin (the rotation pivot)
185
- const local = [
186
- { x: 0, y: 0 },
187
- { x: rect.width, y: 0 },
188
- { x: rect.width, y: rect.height },
189
- { x: 0, y: rect.height },
190
- ];
191
- const place = (c) => {
192
- const r = rotate({ x: c.x, y: c.y, z: 0 }, rotX, rotY, rotZ);
193
- return { x: world.x + r.x, y: world.y + r.y, z: world.z + r.z };
194
- };
195
- return [place(local[0]), place(local[1]), place(local[2]), place(local[3])];
196
- };
197
- /**
198
- * A world point in the camera's frame, measured relative to `origin` (the
199
- * viewport center). The camera's `x`/`y` are a pan *from* the origin, so a
200
- * resting camera (pan 0) keeps world-x/y = screen-x/y at unit scale. `+z` in
201
- * the result is in front of the camera: the camera looks down world -z, so a
202
- * resting camera at z=focalLength sees the z=0 plane at view-z = focalLength.
203
- */
204
- export const toView = (camera, p, origin) => {
183
+ export const toView = (camera, p) => {
205
184
  const translated = {
206
- x: p.x - origin.x - camera.x,
207
- y: p.y - origin.y - camera.y,
185
+ x: p.x - camera.x,
186
+ y: p.y - camera.y,
208
187
  z: camera.z - p.z, // flip so in-front is +z (camera looks toward -world-z)
209
188
  };
210
189
  return rotateInverse(translated, camera.rotX, camera.rotY, camera.rotZ);
211
190
  };
212
191
  /**
213
- * Project a world point to screen. `origin` is the screen point the camera's
214
- * optical axis passes through — the viewport center — so pan/zoom read as
215
- * "into the middle of the shot", while a resting camera reproduces plain-2D
192
+ * Project a world point to screen. The camera's optical axis passes through
193
+ * screen (0, 0) — the viewport center — so pan/zoom read as "into the
194
+ * middle of the shot", while a resting camera reproduces plain-2D
216
195
  * placement. A point at or behind the camera (view-z <= 0) has no valid
217
196
  * projection; `depth` is still returned for sorting, `scale` clamps to 0.
218
197
  */
219
- export const project = (camera, p, origin) => {
220
- const v = toView(camera, p, origin);
198
+ export const project = (camera, p) => {
199
+ const v = toView(camera, p);
221
200
  const depth = v.z;
222
201
  const scale = depth > 0 ? camera.focalLength / depth : 0;
223
202
  return {
224
- x: origin.x + v.x * scale,
225
- y: origin.y + v.y * scale,
203
+ x: v.x * scale,
204
+ y: v.y * scale,
226
205
  depth,
227
206
  scale,
228
207
  };
229
208
  };
230
- export const billboardAffine = (proj, anchor) => ({
231
- a: proj.scale,
232
- b: 0,
233
- c: 0,
234
- d: proj.scale,
235
- // place the anchor's local coords at the projected screen point
236
- e: proj.x - anchor.x * proj.scale,
237
- f: proj.y - anchor.y * proj.scale,
238
- });
239
- /**
240
- * View-space depth (px) of the near clip plane. A tilted plane can be
241
- * PARTIALLY behind the camera — projecting a corner at depth <= 0 is
242
- * meaningless (the old code pinned it to the viewport center, folding the
243
- * polygon), so the polygon is clipped against this plane first. 1px keeps
244
- * the worst-case projected scale bounded at `focalLength` per unit.
245
- */
246
- const NEAR = 1;
247
- /**
248
- * Project the world-space corners of a tilted plane to a screen polygon.
249
- * The polygon is clipped against the near plane in view space
250
- * (Sutherland–Hodgman, winding preserved) before the per-vertex perspective
251
- * divide, so a plane crossing the camera renders its visible part instead
252
- * of folding — a quad straddling the plane yields up to 5 vertices; a plane
253
- * fully behind yields none (cull). Fully in front, this is plain per-corner
254
- * projection: a receding plane is a true perspective trapezoid.
255
- */
256
- export const projectPlane = (camera, corners, origin) => {
257
- const view = corners.map((c) => toView(camera, c, origin));
258
- return clipPolygonNear(view).map((v) => {
259
- const scale = camera.focalLength / v.z;
260
- return { x: origin.x + v.x * scale, y: origin.y + v.y * scale };
261
- });
262
- };
263
- /**
264
- * Sutherland–Hodgman clip of a view-space polygon against the near plane,
265
- * winding preserved. Shared by tilted planes and closed path subpaths.
266
- */
267
- const clipPolygonNear = (view) => {
268
- const clipped = [];
269
- for (let i = 0; i < view.length; i++) {
270
- // biome-ignore lint/style/noNonNullAssertion: i and (i+1)%length are in bounds
271
- const a = view[i];
272
- // biome-ignore lint/style/noNonNullAssertion: see above
273
- const b = view[(i + 1) % view.length];
274
- const aIn = a.z >= NEAR;
275
- if (aIn) {
276
- clipped.push(a);
277
- }
278
- if (aIn !== b.z >= NEAR) {
279
- const t = (NEAR - a.z) / (b.z - a.z);
280
- clipped.push({
281
- x: a.x + (b.x - a.x) * t,
282
- y: a.y + (b.y - a.y) * t,
283
- z: NEAR,
284
- });
285
- }
286
- }
287
- return clipped;
288
- };
289
- /**
290
- * Project a world-space segment (a skeletal shape's two endpoints) to
291
- * screen. Both endpoints go to view space, are clipped against the near
292
- * plane (lerp to z = NEAR — the 1D case of projectPlane's polygon clip),
293
- * and are projected individually, so a line spanning depth foreshortens
294
- * per endpoint. Returns `undefined` when the segment lies entirely behind
295
- * the near plane (cull). `depth`/`scale` come from the visible midpoint.
296
- */
297
- export const projectSegment = (camera, a, b, origin) => {
298
- let va = toView(camera, a, origin);
299
- let vb = toView(camera, b, origin);
300
- if (va.z < NEAR && vb.z < NEAR) {
301
- return undefined;
302
- }
303
- const clip = (inside, outside) => {
304
- const t = (NEAR - inside.z) / (outside.z - inside.z);
305
- return {
306
- x: inside.x + (outside.x - inside.x) * t,
307
- y: inside.y + (outside.y - inside.y) * t,
308
- z: NEAR,
309
- };
310
- };
311
- if (va.z < NEAR) {
312
- va = clip(vb, va);
313
- }
314
- else if (vb.z < NEAR) {
315
- vb = clip(va, vb);
316
- }
317
- const toScreen = (v) => {
318
- const s = camera.focalLength / v.z;
319
- return { x: origin.x + v.x * s, y: origin.y + v.y * s };
320
- };
321
- const depth = (va.z + vb.z) / 2;
322
- return {
323
- a: toScreen(va),
324
- b: toScreen(vb),
325
- depth,
326
- scale: camera.focalLength / depth,
327
- };
328
- };
329
- /**
330
- * Project a skeletal path's world-space subpaths to screen — the N-point
331
- * generalization of `projectSegment`. Every point goes to view space and is
332
- * projected individually, so a path spanning depth foreshortens per point.
333
- * Near-plane clipping is per subpath: a closed subpath clips as a polygon
334
- * (Sutherland–Hodgman, winding preserved — the tilted-plane treatment); an
335
- * open subpath clips per span (lerp to z = NEAR) and SPLITS into separate
336
- * visible pieces when interior points fall behind the plane. Returns
337
- * `undefined` when nothing survives (cull). `depth`/`scale` come from the
338
- * mean view depth of all emitted points — one key per path, the same
339
- * ceiling as a segment's midpoint.
340
- */
341
- export const projectPath = (camera, subpaths, origin) => {
342
- const pieces = [];
343
- for (const subpath of subpaths) {
344
- const view = subpath.points.map((p) => toView(camera, p, origin));
345
- if (subpath.closed) {
346
- const clipped = clipPolygonNear(view);
347
- if (clipped.length >= 2) {
348
- pieces.push({ points: clipped, closed: true });
349
- }
350
- continue;
351
- }
352
- // open: walk the spans, clipping and splitting at the near plane
353
- let current = [];
354
- const clip = (a, b) => {
355
- const t = (NEAR - a.z) / (b.z - a.z);
356
- return {
357
- x: a.x + (b.x - a.x) * t,
358
- y: a.y + (b.y - a.y) * t,
359
- z: NEAR,
360
- };
361
- };
362
- const flush = () => {
363
- if (current.length >= 2) {
364
- pieces.push({ points: current, closed: false });
365
- }
366
- current = [];
367
- };
368
- for (let i = 0; i + 1 < view.length; i++) {
369
- // biome-ignore lint/style/noNonNullAssertion: i and i+1 are in bounds
370
- const a = view[i];
371
- // biome-ignore lint/style/noNonNullAssertion: see above
372
- const b = view[i + 1];
373
- const aIn = a.z >= NEAR;
374
- const bIn = b.z >= NEAR;
375
- if (aIn) {
376
- if (current.length === 0) {
377
- current.push(a);
378
- }
379
- if (bIn) {
380
- current.push(b);
381
- }
382
- else {
383
- current.push(clip(a, b));
384
- flush();
385
- }
386
- }
387
- else if (bIn) {
388
- current = [clip(b, a), b];
389
- }
390
- }
391
- flush();
392
- }
393
- if (pieces.length === 0) {
394
- return undefined;
395
- }
396
- let depthSum = 0;
397
- let depthCount = 0;
398
- for (const piece of pieces) {
399
- for (const v of piece.points) {
400
- depthSum += v.z;
401
- depthCount++;
402
- }
403
- }
404
- const depth = depthSum / depthCount;
405
- const toScreen = (v) => {
406
- const s = camera.focalLength / v.z;
407
- return { x: origin.x + v.x * s, y: origin.y + v.y * s };
408
- };
409
- return {
410
- subpaths: pieces.map((piece) => ({
411
- points: piece.points.map(toScreen),
412
- closed: piece.closed,
413
- })),
414
- depth,
415
- scale: camera.focalLength / depth,
416
- };
417
- };
418
- /**
419
- * Clip a screen-space segment to a rectangle (Liang–Barsky). Returns the
420
- * clipped pair, or `undefined` when the segment lies entirely outside.
421
- * ThorVG's software rasterizer pays stroke cost proportional to a path's
422
- * full extent — offscreen included — so segments are clipped to the
423
- * viewport (plus a stroke margin) before painting; a near-camera line can
424
- * project tens of thousands of px wide otherwise (measured ~7× the cost
425
- * of its visible part).
426
- */
427
- export const clipSegmentToRect = (a, b, min, max) => {
428
- const dx = b.x - a.x;
429
- const dy = b.y - a.y;
430
- let t0 = 0;
431
- let t1 = 1;
432
- const edges = [
433
- [-dx, a.x - min.x],
434
- [dx, max.x - a.x],
435
- [-dy, a.y - min.y],
436
- [dy, max.y - a.y],
437
- ];
438
- for (const [p, q] of edges) {
439
- if (p === 0) {
440
- // parallel to this edge: outside it means fully outside
441
- if (q < 0) {
442
- return undefined;
443
- }
444
- continue;
445
- }
446
- const r = q / p;
447
- if (p < 0) {
448
- if (r > t1) {
449
- return undefined;
450
- }
451
- if (r > t0) {
452
- t0 = r;
453
- }
454
- }
455
- else {
456
- if (r < t0) {
457
- return undefined;
458
- }
459
- if (r < t1) {
460
- t1 = r;
461
- }
462
- }
463
- }
464
- const at = (t) => ({ x: a.x + dx * t, y: a.y + dy * t });
465
- return [t0 === 0 ? a : at(t0), t1 === 1 ? b : at(t1)];
466
- };
467
209
  /**
468
210
  * The view-space depth of a world point — the painter's-sort key alone.
469
211
  * With no camera rotation this is `camera.z - p.z`; rotation tilts the
470
- * depth axis, so the full view transform is used. Origin only shifts x/y,
471
- * never depth, so a zero origin suffices.
212
+ * depth axis, so the full view transform is used.
472
213
  */
473
- export const depthOf = (camera, p) => toView(camera, p, { x: 0, y: 0 }).z;
214
+ export const depthOf = (camera, p) => toView(camera, p).z;
@@ -0,0 +1,26 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { EffectMotionError } from "./EffectMotionError.js";
3
+ /**
4
+ * The shared loader brand. Every loader service (FontLoader, ImageLoader,
5
+ * future kinds) carries it, and it is the ONLY thing they share: loader
6
+ * shapes stay separate per kind (fonts grow font metadata, images image
7
+ * metadata) so nothing over-abstracts, while the type utilities below are
8
+ * written once against the brand.
9
+ */
10
+ export declare const LoaderTypeId: "~effect-motion/Resources/Loader";
11
+ export interface LoaderBrand {
12
+ readonly [LoaderTypeId]: typeof LoaderTypeId;
13
+ }
14
+ /**
15
+ * The loader members of a requirements union — what a scene's frames carry
16
+ * as `Frame<Resources>` and what the renderer requires. Distributes:
17
+ * `FontLoader<"a"> | Runner` → `FontLoader<"a">`.
18
+ */
19
+ export type ExtractLoaders<R> = R extends LoaderBrand ? R : never;
20
+ /**
21
+ * A requirements union with the loader members removed — what running a
22
+ * scene actually needs. Frames are pure of resource bytes (the engine
23
+ * cannot measure text), so `Scene.run`/`stream` require only this.
24
+ */
25
+ export type ExcludeLoaders<R> = R extends LoaderBrand ? never : R;
26
+ export declare const fetchBytes: (url: string) => Effect.Effect<Uint8Array, EffectMotionError>;
@@ -0,0 +1,41 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { EffectMotionError } from "./EffectMotionError.js";
3
+ /**
4
+ * The shared loader brand. Every loader service (FontLoader, ImageLoader,
5
+ * future kinds) carries it, and it is the ONLY thing they share: loader
6
+ * shapes stay separate per kind (fonts grow font metadata, images image
7
+ * metadata) so nothing over-abstracts, while the type utilities below are
8
+ * written once against the brand.
9
+ */
10
+ export const LoaderTypeId = "~effect-motion/Resources/Loader";
11
+ /**
12
+ * Fetch a resource's bytes by URL — the common browser-side load effect for
13
+ * `Font.layer`/`Image.layer`. Fails with a typed error naming the URL, so a
14
+ * bad source surfaces at layer construction (e.g. the player's error
15
+ * state), never at frame time. Compose retries on top as needed.
16
+ *
17
+ * Memoized per URL at module level: asset bytes are immutable, and layers
18
+ * rebuild per Player mount (studio scene switches), so each URL is fetched
19
+ * at most once per process. A FAILED fetch is not cached — the next
20
+ * construction retries. Custom load effects cache themselves (this pattern)
21
+ * when they should.
22
+ */
23
+ const fetchCache = new Map();
24
+ export const fetchBytes = (url) => Effect.tryPromise({
25
+ try: () => {
26
+ let pending = fetchCache.get(url);
27
+ if (pending === undefined) {
28
+ pending = fetch(url).then(async (response) => {
29
+ if (!response.ok) {
30
+ throw new Error(`HTTP ${response.status}`);
31
+ }
32
+ return new Uint8Array(await response.arrayBuffer());
33
+ });
34
+ // evict on failure so a transient error can retry
35
+ pending.catch(() => fetchCache.delete(url));
36
+ fetchCache.set(url, pending);
37
+ }
38
+ return pending;
39
+ },
40
+ catch: (cause) => EffectMotionError.of(`Resource fetch failed: ${url}`, cause),
41
+ });