effect-motion 0.2.0 → 0.3.1

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 (103) hide show
  1. package/dist/Camera.d.ts +50 -28
  2. package/dist/Camera.js +71 -19
  3. package/dist/CameraHelpers.d.ts +70 -0
  4. package/dist/CameraHelpers.js +239 -0
  5. package/dist/CanvasExporter.d.ts +12 -0
  6. package/dist/CanvasExporter.js +40 -0
  7. package/dist/Color.d.ts +428 -0
  8. package/dist/Color.js +535 -0
  9. package/dist/EffectMotionError.d.ts +10 -0
  10. package/dist/EffectMotionError.js +6 -0
  11. package/dist/Entity.d.ts +9 -8
  12. package/dist/Entity.js +1 -4
  13. package/dist/Fonts.d.ts +10 -0
  14. package/dist/Fonts.js +16 -0
  15. package/dist/Images.d.ts +33 -0
  16. package/dist/Images.js +24 -0
  17. package/dist/Instance.d.ts +4 -4
  18. package/dist/Motion.d.ts +24 -7
  19. package/dist/Motion.js +54 -9
  20. package/dist/Phaser.d.ts +1 -1
  21. package/dist/Phaser.js +13 -9
  22. package/dist/Physics.d.ts +3 -3
  23. package/dist/Physics.js +4 -4
  24. package/dist/PngExporter.d.ts +6 -0
  25. package/dist/PngExporter.js +85 -0
  26. package/dist/Projection.d.ts +225 -0
  27. package/dist/Projection.js +473 -0
  28. package/dist/Renderer.d.ts +102 -53
  29. package/dist/Renderer.js +369 -78
  30. package/dist/Runner.d.ts +126 -42
  31. package/dist/Runner.js +42 -17
  32. package/dist/Scene.d.ts +88 -59
  33. package/dist/Scene.js +24 -15
  34. package/dist/Shapes.d.ts +11 -0
  35. package/dist/Shapes.js +11 -0
  36. package/dist/demo.d.ts +3 -186
  37. package/dist/demo.js +24 -35
  38. package/dist/index.d.ts +14 -13
  39. package/dist/index.js +14 -13
  40. package/dist/particles/Particle.d.ts +4 -3
  41. package/dist/particles/ParticleField.d.ts +11 -48
  42. package/dist/particles/ParticleField.js +5 -4
  43. package/dist/particles/constructors.d.ts +8 -7
  44. package/dist/particles/constructors.js +2 -2
  45. package/dist/particles/index.d.ts +5 -6
  46. package/dist/particles/index.js +5 -6
  47. package/dist/particles/overLife.d.ts +1 -1
  48. package/dist/particles/overLife.js +1 -1
  49. package/dist/particles/simulate.d.ts +2 -2
  50. package/dist/particles/simulate.js +6 -6
  51. package/dist/particles/step.d.ts +2 -2
  52. package/dist/particles/step.js +5 -3
  53. package/dist/render/dof.d.ts +27 -0
  54. package/dist/render/dof.js +37 -0
  55. package/dist/render/paint.d.ts +30 -0
  56. package/dist/render/paint.js +36 -0
  57. package/dist/render/shapes.d.ts +42 -0
  58. package/dist/render/shapes.js +310 -0
  59. package/dist/shapes/Circle.d.ts +10 -15
  60. package/dist/shapes/Circle.js +2 -2
  61. package/dist/shapes/Ellipse.d.ts +10 -16
  62. package/dist/shapes/Ellipse.js +2 -2
  63. package/dist/shapes/Group.d.ts +5 -39
  64. package/dist/shapes/Group.js +4 -4
  65. package/dist/shapes/Hud.d.ts +38 -0
  66. package/dist/shapes/Hud.js +35 -0
  67. package/dist/shapes/Image.d.ts +45 -0
  68. package/dist/shapes/Image.js +28 -0
  69. package/dist/shapes/Line.d.ts +12 -12
  70. package/dist/shapes/Line.js +11 -6
  71. package/dist/shapes/Path.d.ts +87 -18
  72. package/dist/shapes/Path.js +25 -6
  73. package/dist/shapes/Rect.d.ts +33 -23
  74. package/dist/shapes/Rect.js +14 -2
  75. package/dist/shapes/Shape2D.d.ts +13 -4
  76. package/dist/shapes/Shape2D.js +16 -5
  77. package/dist/shapes/Shapes.d.ts +11 -0
  78. package/dist/shapes/Shapes.js +11 -0
  79. package/dist/shapes/Square.d.ts +10 -15
  80. package/dist/shapes/Square.js +2 -2
  81. package/dist/shapes/Text.d.ts +10 -19
  82. package/dist/shapes/Text.js +2 -2
  83. package/package.json +13 -3
  84. package/dist/particles/render.d.ts +0 -13
  85. package/dist/particles/render.js +0 -33
  86. package/dist/shapes/Layer.d.ts +0 -9
  87. package/dist/shapes/Layer.js +0 -23
  88. package/dist/shapes/index.d.ts +0 -10
  89. package/dist/shapes/index.js +0 -10
  90. package/dist/svg/SvgDomRenderer.d.ts +0 -28
  91. package/dist/svg/SvgDomRenderer.js +0 -50
  92. package/dist/svg/SvgNode.d.ts +0 -13
  93. package/dist/svg/SvgNode.js +0 -18
  94. package/dist/svg/SvgRenderer.d.ts +0 -22
  95. package/dist/svg/SvgRenderer.js +0 -20
  96. package/dist/svg/camera.d.ts +0 -21
  97. package/dist/svg/camera.js +0 -43
  98. package/dist/svg/index.d.ts +0 -6
  99. package/dist/svg/index.js +0 -6
  100. package/dist/svg/layers.d.ts +0 -18
  101. package/dist/svg/layers.js +0 -13
  102. package/dist/svg/shapes.d.ts +0 -1089
  103. package/dist/svg/shapes.js +0 -119
