oddlyalive 0.2.0-alpha.2

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 (86) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +158 -0
  5. package/SECURITY.md +29 -0
  6. package/assets/photoreal/PROVENANCE.md +19 -0
  7. package/assets/photoreal/baseball.png +0 -0
  8. package/assets/photoreal/basketball.png +0 -0
  9. package/assets/photoreal/crystal.png +0 -0
  10. package/assets/photoreal/kicking-cleat.png +0 -0
  11. package/assets/photoreal/letter-charm-square.png +0 -0
  12. package/assets/photoreal/letter-charm.png +0 -0
  13. package/assets/photoreal/sneaker.png +0 -0
  14. package/assets/photoreal/soccer-ball.png +0 -0
  15. package/bin/oddlyalive.js +283 -0
  16. package/docs/ARCHITECTURE.md +55 -0
  17. package/docs/DEMO-VIDEOS.md +122 -0
  18. package/docs/QUICKSTART.md +115 -0
  19. package/docs/ROADMAP.md +33 -0
  20. package/examples/ball-lab/PROVENANCE.md +4 -0
  21. package/examples/ball-lab/app.js +33 -0
  22. package/examples/ball-lab/index.html +56 -0
  23. package/examples/ball-lab/scene.json +68 -0
  24. package/examples/crystal-mobile/PROVENANCE.md +4 -0
  25. package/examples/crystal-mobile/app.js +26 -0
  26. package/examples/crystal-mobile/index.html +56 -0
  27. package/examples/crystal-mobile/scene.json +69 -0
  28. package/examples/football-kick/PROVENANCE.md +4 -0
  29. package/examples/football-kick/app.js +33 -0
  30. package/examples/football-kick/index.html +56 -0
  31. package/examples/football-kick/scene.json +44 -0
  32. package/examples/gallery.css +379 -0
  33. package/examples/index.html +109 -0
  34. package/examples/shared/example.css +320 -0
  35. package/examples/shared/player.js +85 -0
  36. package/examples/shoe-splash/PROVENANCE.md +4 -0
  37. package/examples/shoe-splash/app.js +26 -0
  38. package/examples/shoe-splash/index.html +56 -0
  39. package/examples/shoe-splash/scene.json +38 -0
  40. package/examples/string-touch/PROVENANCE.md +11 -0
  41. package/examples/string-touch/app.js +79 -0
  42. package/examples/string-touch/index.html +70 -0
  43. package/examples/string-touch/scene.json +73 -0
  44. package/examples/string-touch/styles.css +273 -0
  45. package/package.json +82 -0
  46. package/schemas/scene.schema.json +336 -0
  47. package/scripts/render-demo-videos.sh +77 -0
  48. package/scripts/serve.js +88 -0
  49. package/scripts/update-visual-fixtures.mjs +60 -0
  50. package/scripts/verify-demo-videos.mjs +138 -0
  51. package/skills/oddlyalive/SKILL.md +68 -0
  52. package/skills/oddlyalive/agents/openai.yaml +4 -0
  53. package/skills/oddlyalive/references/object-models.md +25 -0
  54. package/skills/oddlyalive/references/recipes.md +27 -0
  55. package/skills/oddlyalive/references/scene-schema.md +84 -0
  56. package/src/crystal-renderer.js +338 -0
  57. package/src/gesture.js +58 -0
  58. package/src/index.js +44 -0
  59. package/src/rigid-balls.js +304 -0
  60. package/src/rigid-renderer.js +760 -0
  61. package/src/rigid-scene.js +223 -0
  62. package/src/scene-registry.js +17 -0
  63. package/src/scene.js +206 -0
  64. package/src/state-hash.js +17 -0
  65. package/src/strands.js +773 -0
  66. package/src/surface-wave.js +209 -0
  67. package/src/svg-renderer.js +348 -0
  68. package/src/wave-renderer.js +463 -0
  69. package/src/wave-scene.js +163 -0
  70. package/tests/cli.test.js +101 -0
  71. package/tests/determinism.test.js +57 -0
  72. package/tests/fixtures/visual-baselines.json +27 -0
  73. package/tests/rigid-balls.test.js +77 -0
  74. package/tests/scene.test.js +78 -0
  75. package/tests/surface-wave.test.js +39 -0
  76. package/tests/visual-fixtures.test.js +43 -0
  77. package/videos/oddlyalive-demos/BRIEF.md +40 -0
  78. package/videos/oddlyalive-demos/DEMO-PLAN.md +13 -0
  79. package/videos/oddlyalive-demos/README.md +45 -0
  80. package/videos/oddlyalive-demos/app.js +212 -0
  81. package/videos/oddlyalive-demos/hyperframes.json +9 -0
  82. package/videos/oddlyalive-demos/index.html +246 -0
  83. package/videos/oddlyalive-demos/meta.json +5 -0
  84. package/videos/oddlyalive-demos/package-lock.json +506 -0
  85. package/videos/oddlyalive-demos/package.json +19 -0
  86. package/videos/oddlyalive-demos/prepare-assets.mjs +16 -0
