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
package/dist/Entity.js CHANGED
@@ -1,29 +1,273 @@
1
+ import * as Effect from "effect/Effect";
1
2
  import * as Schema from "effect/Schema";
2
- export const TypeId = "~motion/Entity";
3
- /** the entity's trait lens, or a defect naming entity and trait */
4
- export const traitOrDie = (entity, key) => {
5
- const lens = entity.traits[key];
6
- if (lens === undefined) {
7
- throw new Error(`Entity "${entity.name}" does not implement the "${key}" trait`);
8
- }
9
- return lens;
10
- };
11
- const normalizeStructLike = (data) => {
12
- return (Schema.isSchema(data) ? data : Schema.Struct(data));
13
- };
14
- export const make = (name, data, traits) => {
15
- const normalized = normalizeStructLike(data);
16
- // `$` is reserved for builtin, engine-owned instance properties (e.g.
17
- // `$visible`), which live beside the data — never as entity-data fields.
18
- for (const field of Object.keys(normalized.fields)) {
19
- if (field.startsWith("$")) {
20
- throw new Error(`Entity "${name}": field "${field}" uses the reserved "$" prefix (reserved for builtin instance properties like $visible)`);
21
- }
22
- }
23
- return {
24
- [TypeId]: TypeId,
25
- name,
26
- data: normalized,
27
- traits: traits ?? {},
28
- };
3
+ import * as Color from "./Color.js";
4
+ import * as Font from "./Font.js";
5
+ import * as ImageResource from "./Image.js";
6
+ /**
7
+ * The closed entity world.
8
+ *
9
+ * Every entity the library knows is a member of one tagged union, declared
10
+ * here. There is deliberately no way to define an entity outside this module:
11
+ * an entity's complete field set is derivable from its `_tag` alone, which
12
+ * is what lets the runner, the animators, and the renderer narrow instead
13
+ * of casting.
14
+ *
15
+ * Shared fields come from mixins, never from per-entity declarations, so no
16
+ * entity can quietly omit or rename one. An `Instance` is a REFERENCE to a
17
+ * live entity in the runner tree — see `Instance.ts`.
18
+ */
19
+ /**
20
+ * A point or offset in 3D space.
21
+ *
22
+ * @remarks
23
+ * Scene coordinates with depth: `x` right, `y` UP (the mathematical
24
+ * convention), `z` toward the viewer, origin at the viewport center — a
25
+ * right-handed frame. Every axis defaults to 0, so `vec3({ x: 100 })` is a
26
+ * valid horizontal offset.
27
+ *
28
+ * At `z = 0` content renders exactly as flat 2D. Non-zero `z` is only
29
+ * meaningful under a perspective camera, where it changes apparent size and
30
+ * parallax.
31
+ */
32
+ export const Vec3 = Schema.TaggedStruct("Vec3", {
33
+ x: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0))),
34
+ y: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0))),
35
+ z: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0))),
36
+ });
37
+ /**
38
+ * Build a {@link Vec3}; omitted axes are 0.
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * Entity.vec3({ x: 100, y: 50 })
43
+ * ```
44
+ */
45
+ export const vec3 = Vec3.make;
46
+ const vec3Default = (x, y, z) => Vec3.pipe(Schema.withConstructorDefault(Effect.sync(() => Vec3.make({ x, y, z }))));
47
+ const defaultedNumber = (value) => Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(value)));
48
+ /**
49
+ * Position and orientation. Carried by EVERY entity, the camera included:
50
+ * a viewpoint has a location and an orientation like anything else.
51
+ */
52
+ const transformMixin = {
53
+ position: vec3Default(0, 0, 0),
54
+ rotation: vec3Default(0, 0, 0),
29
55
  };