package/dist/Camera.d.ts CHANGED
@@ -1,49 +1,71 @@
1
- import type * as Schema from "effect/Schema";
2
- import * as Entity from "./Entity";
1
+ import * as Schema from "effect/Schema";
2
+ import * as Entity from "./Entity.js";
3
+ import * as Projection from "./Projection.js";
3
4
  /**
4
- * The camera is view state, not a shape — it is never registered with a
5
- * sink and never drawn. It exists as an ordinary Instance so the existing
6
- * animators drive it for free: `camera.pipe(moveTo({ x: 400 }))`,
7
- * `tween("zoom", ...)`, `spring`, `Scene.fork`, etc.
5
+ * The camera is view state, not a shape — it is never registered with a sink
6
+ * and never drawn. It exists as an ordinary Instance so the existing
7
+ * animators drive it for free: `camera.pipe(moveTo({ z: -800 }))`,
8
+ * `tween("rotY", ...)`, `spring`, `Scene.fork`, etc.
8
9
  *
9
- * `~position` (x/y) is the pan in world units; `zoom` is a uniform scale
10
- * (1 = identity). The sink reads these off `FrameMeta.camera` and applies
11
- * them per top-level layer, scaled by each layer's `depth` — instance data
12
- * stays in world coordinates, so determinism and `moveTo` semantics are
13
- * untouched by the camera.
10
+ * `~position` (now x/y/z) is the camera's world position; `rotX/rotY/rotZ`
11
+ * are its Euler orientation; `focalLength` sets the field of view. At rest
12
+ * the camera sits a focal-length back on +z looking down world -z, so a
13
+ * world point at z=0 projects to plain-2D screen coordinates — see
14
+ * `Projection.ts`. The sink reads these off `FrameMeta.camera` and projects
15
+ * every instance through them; instance data stays in world coordinates, so
16
+ * determinism and `moveTo` semantics are untouched by the camera.
17
+ *
18
+ * `z` and `focalLength` have no static schema default: the right resting
19
+ * values are width-relative (After Effects' 50mm-equivalent — see
20
+ * `Projection.defaultFocalLength`), and only the Runner knows the scene
21
+ * width. The Runner fills both at instantiate time for every Camera
22
+ * instance, so by the time animators or the renderer read the data they are
23
+ * always concrete.
14
24
  */
15
25
  declare const fields: {
16
26
  x: Schema.withConstructorDefault<Schema.Number>;
17
27
  y: Schema.withConstructorDefault<Schema.Number>;
18
- zoom: Schema.withConstructorDefault<Schema.Number>;
28
+ z: Schema.withConstructorDefault<Schema.Number>;
29
+ rotX: Schema.withConstructorDefault<Schema.Number>;
30
+ rotY: Schema.withConstructorDefault<Schema.Number>;
31
+ rotZ: Schema.withConstructorDefault<Schema.Number>;
32
+ focalLength: Schema.withConstructorDefault<Schema.Number>;
33
+ focusDistance: Schema.withConstructorDefault<Schema.Number>;
34
+ aperture: Schema.withConstructorDefault<Schema.Number>;
35
+ poiX: Schema.optionalKey<Schema.Number>;
36
+ poiY: Schema.optionalKey<Schema.Number>;
37
+ poiZ: Schema.optionalKey<Schema.Number>;
19
38
  };
20
39
  type CameraData = Schema.Struct<typeof fields>["Type"];
