@wenathlan/cadria 2.0.94 → 2.0.118
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,372 @@
|
|
|
1
|
+
// # imageproject.test — the PROJECT layer's contract, runnable with vitest:
|
|
2
|
+
// pnpm vitest run tests/imageproject.test.ts
|
|
3
|
+
// The tests watch five promises: the seeded rng replays bit-for-bit and stays
|
|
4
|
+
// uniform, buildImageProject validates every field and derives the rest
|
|
5
|
+
// (audio stats, id slug, orientation), the bundle is deep-frozen while its
|
|
6
|
+
// inputs stay untouched, serialize→parse round trips exactly (and refuses
|
|
7
|
+
// other versions), and projectVariants keep the audio while rotating seed +
|
|
8
|
+
// anchor hue. Every damage path answers ProjectError with its taxonomy code.
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { describe, it } from "vitest";
|
|
11
|
+
import type { AudioDescriptor } from "../audioattributes.ts";
|
|
12
|
+
import type { ImageProject } from "../imageproject.ts";
|
|
13
|
+
import {
|
|
14
|
+
buildImageProject,
|
|
15
|
+
ProjectError,
|
|
16
|
+
parseProject,
|
|
17
|
+
projectVariants,
|
|
18
|
+
rngFromSeed,
|
|
19
|
+
rngInt,
|
|
20
|
+
rngPick,
|
|
21
|
+
serializeProject,
|
|
22
|
+
VARIANT_HUE_STEP,
|
|
23
|
+
} from "../imageproject.ts";
|
|
24
|
+
import { compositionBlocks } from "../synthcomposition.ts";
|
|
25
|
+
import { synthMotion } from "../synthmotion.ts";
|
|
26
|
+
import { synthPalette } from "../synthpalette.ts";
|
|
27
|
+
import { synthTexture } from "../synthtexture.ts";
|
|
28
|
+
|
|
29
|
+
const SEED = "9a7c1e2b44d0f836";
|
|
30
|
+
|
|
31
|
+
/** a contract-true fixture descriptor: 32 mid dims, a few peaks, real scalars. */
|
|
32
|
+
function descriptor(): AudioDescriptor {
|
|
33
|
+
const vector = new Array<number>(32).fill(0.4);
|
|
34
|
+
vector[9] = 0.6;
|
|
35
|
+
vector[12] = 0.8;
|
|
36
|
+
vector[22] = 0.7;
|
|
37
|
+
vector[24] = 0.6;
|
|
38
|
+
vector[26] = 1;
|
|
39
|
+
vector[27] = 0.5;
|
|
40
|
+
vector[29] = 0.55;
|
|
41
|
+
return { version: 1, seed: SEED, vector, scalar: { bpm: 124, durationMs: 96000, tonic: 9, minor: 1 } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** a fully valid project assembled from the real wave-2 synth chain. */
|
|
45
|
+
function project(width = 1080, height = 1080, seed?: string): ImageProject {
|
|
46
|
+
const d = descriptor();
|
|
47
|
+
return buildImageProject({
|
|
48
|
+
descriptor: d,
|
|
49
|
+
seed,
|
|
50
|
+
palette: synthPalette(d),
|
|
51
|
+
blocks: compositionBlocks(d),
|
|
52
|
+
texture: synthTexture(d),
|
|
53
|
+
motion: synthMotion(d),
|
|
54
|
+
width,
|
|
55
|
+
height,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** the fixture's four wave-2 specs, ready to mix into damaged builds. */
|
|
60
|
+
function specs() {
|
|
61
|
+
const d = descriptor();
|
|
62
|
+
return {
|
|
63
|
+
d,
|
|
64
|
+
palette: synthPalette(d),
|
|
65
|
+
blocks: compositionBlocks(d),
|
|
66
|
+
texture: synthTexture(d),
|
|
67
|
+
motion: synthMotion(d),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** runs fn, asserts a ProjectError comes back, answers its taxonomy code. */
|
|
72
|
+
function codeOf(run: () => unknown): string {
|
|
73
|
+
try {
|
|
74
|
+
run();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
assert.ok(error instanceof ProjectError, "throws ProjectError");
|
|
77
|
+
return error.code;
|
|
78
|
+
}
|
|
79
|
+
assert.fail("expected a throw");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("seeded rng", () => {
|
|
83
|
+
it("same seed replays the identical 100-draw sequence; another seed differs", () => {
|
|
84
|
+
const a = Array.from({ length: 100 }, () => rngFromSeed(SEED)());
|
|
85
|
+
const b = Array.from({ length: 100 }, () => rngFromSeed(SEED)());
|
|
86
|
+
assert.deepEqual(a, b);
|
|
87
|
+
const other = Array.from({ length: 100 }, () => rngFromSeed("another-lane")());
|
|
88
|
+
assert.notDeepEqual(a, other);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("draws land in [0,1) with a sane mean over 1000 draws", () => {
|
|
92
|
+
const rng = rngFromSeed("distribution-probe");
|
|
93
|
+
const draws = Array.from({ length: 1000 }, () => rng());
|
|
94
|
+
for (const v of draws) assert.ok(v >= 0 && v < 1, `draw ${v} in [0,1)`);
|
|
95
|
+
const mean = draws.reduce((sum, v) => sum + v, 0) / draws.length;
|
|
96
|
+
assert.ok(mean >= 0.4 && mean <= 0.6, `mean ${mean} in 0.4-0.6`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rngInt is inclusive and deterministic; rngPick stays in the list; damage coded", () => {
|
|
100
|
+
const rng = rngFromSeed("int-probe");
|
|
101
|
+
const draws = Array.from({ length: 200 }, () => rngInt(rng, 2, 5));
|
|
102
|
+
for (const v of draws) assert.ok(Number.isInteger(v) && v >= 2 && v <= 5, `int ${v} in [2,5]`);
|
|
103
|
+
assert.ok(new Set(draws).size > 1, "both bounds reachable");
|
|
104
|
+
assert.deepEqual(
|
|
105
|
+
Array.from({ length: 8 }, () => rngInt(rngFromSeed("same-lane"), 0, 9)),
|
|
106
|
+
Array.from({ length: 8 }, () => rngInt(rngFromSeed("same-lane"), 0, 9)),
|
|
107
|
+
);
|
|
108
|
+
for (let i = 0; i < 20; i += 1) assert.ok(["a", "b", "c"].includes(rngPick(rng, ["a", "b", "c"])));
|
|
109
|
+
assert.equal(
|
|
110
|
+
codeOf(() => rngPick(rng, [] as string[])),
|
|
111
|
+
"project-shape",
|
|
112
|
+
);
|
|
113
|
+
assert.equal(
|
|
114
|
+
codeOf(() => rngInt(rng, 5, 2)),
|
|
115
|
+
"project-range",
|
|
116
|
+
);
|
|
117
|
+
assert.equal(
|
|
118
|
+
codeOf(() => rngInt(rng, 1.5, 4)),
|
|
119
|
+
"project-range",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("buildImageProject", () => {
|
|
125
|
+
it("validates and derives every field from the wave-2 chain", () => {
|
|
126
|
+
const p = project();
|
|
127
|
+
assert.equal(p.version, 1);
|
|
128
|
+
assert.equal(p.seed, SEED); // seed defaults to descriptor.seed
|
|
129
|
+
assert.equal(p.style, "atelier"); // style default
|
|
130
|
+
assert.equal(p.id, `${SEED}-atelier`); // slug of seed + style
|
|
131
|
+
assert.deepEqual(p.audio, { durationMs: 96000, bpm: 124, key: "am" });
|
|
132
|
+
assert.deepEqual(p.canvas, { width: 1080, height: 1080, orientation: "square" });
|
|
133
|
+
assert.equal(p.descriptor.seed, SEED);
|
|
134
|
+
assert.equal(p.descriptor.vector.length, 32);
|
|
135
|
+
assert.equal(Object.keys(p.texture).length, 10);
|
|
136
|
+
assert.ok(p.blocks.length >= 1 && p.motion.keyframes.length >= 1);
|
|
137
|
+
assert.equal(Number.isNaN(Date.parse(p.createdAt)), false); // ISO stamp present
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("orientation derives square / portrait / landscape from w×h", () => {
|
|
141
|
+
assert.equal(project(1080, 1080).canvas.orientation, "square");
|
|
142
|
+
assert.equal(project(1080, 1350).canvas.orientation, "portrait");
|
|
143
|
+
assert.equal(project(1350, 1080).canvas.orientation, "landscape");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("id slug is deterministic, slugified and seed-sensitive", () => {
|
|
147
|
+
assert.equal(project(1080, 1080).id, project(1080, 1080).id);
|
|
148
|
+
assert.notEqual(project(1080, 1080, "aa11bb22cc33").id, project(1080, 1080, "dd44ee55ff66").id);
|
|
149
|
+
const d = descriptor();
|
|
150
|
+
const styled = buildImageProject({
|
|
151
|
+
descriptor: d,
|
|
152
|
+
style: "Atelier V2!",
|
|
153
|
+
palette: synthPalette(d),
|
|
154
|
+
blocks: compositionBlocks(d),
|
|
155
|
+
texture: synthTexture(d),
|
|
156
|
+
motion: synthMotion(d),
|
|
157
|
+
});
|
|
158
|
+
assert.equal(styled.id, `${SEED}-atelier-v2`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("the bundle is deep-frozen at every level; caller inputs stay untouched", () => {
|
|
162
|
+
const d = descriptor();
|
|
163
|
+
const palette = synthPalette(d);
|
|
164
|
+
const p = buildImageProject({
|
|
165
|
+
descriptor: d,
|
|
166
|
+
palette,
|
|
167
|
+
blocks: compositionBlocks(d),
|
|
168
|
+
texture: synthTexture(d),
|
|
169
|
+
motion: synthMotion(d),
|
|
170
|
+
});
|
|
171
|
+
for (const frozen of [
|
|
172
|
+
p,
|
|
173
|
+
p.audio,
|
|
174
|
+
p.descriptor,
|
|
175
|
+
p.descriptor.vector,
|
|
176
|
+
p.descriptor.scalar,
|
|
177
|
+
p.palette,
|
|
178
|
+
p.palette.support,
|
|
179
|
+
p.palette.accents,
|
|
180
|
+
p.palette.oklch.anchor,
|
|
181
|
+
p.blocks,
|
|
182
|
+
p.blocks[0].rect,
|
|
183
|
+
p.texture,
|
|
184
|
+
p.motion,
|
|
185
|
+
p.motion.keyframes[0],
|
|
186
|
+
p.canvas,
|
|
187
|
+
])
|
|
188
|
+
assert.ok(Object.isFrozen(frozen), "frozen");
|
|
189
|
+
assert.equal(Object.isFrozen(d.vector), false); // the input replica is fresh
|
|
190
|
+
assert.equal(Object.isFrozen(palette), false);
|
|
191
|
+
assert.equal(d.vector[9], 0.6); // inputs unmutated
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("damaged input answers ProjectError code project-shape", () => {
|
|
195
|
+
const { d, palette, blocks, texture, motion } = specs();
|
|
196
|
+
const short = { version: 1, seed: "x", vector: new Array(31).fill(0.5), scalar: {} } as unknown as AudioDescriptor;
|
|
197
|
+
assert.equal(
|
|
198
|
+
codeOf(() => buildImageProject({ descriptor: short, palette, blocks, texture, motion })),
|
|
199
|
+
"project-shape",
|
|
200
|
+
);
|
|
201
|
+
assert.equal(
|
|
202
|
+
codeOf(() => buildImageProject({ descriptor: d, palette, blocks: [], texture, motion })),
|
|
203
|
+
"project-shape",
|
|
204
|
+
);
|
|
205
|
+
assert.equal(
|
|
206
|
+
codeOf(() =>
|
|
207
|
+
buildImageProject({ descriptor: d, palette: { ...palette, anchor: "red" }, blocks, texture, motion }),
|
|
208
|
+
),
|
|
209
|
+
"project-shape",
|
|
210
|
+
);
|
|
211
|
+
assert.equal(
|
|
212
|
+
codeOf(() =>
|
|
213
|
+
buildImageProject({ descriptor: d, palette, blocks, texture, motion: { ...motion, easing: "elastic" } }),
|
|
214
|
+
),
|
|
215
|
+
"project-shape",
|
|
216
|
+
);
|
|
217
|
+
assert.equal(
|
|
218
|
+
codeOf(() =>
|
|
219
|
+
buildImageProject({ descriptor: d, palette, blocks, texture: { ...texture, grainSize: "fat" }, motion }),
|
|
220
|
+
),
|
|
221
|
+
"project-shape",
|
|
222
|
+
);
|
|
223
|
+
assert.equal(
|
|
224
|
+
codeOf(() => buildImageProject(null as never)),
|
|
225
|
+
"project-shape",
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("non-finite / out-of-range numbers answer ProjectError code project-range", () => {
|
|
230
|
+
const { d, palette, blocks, texture, motion } = specs();
|
|
231
|
+
const nanPalette = { ...palette, oklch: { ...palette.oklch, anchor: { ...palette.oklch.anchor, l: NaN } } };
|
|
232
|
+
assert.equal(
|
|
233
|
+
codeOf(() => buildImageProject({ descriptor: d, palette: nanPalette, blocks, texture, motion })),
|
|
234
|
+
"project-range",
|
|
235
|
+
);
|
|
236
|
+
const nanVector = descriptor();
|
|
237
|
+
nanVector.vector[3] = NaN;
|
|
238
|
+
assert.equal(
|
|
239
|
+
codeOf(() => buildImageProject({ descriptor: nanVector, palette, blocks, texture, motion })),
|
|
240
|
+
"project-range",
|
|
241
|
+
);
|
|
242
|
+
assert.equal(
|
|
243
|
+
codeOf(() =>
|
|
244
|
+
buildImageProject({ descriptor: d, palette, blocks, texture: { ...texture, grainDensity: 4 }, motion }),
|
|
245
|
+
),
|
|
246
|
+
"project-range",
|
|
247
|
+
);
|
|
248
|
+
const fat = blocks.map((b, i) => (i === 0 ? { ...b, weight: 1.5 } : b));
|
|
249
|
+
assert.equal(
|
|
250
|
+
codeOf(() => buildImageProject({ descriptor: d, palette, blocks: fat, texture, motion })),
|
|
251
|
+
"project-range",
|
|
252
|
+
);
|
|
253
|
+
assert.equal(
|
|
254
|
+
codeOf(() => buildImageProject({ descriptor: d, palette, blocks, texture, motion, width: 99999 })),
|
|
255
|
+
"project-range",
|
|
256
|
+
);
|
|
257
|
+
assert.equal(
|
|
258
|
+
codeOf(() =>
|
|
259
|
+
buildImageProject({ descriptor: d, palette, blocks, texture, motion: { ...motion, parallaxDepth: 2 } }),
|
|
260
|
+
),
|
|
261
|
+
"project-range",
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("an unreadable descriptor or bundle version answers project-version", () => {
|
|
266
|
+
const { palette, blocks, texture, motion } = specs();
|
|
267
|
+
const v2 = { ...descriptor(), version: 2 } as unknown as AudioDescriptor;
|
|
268
|
+
assert.equal(
|
|
269
|
+
codeOf(() => buildImageProject({ descriptor: v2, palette, blocks, texture, motion })),
|
|
270
|
+
"project-version",
|
|
271
|
+
);
|
|
272
|
+
const p = project();
|
|
273
|
+
const bumped = JSON.stringify({ ...JSON.parse(serializeProject(p)), version: 2 });
|
|
274
|
+
assert.equal(
|
|
275
|
+
codeOf(() => parseProject(bumped)),
|
|
276
|
+
"project-version",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("serialize / parse", () => {
|
|
282
|
+
it("serialize→parse round trips deep-equal, minus nothing", () => {
|
|
283
|
+
const p = project(1080, 1350);
|
|
284
|
+
assert.deepEqual(parseProject(serializeProject(p)), p);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("the JSON round trip keeps everything frozen", () => {
|
|
288
|
+
const parsed = parseProject(serializeProject(project()));
|
|
289
|
+
for (const frozen of [
|
|
290
|
+
parsed,
|
|
291
|
+
parsed.audio,
|
|
292
|
+
parsed.descriptor.vector,
|
|
293
|
+
parsed.palette,
|
|
294
|
+
parsed.blocks[0].rect,
|
|
295
|
+
parsed.texture,
|
|
296
|
+
parsed.motion,
|
|
297
|
+
parsed.canvas,
|
|
298
|
+
])
|
|
299
|
+
assert.ok(Object.isFrozen(frozen), "frozen");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("parse refuses future versions and damaged payloads with the right code", () => {
|
|
303
|
+
const raw = JSON.parse(serializeProject(project()));
|
|
304
|
+
assert.equal(
|
|
305
|
+
codeOf(() => parseProject(JSON.stringify({ ...raw, version: 99 }))),
|
|
306
|
+
"project-version",
|
|
307
|
+
);
|
|
308
|
+
assert.equal(
|
|
309
|
+
codeOf(() => parseProject(JSON.stringify({ ...raw, version: 1.5 }))),
|
|
310
|
+
"project-version",
|
|
311
|
+
);
|
|
312
|
+
assert.equal(
|
|
313
|
+
codeOf(() => parseProject("{not json")),
|
|
314
|
+
"project-shape",
|
|
315
|
+
);
|
|
316
|
+
assert.equal(
|
|
317
|
+
codeOf(() => parseProject("[1,2]")),
|
|
318
|
+
"project-shape",
|
|
319
|
+
);
|
|
320
|
+
assert.equal(
|
|
321
|
+
codeOf(() => parseProject("")),
|
|
322
|
+
"project-shape",
|
|
323
|
+
);
|
|
324
|
+
delete raw.palette;
|
|
325
|
+
assert.equal(
|
|
326
|
+
codeOf(() => parseProject(JSON.stringify(raw))),
|
|
327
|
+
"project-shape",
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("projectVariants", () => {
|
|
333
|
+
it("count + distinct derived seeds + identical audio/blocks/texture stats", () => {
|
|
334
|
+
const p = project();
|
|
335
|
+
const variants = projectVariants(p, 3);
|
|
336
|
+
assert.equal(variants.length, 3);
|
|
337
|
+
assert.equal(new Set(variants.map((v) => v.seed)).size, 3);
|
|
338
|
+
variants.forEach((v, i) => {
|
|
339
|
+
assert.equal(v.seed, `${SEED}::variant${i}`);
|
|
340
|
+
assert.deepEqual(v.audio, p.audio); // same audio stats
|
|
341
|
+
assert.deepEqual(v.blocks, p.blocks);
|
|
342
|
+
assert.deepEqual(v.texture, p.texture);
|
|
343
|
+
assert.notEqual(v.id, p.id); // fresh slug
|
|
344
|
+
});
|
|
345
|
+
assert.equal(projectVariants(p, 0).length, 0);
|
|
346
|
+
assert.equal(
|
|
347
|
+
codeOf(() => projectVariants(p, -1)),
|
|
348
|
+
"project-range",
|
|
349
|
+
);
|
|
350
|
+
assert.equal(
|
|
351
|
+
codeOf(() => projectVariants(p, 1.5)),
|
|
352
|
+
"project-range",
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("the documented golden-angle rotation moves the anchor hue, accents untouched", () => {
|
|
357
|
+
const p = project();
|
|
358
|
+
const baseHue = p.palette.oklch.anchor.h;
|
|
359
|
+
projectVariants(p, 2).forEach((v, i) => {
|
|
360
|
+
const delta = (v.palette.oklch.anchor.h - baseHue + 360) % 360;
|
|
361
|
+
assert.ok(Math.abs(delta - (((i + 1) * VARIANT_HUE_STEP) % 360)) < 1e-6, `delta ${delta} = (i+1)·step`);
|
|
362
|
+
assert.notEqual(v.palette.anchor, p.palette.anchor); // hex re-rendered
|
|
363
|
+
assert.deepEqual(v.palette.oklch.accents, p.palette.oklch.accents); // accents untouched
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("variants are frozen bundles that serialize too", () => {
|
|
368
|
+
const v = projectVariants(project(), 1)[0];
|
|
369
|
+
assert.ok(Object.isFrozen(v) && Object.isFrozen(v.palette));
|
|
370
|
+
assert.deepEqual(parseProject(serializeProject(v)), v);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// # imagerender.test — the render layer of the cadria image wave, runnable
|
|
2
|
+
// with vitest:
|
|
3
|
+
// pnpm vitest run tests/imagerender.test.ts
|
|
4
|
+
// The tests watch the promises the module makes: the same project compiles to
|
|
5
|
+
// bit-identical commands, svg and signature; geometry stays normalized 0-1 and
|
|
6
|
+
// every color is well-formed hex; the z bands are monotonic (background first,
|
|
7
|
+
// specular last); the 4096-command budget truncates grain first and keeps the
|
|
8
|
+
// specular; the svg is well-formed (regex tag balance, no NaN/Infinity/exponent
|
|
9
|
+
// tokens); the signature is stable across runs but moves when commands change;
|
|
10
|
+
// perturbations are pure (pulse scales the hero by pulseScale at strength 1 and
|
|
11
|
+
// is the identity at 0, wipe shifts exactly the documented middle-third band);
|
|
12
|
+
// an empty cast renders background-only; grain count tracks grainDensity.
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { describe, it } from "vitest";
|
|
15
|
+
import {
|
|
16
|
+
COMMAND_BUDGET,
|
|
17
|
+
type DrawCommand,
|
|
18
|
+
RENDER_DEFAULT_SIZE,
|
|
19
|
+
renderCommands,
|
|
20
|
+
renderKeyframePerturbation,
|
|
21
|
+
renderThumbnailSignature,
|
|
22
|
+
WIPE_BAND,
|
|
23
|
+
WIPE_SHIFT,
|
|
24
|
+
Z_SPECULAR,
|
|
25
|
+
} from "../imagerender.ts";
|
|
26
|
+
import { renderSvg } from "../imagerenderpaint.ts";
|
|
27
|
+
import type { Block } from "../synthcomposition.ts";
|
|
28
|
+
import type { Palette } from "../synthpalette.ts";
|
|
29
|
+
|
|
30
|
+
/** a layout block shorthand. */
|
|
31
|
+
function block(x: number, y: number, w: number, h: number, role: Block["role"], weight = 0.1): Block {
|
|
32
|
+
return { rect: { x, y, w, h }, weight, role };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PALETTE: Palette = {
|
|
36
|
+
anchor: "#c2643f",
|
|
37
|
+
support: ["#8fa38a", "#5d6e63"],
|
|
38
|
+
ink: "#131a16",
|
|
39
|
+
surface: "#f2ede4",
|
|
40
|
+
accents: ["#3f7fc2", "#c23f7f", "#7fc23f"],
|
|
41
|
+
oklch: {
|
|
42
|
+
anchor: { l: 0.6, c: 0.12, h: 40 },
|
|
43
|
+
accents: [
|
|
44
|
+
{ l: 0.55, c: 0.12, h: 250 },
|
|
45
|
+
{ l: 0.55, c: 0.12, h: 340 },
|
|
46
|
+
{ l: 0.6, c: 0.12, h: 110 },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
/** cadre1 sits above the wipe band, cadre2 inside it; edge/field sit outside. */
|
|
51
|
+
const BLOCKS: Block[] = [
|
|
52
|
+
block(0.3, 0.35, 0.4, 0.3, "hero", 0.4),
|
|
53
|
+
block(0.1, 0.15, 0.15, 0.15, "cadre"),
|
|
54
|
+
block(0.7, 0.4, 0.15, 0.15, "cadre"),
|
|
55
|
+
block(0.1, 0.7, 0.15, 0.2, "field", 0.05),
|
|
56
|
+
block(0, 0, 1, 0.1, "edge", 0.05),
|
|
57
|
+
];
|
|
58
|
+
const TEXTURE = {
|
|
59
|
+
grainDensity: 0.4,
|
|
60
|
+
grainSize: 2,
|
|
61
|
+
grainOpacity: 0.3,
|
|
62
|
+
strokeSoftness: 0.5,
|
|
63
|
+
strokeWeight: 6,
|
|
64
|
+
strokeJitter: 0.4,
|
|
65
|
+
glazeLayers: 3,
|
|
66
|
+
glazeOpacity: 0.4,
|
|
67
|
+
specular: 0.5,
|
|
68
|
+
turbulence: 0.4,
|
|
69
|
+
};
|
|
70
|
+
const PROJECT = { seed: "b7render", palette: PALETTE, blocks: BLOCKS, texture: TEXTURE };
|
|
71
|
+
|
|
72
|
+
/** horizontal x anchor of one command's geometry (wipe displacement probe). */
|
|
73
|
+
function xOf(c: DrawCommand): number {
|
|
74
|
+
if (c.kind === "ellipse") return c.cx;
|
|
75
|
+
if (c.kind === "path" || c.kind === "strokepath") return c.points[0];
|
|
76
|
+
return c.x;
|
|
77
|
+
}
|
|
78
|
+
/** vertical center of one command's geometry (wipe band membership). */
|
|
79
|
+
function cyOf(c: DrawCommand): number {
|
|
80
|
+
if (c.kind === "rect" || c.kind === "gradientwash") return c.y + c.h / 2;
|
|
81
|
+
if (c.kind === "ellipse") return c.cy;
|
|
82
|
+
if (c.kind === "path" || c.kind === "strokepath")
|
|
83
|
+
return c.points.reduce((s, _, i) => (i % 2 === 1 ? s + c.points[i] : s), 0) / (c.points.length / 2);
|
|
84
|
+
return c.y;
|
|
85
|
+
}
|
|
86
|
+
type Wash = Extract<DrawCommand, { kind: "gradientwash" }>;
|
|
87
|
+
type Ellipse = Extract<DrawCommand, { kind: "ellipse" }>;
|
|
88
|
+
const washOf = (list: readonly DrawCommand[]): Wash => list.find((c): c is Wash => c.kind === "gradientwash") as Wash;
|
|
89
|
+
const ellipseOf = (list: readonly DrawCommand[]): Ellipse =>
|
|
90
|
+
list.find((c): c is Ellipse => c.kind === "ellipse") as Ellipse;
|
|
91
|
+
|
|
92
|
+
describe("renderCommands determinism and geometry", () => {
|
|
93
|
+
it("same project → identical commands, svg and signature", () => {
|
|
94
|
+
const a = renderCommands(PROJECT);
|
|
95
|
+
const b = renderCommands(PROJECT);
|
|
96
|
+
assert.deepEqual(a, b);
|
|
97
|
+
assert.equal(renderSvg(a), renderSvg(b));
|
|
98
|
+
assert.equal(renderThumbnailSignature(a.commands), renderThumbnailSignature(b.commands));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("geometry stays inside [0,1] for every command", () => {
|
|
102
|
+
const { commands } = renderCommands(PROJECT);
|
|
103
|
+
for (const c of commands) {
|
|
104
|
+
if (c.kind === "rect" || c.kind === "gradientwash") {
|
|
105
|
+
assert.ok(c.x >= 0 && c.y >= 0 && c.x + c.w <= 1 + 1e-9 && c.y + c.h <= 1 + 1e-9, c.kind);
|
|
106
|
+
} else if (c.kind === "ellipse") {
|
|
107
|
+
assert.ok(
|
|
108
|
+
c.cx - c.rx >= -1e-9 && c.cx + c.rx <= 1 + 1e-9 && c.cy - c.ry >= -1e-9 && c.cy + c.ry <= 1 + 1e-9,
|
|
109
|
+
"ellipse",
|
|
110
|
+
);
|
|
111
|
+
} else if (c.kind === "grainfield") {
|
|
112
|
+
assert.ok(c.x >= 0 && c.x < 1 && c.y >= 0 && c.y < 1, "grain");
|
|
113
|
+
} else {
|
|
114
|
+
for (const v of c.points) assert.ok(v >= 0 && v <= 1 + 1e-9, c.kind);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("every color field is a well-formed #rrggbb hex", () => {
|
|
120
|
+
const { commands } = renderCommands(PROJECT);
|
|
121
|
+
const hex = /^#[0-9a-f]{6}$/;
|
|
122
|
+
for (const c of commands) {
|
|
123
|
+
const colors = c.kind === "gradientwash" ? [c.from, c.to] : c.kind === "strokepath" ? [c.stroke] : [c.fill];
|
|
124
|
+
for (const value of colors) assert.ok(hex.test(value), `${c.kind}: ${value}`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("empty blocks render background-only", () => {
|
|
129
|
+
const frame = renderCommands({ seed: "solo", palette: PALETTE, texture: TEXTURE });
|
|
130
|
+
assert.equal(frame.commands.length, 1);
|
|
131
|
+
assert.equal(frame.commands[0].kind, "rect");
|
|
132
|
+
assert.equal(frame.commands[0].z, 0);
|
|
133
|
+
assert.equal(frame.background, PALETTE.surface);
|
|
134
|
+
assert.equal((frame.commands[0] as Extract<DrawCommand, { kind: "rect" }>).fill, PALETTE.surface);
|
|
135
|
+
assert.match(renderSvg(frame), new RegExp(`width="${RENDER_DEFAULT_SIZE}" height="${RENDER_DEFAULT_SIZE}"`));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("all six command kinds appear; glaze opacity follows the layer stack", () => {
|
|
139
|
+
const { commands } = renderCommands(PROJECT);
|
|
140
|
+
for (const kind of ["rect", "ellipse", "gradientwash", "path", "strokepath", "grainfield"]) {
|
|
141
|
+
assert.ok(
|
|
142
|
+
commands.some((c) => c.kind === kind),
|
|
143
|
+
kind,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const glaze = 1 - (1 - 0.4) ** 3;
|
|
147
|
+
const field = commands.find((c) => c.kind === "rect" && c.role === "field") as Extract<
|
|
148
|
+
DrawCommand,
|
|
149
|
+
{ kind: "rect" }
|
|
150
|
+
>;
|
|
151
|
+
const edge = commands.find((c) => c.kind === "rect" && c.role === "edge") as Extract<DrawCommand, { kind: "rect" }>;
|
|
152
|
+
assert.equal(field.opacity, glaze);
|
|
153
|
+
assert.equal(edge.opacity, 0.4);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("damaged input is total: defaults hold and nothing is NaN", () => {
|
|
157
|
+
const frame = renderCommands({
|
|
158
|
+
canvas: { width: NaN, height: Infinity },
|
|
159
|
+
texture: { grainDensity: NaN, glazeLayers: 99, strokeWeight: -4 },
|
|
160
|
+
blocks: [{ rect: { x: NaN, y: 0.2, w: -1, h: 0.5 }, weight: 0.5, role: "cadre" }],
|
|
161
|
+
});
|
|
162
|
+
assert.equal(frame.width, RENDER_DEFAULT_SIZE);
|
|
163
|
+
assert.equal(frame.height, RENDER_DEFAULT_SIZE);
|
|
164
|
+
assert.match(frame.background, /^#[0-9a-f]{6}$/);
|
|
165
|
+
assert.ok(frame.commands.length > 1);
|
|
166
|
+
for (const c of frame.commands) {
|
|
167
|
+
assert.ok(Number.isFinite(c.z) && Number.isFinite(c.opacity) && Number.isFinite(c.blur), c.kind);
|
|
168
|
+
if (c.kind === "grainfield") assert.ok(Number.isFinite(c.x) && Number.isFinite(c.y) && Number.isFinite(c.r));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("command count tracks grainDensity monotonically (two fixtures)", () => {
|
|
173
|
+
const sparse = renderCommands({ ...PROJECT, texture: { ...TEXTURE, grainDensity: 0.1 } });
|
|
174
|
+
const rich = renderCommands({ ...PROJECT, texture: { ...TEXTURE, grainDensity: 0.9 } });
|
|
175
|
+
const sparseGrains = sparse.commands.filter((c) => c.kind === "grainfield").length;
|
|
176
|
+
const richGrains = rich.commands.filter((c) => c.kind === "grainfield").length;
|
|
177
|
+
assert.ok(richGrains > sparseGrains);
|
|
178
|
+
const base = (RENDER_DEFAULT_SIZE * RENDER_DEFAULT_SIZE) / 1600;
|
|
179
|
+
assert.equal(sparseGrains, Math.ceil(0.1 * base));
|
|
180
|
+
assert.equal(richGrains, Math.ceil(0.9 * base));
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("budget and paint order", () => {
|
|
185
|
+
it("dense canvas truncates to exactly the 4096 budget, specular kept last", () => {
|
|
186
|
+
const dense = renderCommands({
|
|
187
|
+
...PROJECT,
|
|
188
|
+
canvas: { width: 8192, height: 8192 },
|
|
189
|
+
texture: { ...TEXTURE, grainDensity: 1 },
|
|
190
|
+
});
|
|
191
|
+
assert.equal(dense.commands.length, COMMAND_BUDGET);
|
|
192
|
+
assert.equal(dense.commands[dense.commands.length - 1].kind, "path");
|
|
193
|
+
assert.equal(dense.commands[dense.commands.length - 1].z, Z_SPECULAR);
|
|
194
|
+
assert.ok(dense.commands.some((c) => c.kind === "grainfield"));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("z-order monotonic: background first, specular last", () => {
|
|
198
|
+
const { commands } = renderCommands(PROJECT);
|
|
199
|
+
for (let i = 1; i < commands.length; i += 1) assert.ok(commands[i - 1].z <= commands[i].z, `z at ${i}`);
|
|
200
|
+
assert.equal(commands[0].z, 0);
|
|
201
|
+
assert.equal(commands[0].kind, "rect");
|
|
202
|
+
assert.equal(commands[commands.length - 1].kind, "path");
|
|
203
|
+
assert.equal(commands[commands.length - 1].z, Z_SPECULAR);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("renderSvg serialization", () => {
|
|
208
|
+
it("svg is well-formed: one root, balanced tags, unique ids", () => {
|
|
209
|
+
const svg = renderSvg(renderCommands(PROJECT));
|
|
210
|
+
assert.equal(svg.match(/<svg[ >]/g)?.length, 1);
|
|
211
|
+
const ids = [...svg.matchAll(/id="([^"]+)"/g)].map((m) => m[1]);
|
|
212
|
+
assert.equal(new Set(ids).size, ids.length);
|
|
213
|
+
const stack: string[] = [];
|
|
214
|
+
for (const m of svg.matchAll(/<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|[^>"'])*)>/g)) {
|
|
215
|
+
const closing = m[1] === "/";
|
|
216
|
+
const self = /\/\s*$/.test(m[3]);
|
|
217
|
+
if (closing) assert.equal(stack.pop(), m[2]);
|
|
218
|
+
else if (!self) stack.push(m[2]);
|
|
219
|
+
}
|
|
220
|
+
assert.deepEqual(stack, []);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("svg carries no NaN, Infinity or exponent tokens", () => {
|
|
224
|
+
const svg = renderSvg(renderCommands(PROJECT));
|
|
225
|
+
assert.doesNotMatch(svg, /NaN|Infinity|[eE][+-][0-9]/);
|
|
226
|
+
assert.match(svg, /fill="#f2ede4"/);
|
|
227
|
+
assert.match(svg, /url\(#wash-\d+\)/);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("thumbnail signature", () => {
|
|
232
|
+
it("signature is stable across runs and moves when commands change", () => {
|
|
233
|
+
const { commands } = renderCommands(PROJECT);
|
|
234
|
+
assert.equal(renderThumbnailSignature(commands), renderThumbnailSignature(renderCommands(PROJECT).commands));
|
|
235
|
+
assert.notEqual(renderThumbnailSignature(commands), renderThumbnailSignature(commands.slice(0, -1)));
|
|
236
|
+
const moved = renderKeyframePerturbation(commands, { kind: "shift", strength: 0.5, targets: [], tMs: 0 });
|
|
237
|
+
assert.notEqual(renderThumbnailSignature(commands), renderThumbnailSignature(moved));
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("perturbation hooks", () => {
|
|
242
|
+
it("pulse at strength 1 scales hero geometry by pulseScale (±0.01)", () => {
|
|
243
|
+
const { commands } = renderCommands(PROJECT);
|
|
244
|
+
const out = renderKeyframePerturbation(commands, { kind: "pulse", strength: 1, targets: [], tMs: 0 }, 1.08);
|
|
245
|
+
const washA = washOf(commands);
|
|
246
|
+
const washB = washOf(out);
|
|
247
|
+
const ellipseA = ellipseOf(commands);
|
|
248
|
+
const ellipseB = ellipseOf(out);
|
|
249
|
+
assert.ok(Math.abs(washB.w / washA.w - 1.08) <= 0.01);
|
|
250
|
+
assert.ok(Math.abs(washB.h / washA.h - 1.08) <= 0.01);
|
|
251
|
+
assert.ok(Math.abs(ellipseB.rx / ellipseA.rx - 1.08) <= 0.01);
|
|
252
|
+
assert.ok(Math.abs(ellipseB.ry / ellipseA.ry - 1.08) <= 0.01);
|
|
253
|
+
const nonHero = out.filter((c) => c.role !== "hero");
|
|
254
|
+
const nonHeroBefore = commands.filter((c) => c.role !== "hero");
|
|
255
|
+
assert.deepEqual(nonHero, nonHeroBefore);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("pulse at strength 0 is the identity and never mutates the input", () => {
|
|
259
|
+
const { commands } = renderCommands(PROJECT);
|
|
260
|
+
const snapshot = structuredClone(commands);
|
|
261
|
+
const out = renderKeyframePerturbation(commands, { kind: "pulse", strength: 0, targets: [], tMs: 0 }, 1.08);
|
|
262
|
+
assert.deepEqual(out, commands);
|
|
263
|
+
assert.deepEqual(commands, snapshot);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("wipe shifts exactly the documented middle-third band", () => {
|
|
267
|
+
const { commands } = renderCommands(PROJECT);
|
|
268
|
+
const out = renderKeyframePerturbation(commands, { kind: "wipe", strength: 1, targets: [], tMs: 0 });
|
|
269
|
+
let sawInBand = false;
|
|
270
|
+
for (let i = 0; i < commands.length; i += 1) {
|
|
271
|
+
const a = commands[i];
|
|
272
|
+
const inBand = a.z > 0 && a.kind !== "grainfield" && cyOf(a) >= WIPE_BAND.top && cyOf(a) <= WIPE_BAND.bottom;
|
|
273
|
+
const dx = xOf(out[i]) - xOf(a);
|
|
274
|
+
if (inBand) {
|
|
275
|
+
sawInBand = true;
|
|
276
|
+
assert.ok(Math.abs(dx - WIPE_SHIFT) < 1e-9, `${a.kind} dx=${dx}`);
|
|
277
|
+
} else {
|
|
278
|
+
assert.equal(dx, 0, `${a.kind} dy-shift`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
assert.ok(sawInBand);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("reveal scales non-background opacity by strength; background holds", () => {
|
|
285
|
+
const { commands } = renderCommands(PROJECT);
|
|
286
|
+
const out = renderKeyframePerturbation(commands, { kind: "reveal", strength: 0.5, targets: [], tMs: 0 });
|
|
287
|
+
assert.equal(out[0].opacity, 1);
|
|
288
|
+
assert.equal(out[1].opacity, commands[1].opacity * 0.5);
|
|
289
|
+
assert.equal(out.length, commands.length);
|
|
290
|
+
});
|
|
291
|
+
});
|