56
+ /**
57
+ * Presentation state for things that PAINT. The camera is the one entity
58
+ * that does not (it is view state, omitted from the frame's instance map),
59
+ * so it takes the transform mixin and none of this.
60
+ */
61
+ const appearanceMixin = {
62
+ scale: vec3Default(1, 1, 1),
63
+ opacity: defaultedNumber(1),
64
+ visible: Schema.Boolean.pipe(Schema.withConstructorDefault(Effect.succeed(true))),
65
+ };
66
+ /** transform + appearance: the base every paintable entity shares */
67
+ const paintableMixin = {
68
+ ...transformMixin,
69
+ ...appearanceMixin,
70
+ };
71
+ const strokeMixin = {
72
+ strokeWidth: defaultedNumber(1),
73
+ strokeColor: Color.Color.pipe(Schema.withConstructorDefault(Effect.succeed(Color.black))),
74
+ };
75
+ const fillMixin = {
76
+ fillColor: Color.Color.pipe(Schema.withConstructorDefault(Effect.succeed(Color.white))),
77
+ };
78
+ /**
79
+ * Children are stored as instance ids, in paint order. The AUTHORING input
80
+ * is more permissive (a string becomes a Text, an Instance contributes its
81
+ * id, an un-yielded `instantiate` effect is resolved) — the runner
82
+ * normalizes it here, so stored data stays plain ids.
83
+ */
84
+ const childrenMixin = {
85
+ children: Schema.Array(Schema.String).pipe(Schema.withConstructorDefault(Effect.sync(() => []))),
86
+ };
87
+ /**
88
+ * A path command point, relative to the Path's own `position` — the same
89
+ * relative rule Line's endpoints follow, so translating a Path is rigid
90
+ * without rewriting the command array.
91
+ */
92
+ const point = {
93
+ x: Schema.Number,
94
+ y: Schema.Number,
95
+ z: Schema.optionalKey(Schema.Number),
96
+ };
97
+ export const PathCommand = Schema.TaggedUnion({
98
+ M: point,
99
+ L: point,
100
+ Z: {},
101
+ });
102
+ // ── the entities ─────────────────────────────────────────────────────────
103
+ /**
104
+ * A segment. `start`/`end` are offsets from `position`, so moving the line
105
+ * translates it rigidly — no endpoint compensation anywhere in the system.
106
+ * Skeletal: parametrized by its endpoints, never by an anchor plus size.
107
+ */
108
+ export const Line = Schema.TaggedStruct("Line", {
109
+ ...paintableMixin,
110
+ ...strokeMixin,
111
+ start: vec3Default(0, 0, 0),
112
+ end: vec3Default(0, 0, 0),
113
+ });
114
+ /**
115
+ * A polyline/polygon. Command points are relative to `position` (see
116
+ * `point`). The first command must be `M` — an open vocabulary of M/L/Z;
117
+ * curves and arcs arrive later via deterministic flattening.
118
+ */
119
+ export const Path = Schema.TaggedStruct("Path", {
120
+ ...paintableMixin,
121
+ ...fillMixin,
122
+ ...strokeMixin,
123
+ commands: Schema.NonEmptyArray(PathCommand).check(Schema.makeFilter((commands) => commands[0]._tag === "M"
124
+ ? undefined
125
+ : { path: [0], issue: "the first path command must be M" })),
126
+ });
127
+ /**
128
+ * The canonical 2.5D plane. All-zero rotation is a camera-facing billboard;
129
+ * non-zero tilts it as a real plane in 3D.
130
+ */
131
+ export const Rect = Schema.TaggedStruct("Rect", {
132
+ ...paintableMixin,
133
+ ...fillMixin,
134
+ ...strokeMixin,
135
+ width: defaultedNumber(100),
136
+ height: defaultedNumber(100),
137
+ });
138
+ /** A filled circle of a given `radius`, centered on its `position`. */
139
+ export const Circle = Schema.TaggedStruct("Circle", {
140
+ ...paintableMixin,
141
+ ...fillMixin,
142
+ ...strokeMixin,
143
+ radius: defaultedNumber(10),
144
+ });
145
+ /** An ellipse with independent `radiusX` and `radiusY`. */
146
+ export const Ellipse = Schema.TaggedStruct("Ellipse", {
147
+ ...paintableMixin,
148
+ ...fillMixin,
149
+ ...strokeMixin,
150
+ radiusX: defaultedNumber(20),
151
+ radiusY: defaultedNumber(10),
152
+ });
153
+ /**
154
+ * A plain-string text leaf. The engine cannot measure text, so `text` has
155
+ * no dimensional fields — layout is the renderer's business. Inline
156
+ * formatting is expressed by composing several Texts, not by a rich tree.
157
+ */
158
+ export const Text = Schema.TaggedStruct("Text", {
159
+ ...paintableMixin,
160
+ ...fillMixin,
161
+ text: Schema.String,
162
+ // numeric, therefore tweenable
163
+ fontSize: defaultedNumber(16),
164
+ // a Font resource reference ({_tag, id}), never a bare string. Defaults
165
+ // to the built-in font, so bare Text stays zero-ceremony and the default
166
+ // never enters the scene's loader requirements.
167
+ fontFamily: Font.schema.pipe(Schema.withConstructorDefault(Effect.sync(() => Font.schema.make({ id: Font.defaultFont.id })))),
168
+ textAnchor: Schema.optionalKey(Schema.Literals(["start", "middle", "end"])),
169
+ baseline: Schema.optionalKey(Schema.Literals(["auto", "middle", "hanging"])),
170
+ });
171
+ /**
172
+ * A container: positions and structures its children, paints nothing
173
+ * itself. Its transform composes down the subtree like any entity's.
174
+ *
175
+ * It carries no composition bounds. `width`/`height`/`backgroundColor` used
176
+ * to live here so `Scene.play` could copy a nested scene's dimensions onto
177
+ * the mount group; a Scene owns those itself, and a render-to-texture
178
+ * boundary is `Scene.play`'s business, not a field on every group.
179
+ */
180
+ export const Group = Schema.TaggedStruct("Group", {
181
+ ...paintableMixin,
182
+ ...childrenMixin,
183
+ });
184
+ /**
185
+ * A screen-space container: its subtree is projected through the identity
186
+ * camera rather than the active one, so HUD content ignores camera
187
+ * movement, zoom, and shake, and always paints on top.
188
+ *
189
+ * `position.z` is depth WITHIN the HUD tier — z consistently means depth in
190
+ * the entity's own coordinate space, world for world content and screen for
191
+ * HUD content. A Hud must be a top-level child of the root (or of another
192
+ * Hud); nesting one inside world content is a loud defect.
193
+ */
194
+ export const Hud = Schema.TaggedStruct("Hud", {
195
+ ...paintableMixin,
196
+ ...childrenMixin,
197
+ });
198
+ /**
199
+ * A raster/vector image leaf. `image` is an Image resource reference; the
200
+ * bytes live in the scene's requirements, never in frame data.
201
+ *
202
+ * `width`/`height` are optional and undefaulted: set BOTH to draw at that
203
+ * size (numeric, so they tween), leave both absent for the source's natural
204
+ * size. A lone dimension is ignored — aspect math needs the natural size,
205
+ * which frame data never sees.
206
+ */
207
+ export const Image = Schema.TaggedStruct("Image", {
208
+ ...paintableMixin,
209
+ image: ImageResource.schema,
210
+ width: Schema.optionalKey(Schema.Number),
211
+ height: Schema.optionalKey(Schema.Number),
212
+ });
213
+ /**
214
+ * The viewpoint — the one entity that is never drawn.
215
+ *
216
+ * @remarks
217
+ * A camera is view state rather than content, so it carries `position` and
218
+ * `rotation` but no `scale`, `opacity`, or `visible`, and is absent from the
219
+ * frame's instance map. Every scene has one already; reach for it with
220
+ * `Scene.camera`.
221
+ *
222
+ * `focalLength` sets the perspective strength — a longer lens flattens
223
+ * depth, a shorter one exaggerates it. It and the resting `z` are
224
+ * width-relative and filled in by the runner at instantiate time (only the
225
+ * runner knows the composition width), so the zero defaults here are
226
+ * placeholders that are always overwritten. The resting values are chosen so
227
+ * content at `z = 0` renders exactly as flat 2D.
228
+ *
229
+ * `poi` is an optional point of interest: when set, the camera auto-aims at
230
+ * that world point and any explicit `rotation` composes on top of the aim.
231
+ * The `Camera` module's helpers are the ergonomic way to drive it.
232
+ *
233
+ * `aperture` and `focusDistance` describe depth of field. They are carried
234
+ * on the frame but currently have NO visual effect — depth-of-field
235
+ * rendering is not implemented, and every frame renders sharp.
236
+ */
237
+ export const Camera = Schema.TaggedStruct("Camera", {
238
+ ...transformMixin,
239
+ focalLength: defaultedNumber(0),
240
+ focusDistance: defaultedNumber(0),
241
+ aperture: defaultedNumber(0),
242
+ poi: Vec3.pipe(Schema.NullOr, Schema.withConstructorDefault(Effect.succeed(null))),
243
+ });
244
+ // ── the union ────────────────────────────────────────────────────────────
245
+ export const EntityMap = {
246
+ Line,
247
+ Path,
248
+ Rect,
249
+ Circle,
250
+ Ellipse,
251
+ Text,
252
+ Group,
253
+ Hud,
254
+ Image,
255
+ Camera,
256
+ };
257
+ export const getEntityDefinitionByTag = (tag) => EntityMap[tag];
258
+ /**
259
+ * Every container tag, exhaustively. `Record<ContainerTag, true>` (not
260
+ * `satisfies Array<ContainerTag>`) is deliberate: an array only checks its
261
+ * members are valid tags, so a newly-added container would silently miss
262
+ * this set. A Record demands every key, so omitting one fails the build.
263
+ */
264
+ const containerTags = {
265
+ Group: true,
266
+ Hud: true,
267
+ };
268
+ /**
269
+ * Whether an entity can hold children. Previously this was "does its data
270
+ * have a `children` field", answerable only at runtime because entity shapes
271
+ * were unknowable; now it is a property of the tag.
272
+ */
273
+ export const isContainer = (entity) => entity._tag in containerTags;
package/dist/Font.d.ts ADDED
@@ -0,0 +1,108 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as Schema from "effect/Schema";
5
+ import * as Resource from "./Resource.js";
6
+ import type { EnsureLiteral } from "./types.js";
7
+ export declare const tag: "effect-motion/Resources/Font";
8
+ /**
9
+ * A font reference as entity data stores it: `{ _tag, id }`. The id is the
10
+ * family name text paints register and select by. Obtain one by yielding a
11
+ * {@link Font} constant inside a scene — that is what puts the matching
12
+ * `FontLoader<ID>` into the scene's requirements.
13
+ */
14
+ export interface Font<ID extends string = string> {
15
+ readonly _tag: typeof tag;
16
+ readonly id: ID;
17
+ }
18
+ /** The stored-data schema for font references (see `Text.fontFamily`). */
19
+ export declare const schema: Schema.TaggedStruct<"effect-motion/Resources/Font", {
20
+ readonly id: Schema.String;
21
+ }>;
22
+ /**
23
+ * A loaded font, provided as a context service: bytes are already in memory
24
+ * by the time any consumer reads this (loads run eagerly at layer
25
+ * construction — see {@link layer}). `format` is optional; consumers sniff
26
+ * magic bytes when absent.
27
+ */
28
+ export interface FontLoader<ID extends string = string> extends Resource.LoaderBrand {
29
+ readonly id: ID;
30
+ readonly bytes: Uint8Array;
31
+ readonly format?: "ttf" | "otf";
32
+ }
33
+ /**
34
+ * The context key for a font's loader, derived from the id string alone —
35
+ * the bridge over literal erasure: frame data carries only `{ id: string }`,
36
+ * and rebuilding the key from that string resolves the same service the
37
+ * authored constant provides.
38
+ */
39
+ export declare const Loader: <ID extends string>(id: ID) => Context.Service<FontLoader<ID>, FontLoader<ID>>;
40
+ /**
41
+ * One font constant, two faces:
42
+ *
43
+ * - **author side**: `yield*` it in a scene to get the {@link Font} value for
44
+ * `fontFamily`, adding `FontLoader<ID>` to the scene's requirements. The
45
+ * requirement is PHANTOM — the effect succeeds without touching context,
46
+ * so `Scene.run`/`stream` stay loader-free and only the render path
47
+ * (the first actual consumer of bytes) demands the loader.
48
+ * - **provider side**: `.Loader` is the context key; pair it with
49
+ * {@link layer} to provide bytes.
50
+ */
51
+ export interface FontResource<ID extends string = string> extends Effect.Effect<Font<ID>, never, FontLoader<ID>> {
52
+ readonly id: ID;
53
+ readonly Loader: Context.Service<FontLoader<ID>, FontLoader<ID>>;
54
+ }
55
+ /**
56
+ * Declare a font your scene uses.
57
+ *
58
+ * @remarks
59
+ * Call it once at module scope to get a constant with two faces: `yield*` it
60
+ * inside a scene to obtain the value for a Text's `fontFamily`, and pair it
61
+ * with {@link layer} to supply the actual bytes when rendering.
62
+ *
63
+ * Yielding the constant is what records the font in the scene's
64
+ * requirements, so a scene that uses a font it was never given fails to
65
+ * typecheck instead of rendering in the wrong face.
66
+ *
67
+ * The id must be a literal string — that literal is what ties the
68
+ * requirement to the provider.
69
+ *
70
+ * @param id - The family name, as a literal string.
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * const Inter = Font.Font("Inter");
75
+ *
76
+ * const scene = Scene.make(function* () {
77
+ * const inter = yield* Inter;
78
+ * yield* Scene.instantiate("Text", { text: "hello", fontFamily: inter });
79
+ * });
80
+ * ```
81
+ */
82
+ export declare const Font: <const ID extends string>(id: ID & EnsureLiteral<ID, "Font id must be a literal string">) => FontResource<ID>;
83
+ /**
84
+ * Provide a font's bytes. The load effect runs ONCE, at layer construction
85
+ * (runtime build) — never at frame time; compose retries/timeouts on the
86
+ * load effect itself. Every provided font loads regardless of whether the
87
+ * scene ends up using it (preload-all-provided policy).
88
+ */
89
+ export declare const layer: <ID extends string, E, R>(font: FontResource<ID>, load: Effect.Effect<Uint8Array, E, R>, options?: {
90
+ readonly format?: "ttf" | "otf";
91
+ }) => Layer.Layer<FontLoader<ID>, E, R>;
92
+ /**
93
+ * The built-in default font under the RESERVED id `"sans-serif"` — the
94
+ * `Text.fontFamily` constructor default. It never appears in a scene's
95
+ * requirements (no `yield*` happens for a schema default); the render path
96
+ * auto-provides its loader beneath caller context, so providing your own
97
+ * loader under the `"sans-serif"` id overrides the built-in bytes.
98
+ */
99
+ export declare const defaultFont: FontResource<"sans-serif">;
100
+ export { defaultFont as default };
101
+ /**
102
+ * A CORS-open, static-weight TrueType mapped to the default family. Lived
103
+ * on the render engine before this refactor; now the render path fetches it
104
+ * (module-cached) only when a frame actually uses the default font and no
105
+ * caller-provided `"sans-serif"` loader is in context.
106
+ */
107
+ export declare const DEFAULT_FONT_URL = "https://cdn.jsdelivr.net/npm/@expo-google-fonts/inter@0.2.3/Inter_400Regular.ttf";
108
+ export declare const loadDefaultBytes: Effect.Effect<Uint8Array>;
package/dist/Font.js ADDED
@@ -0,0 +1,95 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as Schema from "effect/Schema";
5
+ import * as Resource from "./Resource.js";
6
+ export const tag = "effect-motion/Resources/Font";
7
+ /** The stored-data schema for font references (see `Text.fontFamily`). */
8
+ export const schema = Schema.TaggedStruct(tag, {
9
+ id: Schema.String,
10
+ });
11
+ const loaderKeyPrefix = "effect-motion/Resources/FontLoader/";
12
+ /**
13
+ * The context key for a font's loader, derived from the id string alone —
14
+ * the bridge over literal erasure: frame data carries only `{ id: string }`,
15
+ * and rebuilding the key from that string resolves the same service the
16
+ * authored constant provides.
17
+ */
18
+ export const Loader = (id) => Context.Service(`${loaderKeyPrefix}${id}`);
19
+ /**
20
+ * Declare a font your scene uses.
21
+ *
22
+ * @remarks
23
+ * Call it once at module scope to get a constant with two faces: `yield*` it
24
+ * inside a scene to obtain the value for a Text's `fontFamily`, and pair it
25
+ * with {@link layer} to supply the actual bytes when rendering.
26
+ *
27
+ * Yielding the constant is what records the font in the scene's
28
+ * requirements, so a scene that uses a font it was never given fails to
29
+ * typecheck instead of rendering in the wrong face.
30
+ *
31
+ * The id must be a literal string — that literal is what ties the
32
+ * requirement to the provider.
33
+ *
34
+ * @param id - The family name, as a literal string.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * const Inter = Font.Font("Inter");
39
+ *
40
+ * const scene = Scene.make(function* () {
41
+ * const inter = yield* Inter;
42
+ * yield* Scene.instantiate("Text", { text: "hello", fontFamily: inter });
43
+ * });
44
+ * ```
45
+ */
46
+ export const Font = (id) => {
47
+ const value = { _tag: tag, id };
48
+ // R is covariant, so the loader-free succeed widens to the phantom
49
+ // FontLoader<ID> requirement without a cast
50
+ return Object.assign(Effect.succeed(value), {
51
+ id: id,
52
+ Loader: Loader(id),
53
+ });
54
+ };
55
+ /**
56
+ * Provide a font's bytes. The load effect runs ONCE, at layer construction
57
+ * (runtime build) — never at frame time; compose retries/timeouts on the
58
+ * load effect itself. Every provided font loads regardless of whether the
59
+ * scene ends up using it (preload-all-provided policy).
60
+ */
61
+ export const layer = (font, load, options) => Layer.effect(font.Loader, load.pipe(Effect.map((bytes) => ({
62
+ [Resource.LoaderTypeId]: Resource.LoaderTypeId,
63
+ id: font.id,
64
+ bytes,
65
+ ...(options?.format !== undefined ? { format: options.format } : {}),
66
+ }))));
67
+ /**
68
+ * The built-in default font under the RESERVED id `"sans-serif"` — the
69
+ * `Text.fontFamily` constructor default. It never appears in a scene's
70
+ * requirements (no `yield*` happens for a schema default); the render path
71
+ * auto-provides its loader beneath caller context, so providing your own
72
+ * loader under the `"sans-serif"` id overrides the built-in bytes.
73
+ */
74
+ export const defaultFont = Font("sans-serif");
75
+ export { defaultFont as default };
76
+ /**
77
+ * A CORS-open, static-weight TrueType mapped to the default family. Lived
78
+ * on the render engine before this refactor; now the render path fetches it
79
+ * (module-cached) only when a frame actually uses the default font and no
80
+ * caller-provided `"sans-serif"` loader is in context.
81
+ */
82
+ export const DEFAULT_FONT_URL = "https://cdn.jsdelivr.net/npm/@expo-google-fonts/inter@0.2.3/Inter_400Regular.ttf";
83
+ // immutable bytes, fetched at most once per process — the default font must
84
+ // not refetch per render call
85
+ let defaultBytesCache;
86
+ export const loadDefaultBytes = Effect.promise(() => {
87
+ defaultBytesCache ??= fetch(DEFAULT_FONT_URL).then(async (response) => {
88
+ if (!response.ok) {
89
+ defaultBytesCache = undefined;
90
+ throw new Error(`default font fetch failed (HTTP ${response.status}): ${DEFAULT_FONT_URL}`);
91
+ }
92
+ return new Uint8Array(await response.arrayBuffer());
93
+ });
94
+ return defaultBytesCache;
95
+ });
@@ -0,0 +1,71 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as Schema from "effect/Schema";
5
+ import * as Resource from "./Resource.js";
6
+ import type { EnsureLiteral } from "./types.js";
7
+ export declare const tag: "effect-motion/Resources/Image";
8
+ /**
9
+ * An image reference as entity data stores it: `{ _tag, id }`. Obtain one
10
+ * by yielding an {@link Image} constant inside a scene — that is what puts
11
+ * the matching `ImageLoader<ID>` into the scene's requirements.
12
+ */
13
+ export interface Image<ID extends string = string> {
14
+ readonly _tag: typeof tag;
15
+ readonly id: ID;
16
+ }
17
+ /** The stored-data schema for image references (see `Shapes.Image`). */
18
+ export declare const schema: Schema.TaggedStruct<"effect-motion/Resources/Image", {
19
+ readonly id: Schema.String;
20
+ }>;
21
+ /**
22
+ * A loaded image (encoded bytes — png/jpg/webp per the render session's
23
+ * decoder), provided as a context service: bytes are already in memory by
24
+ * the time any consumer reads this (loads run eagerly at layer
25
+ * construction — see {@link layer}). A separate shape from FontLoader on
26
+ * purpose (kind-specific metadata will diverge); only the loader brand is
27
+ * shared.
28
+ */
29
+ export interface ImageLoader<ID extends string = string> extends Resource.LoaderBrand {
30
+ readonly id: ID;
31
+ readonly bytes: Uint8Array;
32
+ }
33
+ /** The context key for an image's loader, derived from the id string alone. */
34
+ export declare const Loader: <ID extends string>(id: ID) => Context.Service<ImageLoader<ID>, ImageLoader<ID>>;
35
+ /**
36
+ * One image constant, two faces — author side (`yield*` for the value and
37
+ * the phantom `ImageLoader<ID>` requirement) and provider side (`.Loader`
38
+ * plus {@link layer}). See `Font.Font` for the full contract; images follow
39
+ * it exactly.
40
+ */
41
+ export interface ImageResource<ID extends string = string> extends Effect.Effect<Image<ID>, never, ImageLoader<ID>> {
42
+ readonly id: ID;
43
+ readonly Loader: Context.Service<ImageLoader<ID>, ImageLoader<ID>>;
44
+ }
45
+ /**
46
+ * Declare an image your scene uses.
47
+ *
48
+ * @remarks
49
+ * Follows the same two-faced contract as `Font.Font`: `yield*` the constant
50
+ * in a scene to get the reference for an Image entity, and pair it with
51
+ * {@link layer} to provide the encoded bytes. Yielding is what puts the
52
+ * requirement into the scene's type.
53
+ *
54
+ * @param id - An identifier for this image, as a literal string.
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * const Logo = Image.Image("logo");
59
+ *
60
+ * const scene = Scene.make(function* () {
61
+ * const logo = yield* Logo;
62
+ * yield* Scene.instantiate("Image", { image: logo, width: 200, height: 80 });
63
+ * });
64
+ * ```
65
+ */
66
+ export declare const Image: <const ID extends string>(id: ID & EnsureLiteral<ID, "Image id must be a literal string">) => ImageResource<ID>;
67
+ /**
68
+ * Provide an image's encoded bytes. The load effect runs ONCE, at layer
69
+ * construction — never at frame time; compose retries on the load effect.
70
+ */
71
+ export declare const layer: <ID extends string, E, R>(image: ImageResource<ID>, load: Effect.Effect<Uint8Array, E, R>) => Layer.Layer<ImageLoader<ID>, E, R>;
package/dist/Image.js ADDED
@@ -0,0 +1,50 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as Schema from "effect/Schema";
5
+ import * as Resource from "./Resource.js";
6
+ export const tag = "effect-motion/Resources/Image";
7
+ /** The stored-data schema for image references (see `Shapes.Image`). */
8
+ export const schema = Schema.TaggedStruct(tag, {
9
+ id: Schema.String,
10
+ });
11
+ const loaderKeyPrefix = "effect-motion/Resources/ImageLoader/";
12
+ /** The context key for an image's loader, derived from the id string alone. */
13
+ export const Loader = (id) => Context.Service(`${loaderKeyPrefix}${id}`);
14
+ /**
15
+ * Declare an image your scene uses.
16
+ *
17
+ * @remarks
18
+ * Follows the same two-faced contract as `Font.Font`: `yield*` the constant
19
+ * in a scene to get the reference for an Image entity, and pair it with
20
+ * {@link layer} to provide the encoded bytes. Yielding is what puts the
21
+ * requirement into the scene's type.
22
+ *
23
+ * @param id - An identifier for this image, as a literal string.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const Logo = Image.Image("logo");
28
+ *
29
+ * const scene = Scene.make(function* () {
30
+ * const logo = yield* Logo;
31
+ * yield* Scene.instantiate("Image", { image: logo, width: 200, height: 80 });
32
+ * });
33
+ * ```
34
+ */
35
+ export const Image = (id) => {
36
+ const value = { _tag: tag, id };
37
+ return Object.assign(Effect.succeed(value), {
38
+ id: id,
39
+ Loader: Loader(id),
40
+ });
41
+ };
42
+ /**
43
+ * Provide an image's encoded bytes. The load effect runs ONCE, at layer
44
+ * construction — never at frame time; compose retries on the load effect.
45
+ */
46
+ export const layer = (image, load) => Layer.effect(image.Loader, load.pipe(Effect.map((bytes) => ({
47
+ [Resource.LoaderTypeId]: Resource.LoaderTypeId,
48
+ id: image.id,
49
+ bytes,
50
+ }))));