21
40
  export declare const Camera: Entity.Entity<"Camera", Schema.Struct<{
22
41
  x: Schema.withConstructorDefault<Schema.Number>;
23
42
  y: Schema.withConstructorDefault<Schema.Number>;
24
- zoom: Schema.withConstructorDefault<Schema.Number>;
43
+ z: Schema.withConstructorDefault<Schema.Number>;
44
+ rotX: Schema.withConstructorDefault<Schema.Number>;
45
+ rotY: Schema.withConstructorDefault<Schema.Number>;
46
+ rotZ: Schema.withConstructorDefault<Schema.Number>;
47
+ focalLength: Schema.withConstructorDefault<Schema.Number>;
48
+ focusDistance: Schema.withConstructorDefault<Schema.Number>;
49
+ aperture: Schema.withConstructorDefault<Schema.Number>;
50
+ poiX: Schema.optionalKey<Schema.Number>;
51
+ poiY: Schema.optionalKey<Schema.Number>;
52
+ poiZ: Schema.optionalKey<Schema.Number>;
25
53
  }>, {
26
54
  readonly "~position": {
27
55
  get: (data: CameraData) => {
28
56
  x: number;
29
57
  y: number;
58
+ z: number;
30
59
  };
31
60
  set: (data: CameraData, value: Entity.Position) => CameraData;
32
61
  };
33
- }, {
34
- readonly x?: number;
35
- readonly y?: number;
36
- readonly zoom?: number;
37
62
  }>;
38
- /** The identity view: no pan, no zoom. */
39
- export declare const IDENTITY: {
40
- readonly x: 0;
41
- readonly y: 0;
42
- readonly zoom: 1;
43
- };
44
- export interface CameraState {
45
- readonly x: number;
46
- readonly y: number;
47
- readonly zoom: number;
48
- }
63
+ /**
64
+ * The identity view for a comp of the given width: resting camera, no
65
+ * rotation, the width-relative default focal length (AE's 50mm equivalent).
66
+ * Projects z=0 content to its plain-2D screen position at scale 1.
67
+ */
68
+ export declare const identity: (width: number) => CameraState;
69
+ /** The camera view carried on each frame (POI rides along when set). */
70
+ export type CameraState = Projection.CameraView & Projection.PointOfInterest;
49
71
  export {};
package/dist/Camera.js CHANGED
@@ -1,33 +1,85 @@
1
- import * as Entity from "./Entity";
2
- import * as Shape2D from "./shapes/Shape2D";
1
+ import { Effect } from "effect";
2
+ import * as Schema from "effect/Schema";
3
+ import * as Entity from "./Entity.js";
4
+ import * as Projection from "./Projection.js";
5
+ import * as Shape2D from "./shapes/Shape2D.js";
3
6
  /**
4
- * The camera is view state, not a shape — it is never registered with a
5
- * sink and never drawn. It exists as an ordinary Instance so the existing
6
- * animators drive it for free: `camera.pipe(moveTo({ x: 400 }))`,
7
- * `tween("zoom", ...)`, `spring`, `Scene.fork`, etc.
7
+ * The camera is view state, not a shape — it is never registered with a sink
8
+ * and never drawn. It exists as an ordinary Instance so the existing
9
+ * animators drive it for free: `camera.pipe(moveTo({ z: -800 }))`,
10
+ * `tween("rotY", ...)`, `spring`, `Scene.fork`, etc.
8
11
  *
9
- * `~position` (x/y) is the pan in world units; `zoom` is a uniform scale
10
- * (1 = identity). The sink reads these off `FrameMeta.camera` and applies
11
- * them per top-level layer, scaled by each layer's `depth` — instance data
12
- * stays in world coordinates, so determinism and `moveTo` semantics are
13
- * untouched by the camera.
12
+ * `~position` (now x/y/z) is the camera's world position; `rotX/rotY/rotZ`
13
+ * are its Euler orientation; `focalLength` sets the field of view. At rest
14
+ * the camera sits a focal-length back on +z looking down world -z, so a
15
+ * world point at z=0 projects to plain-2D screen coordinates — see
16
+ * `Projection.ts`. The sink reads these off `FrameMeta.camera` and projects
17
+ * every instance through them; instance data stays in world coordinates, so
18
+ * determinism and `moveTo` semantics are untouched by the camera.
19
+ *
20
+ * `z` and `focalLength` have no static schema default: the right resting
21
+ * values are width-relative (After Effects' 50mm-equivalent — see
22
+ * `Projection.defaultFocalLength`), and only the Runner knows the scene
23
+ * width. The Runner fills both at instantiate time for every Camera
24
+ * instance, so by the time animators or the renderer read the data they are
25
+ * always concrete.
14
26
  */
