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.
- package/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +62 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/SECURITY.md +29 -0
- package/assets/photoreal/PROVENANCE.md +19 -0
- package/assets/photoreal/baseball.png +0 -0
- package/assets/photoreal/basketball.png +0 -0
- package/assets/photoreal/crystal.png +0 -0
- package/assets/photoreal/kicking-cleat.png +0 -0
- package/assets/photoreal/letter-charm-square.png +0 -0
- package/assets/photoreal/letter-charm.png +0 -0
- package/assets/photoreal/sneaker.png +0 -0
- package/assets/photoreal/soccer-ball.png +0 -0
- package/bin/oddlyalive.js +283 -0
- package/docs/ARCHITECTURE.md +55 -0
- package/docs/DEMO-VIDEOS.md +122 -0
- package/docs/QUICKSTART.md +115 -0
- package/docs/ROADMAP.md +33 -0
- package/examples/ball-lab/PROVENANCE.md +4 -0
- package/examples/ball-lab/app.js +33 -0
- package/examples/ball-lab/index.html +56 -0
- package/examples/ball-lab/scene.json +68 -0
- package/examples/crystal-mobile/PROVENANCE.md +4 -0
- package/examples/crystal-mobile/app.js +26 -0
- package/examples/crystal-mobile/index.html +56 -0
- package/examples/crystal-mobile/scene.json +69 -0
- package/examples/football-kick/PROVENANCE.md +4 -0
- package/examples/football-kick/app.js +33 -0
- package/examples/football-kick/index.html +56 -0
- package/examples/football-kick/scene.json +44 -0
- package/examples/gallery.css +379 -0
- package/examples/index.html +109 -0
- package/examples/shared/example.css +320 -0
- package/examples/shared/player.js +85 -0
- package/examples/shoe-splash/PROVENANCE.md +4 -0
- package/examples/shoe-splash/app.js +26 -0
- package/examples/shoe-splash/index.html +56 -0
- package/examples/shoe-splash/scene.json +38 -0
- package/examples/string-touch/PROVENANCE.md +11 -0
- package/examples/string-touch/app.js +79 -0
- package/examples/string-touch/index.html +70 -0
- package/examples/string-touch/scene.json +73 -0
- package/examples/string-touch/styles.css +273 -0
- package/package.json +82 -0
- package/schemas/scene.schema.json +336 -0
- package/scripts/render-demo-videos.sh +77 -0
- package/scripts/serve.js +88 -0
- package/scripts/update-visual-fixtures.mjs +60 -0
- package/scripts/verify-demo-videos.mjs +138 -0
- package/skills/oddlyalive/SKILL.md +68 -0
- package/skills/oddlyalive/agents/openai.yaml +4 -0
- package/skills/oddlyalive/references/object-models.md +25 -0
- package/skills/oddlyalive/references/recipes.md +27 -0
- package/skills/oddlyalive/references/scene-schema.md +84 -0
- package/src/crystal-renderer.js +338 -0
- package/src/gesture.js +58 -0
- package/src/index.js +44 -0
- package/src/rigid-balls.js +304 -0
- package/src/rigid-renderer.js +760 -0
- package/src/rigid-scene.js +223 -0
- package/src/scene-registry.js +17 -0
- package/src/scene.js +206 -0
- package/src/state-hash.js +17 -0
- package/src/strands.js +773 -0
- package/src/surface-wave.js +209 -0
- package/src/svg-renderer.js +348 -0
- package/src/wave-renderer.js +463 -0
- package/src/wave-scene.js +163 -0
- package/tests/cli.test.js +101 -0
- package/tests/determinism.test.js +57 -0
- package/tests/fixtures/visual-baselines.json +27 -0
- package/tests/rigid-balls.test.js +77 -0
- package/tests/scene.test.js +78 -0
- package/tests/surface-wave.test.js +39 -0
- package/tests/visual-fixtures.test.js +43 -0
- package/videos/oddlyalive-demos/BRIEF.md +40 -0
- package/videos/oddlyalive-demos/DEMO-PLAN.md +13 -0
- package/videos/oddlyalive-demos/README.md +45 -0
- package/videos/oddlyalive-demos/app.js +212 -0
- package/videos/oddlyalive-demos/hyperframes.json +9 -0
- package/videos/oddlyalive-demos/index.html +246 -0
- package/videos/oddlyalive-demos/meta.json +5 -0
- package/videos/oddlyalive-demos/package-lock.json +506 -0
- package/videos/oddlyalive-demos/package.json +19 -0
- package/videos/oddlyalive-demos/prepare-assets.mjs +16 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
|
|
8
|
+
test("the CLI creates a dependency-free strand starter", async () => {
|
|
9
|
+
const temporaryRoot = await mkdtemp(join(tmpdir(), "oddlyalive-"));
|
|
10
|
+
const target = join(temporaryRoot, "my-motion");
|
|
11
|
+
const result = spawnSync(
|
|
12
|
+
process.execPath,
|
|
13
|
+
[
|
|
14
|
+
"./bin/oddlyalive.js",
|
|
15
|
+
"new",
|
|
16
|
+
target,
|
|
17
|
+
"--text",
|
|
18
|
+
"HELLO MOTION"
|
|
19
|
+
],
|
|
20
|
+
{ cwd: new URL("..", import.meta.url), encoding: "utf8" }
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
assert.equal(result.status, 0, result.stderr);
|
|
24
|
+
const scene = JSON.parse(
|
|
25
|
+
await readFile(join(target, "examples/string-touch/scene.json"), "utf8")
|
|
26
|
+
);
|
|
27
|
+
const packageJson = JSON.parse(
|
|
28
|
+
await readFile(join(target, "package.json"), "utf8")
|
|
29
|
+
);
|
|
30
|
+
assert.match(scene.payload.text, /HELLO MOTION/);
|
|
31
|
+
assert.equal(
|
|
32
|
+
packageJson.scripts.start,
|
|
33
|
+
"node ./scripts/serve.js --path /examples/string-touch/"
|
|
34
|
+
);
|
|
35
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the CLI can create every built-in recipe", async () => {
|
|
39
|
+
const temporaryRoot = await mkdtemp(join(tmpdir(), "oddlyalive-recipes-"));
|
|
40
|
+
const presets = [
|
|
41
|
+
"string-touch",
|
|
42
|
+
"crystal-mobile",
|
|
43
|
+
"ball-lab",
|
|
44
|
+
"football-kick",
|
|
45
|
+
"shoe-splash"
|
|
46
|
+
];
|
|
47
|
+
const requiredAssets = {
|
|
48
|
+
"string-touch": ["letter-charm-square.png"],
|
|
49
|
+
"crystal-mobile": ["crystal.png"],
|
|
50
|
+
"ball-lab": ["baseball.png", "basketball.png", "soccer-ball.png"],
|
|
51
|
+
"football-kick": ["kicking-cleat.png", "soccer-ball.png"],
|
|
52
|
+
"shoe-splash": ["sneaker.png"]
|
|
53
|
+
};
|
|
54
|
+
for (const preset of presets) {
|
|
55
|
+
const target = join(temporaryRoot, preset);
|
|
56
|
+
const result = spawnSync(
|
|
57
|
+
process.execPath,
|
|
58
|
+
["./bin/oddlyalive.js", "new", target, "--preset", preset],
|
|
59
|
+
{ cwd: new URL("..", import.meta.url), encoding: "utf8" }
|
|
60
|
+
);
|
|
61
|
+
assert.equal(result.status, 0, `${preset}: ${result.stderr}`);
|
|
62
|
+
const scene = JSON.parse(
|
|
63
|
+
await readFile(join(target, `examples/${preset}/scene.json`), "utf8")
|
|
64
|
+
);
|
|
65
|
+
assert.equal(typeof scene.type, "string");
|
|
66
|
+
await readFile(join(target, "examples/shared/player.js"), "utf8");
|
|
67
|
+
await readFile(join(target, "schemas/scene.schema.json"), "utf8");
|
|
68
|
+
for (const asset of requiredAssets[preset]) {
|
|
69
|
+
await readFile(join(target, "assets/photoreal", asset));
|
|
70
|
+
}
|
|
71
|
+
await readFile(join(target, "assets/photoreal/PROVENANCE.md"), "utf8");
|
|
72
|
+
await readFile(join(target, "scripts/serve.js"), "utf8");
|
|
73
|
+
const html = await readFile(
|
|
74
|
+
join(target, `examples/${preset}/index.html`),
|
|
75
|
+
"utf8"
|
|
76
|
+
);
|
|
77
|
+
assert.doesNotMatch(
|
|
78
|
+
html,
|
|
79
|
+
/href="\.\.\/(string-touch|crystal-mobile|ball-lab|football-kick|shoe-splash)\//
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("the CLI lists the complete recipe gallery", () => {
|
|
86
|
+
const result = spawnSync(
|
|
87
|
+
process.execPath,
|
|
88
|
+
["./bin/oddlyalive.js", "list"],
|
|
89
|
+
{ cwd: new URL("..", import.meta.url), encoding: "utf8" }
|
|
90
|
+
);
|
|
91
|
+
assert.equal(result.status, 0, result.stderr);
|
|
92
|
+
for (const preset of [
|
|
93
|
+
"string-touch",
|
|
94
|
+
"crystal-mobile",
|
|
95
|
+
"ball-lab",
|
|
96
|
+
"football-kick",
|
|
97
|
+
"shoe-splash"
|
|
98
|
+
]) {
|
|
99
|
+
assert.match(result.stdout, new RegExp(preset));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createStringTouchScene,
|
|
5
|
+
simulateScene
|
|
6
|
+
} from "../src/index.js";
|
|
7
|
+
|
|
8
|
+
const compactScene = createStringTouchScene({
|
|
9
|
+
timing: {
|
|
10
|
+
duration: 4.8,
|
|
11
|
+
fps: 30,
|
|
12
|
+
substeps: 8,
|
|
13
|
+
preRoll: 0.5
|
|
14
|
+
},
|
|
15
|
+
field: {
|
|
16
|
+
columns: 13,
|
|
17
|
+
rows: 10,
|
|
18
|
+
originX: 145,
|
|
19
|
+
originY: 108,
|
|
20
|
+
spacingX: 48,
|
|
21
|
+
spacingY: 28
|
|
22
|
+
},
|
|
23
|
+
contact: {
|
|
24
|
+
maxStaticGrip: 6,
|
|
25
|
+
captureStartRow: 3,
|
|
26
|
+
captureEndRow: 9
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("the same scene produces the same simulation fingerprint", () => {
|
|
31
|
+
const first = simulateScene(compactScene, { includeFrames: false });
|
|
32
|
+
const second = simulateScene(compactScene, { includeFrames: false });
|
|
33
|
+
assert.equal(first.fingerprint, second.fingerprint);
|
|
34
|
+
assert.equal(
|
|
35
|
+
first.diagnostics.observedMaxStaticGrip,
|
|
36
|
+
second.diagnostics.observedMaxStaticGrip
|
|
37
|
+
);
|
|
38
|
+
assert.ok(first.diagnostics.observedMaxStaticGrip > 0);
|
|
39
|
+
assert.ok(
|
|
40
|
+
first.diagnostics.observedMaxStaticGrip <=
|
|
41
|
+
compactScene.contact.maxStaticGrip
|
|
42
|
+
);
|
|
43
|
+
assert.ok(first.diagnostics.firstGripTime !== null);
|
|
44
|
+
assert.ok(
|
|
45
|
+
first.diagnostics.observedMaxStretch <
|
|
46
|
+
compactScene.material.maxStretch + 0.02
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("changing the material seed changes the fingerprint", () => {
|
|
51
|
+
const first = simulateScene(compactScene, { includeFrames: false });
|
|
52
|
+
const second = simulateScene(
|
|
53
|
+
{ ...compactScene, seed: compactScene.seed + 1 },
|
|
54
|
+
{ includeFrames: false }
|
|
55
|
+
);
|
|
56
|
+
assert.notEqual(first.fingerprint, second.fingerprint);
|
|
57
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"description": "Approved deterministic motion and visual-source baselines. Update only after visual review.",
|
|
4
|
+
"sceneFingerprints": {
|
|
5
|
+
"string-touch": "4c56b4e5",
|
|
6
|
+
"crystal-mobile": "04e45087",
|
|
7
|
+
"ball-lab": "c3b9e206",
|
|
8
|
+
"football-kick": "2179d5a7",
|
|
9
|
+
"shoe-splash": "438ccfca"
|
|
10
|
+
},
|
|
11
|
+
"fileSha256": {
|
|
12
|
+
"assets/photoreal/baseball.png": "0d8e6be0dc67ad577a40772d6d6b17cd70732d15f57b1184500c9676abd387b6",
|
|
13
|
+
"assets/photoreal/basketball.png": "5c5c8e714525066a46fa8f694f1ada3312f202b6bd8e9059015c4b6acf92f6af",
|
|
14
|
+
"assets/photoreal/crystal.png": "92ea76d9d9fb24899510764670eb0d88887a70822d6689fe5df4f374a2c62903",
|
|
15
|
+
"assets/photoreal/kicking-cleat.png": "9151599c10eb26e3250e9f7c4921e6e4b44dd8286a19a61fa6ae09a79dfa673f",
|
|
16
|
+
"assets/photoreal/letter-charm-square.png": "b2bccab5e482bfa86c0f71ae70f6d5cef44ab1d892135384c27a6f763bd73cae",
|
|
17
|
+
"assets/photoreal/letter-charm.png": "e20eb93043feb7bf307a432db53cf00af0997c3d17d6e59c070f835375b87364",
|
|
18
|
+
"assets/photoreal/sneaker.png": "a6736fbdc0120cbd95cdb6157522e980226c9dc53e7d9af31e1817df180c6da9",
|
|
19
|
+
"assets/photoreal/soccer-ball.png": "38e85510f7380e8334f2f6562aad55240132af21787de8746d2698e000bcf542",
|
|
20
|
+
"src/crystal-renderer.js": "e63ead4940827c4a47f685ce1657b2e0f12ef721ad533f3659fd5d5edcbc8059",
|
|
21
|
+
"src/rigid-renderer.js": "39e1fe69fa84a3c9ec499554b8551655443988d18c6f357db6e86109288976b3",
|
|
22
|
+
"src/svg-renderer.js": "c5848d44d7560a23b32db26503b231f2482a41dbcbb6f1bab01e6a3e615ece3a",
|
|
23
|
+
"src/wave-renderer.js": "5c8d1d2aff3dbfe8aede895ddf1cce8317b31a962ccc70ff569bfe2125cc384d",
|
|
24
|
+
"videos/oddlyalive-demos/app.js": "393414f946090b67a2dec2a2bf254d4b8fd4a2b74d4fdeb1022da4d005841a68",
|
|
25
|
+
"videos/oddlyalive-demos/index.html": "86bf5c2502a67c9e70154ec11f9bcb79bb96344f8cba85c6b354ad5b0e8a5107"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createRigidBallsScene,
|
|
5
|
+
simulateScene,
|
|
6
|
+
validateScene
|
|
7
|
+
} from "../src/index.js";
|
|
8
|
+
|
|
9
|
+
test("rigid ball worlds are deterministic and collide within bounds", () => {
|
|
10
|
+
const scene = validateScene(createRigidBallsScene());
|
|
11
|
+
const first = simulateScene(scene, { includeFrames: false });
|
|
12
|
+
const second = simulateScene(scene, { includeFrames: false });
|
|
13
|
+
|
|
14
|
+
assert.equal(first.fingerprint, second.fingerprint);
|
|
15
|
+
assert.ok(first.diagnostics.collisionCount > 0);
|
|
16
|
+
assert.ok(first.diagnostics.firstImpactTime > 0.5);
|
|
17
|
+
assert.ok(first.diagnostics.firstImpactTime < 1.5);
|
|
18
|
+
assert.ok(first.diagnostics.maxPenetration < 10);
|
|
19
|
+
assert.equal(first.diagnostics.appliedImpulses, 0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a scheduled kick is applied exactly once and changes the world", () => {
|
|
23
|
+
const scene = createRigidBallsScene({
|
|
24
|
+
bodies: [
|
|
25
|
+
{
|
|
26
|
+
id: "ball",
|
|
27
|
+
kind: "football",
|
|
28
|
+
label: "FOOTBALL",
|
|
29
|
+
x: 180,
|
|
30
|
+
y: 392,
|
|
31
|
+
radius: 38,
|
|
32
|
+
mass: 0.43,
|
|
33
|
+
restitution: 0.62,
|
|
34
|
+
friction: 0.58,
|
|
35
|
+
vx: 0,
|
|
36
|
+
vy: 0,
|
|
37
|
+
angle: 0,
|
|
38
|
+
angularVelocity: 0,
|
|
39
|
+
color: "#f6f3e9"
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
world: { floorY: 430, left: 70, right: 910, groundFriction: 2.4 },
|
|
43
|
+
impulses: [
|
|
44
|
+
{
|
|
45
|
+
time: 0.62,
|
|
46
|
+
body: "ball",
|
|
47
|
+
impulseX: 300,
|
|
48
|
+
impulseY: -235,
|
|
49
|
+
angularImpulse: 4.6
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
});
|
|
53
|
+
const kicked = simulateScene(scene, { includeFrames: false });
|
|
54
|
+
const still = simulateScene(
|
|
55
|
+
createRigidBallsScene({ ...scene, impulses: [] }),
|
|
56
|
+
{ includeFrames: false }
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
assert.equal(kicked.diagnostics.appliedImpulses, 1);
|
|
60
|
+
assert.notEqual(kicked.fingerprint, still.fingerprint);
|
|
61
|
+
assert.ok(kicked.diagnostics.maxSpeed > 500);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("rigid pre-roll advances the world before output frame zero", () => {
|
|
65
|
+
const withoutPreRoll = simulateScene(
|
|
66
|
+
createRigidBallsScene({
|
|
67
|
+
timing: { duration: 1, fps: 60, substeps: 4, preRoll: 0 }
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
const withPreRoll = simulateScene(
|
|
71
|
+
createRigidBallsScene({
|
|
72
|
+
timing: { duration: 1, fps: 60, substeps: 4, preRoll: 0.25 }
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
assert.ok(withPreRoll.frames[0].y[0] > withoutPreRoll.frames[0].y[0]);
|
|
77
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createRigidBallsScene,
|
|
5
|
+
createStringTouchScene,
|
|
6
|
+
createSurfaceWaveScene,
|
|
7
|
+
sampleGesture,
|
|
8
|
+
validateScene
|
|
9
|
+
} from "../src/index.js";
|
|
10
|
+
|
|
11
|
+
test("the default scene validates and merges payload overrides", () => {
|
|
12
|
+
const scene = validateScene(
|
|
13
|
+
createStringTouchScene({ payload: { text: "HELLO" } })
|
|
14
|
+
);
|
|
15
|
+
assert.equal(scene.payload.text, "HELLO");
|
|
16
|
+
assert.equal(scene.field.columns, 23);
|
|
17
|
+
assert.equal(scene.timing.fps * scene.timing.substeps, 240);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("the registry validates every implemented scene family", () => {
|
|
21
|
+
assert.equal(validateScene(createRigidBallsScene()).type, "rigid-balls");
|
|
22
|
+
assert.equal(validateScene(createSurfaceWaveScene()).type, "surface-wave");
|
|
23
|
+
assert.throws(
|
|
24
|
+
() => validateScene({ type: "cloth" }),
|
|
25
|
+
/Unsupported scene type/
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rigid impulse references and wave sample counts are guarded", () => {
|
|
30
|
+
assert.throws(
|
|
31
|
+
() =>
|
|
32
|
+
validateScene(
|
|
33
|
+
createRigidBallsScene({
|
|
34
|
+
impulses: [
|
|
35
|
+
{
|
|
36
|
+
time: 0.2,
|
|
37
|
+
body: "missing",
|
|
38
|
+
impulseX: 1,
|
|
39
|
+
impulseY: 1
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
})
|
|
43
|
+
),
|
|
44
|
+
/unknown body/
|
|
45
|
+
);
|
|
46
|
+
assert.throws(
|
|
47
|
+
() =>
|
|
48
|
+
validateScene(
|
|
49
|
+
createSurfaceWaveScene({ surface: { samples: 4 } })
|
|
50
|
+
),
|
|
51
|
+
/at least 16/
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("gesture sampling is smooth and bounded at both ends", () => {
|
|
56
|
+
const scene = createStringTouchScene();
|
|
57
|
+
const before = sampleGesture(scene.gesture.points, -10);
|
|
58
|
+
const middle = sampleGesture(scene.gesture.points, 1.3);
|
|
59
|
+
const after = sampleGesture(scene.gesture.points, 20);
|
|
60
|
+
assert.equal(before.pressure, 0);
|
|
61
|
+
assert.ok(middle.pressure > 0.9);
|
|
62
|
+
assert.equal(after.pressure, 0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("invalid pressure and unordered gesture points are rejected", () => {
|
|
66
|
+
assert.throws(
|
|
67
|
+
() =>
|
|
68
|
+
validateScene({
|
|
69
|
+
gesture: {
|
|
70
|
+
points: [
|
|
71
|
+
{ time: 1, x: 0, y: 0, pressure: 0 },
|
|
72
|
+
{ time: 0, x: 1, y: 1, pressure: 1.2 }
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
}),
|
|
76
|
+
/pressure|ordered/
|
|
77
|
+
);
|
|
78
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createSurfaceWaveScene,
|
|
5
|
+
simulateScene,
|
|
6
|
+
validateScene
|
|
7
|
+
} from "../src/index.js";
|
|
8
|
+
|
|
9
|
+
test("the coupled wave is deterministic and responds to impact", () => {
|
|
10
|
+
const scene = validateScene(createSurfaceWaveScene());
|
|
11
|
+
const first = simulateScene(scene, { includeFrames: false });
|
|
12
|
+
const second = simulateScene(scene, { includeFrames: false });
|
|
13
|
+
|
|
14
|
+
assert.equal(first.fingerprint, second.fingerprint);
|
|
15
|
+
assert.ok(first.diagnostics.impactCount > 0);
|
|
16
|
+
assert.ok(first.diagnostics.firstImpactTime > 0.5);
|
|
17
|
+
assert.ok(first.diagnostics.firstImpactTime < 1.5);
|
|
18
|
+
assert.ok(first.diagnostics.maxWaveHeight > 1);
|
|
19
|
+
assert.ok(first.diagnostics.maxWaveHeight < 100);
|
|
20
|
+
assert.ok(first.diagnostics.maxSubmersion < scene.body.height);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("surface parameters alter the deterministic result", () => {
|
|
24
|
+
const original = simulateScene(createSurfaceWaveScene(), {
|
|
25
|
+
includeFrames: false
|
|
26
|
+
});
|
|
27
|
+
const calmer = simulateScene(
|
|
28
|
+
createSurfaceWaveScene({
|
|
29
|
+
surface: { damping: 5.5, coupling: 0.22 }
|
|
30
|
+
}),
|
|
31
|
+
{ includeFrames: false }
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
assert.notEqual(original.fingerprint, calmer.fingerprint);
|
|
35
|
+
assert.ok(
|
|
36
|
+
calmer.diagnostics.maxWaveHeight <
|
|
37
|
+
original.diagnostics.maxWaveHeight
|
|
38
|
+
);
|
|
39
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { simulateScene, validateScene } from "../src/index.js";
|
|
9
|
+
|
|
10
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const baseline = JSON.parse(
|
|
12
|
+
await readFile(resolve(root, "tests/fixtures/visual-baselines.json"), "utf8")
|
|
13
|
+
);
|
|
14
|
+
const expectedScenes = [
|
|
15
|
+
"string-touch",
|
|
16
|
+
"crystal-mobile",
|
|
17
|
+
"ball-lab",
|
|
18
|
+
"football-kick",
|
|
19
|
+
"shoe-splash"
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
test("approved recipe motion fingerprints remain stable", async () => {
|
|
23
|
+
assert.deepEqual(Object.keys(baseline.sceneFingerprints), expectedScenes);
|
|
24
|
+
for (const name of expectedScenes) {
|
|
25
|
+
const scene = validateScene(
|
|
26
|
+
JSON.parse(
|
|
27
|
+
await readFile(resolve(root, `examples/${name}/scene.json`), "utf8")
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
const actual = simulateScene(scene, { includeFrames: false }).fingerprint;
|
|
31
|
+
assert.equal(actual, baseline.sceneFingerprints[name], `${name} changed`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("approved visual source bytes remain stable", async () => {
|
|
36
|
+
assert.ok(Object.keys(baseline.fileSha256).length >= 14);
|
|
37
|
+
for (const [path, expected] of Object.entries(baseline.fileSha256)) {
|
|
38
|
+
const actual = createHash("sha256")
|
|
39
|
+
.update(await readFile(resolve(root, path)))
|
|
40
|
+
.digest("hex");
|
|
41
|
+
assert.equal(actual, expected, `${path} changed`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
workflow: general-video
|
|
3
|
+
flow: automation
|
|
4
|
+
storyboard: no
|
|
5
|
+
message: "OddlyAlive turns ordinary objects into deterministic, physically believable motion."
|
|
6
|
+
destination: github-and-social
|
|
7
|
+
aspect: 1920x1080
|
|
8
|
+
language: en
|
|
9
|
+
audience: creative-coders
|
|
10
|
+
length: 32s
|
|
11
|
+
angle: product-demo
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Intent
|
|
15
|
+
|
|
16
|
+
Create a local-first demo package for the OddlyAlive open-source alpha. Each
|
|
17
|
+
recipe must show the real solver and renderer at work, preserve the gallery's
|
|
18
|
+
paper-and-ink identity, and make the physical behavior understandable without
|
|
19
|
+
voice-over.
|
|
20
|
+
|
|
21
|
+
## Assets
|
|
22
|
+
|
|
23
|
+
- `../../src/` — the current OddlyAlive engine, bundled into the render so the
|
|
24
|
+
browser runtime has no external dependency.
|
|
25
|
+
- `../../assets/photoreal/` — locally frozen transparent object cutouts shared
|
|
26
|
+
with the browser examples; provenance is recorded beside the assets.
|
|
27
|
+
|
|
28
|
+
## Customizations
|
|
29
|
+
|
|
30
|
+
- Five faithful 6.4-second recipe demos.
|
|
31
|
+
- One 32-second combined reel.
|
|
32
|
+
- One lightweight GIF preview for the repository README.
|
|
33
|
+
- Silent by design so the demos work in GitHub, npm, and social previews.
|
|
34
|
+
|
|
35
|
+
## Notes
|
|
36
|
+
|
|
37
|
+
- No stock footage or third-party artwork; locally generated photographic
|
|
38
|
+
cutouts provide material detail while SVG owns deformation and physics.
|
|
39
|
+
- No live network data.
|
|
40
|
+
- Do not commit, push, release, or publish until the user reviews the package.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# OddlyAlive demo plan
|
|
2
|
+
|
|
3
|
+
| Time | Recipe | Physical idea | Visual proof |
|
|
4
|
+
| --- | --- | --- | --- |
|
|
5
|
+
| 00:00–00:06.4 | String Touch | Local contact, grip, slip, peel, and settle | Only nearby strands catch; every strand releases independently |
|
|
6
|
+
| 00:06.4–00:12.8 | Crystal Mobile | Coupled pendulum-like strands with weighted payloads | Crystals lag, overshoot, and settle at different rates |
|
|
7
|
+
| 00:12.8–00:19.2 | Ball Lab | Mass, restitution, friction, and collision | Three familiar balls bounce and roll with distinct character |
|
|
8
|
+
| 00:19.2–00:25.6 | Football Kick | A timed impulse under gravity | The ball launches, spins, bounces, and loses energy naturally |
|
|
9
|
+
| 00:25.6–00:32.0 | Shoe Splash | Rigid-body entry coupled to a wave surface | The shoe displaces water, floats, and leaves propagating waves |
|
|
10
|
+
|
|
11
|
+
Each scene uses the current OddlyAlive implementation at 60 fps with four
|
|
12
|
+
solver substeps per frame. The reel uses hard cuts so no transition hides the
|
|
13
|
+
start or end state of a simulation.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# OddlyAlive demo composition
|
|
2
|
+
|
|
3
|
+
This HyperFrames project renders the current five OddlyAlive recipes from the
|
|
4
|
+
real deterministic engine.
|
|
5
|
+
|
|
6
|
+
## Local preview
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm ci
|
|
10
|
+
npm run build:bundle
|
|
11
|
+
npm run dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Validate
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm run check
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Render the 32-second reel
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm run render -- \
|
|
24
|
+
-o renders/oddlyalive-demos-overview.mp4 \
|
|
25
|
+
--fps 60 \
|
|
26
|
+
--quality high \
|
|
27
|
+
--strict
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The root-level `npm run demos:render` command is the preferred path because it
|
|
31
|
+
also cuts the five clips, rebuilds the README GIF, and verifies every encode.
|
|
32
|
+
|
|
33
|
+
## Source map
|
|
34
|
+
|
|
35
|
+
- `BRIEF.md` — intent and delivery constraints
|
|
36
|
+
- `DEMO-PLAN.md` — exact five-scene sequence
|
|
37
|
+
- `index.html` — visual system and timed clips
|
|
38
|
+
- `app.js` — scene configuration, simulation, and seek-safe timeline
|
|
39
|
+
- `../../src/` — current OddlyAlive source bundled by esbuild
|
|
40
|
+
- `../../assets/photoreal/` — canonical high-resolution object cutouts
|
|
41
|
+
- `prepare-assets.mjs` — copies canonical cutouts into the ignored local render cache
|
|
42
|
+
- `app.bundle.js` — generated by `npm run build:bundle` and ignored by Git
|
|
43
|
+
- `renders/`, `review/`, `snapshots/` — generated and ignored by Git
|
|
44
|
+
|
|
45
|
+
No Git or publishing command is part of this project.
|