@@ -0,0 +1,223 @@
1
+ import { assertFiniteNumber } from "./scene.js";
2
+
3
+ const DEFAULT_RIGID_SCENE = {
4
+ version: 1,
5
+ type: "rigid-balls",
6
+ name: "Ball Lab",
7
+ seed: 21,
8
+ canvas: {
9
+ width: 960,
10
+ height: 540
11
+ },
12
+ timing: {
13
+ duration: 6.4,
14
+ fps: 60,
15
+ substeps: 4,
16
+ preRoll: 0
17
+ },
18
+ world: {
19
+ gravity: 920,
20
+ airDrag: 0.08,
21
+ floorY: 456,
22
+ left: 84,
23
+ right: 876,
24
+ groundFriction: 1.8
25
+ },
26
+ bodies: [
27
+ {
28
+ id: "baseball",
29
+ kind: "baseball",
30
+ label: "BASEBALL",
31
+ x: 250,
32
+ y: 104,
33
+ radius: 29,
34
+ mass: 0.145,
35
+ restitution: 0.52,
36
+ friction: 0.38,
37
+ vx: 34,
38
+ vy: 0,
39
+ angle: -8,
40
+ angularVelocity: 1.2,
41
+ color: "#f4efe4"
42
+ },
43
+ {
44
+ id: "basketball",
45
+ kind: "basketball",
46
+ label: "BASKETBALL",
47
+ x: 480,
48
+ y: 72,
49
+ radius: 48,
50
+ mass: 0.625,
51
+ restitution: 0.76,
52
+ friction: 0.58,
53
+ vx: -18,
54
+ vy: 0,
55
+ angle: 4,
56
+ angularVelocity: -0.5,
57
+ color: "#e66b2d"
58
+ },
59
+ {
60
+ id: "football",
61
+ kind: "football",
62
+ label: "FOOTBALL",
63
+ x: 710,
64
+ y: 124,
65
+ radius: 39,
66
+ mass: 0.43,
67
+ restitution: 0.63,
68
+ friction: 0.5,
69
+ vx: -42,
70
+ vy: 0,
71
+ angle: 11,
72
+ angularVelocity: 0.8,
73
+ color: "#f6f3e9"
74
+ }
75
+ ],
76
+ impulses: []
77
+ };
78
+
79
+ function mergeSection(base, incoming) {
80
+ return { ...base, ...(incoming ?? {}) };
81
+ }
82
+
83
+ export function createRigidBallsScene(overrides = {}) {
84
+ return {
85
+ ...DEFAULT_RIGID_SCENE,
86
+ ...overrides,
87
+ canvas: mergeSection(DEFAULT_RIGID_SCENE.canvas, overrides.canvas),
88
+ timing: mergeSection(DEFAULT_RIGID_SCENE.timing, overrides.timing),
89
+ world: mergeSection(DEFAULT_RIGID_SCENE.world, overrides.world),
90
+ bodies:
91
+ overrides.bodies?.map((body) => ({ ...body })) ??
92
+ DEFAULT_RIGID_SCENE.bodies.map((body) => ({ ...body })),
93
+ impulses:
94
+ overrides.impulses?.map((impulse) => ({ ...impulse })) ??
95
+ DEFAULT_RIGID_SCENE.impulses.map((impulse) => ({ ...impulse }))
96
+ };
97
+ }
98
+
99
+ export function validateRigidBallsScene(input) {
100
+ const scene = createRigidBallsScene(input);
101
+ if (scene.version !== 1) {
102
+ throw new RangeError(`Unsupported scene version: ${scene.version}`);
103
+ }
104
+ if (scene.type !== "rigid-balls") {
105
+ throw new TypeError(`Unsupported rigid scene type: ${scene.type}`);
106
+ }
107
+
108
+ assertFiniteNumber(scene.seed, "seed");
109
+ assertFiniteNumber(scene.canvas.width, "canvas.width", { minimum: 64 });
110
+ assertFiniteNumber(scene.canvas.height, "canvas.height", { minimum: 64 });
111
+ assertFiniteNumber(scene.timing.duration, "timing.duration", {
112
+ minimum: 0.1,
113
+ maximum: 60
114
+ });
115
+ assertFiniteNumber(scene.timing.fps, "timing.fps", {
116
+ minimum: 1,
117
+ maximum: 240
118
+ });
119
+ assertFiniteNumber(scene.timing.substeps, "timing.substeps", {
120
+ minimum: 1,
121
+ maximum: 16
122
+ });
123
+ assertFiniteNumber(scene.timing.preRoll, "timing.preRoll", {
124
+ minimum: 0,
125
+ maximum: 30
126
+ });
127
+ if (!Number.isInteger(scene.timing.fps) || !Number.isInteger(scene.timing.substeps)) {
128
+ throw new TypeError("timing.fps and timing.substeps must be integers.");
129
+ }
130
+
131
+ for (const key of [
132
+ "gravity",
133
+ "airDrag",
134
+ "floorY",
135
+ "left",
136
+ "right",
137
+ "groundFriction"
138
+ ]) {
139
+ assertFiniteNumber(scene.world[key], `world.${key}`);
140
+ }
141
+ if (
142
+ scene.world.airDrag < 0 ||
143
+ scene.world.groundFriction < 0
144
+ ) {
145
+ throw new RangeError("world drag and friction values cannot be negative.");
146
+ }
147
+ if (scene.world.left >= scene.world.right) {
148
+ throw new RangeError("world.left must be less than world.right.");
149
+ }
150
+ if (!Array.isArray(scene.bodies) || scene.bodies.length === 0) {
151
+ throw new TypeError("bodies must contain at least one rigid body.");
152
+ }
153
+ if (scene.bodies.length > 64) {
154
+ throw new RangeError("The alpha supports at most 64 rigid bodies.");
155
+ }
156
+
157
+ const ids = new Set();
158
+ scene.bodies.forEach((body, index) => {
159
+ if (typeof body.id !== "string" || body.id.length === 0) {
160
+ throw new TypeError(`bodies[${index}].id must be a non-empty string.`);
161
+ }
162
+ if (typeof body.kind !== "string" || body.kind.length === 0) {
163
+ throw new TypeError(`bodies[${index}].kind must be a non-empty string.`);
164
+ }
165
+ if (typeof body.color !== "string" || body.color.length === 0) {
166
+ throw new TypeError(`bodies[${index}].color must be a non-empty string.`);
167
+ }
168
+ if (ids.has(body.id)) {
169
+ throw new RangeError(`Duplicate rigid body id: ${body.id}`);
170
+ }
171
+ ids.add(body.id);
172
+ for (const key of [
173
+ "x",
174
+ "y",
175
+ "radius",
176
+ "mass",
177
+ "restitution",
178
+ "friction",
179
+ "vx",
180
+ "vy",
181
+ "angle",
182
+ "angularVelocity"
183
+ ]) {
184
+ assertFiniteNumber(body[key], `bodies[${index}].${key}`);
185
+ }
186
+ if (body.radius <= 0 || body.mass <= 0) {
187
+ throw new RangeError(`bodies[${index}] radius and mass must be positive.`);
188
+ }
189
+ if (body.restitution < 0 || body.restitution > 1) {
190
+ throw new RangeError(`bodies[${index}].restitution must be between 0 and 1.`);
191
+ }
192
+ if (body.friction < 0 || body.friction > 1) {
193
+ throw new RangeError(`bodies[${index}].friction must be between 0 and 1.`);
194
+ }
195
+ });
196
+
197
+ let previousTime = -Infinity;
198
+ scene.impulses.forEach((impulse, index) => {
199
+ assertFiniteNumber(impulse.time, `impulses[${index}].time`);
200
+ assertFiniteNumber(impulse.impulseX, `impulses[${index}].impulseX`);
201
+ assertFiniteNumber(impulse.impulseY, `impulses[${index}].impulseY`);
202
+ assertFiniteNumber(
203
+ impulse.angularImpulse ?? 0,
204
+ `impulses[${index}].angularImpulse`
205
+ );
206
+ if (!ids.has(impulse.body)) {
207
+ throw new RangeError(`impulses[${index}] references unknown body ${impulse.body}.`);
208
+ }
209
+ if (impulse.time < 0 || impulse.time > scene.timing.duration) {
210
+ throw new RangeError(
211
+ `impulses[${index}].time must fall within the scene duration.`
212
+ );
213
+ }
214
+ if (impulse.time < previousTime) {
215
+ throw new RangeError("impulses must be ordered by time.");
216
+ }
217
+ previousTime = impulse.time;
218
+ });
219
+
220
+ return scene;
221
+ }
222
+
223
+ export const defaultRigidBallsScene = createRigidBallsScene();
@@ -0,0 +1,17 @@
1
+ import { validateRigidBallsScene } from "./rigid-scene.js";
2
+ import { validateStrandScene } from "./scene.js";
3
+ import { validateSurfaceWaveScene } from "./wave-scene.js";
4
+
5
+ export function validateScene(input) {
6
+ switch (input?.type) {
7
+ case "strand-field":
8
+ case undefined:
9
+ return validateStrandScene(input);
10
+ case "rigid-balls":
11
+ return validateRigidBallsScene(input);
12
+ case "surface-wave":
13
+ return validateSurfaceWaveScene(input);
14
+ default:
15
+ throw new TypeError(`Unsupported scene type: ${input?.type}`);
16
+ }
17
+ }
package/src/scene.js ADDED
@@ -0,0 +1,206 @@
1
+ const DEFAULT_TOUCH_PATH = [
2
+ { time: 0.48, x: -60, y: 326, pressure: 0 },
3
+ { time: 0.59, x: 30, y: 329, pressure: 0.3 },
4
+ { time: 0.74, x: 265, y: 338, pressure: 0.86 },
5
+ { time: 1.08, x: 402, y: 360, pressure: 1 },
6
+ { time: 1.48, x: 565, y: 386, pressure: 0.98 },
7
+ { time: 1.88, x: 805, y: 397, pressure: 1 },
8
+ { time: 2.14, x: 914, y: 376, pressure: 0.97 },
9
+ { time: 2.28, x: 944, y: 367, pressure: 0.94 },
10
+ { time: 2.44, x: 906, y: 376, pressure: 0.96 },
11
+ { time: 2.7, x: 800, y: 408, pressure: 0.92 },
12
+ { time: 3, x: 665, y: 424, pressure: 0.8 },
13
+ { time: 3.29, x: 520, y: 416, pressure: 0.61 },
14
+ { time: 3.58, x: 390, y: 392, pressure: 0.34 },
15
+ { time: 3.84, x: 250, y: 370, pressure: 0.12 },
16
+ { time: 4.08, x: 120, y: 356, pressure: 0.03 },
17
+ { time: 4.32, x: 40, y: 351, pressure: 0 }
18
+ ];
19
+
20
+ const DEFAULT_SCENE = {
21
+ version: 1,
22
+ type: "strand-field",
23
+ name: "String Touch",
24
+ seed: 11,
25
+ canvas: {
26
+ width: 960,
27
+ height: 540
28
+ },
29
+ timing: {
30
+ duration: 6.4,
31
+ fps: 60,
32
+ substeps: 4,
33
+ preRoll: 2
34
+ },
35
+ field: {
36
+ columns: 23,
37
+ rows: 16,
38
+ originX: 330,
39
+ originY: 112,
40
+ spacingX: 24,
41
+ spacingY: 20
42
+ },
43
+ material: {
44
+ gravity: 480,
45
+ linearDrag: 0.92,
46
+ quadraticDrag: 0.0023,
47
+ lengthCompliance: 0.0000011,
48
+ compressionCompliance: 0.00052,
49
+ bendCompliance: 0.00062,
50
+ maxStretch: 1.045
51
+ },
52
+ contact: {
53
+ radiusX: 53,
54
+ radiusY: 33,
55
+ maxStaticGrip: 8,
56
+ captureStartRow: 6,
57
+ captureEndRow: 14
58
+ },
59
+ gesture: {
60
+ type: "touch-path",
61
+ points: DEFAULT_TOUCH_PATH
62
+ },
63
+ payload: {
64
+ text: "ODDLY ALIVE • MAKE ANYTHING FEEL ALIVE • ",
65
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
66
+ fontSize: 12
67
+ },
68
+ render: {
69
+ background: "#f0e7d3",
70
+ ink: "#20201d",
71
+ accent: "#ec5b36",
72
+ filament: "rgba(32, 32, 29, 0.34)"
73
+ }
74
+ };
75
+
76
+ function mergeSection(base, incoming) {
77
+ return { ...base, ...(incoming ?? {}) };
78
+ }
79
+
80
+ export function createStringTouchScene(overrides = {}) {
81
+ return {
82
+ ...DEFAULT_SCENE,
83
+ ...overrides,
84
+ canvas: mergeSection(DEFAULT_SCENE.canvas, overrides.canvas),
85
+ timing: mergeSection(DEFAULT_SCENE.timing, overrides.timing),
86
+ field: mergeSection(DEFAULT_SCENE.field, overrides.field),
87
+ material: mergeSection(DEFAULT_SCENE.material, overrides.material),
88
+ contact: mergeSection(DEFAULT_SCENE.contact, overrides.contact),
89
+ gesture: {
90
+ ...DEFAULT_SCENE.gesture,
91
+ ...(overrides.gesture ?? {}),
92
+ points:
93
+ overrides.gesture?.points?.map((point) => ({ ...point })) ??
94
+ DEFAULT_TOUCH_PATH.map((point) => ({ ...point }))
95
+ },
96
+ payload: mergeSection(DEFAULT_SCENE.payload, overrides.payload),
97
+ render: mergeSection(DEFAULT_SCENE.render, overrides.render)
98
+ };
99
+ }
100
+
101
+ export function assertFiniteNumber(value, path, { minimum, maximum } = {}) {
102
+ if (!Number.isFinite(value)) {
103
+ throw new TypeError(`${path} must be a finite number.`);
104
+ }
105
+ if (minimum !== undefined && value < minimum) {
106
+ throw new RangeError(`${path} must be at least ${minimum}.`);
107
+ }
108
+ if (maximum !== undefined && value > maximum) {
109
+ throw new RangeError(`${path} must be at most ${maximum}.`);
110
+ }
111
+ }
112
+
113
+ export function validateStrandScene(input) {
114
+ const scene = createStringTouchScene(input);
115
+ if (scene.version !== 1) {
116
+ throw new RangeError(`Unsupported scene version: ${scene.version}`);
117
+ }
118
+ if (scene.type !== "strand-field") {
119
+ throw new TypeError(`Unsupported scene type: ${scene.type}`);
120
+ }
121
+
122
+ assertFiniteNumber(scene.seed, "seed");
123
+ assertFiniteNumber(scene.canvas.width, "canvas.width", { minimum: 64 });
124
+ assertFiniteNumber(scene.canvas.height, "canvas.height", { minimum: 64 });
125
+ assertFiniteNumber(scene.timing.duration, "timing.duration", {
126
+ minimum: 0.1,
127
+ maximum: 60
128
+ });
129
+ assertFiniteNumber(scene.timing.fps, "timing.fps", {
130
+ minimum: 1,
131
+ maximum: 240
132
+ });
133
+ assertFiniteNumber(scene.timing.substeps, "timing.substeps", {
134
+ minimum: 1,
135
+ maximum: 16
136
+ });
137
+ assertFiniteNumber(scene.field.columns, "field.columns", {
138
+ minimum: 1,
139
+ maximum: 128
140
+ });
141
+ assertFiniteNumber(scene.field.rows, "field.rows", {
142
+ minimum: 3,
143
+ maximum: 128
144
+ });
145
+ assertFiniteNumber(scene.material.maxStretch, "material.maxStretch", {
146
+ minimum: 1,
147
+ maximum: 1.5
148
+ });
149
+ assertFiniteNumber(
150
+ scene.contact.maxStaticGrip,
151
+ "contact.maxStaticGrip",
152
+ { minimum: 1, maximum: scene.field.columns }
153
+ );
154
+
155
+ if (!Number.isInteger(scene.field.columns) || !Number.isInteger(scene.field.rows)) {
156
+ throw new TypeError("field.columns and field.rows must be integers.");
157
+ }
158
+ if (!Number.isInteger(scene.timing.fps) || !Number.isInteger(scene.timing.substeps)) {
159
+ throw new TypeError("timing.fps and timing.substeps must be integers.");
160
+ }
161
+ if (!Array.isArray(scene.gesture.points) || scene.gesture.points.length < 2) {
162
+ throw new TypeError("gesture.points must contain at least two points.");
163
+ }
164
+
165
+ let previousTime = -Infinity;
166
+ scene.gesture.points.forEach((point, index) => {
167
+ assertFiniteNumber(point.time, `gesture.points[${index}].time`);
168
+ assertFiniteNumber(point.x, `gesture.points[${index}].x`);
169
+ assertFiniteNumber(point.y, `gesture.points[${index}].y`);
170
+ assertFiniteNumber(point.pressure, `gesture.points[${index}].pressure`, {
171
+ minimum: 0,
172
+ maximum: 1
173
+ });
174
+ if (point.time <= previousTime) {
175
+ throw new RangeError("gesture.points must be strictly ordered by time.");
176
+ }
177
+ previousTime = point.time;
178
+ });
179
+
180
+ if (scene.contact.captureStartRow < 0) {
181
+ throw new RangeError("contact.captureStartRow cannot be negative.");
182
+ }
183
+ scene.contact.captureEndRow = Math.min(
184
+ scene.field.rows - 1,
185
+ scene.contact.captureEndRow
186
+ );
187
+ if (scene.contact.captureStartRow > scene.contact.captureEndRow) {
188
+ throw new RangeError(
189
+ "contact.captureStartRow must not exceed contact.captureEndRow."
190
+ );
191
+ }
192
+ if (typeof scene.payload.text !== "string" || scene.payload.text.length === 0) {
193
+ throw new TypeError("payload.text must be a non-empty string.");
194
+ }
195
+ if (scene.payload.terminalMass !== undefined) {
196
+ assertFiniteNumber(scene.payload.terminalMass, "payload.terminalMass", {
197
+ minimum: 0.1,
198
+ maximum: 50
199
+ });
200
+ }
201
+
202
+ return scene;
203
+ }
204
+
205
+ export const validateScene = validateStrandScene;
206
+ export const defaultStringTouchScene = createStringTouchScene();
@@ -0,0 +1,17 @@
1
+ export function createStateHasher() {
2
+ let hash = 0x811c9dc5;
3
+ return {
4
+ add(value) {
5
+ const rounded = Math.round(value * 1000);
6
+ hash ^= rounded & 0xff;
7
+ hash = Math.imul(hash, 0x01000193);
8
+ hash ^= (rounded >>> 8) & 0xff;
9
+ hash = Math.imul(hash, 0x01000193);
10
+ hash ^= (rounded >>> 16) & 0xff;
11
+ hash = Math.imul(hash, 0x01000193);
12
+ },
13
+ digest() {
14
+ return (hash >>> 0).toString(16).padStart(8, "0");
15
+ }
16
+ };
17
+ }