15
27
  const fields = {
16
- ...Shape2D.position,
17
- zoom: Shape2D.defaultedNumber(1),
28
+ x: Shape2D.defaultedNumber(0),
29
+ y: Shape2D.defaultedNumber(0),
30
+ z: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0))),
31
+ rotX: Shape2D.defaultedNumber(0),
32
+ rotY: Shape2D.defaultedNumber(0),
33
+ rotZ: Shape2D.defaultedNumber(0),
34
+ focalLength: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0))),
35
+ // depth of field: view-space distance to the sharp plane, and blur
36
+ // strength. Like z/focalLength, focusDistance's right default is
37
+ // width-relative (the resting distance, so z=0 is in focus) — the Runner
38
+ // fills it. aperture 0 = pinhole = DoF off (explicit opt-in).
39
+ focusDistance: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0))),
40
+ aperture: Shape2D.defaultedNumber(0),
41
+ // Optional point of interest (world coordinates): when present the camera
42
+ // auto-orients toward it and explicit Euler composes AFTER the aim (the
43
+ // AE two-node model — see Projection.resolveCamera). Flat numeric fields
44
+ // so tween/spring drive the aim like any other field. NOT Runner-filled:
45
+ // absent = today's one-node camera, byte-identical (explicit opt-in).
46
+ poiX: Schema.optionalKey(Schema.Number),
47
+ poiY: Schema.optionalKey(Schema.Number),
48
+ poiZ: Schema.optionalKey(Schema.Number),
18
49
  };
19
50
  export const Camera = Entity.make("Camera", fields, {
20
- // only ~position: zoom is a raw numeric field, animated via tween.
21
- // Inlined (not positionLens()) so the data type flows into the lens —
22
- // the generic helper needs a second trait present to infer it.
51
+ // x/y/z is the camera position; rotation + focalLength are raw numeric
52
+ // fields animated via tween. Inlined (not positionLens()) so the data
53
+ // type flows into the lens.
23
54
  "~position": {
24
- get: (data) => ({ x: data.x, y: data.y }),
55
+ // z is filled by the Runner at instantiate; the ?? 0 is unreachable,
56
+ // it only satisfies the optional schema type
57
+ get: (data) => ({ x: data.x, y: data.y, z: data.z ?? 0 }),
25
58
  set: (data, value) => ({
26
59
  ...data,
27
60
  x: value.x,
28
61
  y: value.y,
62
+ z: value.z,
29
63
  }),
30
64
  },
31
65
  });
