@wenathlan/cadria 2.0.94 → 2.0.117
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/README.md +33 -1
- package/docs/.gitkeep +0 -0
- package/package.json +25 -2
- package/tests/.gitkeep +0 -0
- package/tests/audiodecode.test.ts +406 -0
- package/tests/audiofeatures.test.ts +271 -0
- package/tests/audiopipeline.test.ts +193 -0
- package/tests/audiorhythm.test.ts +172 -0
- package/tests/audiospectrum.test.ts +257 -0
- package/tests/audiostructure.test.ts +292 -0
- package/tests/audiotimbre.test.ts +270 -0
- package/tests/audiotonality.test.ts +289 -0
- package/tests/blendmodes.test.ts +31 -4
- package/tests/colorconvert.test.ts +3 -4
- package/tests/easecurves.test.ts +3 -4
- package/tests/family.test.ts +73 -0
- package/tests/familyurl.test.ts +14 -5
- package/tests/gateway.test.ts +373 -0
- package/tests/generation.test.ts +228 -0
- package/tests/imageexport.test.ts +443 -0
- package/tests/imageproject.test.ts +372 -0
- package/tests/imagerender.test.ts +291 -0
- package/tests/imagerenderpaint.test.ts +119 -0
- package/tests/imagestyles.test.ts +347 -0
- package/tests/masks.test.ts +3 -3
- package/tests/matiz.test.ts +8 -5
- package/tests/mediaprobe.test.ts +66 -22
- package/tests/onboardingstate.test.ts +73 -0
- package/tests/shadercomp.test.ts +120 -0
- package/tests/synthcomposition.test.ts +257 -0
- package/tests/synthmotion.test.ts +372 -0
- package/tests/synthpalette.test.ts +247 -0
- package/tests/synthtexture.test.ts +256 -0
- package/tests/timelineedit.test.ts +3 -4
- package/tests/timelineview.test.ts +248 -0
- package/tests/timeticks.test.ts +4 -4
- package/tests/transcodeplan.test.ts +19 -8
- package/tests/versawaseanimate.test.ts +106 -0
- package/tests/versawasescheduler.test.ts +96 -0
- package/tests/versawaseslices.test.ts +75 -0
- package/tests/visualize.test.ts +6 -6
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// # onboardingstate.test — the pure contract of the seen flags: the host
|
|
2
|
+
// validation of the platform base (https only, public hosts only) and the
|
|
3
|
+
// seen-wins stage merge the accessor and the pages share. runnable with
|
|
4
|
+
// vitest: pnpm vitest run tests/onboardingstate.test.ts
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { describe, it } from "vitest";
|
|
7
|
+
import { entrytarget, hostallowed, mergeStage } from "../onboardingstate.ts";
|
|
8
|
+
|
|
9
|
+
describe("onboarding state", () => {
|
|
10
|
+
it("accepts public https bases", () => {
|
|
11
|
+
assert.equal(hostallowed("https://gateway.wenathlan.dev"), true);
|
|
12
|
+
assert.equal(hostallowed("https://gateway.wenathlan.dev/"), true);
|
|
13
|
+
assert.equal(hostallowed("https://cadria.example.com:8443"), true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("refuses plaintext, localhost, loopback and every private or reserved range", () => {
|
|
17
|
+
const refused = [
|
|
18
|
+
"http://gateway.wenathlan.dev",
|
|
19
|
+
"https://localhost",
|
|
20
|
+
"https://localhost:8787",
|
|
21
|
+
"https://app.localhost",
|
|
22
|
+
"https://box.local",
|
|
23
|
+
"https://service.internal",
|
|
24
|
+
"https://127.0.0.1",
|
|
25
|
+
"https://0.0.0.0",
|
|
26
|
+
"https://10.0.0.9",
|
|
27
|
+
"https://172.16.0.1",
|
|
28
|
+
"https://172.31.255.255",
|
|
29
|
+
"https://192.168.1.4",
|
|
30
|
+
"https://169.254.10.10",
|
|
31
|
+
"https://100.64.0.1",
|
|
32
|
+
"https://192.0.2.7",
|
|
33
|
+
"https://198.51.100.7",
|
|
34
|
+
"https://203.0.113.9",
|
|
35
|
+
"https://[::1]",
|
|
36
|
+
"https://[::ffff:127.0.0.1]",
|
|
37
|
+
"https://[fd00::1]",
|
|
38
|
+
"https://[fe80::1]",
|
|
39
|
+
"not a url",
|
|
40
|
+
"",
|
|
41
|
+
];
|
|
42
|
+
for (const base of refused) assert.equal(hostallowed(base), false, base);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("merges stages seen-wins — a stage never unsees", () => {
|
|
46
|
+
const fresh = { introseen: false, onboardingseen: false };
|
|
47
|
+
assert.deepEqual(mergeStage(fresh, "intro"), { introseen: true, onboardingseen: false });
|
|
48
|
+
assert.deepEqual(mergeStage(fresh, "onboarding"), { introseen: false, onboardingseen: true });
|
|
49
|
+
const half = { introseen: true, onboardingseen: false };
|
|
50
|
+
assert.deepEqual(mergeStage(half, "onboarding"), { introseen: true, onboardingseen: true });
|
|
51
|
+
assert.deepEqual(mergeStage(mergeStage(half, "onboarding"), "intro"), {
|
|
52
|
+
introseen: true,
|
|
53
|
+
onboardingseen: true,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("never mutates the state it merges", () => {
|
|
58
|
+
const state = { introseen: false, onboardingseen: false };
|
|
59
|
+
mergeStage(state, "intro");
|
|
60
|
+
assert.deepEqual(state, { introseen: false, onboardingseen: false });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("hands the entry gate over in the reform order: intro, onboarding, home", () => {
|
|
64
|
+
const unseen = { introseen: false, onboardingseen: false };
|
|
65
|
+
const halfseen = mergeStage(unseen, "intro");
|
|
66
|
+
const fullseen = mergeStage(halfseen, "onboarding");
|
|
67
|
+
assert.equal(entrytarget(unseen), "/intro");
|
|
68
|
+
assert.equal(entrytarget(halfseen), "/onboarding");
|
|
69
|
+
assert.equal(entrytarget(fullseen), "/");
|
|
70
|
+
// the seen-wins merge can never send a visitor back through the flow
|
|
71
|
+
assert.equal(entrytarget(mergeStage(fullseen, "intro")), "/");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// # shadercomp test — determinism of compiled programs, the typed-outcome
|
|
2
|
+
// contract for damaged input, sha-256 integrity against the versawase
|
|
3
|
+
// PrecompiledShader manifest, and the monotone ramps of the dissolve planner.
|
|
4
|
+
// Every suite is pure: no GPU, no DOM, no timers.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
compileEffectProgram,
|
|
9
|
+
dissolvePlan,
|
|
10
|
+
libraryManifestComplete,
|
|
11
|
+
SHADER_EFFECTS,
|
|
12
|
+
shaderIdsKnown,
|
|
13
|
+
shaderManifest,
|
|
14
|
+
} from "../shadercomp.ts";
|
|
15
|
+
import { shaderManifestComplete } from "../versawase.ts";
|
|
16
|
+
|
|
17
|
+
describe("shadercomp library", () => {
|
|
18
|
+
it("ships every effect with a real film-technique vocabulary and honest ranges", () => {
|
|
19
|
+
expect(SHADER_EFFECTS.length).toBe(12);
|
|
20
|
+
for (const effect of SHADER_EFFECTS) {
|
|
21
|
+
expect(effect.label.length).toBeGreaterThan(0);
|
|
22
|
+
expect(effect.minAmount).toBeLessThanOrEqual(effect.defaultAmount);
|
|
23
|
+
expect(effect.defaultAmount).toBeLessThanOrEqual(effect.maxAmount);
|
|
24
|
+
expect(effect.glsl).toContain("vec4 effect(");
|
|
25
|
+
expect(effect.glsl).toContain("}");
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("compiles a program that is a complete, stable fragment source", () => {
|
|
30
|
+
const compiled = compileEffectProgram("halation", 0.4);
|
|
31
|
+
expect(compiled).not.toBeNull();
|
|
32
|
+
if (!compiled) return;
|
|
33
|
+
expect(compiled.program.startsWith("#version 300 es")).toBe(true);
|
|
34
|
+
expect(compiled.program).toContain("uniform sampler2D uSource;");
|
|
35
|
+
expect(compiled.program).toContain("void main()");
|
|
36
|
+
expect(compiled.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("answers the same sha-256 for the same id regardless of amount", () => {
|
|
40
|
+
const small = compileEffectProgram("grainfilm", 0.1);
|
|
41
|
+
const big = compileEffectProgram("grainfilm", 0.9);
|
|
42
|
+
expect(small?.sha256).toBe(big?.sha256);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("clamps amounts into the effect range instead of leaking NaN", () => {
|
|
46
|
+
const low = compileEffectProgram("vignette", -5);
|
|
47
|
+
const high = compileEffectProgram("vignette", 99);
|
|
48
|
+
expect(low?.amount).toBe(0);
|
|
49
|
+
expect(high?.amount).toBe(1);
|
|
50
|
+
const broken = compileEffectProgram("vignette", Number.NaN);
|
|
51
|
+
expect(broken?.amount).toBe(compileEffectProgram("vignette", 0.45)?.amount);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns null for unknown ids — no throw on damaged input", () => {
|
|
55
|
+
expect(compileEffectProgram("does-not-exist", 0.5)).toBeNull();
|
|
56
|
+
expect(shaderIdsKnown(["halation", "vignette"])).toBe(true);
|
|
57
|
+
expect(shaderIdsKnown(["halation", "ghost"])).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("builds a PrecompiledShader manifest versawase.ts accepts as complete", () => {
|
|
61
|
+
const manifest = shaderManifest();
|
|
62
|
+
expect(manifest.length).toBe(12);
|
|
63
|
+
expect(shaderManifestComplete(manifest)).toBe(true);
|
|
64
|
+
expect(libraryManifestComplete()).toBe(true);
|
|
65
|
+
for (const row of manifest) {
|
|
66
|
+
expect(row.stage).toBe("fragment");
|
|
67
|
+
expect(row.artifact.startsWith("inline://shadercomp/")).toBe(true);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("skips unknown ids inside a manifest request instead of failing the batch", () => {
|
|
72
|
+
const manifest = shaderManifest(["halation", "ghost", "vignette"]);
|
|
73
|
+
expect(manifest.length).toBe(2);
|
|
74
|
+
expect(manifest.map((row) => row.id)).toEqual(["halation-fragment", "vignette-fragment"]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("shadercomp dissolve planner", () => {
|
|
79
|
+
it("crossfades a lap dissolve with layers always summing to one", () => {
|
|
80
|
+
const outcome = dissolvePlan("lap-dissolve", 2, 9);
|
|
81
|
+
expect(outcome.ok).toBe(true);
|
|
82
|
+
if (!outcome.ok) return;
|
|
83
|
+
expect(outcome.plan.steps.length).toBe(9);
|
|
84
|
+
for (const step of outcome.plan.steps) {
|
|
85
|
+
expect(step.baseOpacity + step.overlayOpacity).toBeCloseTo(1, 5);
|
|
86
|
+
}
|
|
87
|
+
expect(outcome.plan.steps[0].baseOpacity).toBe(1);
|
|
88
|
+
expect(outcome.plan.steps[8].overlayOpacity).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps a straight cut binary — no half-opaque frames", () => {
|
|
92
|
+
const outcome = dissolvePlan("straight-cut", 1, 5);
|
|
93
|
+
expect(outcome.ok).toBe(true);
|
|
94
|
+
if (!outcome.ok) return;
|
|
95
|
+
for (const step of outcome.plan.steps) {
|
|
96
|
+
for (const opacity of [step.baseOpacity, step.overlayOpacity]) {
|
|
97
|
+
expect(opacity === 0 || opacity === 1).toBe(true);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("dips a fade-through-black to black at the midpoint", () => {
|
|
103
|
+
const outcome = dissolvePlan("fade-through-black", 4, 21);
|
|
104
|
+
expect(outcome.ok).toBe(true);
|
|
105
|
+
if (!outcome.ok) return;
|
|
106
|
+
const mid = outcome.plan.steps[10];
|
|
107
|
+
expect(mid.baseOpacity).toBeLessThanOrEqual(0.2);
|
|
108
|
+
expect(mid.overlayOpacity).toBeLessThanOrEqual(0.2);
|
|
109
|
+
expect(outcome.plan.steps[0].baseOpacity).toBe(1);
|
|
110
|
+
expect(outcome.plan.steps[20].overlayOpacity).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("answers typed errors for unknown technique, bad duration and bad steps", () => {
|
|
114
|
+
expect(dissolvePlan("cube-spin", 2, 9)).toEqual({ ok: false, error: "dissolve-unknown-technique" });
|
|
115
|
+
expect(dissolvePlan("lap-dissolve", 0, 9)).toEqual({ ok: false, error: "dissolve-bad-duration" });
|
|
116
|
+
expect(dissolvePlan("lap-dissolve", Number.NaN, 9)).toEqual({ ok: false, error: "dissolve-bad-duration" });
|
|
117
|
+
expect(dissolvePlan("lap-dissolve", 2, 1)).toEqual({ ok: false, error: "dissolve-bad-steps" });
|
|
118
|
+
expect(dissolvePlan("lap-dissolve", 2, 2.5)).toEqual({ ok: false, error: "dissolve-bad-steps" });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// # synthcomposition.test — the composition grammar of the cadria image wave,
|
|
2
|
+
// runnable with vitest:
|
|
3
|
+
// pnpm vitest run tests/synthcomposition.test.ts
|
|
4
|
+
// The tests watch the promises the module makes: the grid grammar tracks
|
|
5
|
+
// section density and repetition (dense → more columns, repetitive → a tight
|
|
6
|
+
// regular lattice), the role cast is exact (one hero, always the largest and
|
|
7
|
+
// heaviest; ≤ 8 cadre; edge blocks own the border), the contour anchors the
|
|
8
|
+
// hero vertically (rise high, fall low, arch centered, wave two peaks), the
|
|
9
|
+
// geometry is total (rects inside 0-1, no overlap beyond the gutter
|
|
10
|
+
// tolerance, weights summing to 1) and everything is deterministic — same
|
|
11
|
+
// descriptor, same blocks; different seed, different jitter.
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "vitest";
|
|
14
|
+
import type { AudioDescriptor } from "../audioattributes.ts";
|
|
15
|
+
import { DIM_ORDER, NARRATIVE_CONTOUR } from "../audiofeatures.ts";
|
|
16
|
+
import {
|
|
17
|
+
type Block,
|
|
18
|
+
compositionBalance,
|
|
19
|
+
compositionBlocks,
|
|
20
|
+
compositionGrid,
|
|
21
|
+
GRID_MAX,
|
|
22
|
+
GRID_MIN,
|
|
23
|
+
HISTOGRAM_BINS,
|
|
24
|
+
rhythmScatter,
|
|
25
|
+
} from "../synthcomposition.ts";
|
|
26
|
+
|
|
27
|
+
/** builds a descriptor from named dim overrides over a 0.5 mid field. */
|
|
28
|
+
function desc(over: Record<string, number> = {}, seed = "9a2f41c07b3d5e88"): AudioDescriptor {
|
|
29
|
+
const vector = new Array<number>(32).fill(0.5);
|
|
30
|
+
for (const [name, value] of Object.entries(over)) vector[DIM_ORDER.indexOf(name)] = value;
|
|
31
|
+
return { version: 1, seed, vector, scalar: {} };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CONTOUR = NARRATIVE_CONTOUR;
|
|
35
|
+
/** dense modern production: many sections, some repetition, a real peak. */
|
|
36
|
+
const dense = desc({ sectionDensity: 0.9, repetition: 0.2, peakMass: 0.8, narrativeContour: CONTOUR.arch });
|
|
37
|
+
/** sparse through-composed clip: few sections, little repetition. */
|
|
38
|
+
const sparse = desc({ sectionDensity: 0.05, repetition: 0.05, peakMass: 0.3 });
|
|
39
|
+
/** loop-pack tile: maximal repetition over dense sections. */
|
|
40
|
+
const repetitive = desc({ sectionDensity: 0.9, repetition: 1, peakMass: 0.5 });
|
|
41
|
+
/** every structure dim broken at once. */
|
|
42
|
+
const damaged = desc({
|
|
43
|
+
sectionDensity: NaN,
|
|
44
|
+
repetition: NaN,
|
|
45
|
+
peakMass: NaN,
|
|
46
|
+
narrativeContour: NaN,
|
|
47
|
+
onsetDensity: NaN,
|
|
48
|
+
swing: NaN,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function heroOf(blocks: Block[]): Block {
|
|
52
|
+
const heroes = blocks.filter((b) => b.role === "hero");
|
|
53
|
+
assert.equal(heroes.length, 1);
|
|
54
|
+
return heroes[0];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function area(b: Block): number {
|
|
58
|
+
return b.rect.w * b.rect.h;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** a block touches the canvas border when one of its edges sits on 0 or 1. */
|
|
62
|
+
function touchesBorder(b: Block): boolean {
|
|
63
|
+
const r = b.rect;
|
|
64
|
+
return r.x === 0 || r.y === 0 || Math.abs(r.x + r.w - 1) < 1e-9 || Math.abs(r.y + r.h - 1) < 1e-9;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("synthcomposition grid", () => {
|
|
68
|
+
it("tracks section density into more columns, inside the documented 3-12 range", () => {
|
|
69
|
+
const denseGrid = compositionGrid(dense);
|
|
70
|
+
const sparseGrid = compositionGrid(sparse);
|
|
71
|
+
assert.ok(denseGrid.columns > sparseGrid.columns);
|
|
72
|
+
for (const grid of [denseGrid, sparseGrid]) {
|
|
73
|
+
assert.ok(grid.columns >= GRID_MIN && grid.columns <= GRID_MAX);
|
|
74
|
+
assert.ok(grid.rows >= GRID_MIN && grid.rows <= GRID_MAX);
|
|
75
|
+
}
|
|
76
|
+
assert.equal(compositionGrid(desc({ sectionDensity: 1 })).columns, GRID_MAX);
|
|
77
|
+
assert.equal(compositionGrid(desc({ sectionDensity: 0 })).columns, GRID_MIN);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("tiles repetitive audio as a tight regular lattice with uniform cells", () => {
|
|
81
|
+
const tight = compositionGrid(repetitive);
|
|
82
|
+
const loose = compositionGrid(sparse);
|
|
83
|
+
assert.equal(tight.rows, tight.columns); // regular: square lattice
|
|
84
|
+
assert.ok(tight.gutter <= 0.012);
|
|
85
|
+
assert.ok(tight.margin < 0.03);
|
|
86
|
+
assert.ok(loose.gutter > 0.03);
|
|
87
|
+
const fields = compositionBlocks(repetitive).filter((b) => b.role === "field");
|
|
88
|
+
assert.ok(fields.length > 0);
|
|
89
|
+
for (const b of fields) {
|
|
90
|
+
assert.equal(b.rect.w, fields[0].rect.w);
|
|
91
|
+
assert.equal(b.rect.h, fields[0].rect.h);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps the grammar total on damaged descriptors", () => {
|
|
96
|
+
const grid = compositionGrid(damaged);
|
|
97
|
+
assert.ok(Number.isInteger(grid.columns) && Number.isInteger(grid.rows));
|
|
98
|
+
assert.ok(grid.columns >= GRID_MIN && grid.columns <= GRID_MAX);
|
|
99
|
+
assert.ok(grid.rows >= GRID_MIN && grid.rows <= GRID_MAX);
|
|
100
|
+
assert.ok(Number.isFinite(grid.gutter) && grid.gutter >= 0);
|
|
101
|
+
assert.ok(Number.isFinite(grid.margin) && grid.margin >= 0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("synthcomposition blocks", () => {
|
|
106
|
+
it("casts exactly one hero and it is the largest, heaviest block", () => {
|
|
107
|
+
const blocks = compositionBlocks(dense);
|
|
108
|
+
const hero = heroOf(blocks);
|
|
109
|
+
for (const b of blocks) {
|
|
110
|
+
if (b.role !== "hero") assert.ok(area(hero) > area(b));
|
|
111
|
+
}
|
|
112
|
+
const top = blocks.reduce((max, b) => Math.max(max, b.weight), 0);
|
|
113
|
+
assert.equal(hero.weight, top);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rings the hero with at most 8 cadre and fills field and edge roles", () => {
|
|
117
|
+
const blocks = compositionBlocks(dense);
|
|
118
|
+
const cadres = blocks.filter((b) => b.role === "cadre");
|
|
119
|
+
const edges = blocks.filter((b) => b.role === "edge");
|
|
120
|
+
const fields = blocks.filter((b) => b.role === "field");
|
|
121
|
+
assert.ok(cadres.length >= 1 && cadres.length <= 8);
|
|
122
|
+
assert.equal(cadres.length, 8); // the dense hero always gets its full ring
|
|
123
|
+
assert.ok(edges.length > 0 && fields.length > 0);
|
|
124
|
+
for (const b of edges) assert.ok(touchesBorder(b));
|
|
125
|
+
for (const b of fields) assert.ok(!touchesBorder(b));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("places the hero high on a rise contour", () => {
|
|
129
|
+
const rise = desc({ sectionDensity: 0.9, repetition: 0.2, peakMass: 0.8, narrativeContour: CONTOUR.rise });
|
|
130
|
+
const hero = heroOf(compositionBlocks(rise));
|
|
131
|
+
const centerY = hero.rect.y + hero.rect.h / 2;
|
|
132
|
+
assert.ok(centerY < 0.45, `rise hero centerY ${centerY} should sit above 0.45`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("places the hero low on a fall contour", () => {
|
|
136
|
+
const fall = desc({ sectionDensity: 0.9, repetition: 0.2, peakMass: 0.8, narrativeContour: CONTOUR.fall });
|
|
137
|
+
const hero = heroOf(compositionBlocks(fall));
|
|
138
|
+
const centerY = hero.rect.y + hero.rect.h / 2;
|
|
139
|
+
assert.ok(centerY > 0.55, `fall hero centerY ${centerY} should sit below 0.55`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("centers the hero on an arch contour", () => {
|
|
143
|
+
const hero = heroOf(compositionBlocks(dense));
|
|
144
|
+
const centerY = hero.rect.y + hero.rect.h / 2;
|
|
145
|
+
assert.ok(Math.abs(centerY - 0.5) <= 0.05, `arch hero centerY ${centerY} should sit near 0.5`);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("splits a wave contour into two peaks", () => {
|
|
149
|
+
const wave = desc({ sectionDensity: 0.9, repetition: 0.2, peakMass: 0.4, narrativeContour: CONTOUR.wave });
|
|
150
|
+
const blocks = compositionBlocks(wave);
|
|
151
|
+
const hero = heroOf(blocks);
|
|
152
|
+
assert.ok(hero.rect.y + hero.rect.h / 2 < 0.5);
|
|
153
|
+
const lowCadre = blocks.filter((b) => b.role === "cadre" && b.rect.y + b.rect.h / 2 > 0.55);
|
|
154
|
+
assert.ok(lowCadre.length >= 1, "the mirrored second peak should ring low");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("keeps every rect finite and inside the normalized canvas", () => {
|
|
158
|
+
for (const d of [dense, sparse, repetitive, damaged]) {
|
|
159
|
+
for (const b of compositionBlocks(d)) {
|
|
160
|
+
for (const v of [b.rect.x, b.rect.y, b.rect.w, b.rect.h]) {
|
|
161
|
+
assert.ok(Number.isFinite(v) && v >= 0 && v <= 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("never lets blocks overlap beyond the gutter tolerance", () => {
|
|
168
|
+
const jittery = desc({
|
|
169
|
+
sectionDensity: 0.9,
|
|
170
|
+
repetition: 0.2,
|
|
171
|
+
peakMass: 0.8,
|
|
172
|
+
narrativeContour: CONTOUR.arch,
|
|
173
|
+
onsetDensity: 1,
|
|
174
|
+
swing: 0,
|
|
175
|
+
});
|
|
176
|
+
const tol = compositionGrid(jittery).gutter + 1e-9;
|
|
177
|
+
const scattered = rhythmScatter(jittery, compositionBlocks(jittery));
|
|
178
|
+
for (let i = 0; i < scattered.length; i += 1) {
|
|
179
|
+
for (let j = i + 1; j < scattered.length; j += 1) {
|
|
180
|
+
const a = scattered[i].rect;
|
|
181
|
+
const b = scattered[j].rect;
|
|
182
|
+
const ox = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
183
|
+
const oy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
184
|
+
assert.ok(!(ox > tol && oy > tol), `blocks ${i} and ${j} overlap beyond the gutter`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("normalizes weights to sum 1, each inside (0, 1]", () => {
|
|
190
|
+
for (const d of [dense, sparse, repetitive, damaged]) {
|
|
191
|
+
const blocks = compositionBlocks(d);
|
|
192
|
+
const sum = blocks.reduce((acc, b) => acc + b.weight, 0);
|
|
193
|
+
assert.ok(Math.abs(sum - 1) < 1e-9);
|
|
194
|
+
for (const b of blocks) assert.ok(b.weight > 0 && b.weight <= 1 + 1e-12);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("synthcomposition rhythm scatter", () => {
|
|
200
|
+
it("answers identical blocks for identical descriptors and grids", () => {
|
|
201
|
+
assert.deepEqual(compositionBlocks(dense), compositionBlocks(dense));
|
|
202
|
+
assert.deepEqual(compositionBlocks(dense, compositionGrid(dense)), compositionBlocks(dense));
|
|
203
|
+
const blocks = compositionBlocks(dense);
|
|
204
|
+
assert.deepEqual(rhythmScatter(dense, blocks), rhythmScatter(dense, blocks));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("jitters differently under different seeds, same roles and weights", () => {
|
|
208
|
+
const a = desc({ sectionDensity: 0.9, repetition: 0.1, peakMass: 0.5, onsetDensity: 1 }, "1111111111111111");
|
|
209
|
+
const b = desc({ sectionDensity: 0.9, repetition: 0.1, peakMass: 0.5, onsetDensity: 1 }, "2222222222222222");
|
|
210
|
+
const blocks = compositionBlocks(a);
|
|
211
|
+
const scatteredA = rhythmScatter(a, blocks);
|
|
212
|
+
const scatteredB = rhythmScatter(b, blocks);
|
|
213
|
+
assert.deepEqual(
|
|
214
|
+
scatteredA.map((s) => [s.role, s.weight]),
|
|
215
|
+
scatteredB.map((s) => [s.role, s.weight]),
|
|
216
|
+
);
|
|
217
|
+
assert.ok(
|
|
218
|
+
scatteredA.some((s, i) => s.rect.x !== scatteredB[i].rect.x || s.rect.y !== scatteredB[i].rect.y),
|
|
219
|
+
"distinct seeds must move at least one block",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("leans the jitter late under swing", () => {
|
|
224
|
+
const base = { sectionDensity: 0.9, repetition: 0.1, peakMass: 0.5, onsetDensity: 1 };
|
|
225
|
+
const straight = rhythmScatter(desc(base, "feedfacefeedface"), compositionBlocks(desc(base)));
|
|
226
|
+
const swung = rhythmScatter(desc({ ...base, swing: 1 }, "feedfacefeedface"), compositionBlocks(desc(base)));
|
|
227
|
+
const meanX = (blocks: Block[]): number => blocks.reduce((acc, b) => acc + b.rect.x, 0) / blocks.length;
|
|
228
|
+
assert.ok(meanX(swung) > meanX(straight), "swing should push the jitter field in +x");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("synthcomposition balance", () => {
|
|
233
|
+
it("audits a symmetric fixture near the canvas center with a full histogram", () => {
|
|
234
|
+
const symmetric = desc({ sectionDensity: 0.5, repetition: 0.5, peakMass: 0.2, narrativeContour: CONTOUR.arch });
|
|
235
|
+
const report = compositionBalance(compositionBlocks(symmetric));
|
|
236
|
+
assert.ok(Math.abs(report.centerX - 0.5) <= 0.15, `centerX ${report.centerX}`);
|
|
237
|
+
assert.ok(Math.abs(report.centerY - 0.5) <= 0.15, `centerY ${report.centerY}`);
|
|
238
|
+
assert.equal(report.densityHistogram.length, HISTOGRAM_BINS);
|
|
239
|
+
const histSum = report.densityHistogram.reduce((acc, v) => acc + v, 0);
|
|
240
|
+
assert.ok(Math.abs(histSum - 1) < 1e-9);
|
|
241
|
+
for (const v of report.densityHistogram) assert.ok(Number.isFinite(v) && v >= 0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("stays finite and neutral on damaged input", () => {
|
|
245
|
+
const blocks = compositionBlocks(damaged);
|
|
246
|
+
assert.equal(blocks.length, 9); // the quiet 3x3 fallback: one hero, a full ring, no fill
|
|
247
|
+
for (const b of blocks) {
|
|
248
|
+
for (const v of [b.rect.x, b.rect.y, b.rect.w, b.rect.h, b.weight]) assert.ok(Number.isFinite(v));
|
|
249
|
+
}
|
|
250
|
+
const report = compositionBalance(blocks);
|
|
251
|
+
for (const v of [report.centerX, report.centerY, ...report.densityHistogram]) assert.ok(Number.isFinite(v));
|
|
252
|
+
const scattered = rhythmScatter(damaged, blocks);
|
|
253
|
+
for (const b of scattered) {
|
|
254
|
+
for (const v of [b.rect.x, b.rect.y, b.rect.w, b.rect.h]) assert.ok(Number.isFinite(v) && v >= 0 && v <= 1);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
});
|