32
- /** The identity view: no pan, no zoom. */
33
- export const IDENTITY = { x: 0, y: 0, zoom: 1 };
66
+ /**
67
+ * The identity view for a comp of the given width: resting camera, no
68
+ * rotation, the width-relative default focal length (AE's 50mm equivalent).
69
+ * Projects z=0 content to its plain-2D screen position at scale 1.
70
+ */
71
+ export const identity = (width) => {
72
+ const focalLength = Projection.defaultFocalLength(width);
73
+ return {
74
+ x: 0,
75
+ y: 0,
76
+ z: Projection.defaultCameraZ(focalLength),
77
+ rotX: 0,
78
+ rotY: 0,
79
+ rotZ: 0,
80
+ focalLength,
81
+ // the z=0 plane in focus, no depth of field
82
+ focusDistance: Projection.defaultCameraZ(focalLength),
83
+ aperture: 0,
84
+ };
85
+ };
@@ -0,0 +1,70 @@
1
+ import * as Duration from "effect/Duration";
2
+ import * as Effect from "effect/Effect";
3
+ import type { Camera } from "./Camera.js";
4
+ import * as Entity from "./Entity.js";
5
+ import * as Instance from "./Instance.js";
6
+ import * as Runner from "./Runner.js";
7
+ import * as Timing from "./Timing.js";
8
+ /**
9
+ * The public Camera surface: the entity/identity from Camera.js plus the
10
+ * directing helpers. This module (not Camera.ts) is what index exports as
11
+ * the `Camera` namespace — Runner/Renderer import the schema file
12
+ * directly, so helpers can depend on Motion/Scene without an import
13
+ * cycle.
14
+ *
15
+ * Naming rule (recorded in the camera-poi-helpers change): verbs that
16
+ * name their target (`lookAt`, `follow`) have no base/To pair — an
17
+ * optional duration selects instant vs eased. Value-animating helpers
18
+ * (`orbit`/`orbitTo`, `dolly`/`dollyTo`) keep the pair, exactly like
19
+ * `move`/`moveTo`.
20
+ */
21
+ export { Camera, identity } from "./Camera.js";
22
+ export type { CameraState } from "./Camera.js";
23
+ type AnyInstance = Instance.Instance<any, any, any>;
24
+ /**
25
+ * A helper target: an Instance (position read live each frame), an Effect
26
+ * resolving to one (resolved once at helper start, then read live), or a
27
+ * plain position (inherently fixed — the no-entity escape hatch).
28
+ */
29
+ export type CameraTarget = AnyInstance | Effect.Effect<AnyInstance, never, Runner.Runner> | Partial<Entity.Position>;
30
+ type CamData = (typeof Camera)["data"];
31
+ type CamTraits = (typeof Camera)["traits"];
32
+ type CamOrEffect<E = never, R = Runner.Runner> = Instance.InstanceOrEffect<"Camera", CamData, CamTraits, E, R>;
33
+ type CamInstance = Instance.Instance<"Camera", CamData, CamTraits>;
34
+ type CamEffect = Effect.Effect<CamInstance, never, Runner.Runner>;
35
+ /**
36
+ * Aim the camera at a target. No duration: set the point of interest this
37
+ * frame. With a duration: eased re-aim as a retargeted tween (lands
38
+ * exactly on a moving target, no terminal snap). `offset` shifts the aim
39
+ * relative to the target ("slightly above their head"). Dual:
40
+ * `lookAt(cam, target, ...)` or `cam.pipe(lookAt(target, ...))`.
41
+ */
42
+ export declare const lookAt: ((target: CameraTarget, duration?: Duration.Input, timing?: Timing.TimingInput, offset?: Partial<Entity.Position>) => (cam: CamOrEffect) => CamEffect) & ((cam: CamOrEffect, target: CameraTarget, duration?: Duration.Input, timing?: Timing.TimingInput, offset?: Partial<Entity.Position>) => CamEffect);
43
+ /**
44
+ * Track a target for the duration: the point of interest is a hard
45
+ * per-frame copy of the target position (+offset). A plain animator — it
46
+ * pipes, `Scene.all`s, staggers, repeats. No timing input (lag is
47
+ * expressed by springing the POI instead). Ordering practice: within a
48
+ * tick, branches run in fork order — a follow forked before its target's
49
+ * animator reads the previous frame's position, a deterministic one-frame
50
+ * trail. Dual like `lookAt`.
51
+ */
52
+ export declare const follow: ((target: CameraTarget, duration: Duration.Input, offset?: Partial<Entity.Position>) => (cam: CamOrEffect) => CamEffect) & ((cam: CamOrEffect, target: CameraTarget, duration: Duration.Input, offset?: Partial<Entity.Position>) => CamEffect);
53
+ /**
54
+ * Turntable the camera to an absolute azimuth around its point of
55
+ * interest (angle about the world-Y axis through the POI; 0 = directly +z
56
+ * of it). Position travels the arc — orientation comes entirely from the
57
+ * POI, so there is no orientation math to get wrong. Radius and height
58
+ * are preserved. Dies loudly without a POI. Dual like `moveTo`.
59
+ */
60
+ export declare const orbitTo: ((azimuth: number, duration: Duration.Input, timing?: Timing.TimingInput) => (cam: CamOrEffect) => CamEffect) & ((cam: CamOrEffect, azimuth: number, duration: Duration.Input, timing?: Timing.TimingInput) => CamEffect);
61
+ /** Like `orbitTo`, but from an explicit start azimuth. */
62
+ export declare const orbit: ((from: number, to: number, duration: Duration.Input, timing?: Timing.TimingInput) => (cam: CamOrEffect) => CamEffect) & ((cam: CamOrEffect, from: number, to: number, duration: Duration.Input, timing?: Timing.TimingInput) => CamEffect);
63
+ /**
64
+ * Move the camera along its view axis to an absolute distance from the
65
+ * point of interest (in: toward it, out: away), aim unchanged. Dies
66
+ * loudly without a POI. Dual like `moveTo`.
67
+ */
68
+ export declare const dollyTo: ((distance: number, duration: Duration.Input, timing?: Timing.TimingInput) => (cam: CamOrEffect) => CamEffect) & ((cam: CamOrEffect, distance: number, duration: Duration.Input, timing?: Timing.TimingInput) => CamEffect);
69
+ /** Like `dollyTo`, but from an explicit start distance. */
70
+ export declare const dolly: ((from: number, to: number, duration: Duration.Input, timing?: Timing.TimingInput) => (cam: CamOrEffect) => CamEffect) & ((cam: CamOrEffect, from: number, to: number, duration: Duration.Input, timing?: Timing.TimingInput) => CamEffect);
@@ -0,0 +1,239 @@
1
+ import * as Duration from "effect/Duration";
2
+ import * as Effect from "effect/Effect";
3
+ import { dual } from "effect/Function";
4
+ import * as Entity from "./Entity.js";
5
+ import * as Instance from "./Instance.js";
6
+ import * as Motion from "./Motion.js";
7
+ import * as Runner from "./Runner.js";
8
+ import * as Scene from "./Scene.js";
9
+ import * as Time from "./Time.js";
10
+ import * as Timing from "./Timing.js";
11
+ /**
12
+ * The public Camera surface: the entity/identity from Camera.js plus the
13
+ * directing helpers. This module (not Camera.ts) is what index exports as
14
+ * the `Camera` namespace — Runner/Renderer import the schema file
15
+ * directly, so helpers can depend on Motion/Scene without an import
16
+ * cycle.
17
+ *
18
+ * Naming rule (recorded in the camera-poi-helpers change): verbs that
19
+ * name their target (`lookAt`, `follow`) have no base/To pair — an
20
+ * optional duration selects instant vs eased. Value-animating helpers
21
+ * (`orbit`/`orbitTo`, `dolly`/`dollyTo`) keep the pair, exactly like
22
+ * `move`/`moveTo`.
23
+ */
24
+ // explicit named re-exports (not `export *`) so the public Camera surface
25
+ // is enumerable at a glance: the entity + identity from the schema module,
26
+ // plus the helpers below
27
+ export { Camera, identity } from "./Camera.js";
28
+ // a target argument (vs a duration/timing in the same slot): instances,
29
+ // effects, or a position-like object — Durations are objects too, so
30
+ // exclude them explicitly
31
+ const isTargetArg = (v) => Instance.isInstance(v) ||
32
+ Effect.isEffect(v) ||
33
+ (typeof v === "object" &&
34
+ v !== null &&
35
+ !Duration.isDuration(v) &&
36
+ ("x" in v || "y" in v || "z" in v));
37
+ // data-first iff the first arg is an instance AND the second is a target —
38
+ // plain firstArgIsInstance would misread `cam.pipe`-less pipeable calls
39
+ // whose TARGET is an instance (`lookAt(hero, "1 second")`)
40
+ const dataFirst = (args) => Instance.isInstance(args[0]) && isTargetArg(args[1]);
41
+ /**
42
+ * Resolve a target once (Effects yield their Instance), returning a
43
+ * per-frame position reader with the offset folded in: live for
44
+ * instances, fixed for plain positions.
45
+ */
46
+ const targetReader = Effect.fnUntraced(function* (target, offset) {
47
+ const ox = offset?.x ?? 0;
48
+ const oy = offset?.y ?? 0;
49
+ const oz = offset?.z ?? 0;
50
+ if (Instance.isInstance(target) || Effect.isEffect(target)) {
51
+ const instance = yield* Instance.flatten(target);
52
+ const lens = Entity.traitOrDie(instance.entity, "~position");
53
+ return Scene.data(instance).pipe(Effect.map((data) => {
54
+ const p = lens.get(data);
55
+ return { x: p.x + ox, y: p.y + oy, z: p.z + oz };
56
+ }));
57
+ }
58
+ // AnyInstance's `any` params defeat narrowing — the guards above
59
+ // returned for instances/effects, so this is a plain position
60
+ const point = target;
61
+ const fixed = {
62
+ x: (point.x ?? 0) + ox,
63
+ y: (point.y ?? 0) + oy,
64
+ z: (point.z ?? 0) + oz,
65
+ };
66
+ return Effect.succeed(fixed);
67
+ });
68
+ const setPoi = (data, p) => Object.assign({}, data, { poiX: p.x, poiY: p.y, poiZ: p.z });
69
+ // the camera's WORLD position: x/y are pan-from-viewport-center
70
+ const worldPosition = Effect.fnUntraced(function* (cam) {
71
+ const { settings } = yield* Runner.Runner;
72
+ const data = (yield* Scene.data(cam));
73
+ return {
74
+ x: settings.width / 2 + data.x,
75
+ y: settings.height / 2 + data.y,
76
+ z: data.z ?? 0,
77
+ };
78
+ });
79
+ const lookAtImpl = Effect.fnUntraced(function* (camOrEffect, target, duration, timing, offset) {
80
+ const cam = yield* Instance.flatten(camOrEffect);
81
+ const read = yield* targetReader(target, offset);
82
+ if (duration === undefined) {
83
+ const p = yield* read;
84
+ yield* Scene.update(cam, (d) => setPoi(d, p));
85
+ return cam;
86
+ }
87
+ // eased re-aim: a RETARGETED tween — each frame interpolates from the
88
+ // start POI toward the target's CURRENT position, converging exactly
89
+ // onto a moving target at t = 1 (a plain-Position target degenerates to
90
+ // a fixed tween). Effectful per-frame read, so this runs its own loop
91
+ // rather than Motion.drive (whose callback is pure).
92
+ const runner = yield* Runner.Runner;
93
+ const timingFn = Timing.resolve(timing ?? "linear");
94
+ const data = (yield* Scene.data(cam));
95
+ let start;
96
+ if (data.poiX !== undefined &&
97
+ data.poiY !== undefined &&
98
+ data.poiZ !== undefined) {
99
+ start = { x: data.poiX, y: data.poiY, z: data.poiZ };
100
+ }
101
+ else {
102
+ // no POI yet: seed on the camera's UNAIMED axis (straight down world
103
+ // -z) at the target's distance. resolveCamera derives zero aim for
104
+ // that point, so the explicit Euler alone carries the view — engaging
105
+ // POI mode is snap-free for ANY current orientation, and the tween
106
+ // takes over from there.
107
+ const world = yield* worldPosition(cam);
108
+ const first = yield* read;
109
+ const distance = Math.hypot(first.x - world.x, first.y - world.y, first.z - world.z);
110
+ start = { x: world.x, y: world.y, z: world.z - distance };
111
+ }
112
+ const frames = Math.max(1, Time.toFrames(duration, runner.settings.frameRate));
113
+ for (let i = 1; i <= frames; i++) {
114
+ const t = timingFn(i / frames);
115
+ const p = yield* read;
116
+ yield* Scene.update(cam, (d) => setPoi(d, {
117
+ x: start.x + (p.x - start.x) * t,
118
+ y: start.y + (p.y - start.y) * t,
119
+ z: start.z + (p.z - start.z) * t,
120
+ }));
121
+ yield* Scene.tick;
122
+ }
123
+ return cam;
124
+ });
125
+ /**
126
+ * Aim the camera at a target. No duration: set the point of interest this
127
+ * frame. With a duration: eased re-aim as a retargeted tween (lands
128
+ * exactly on a moving target, no terminal snap). `offset` shifts the aim
129
+ * relative to the target ("slightly above their head"). Dual:
130
+ * `lookAt(cam, target, ...)` or `cam.pipe(lookAt(target, ...))`.
131
+ */
132
+ export const lookAt = dual(dataFirst, lookAtImpl);
133
+ /**
134
+ * Track a target for the duration: the point of interest is a hard
135
+ * per-frame copy of the target position (+offset). A plain animator — it
136
+ * pipes, `Scene.all`s, staggers, repeats. No timing input (lag is
137
+ * expressed by springing the POI instead). Ordering practice: within a
138
+ * tick, branches run in fork order — a follow forked before its target's
139
+ * animator reads the previous frame's position, a deterministic one-frame
140
+ * trail. Dual like `lookAt`.
141
+ */
142
+ export const follow = dual(dataFirst, Effect.fnUntraced(function* (camOrEffect, target, duration, offset) {
143
+ const cam = yield* Instance.flatten(camOrEffect);
144
+ const read = yield* targetReader(target, offset);
145
+ const runner = yield* Runner.Runner;
146
+ const frames = Math.max(1, Time.toFrames(duration, runner.settings.frameRate));
147
+ for (let i = 1; i <= frames; i++) {
148
+ const p = yield* read;
149
+ yield* Scene.update(cam, (d) => setPoi(d, p));
150
+ yield* Scene.tick;
151
+ }
152
+ return cam;
153
+ }));
154
+ // orbit/dolly are defined relative to the POI — die loudly without one
155
+ const poiOrDie = (data) => {
156
+ if (data.poiX === undefined ||
157
+ data.poiY === undefined ||
158
+ data.poiZ === undefined) {
159
+ throw new Error("Camera: orbit/dolly need a point of interest — set one first (Camera.lookAt(target))");
160
+ }
161
+ return { x: data.poiX, y: data.poiY, z: data.poiZ };
162
+ };
163
+ const orbitImpl = Effect.fnUntraced(function* (camOrEffect, from, to, duration, timing) {
164
+ const cam = yield* Instance.flatten(camOrEffect);
165
+ const { settings } = yield* Runner.Runner;
166
+ const origin = { x: settings.width / 2, y: settings.height / 2 };
167
+ const startData = (yield* Scene.data(cam));
168
+ const poi = poiOrDie(startData);
169
+ const world = {
170
+ x: origin.x + startData.x,
171
+ z: startData.z ?? 0,
172
+ };
173
+ // azimuth 0 = directly +z of the POI (the resting side); radius = the
174
+ // current horizontal distance, preserved through the arc; height too
175
+ const radius = Math.hypot(world.x - poi.x, world.z - poi.z);
176
+ const startAzimuth = from ?? Math.atan2(world.x - poi.x, world.z - poi.z);
177
+ return yield* Motion.drive(cam, duration, timing ?? "linear", (t, d) => {
178
+ const data = d;
179
+ // POI read from live data: orbiting a moving POI stays centered on it
180
+ const p = poiOrDie(data);
181
+ const angle = startAzimuth + (to - startAzimuth) * t;
182
+ return Object.assign({}, d, {
183
+ x: p.x + radius * Math.sin(angle) - origin.x,
184
+ z: p.z + radius * Math.cos(angle),
185
+ });
186
+ });
187
+ });
188
+ /**
189
+ * Turntable the camera to an absolute azimuth around its point of
190
+ * interest (angle about the world-Y axis through the POI; 0 = directly +z
191
+ * of it). Position travels the arc — orientation comes entirely from the
192
+ * POI, so there is no orientation math to get wrong. Radius and height
193
+ * are preserved. Dies loudly without a POI. Dual like `moveTo`.
194
+ */
195
+ export const orbitTo = dual((args) => Instance.isInstance(args[0]), ((cam, azimuth, duration, timing) => orbitImpl(cam, undefined, azimuth, duration, timing)));
196
+ /** Like `orbitTo`, but from an explicit start azimuth. */
197
+ export const orbit = dual((args) => Instance.isInstance(args[0]), orbitImpl);
198
+ const dollyImpl = Effect.fnUntraced(function* (camOrEffect, from, to, duration, timing) {
199
+ const cam = yield* Instance.flatten(camOrEffect);
200
+ const { settings } = yield* Runner.Runner;
201
+ const origin = { x: settings.width / 2, y: settings.height / 2 };
202
+ const startData = (yield* Scene.data(cam));
203
+ const poi = poiOrDie(startData);
204
+ const world = {
205
+ x: origin.x + startData.x,
206
+ y: origin.y + startData.y,
207
+ z: startData.z ?? 0,
208
+ };
209
+ const startDistance = Math.hypot(world.x - poi.x, world.y - poi.y, world.z - poi.z);
210
+ // the fixed unit direction POI → camera; a zero distance has no
211
+ // direction to dolly along
212
+ if (startDistance === 0) {
213
+ throw new Error("Camera: dolly from distance 0 — the camera sits ON its point of interest, so there is no view axis to move along");
214
+ }
215
+ const u = {
216
+ x: (world.x - poi.x) / startDistance,
217
+ y: (world.y - poi.y) / startDistance,
218
+ z: (world.z - poi.z) / startDistance,
219
+ };
220
+ const d0 = from ?? startDistance;
221
+ return yield* Motion.drive(cam, duration, timing ?? "linear", (t, d) => {
222
+ const data = d;
223
+ const p = poiOrDie(data);
224
+ const dist = d0 + (to - d0) * t;
225
+ return Object.assign({}, d, {
226
+ x: p.x + u.x * dist - origin.x,
227
+ y: p.y + u.y * dist - origin.y,
228
+ z: p.z + u.z * dist,
229
+ });
230
+ });
231
+ });
232
+ /**
233
+ * Move the camera along its view axis to an absolute distance from the
234
+ * point of interest (in: toward it, out: away), aim unchanged. Dies
235
+ * loudly without a POI. Dual like `moveTo`.
236
+ */
237
+ export const dollyTo = dual((args) => Instance.isInstance(args[0]), ((cam, distance, duration, timing) => dollyImpl(cam, undefined, distance, duration, timing)));
238
+ /** Like `dollyTo`, but from an explicit start distance. */
239
+ export const dolly = dual((args) => Instance.isInstance(args[0]), dollyImpl);
@@ -0,0 +1,12 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { EffectMotionError } from "./EffectMotionError.js";
3
+ import type * as Renderer from "./Renderer.js";
4
+ /**
5
+ * Blit a framebuffer onto a DOM canvas. The canvas buffer takes the
6
+ * framebuffer's PHYSICAL size (logical × dpr); CSS display size is left to
7
+ * the caller — the intrinsic aspect ratio is preserved by the attributes, so
8
+ * responsive styling (`width: 100%`) or a fixed `fb.logicalWidth`px both
9
+ * work. Writing CSS here would clobber caller styling (e.g. React inline
10
+ * styles are not re-applied on rerender).
11
+ */
12
+ export declare const toCanvas: (fb: Renderer.Framebuffer, target: "string" | HTMLCanvasElement) => Effect.Effect<undefined, EffectMotionError, never>;
@@ -0,0 +1,40 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { EffectMotionError } from "./EffectMotionError.js";
3
+ const resolveCanvas = Effect.fnUntraced(function* (target) {
4
+ if (typeof target === "string") {
5
+ if (typeof document === "undefined") {
6
+ return yield* Effect.fail(EffectMotionError.of("'document' is not available in the current environment"));
7
+ }
8
+ const maybeCanvas = document.querySelector(target);
9
+ if (maybeCanvas === null) {
10
+ return yield* Effect.fail(EffectMotionError.of(`Could not find canvas with selector '${target}'`));
11
+ }
12
+ if (!(maybeCanvas instanceof HTMLCanvasElement)) {
13
+ return yield* Effect.fail(EffectMotionError.of(`Could not find canvas with selector '${target}'`));
14
+ }
15
+ return maybeCanvas;
16
+ }
17
+ if (!(target instanceof HTMLCanvasElement)) {
18
+ return yield* Effect.fail(EffectMotionError.of(`'target' is not a HTMLCanvasElement`));
19
+ }
20
+ return target;
21
+ });
22
+ /**
23
+ * Blit a framebuffer onto a DOM canvas. The canvas buffer takes the
24
+ * framebuffer's PHYSICAL size (logical × dpr); CSS display size is left to
25
+ * the caller — the intrinsic aspect ratio is preserved by the attributes, so
26
+ * responsive styling (`width: 100%`) or a fixed `fb.logicalWidth`px both
27
+ * work. Writing CSS here would clobber caller styling (e.g. React inline
28
+ * styles are not re-applied on rerender).
29
+ */
30
+ export const toCanvas = Effect.fnUntraced(function* (fb, target) {
31
+ const canvas = yield* resolveCanvas(target);
32
+ canvas.width = fb.width;
33
+ canvas.height = fb.height;
34
+ const ctx = canvas.getContext("2d");
35
+ if (ctx === null) {
36
+ return yield* Effect.fail(EffectMotionError.of("Could not get a 2D context from the canvas"));
37
+ }
38
+ const clamped = Uint8ClampedArray.from(fb.rgba);
39
+ ctx.putImageData(new ImageData(clamped, fb.width, fb.height), 0, 0);
40
+ });