@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.
Files changed (41) hide show
  1. package/README.md +33 -1
  2. package/docs/.gitkeep +0 -0
  3. package/package.json +25 -2
  4. package/tests/.gitkeep +0 -0
  5. package/tests/audiodecode.test.ts +406 -0
  6. package/tests/audiofeatures.test.ts +271 -0
  7. package/tests/audiopipeline.test.ts +193 -0
  8. package/tests/audiorhythm.test.ts +172 -0
  9. package/tests/audiospectrum.test.ts +257 -0
  10. package/tests/audiostructure.test.ts +292 -0
  11. package/tests/audiotimbre.test.ts +270 -0
  12. package/tests/audiotonality.test.ts +289 -0
  13. package/tests/blendmodes.test.ts +31 -4
  14. package/tests/colorconvert.test.ts +3 -4
  15. package/tests/easecurves.test.ts +3 -4
  16. package/tests/family.test.ts +73 -0
  17. package/tests/familyurl.test.ts +14 -5
  18. package/tests/gateway.test.ts +373 -0
  19. package/tests/generation.test.ts +228 -0
  20. package/tests/imageexport.test.ts +443 -0
  21. package/tests/imageproject.test.ts +372 -0
  22. package/tests/imagerender.test.ts +291 -0
  23. package/tests/imagerenderpaint.test.ts +119 -0
  24. package/tests/imagestyles.test.ts +347 -0
  25. package/tests/masks.test.ts +3 -3
  26. package/tests/matiz.test.ts +8 -5
  27. package/tests/mediaprobe.test.ts +66 -22
  28. package/tests/onboardingstate.test.ts +73 -0
  29. package/tests/shadercomp.test.ts +120 -0
  30. package/tests/synthcomposition.test.ts +257 -0
  31. package/tests/synthmotion.test.ts +372 -0
  32. package/tests/synthpalette.test.ts +247 -0
  33. package/tests/synthtexture.test.ts +256 -0
  34. package/tests/timelineedit.test.ts +3 -4
  35. package/tests/timelineview.test.ts +248 -0
  36. package/tests/timeticks.test.ts +4 -4
  37. package/tests/transcodeplan.test.ts +19 -8
  38. package/tests/versawaseanimate.test.ts +106 -0
  39. package/tests/versawasescheduler.test.ts +96 -0
  40. package/tests/versawaseslices.test.ts +75 -0
  41. package/tests/visualize.test.ts +6 -6
@@ -0,0 +1,119 @@
1
+ // # imagerenderpaint.test — honest unit tests for the SVG serializer arm,
2
+ // runnable with vitest:
3
+ // pnpm vitest run tests/imagerenderpaint.test.ts
4
+ // Every test watches the two invariants the module promises: the same frame
5
+ // always serializes to the same string byte for byte (no timestamps, no
6
+ // locale floats, no exponents) and damaged input never throws nor spells
7
+ // NaN — non-finite numbers answer "0.000" and damaged frames fall back to
8
+ // the defaults. The geometry checks pin the normalized→px scaling and the
9
+ // gradient endpoint math at 90 degrees, where the answer is exact by hand.
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "vitest";
12
+ import type { DrawCommand, RenderFrame } from "../imagerender.ts";
13
+ import { renderSvg } from "../imagerenderpaint.ts";
14
+
15
+ /** one painted rect command with the given fields. */
16
+ function rect(fields: Partial<Extract<DrawCommand, { kind: "rect" }>>): DrawCommand {
17
+ return { kind: "rect", x: 0.1, y: 0.2, w: 0.3, h: 0.4, fill: "#102030", opacity: 1, blur: 0, z: 1, ...fields };
18
+ }
19
+
20
+ describe("imagerenderpaint scaling", () => {
21
+ it("scales normalized geometry by the frame size at fixed 3 decimals", () => {
22
+ const svg = renderSvg({ width: 1080, height: 720, background: "#000000", commands: [rect({ x: 0.5, y: 0.25, w: 1, h: 0.5 })] });
23
+ assert.ok(svg.includes('<rect x="540.000" y="180.000" width="1080.000" height="360.000" fill="#102030"/>'));
24
+ });
25
+ it("carries fill-opacity only below 1 and never emits an exponent", () => {
26
+ const svg = renderSvg({ width: 100, height: 100, background: "#000000", commands: [rect({ opacity: 0.5 })] });
27
+ assert.ok(svg.includes('fill-opacity="0.500"'));
28
+ assert.ok(!svg.includes("e-"));
29
+ assert.ok(!svg.toLowerCase().includes("nan"));
30
+ });
31
+ });
32
+
33
+ describe("imagerenderpaint determinism", () => {
34
+ it("serializes the same frame to the same string byte for byte", () => {
35
+ const frame: RenderFrame = {
36
+ width: 320,
37
+ height: 200,
38
+ background: "#0a0a0b",
39
+ commands: [
40
+ rect({}),
41
+ { kind: "ellipse", cx: 0.5, cy: 0.5, rx: 0.2, ry: 0.1, fill: "#242424", opacity: 1, blur: 0, z: 2 },
42
+ { kind: "grainfield", x: 0.9, y: 0.9, r: 1.5, fill: "#333333", opacity: 0.4, blur: 0, z: 3 },
43
+ ],
44
+ };
45
+ assert.equal(renderSvg(frame), renderSvg(frame));
46
+ });
47
+ });
48
+
49
+ describe("imagerenderpaint damage", () => {
50
+ it("answers 0.000 for non-finite numbers and never spells NaN", () => {
51
+ const svg = renderSvg({
52
+ width: 100,
53
+ height: 100,
54
+ background: "#000000",
55
+ commands: [rect({ x: Number.NaN, y: Number.POSITIVE_INFINITY, fill: "#102030" })],
56
+ });
57
+ assert.ok(svg.includes('x="0.000" y="0.000"'));
58
+ assert.ok(!svg.toLowerCase().includes("nan"));
59
+ assert.ok(!svg.includes("infinity"));
60
+ });
61
+ it("falls back to the 1080 square and an empty body on a damaged frame", () => {
62
+ const svg = renderSvg({ width: Number.NaN, height: Number.NaN, background: "#000000", commands: [] });
63
+ assert.ok(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080" viewBox="0 0 1080 1080">'));
64
+ assert.ok(svg.trimEnd().endsWith("</svg>"));
65
+ assert.ok(svg.endsWith("\n"));
66
+ assert.ok(!svg.includes("<defs>"));
67
+ });
68
+ });
69
+
70
+ describe("imagerenderpaint washes and strokes", () => {
71
+ it("spells the 90 degree wash endpoints exactly and registers the gradient def", () => {
72
+ const svg = renderSvg({
73
+ width: 200,
74
+ height: 200,
75
+ background: "#000000",
76
+ commands: [
77
+ {
78
+ kind: "gradientwash",
79
+ x: 0,
80
+ y: 0,
81
+ w: 1,
82
+ h: 1,
83
+ angle: 90,
84
+ from: "#111111",
85
+ to: "#222222",
86
+ opacity: 1,
87
+ blur: 0,
88
+ z: 0,
89
+ },
90
+ ],
91
+ });
92
+ assert.ok(svg.includes('x1="0.500" y1="0.000" x2="0.500" y2="1.000"'));
93
+ assert.ok(svg.includes('<linearGradient id="wash-0"'));
94
+ assert.ok(svg.includes('fill="url(#wash-0)"'));
95
+ });
96
+ it("closes paths, keeps strokepaths open and registers blur filters once", () => {
97
+ const svg = renderSvg({
98
+ width: 100,
99
+ height: 100,
100
+ background: "#000000",
101
+ commands: [
102
+ { kind: "path", points: [0, 0, 1, 0, 0.5, 1], fill: "#112233", opacity: 1, blur: 0, z: 1 },
103
+ {
104
+ kind: "strokepath",
105
+ points: [0, 0.5, 1, 0.5],
106
+ stroke: "#445566",
107
+ strokeWidth: 2,
108
+ opacity: 0.8,
109
+ blur: 3,
110
+ z: 2,
111
+ },
112
+ ],
113
+ });
114
+ assert.ok(svg.includes('d="M 0.000 0.000 L 100.000 0.000 L 50.000 100.000 Z"'));
115
+ assert.ok(svg.includes('stroke-width="2.000" stroke-opacity="0.800"'));
116
+ assert.ok(svg.includes('<filter id="blur-1"'));
117
+ assert.ok(svg.includes('filter="url(#blur-1)"'));
118
+ });
119
+ });
@@ -0,0 +1,347 @@
1
+ // # imagestyles.test — the style layer of the cadria image wave, runnable
2
+ // with vitest:
3
+ // pnpm vitest run tests/imagestyles.test.ts
4
+ // The tests watch the promises the module makes: exactly 15 styles (5 calm,
5
+ // 5 kinetic, 5 dark) with unique lowercase names and no repeated endings,
6
+ // every bias finite inside its documented range, lookup by (normalized)
7
+ // name throwing style-unknown on misses, deterministic descriptor matching
8
+ // with the documented weighted score, stable + distinct fingerprints, and
9
+ // applyStyleBias clamping every output into its documented range even
10
+ // under stress input.
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "vitest";
13
+ import type { AudioDescriptor } from "../audioattributes.ts";
14
+ import { DIM_ORDER } from "../audiofeatures.ts";
15
+ import {
16
+ applyStyleBias,
17
+ imageStyles,
18
+ STYLE_MATCH_WEIGHTS,
19
+ STYLE_RANGES,
20
+ type StyleBase,
21
+ type StyleError,
22
+ type StyleSpec,
23
+ styleByName,
24
+ styleFingerprint,
25
+ styleForDescriptor,
26
+ TEXTURE_DELTA_CAP,
27
+ } from "../imagestyles.ts";
28
+ import { GRAIN_COUPLING, TEXTURE_RANGES, type TextureSpec } from "../synthtexture.ts";
29
+
30
+ /** builds a descriptor from named dim overrides over a 0.5 mid field. */
31
+ function desc(over: Record<string, number> = {}, seed = "1b6e5a9c3d7f2480"): AudioDescriptor {
32
+ const vector = new Array<number>(32).fill(0.5);
33
+ for (const [name, value] of Object.entries(over)) vector[DIM_ORDER.indexOf(name)] = value;
34
+ return { version: 1, seed, vector, scalar: {} };
35
+ }
36
+
37
+ /** longest common suffix length of two words — the "no repeated endings" gauge. */
38
+ function commonSuffix(a: string, b: string): number {
39
+ let count = 0;
40
+ while (count < a.length && count < b.length && a[a.length - 1 - count] === b[b.length - 1 - count]) count++;
41
+ return count;
42
+ }
43
+
44
+ /** deterministic LCG for descriptor sweeps. */
45
+ function lcg(seed: number): () => number {
46
+ let state = seed >>> 0;
47
+ return () => {
48
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
49
+ return state / 2 ** 32;
50
+ };
51
+ }
52
+
53
+ /** floating-tolerant equality for clamped arithmetic. */
54
+ function near(x: number, y: number, eps = 1e-9): void {
55
+ assert.ok(Math.abs(x - y) < eps, `expected ${x} ≈ ${y}`);
56
+ }
57
+
58
+ describe("imagestyles table", () => {
59
+ it("holds exactly 15 styles — 5 calm, 5 kinetic, 5 dark by first tag — with non-empty tags", () => {
60
+ const styles = imageStyles();
61
+ assert.equal(styles.length, 15);
62
+ for (const family of ["calm", "kinetic", "dark"]) {
63
+ assert.equal(styles.filter((style) => style.tags[0] === family).length, 5, family);
64
+ }
65
+ for (const style of styles) {
66
+ assert.ok(style.tags.length > 0 && style.tags.every((tag) => typeof tag === "string" && tag.length > 0));
67
+ assert.ok(typeof style.mood === "string" && style.mood.length > 8);
68
+ }
69
+ });
70
+
71
+ it("names are unique lowercase words with no repeated endings", () => {
72
+ const names = imageStyles().map((style) => style.name);
73
+ assert.equal(new Set(names).size, 15);
74
+ for (const name of names) assert.match(name, /^[a-z]{5,12}$/);
75
+ for (let i = 0; i < names.length; i++) {
76
+ for (let j = i + 1; j < names.length; j++) {
77
+ assert.ok(commonSuffix(names[i], names[j]) <= 3, `${names[i]} vs ${names[j]} share an ending`);
78
+ }
79
+ }
80
+ });
81
+
82
+ it("every bias is finite and inside its documented range; match weights sum to 1", () => {
83
+ near(
84
+ STYLE_MATCH_WEIGHTS.grain + STYLE_MATCH_WEIGHTS.motion + STYLE_MATCH_WEIGHTS.light + STYLE_MATCH_WEIGHTS.shadow,
85
+ 1,
86
+ );
87
+ for (const style of imageStyles()) {
88
+ const { paletteBias: palette, compositionBias: comp, motionBias: motion } = style;
89
+ for (const value of [palette.chroma, palette.lightness, palette.warmth]) {
90
+ assert.ok(Number.isFinite(value) && value >= STYLE_RANGES.palette[0] && value <= STYLE_RANGES.palette[1]);
91
+ }
92
+ assert.ok(
93
+ Number.isFinite(comp.density) &&
94
+ comp.density >= STYLE_RANGES.density[0] &&
95
+ comp.density <= STYLE_RANGES.density[1],
96
+ );
97
+ assert.ok(
98
+ Number.isFinite(comp.heroScale) &&
99
+ comp.heroScale >= STYLE_RANGES.heroScale[0] &&
100
+ comp.heroScale <= STYLE_RANGES.heroScale[1],
101
+ );
102
+ assert.ok(Number.isFinite(motion.tempo) && motion.tempo >= 0 && motion.tempo <= 1);
103
+ assert.ok(Number.isFinite(motion.driftScale) && motion.driftScale >= 0 && motion.driftScale <= 1);
104
+ assert.ok(["mirror", "rotational", "asymmetric"].includes(comp.symmetry));
105
+ for (const [field, delta] of Object.entries(style.textureBias)) {
106
+ const key = field as keyof TextureSpec;
107
+ assert.ok(Math.abs(delta as number) <= TEXTURE_DELTA_CAP[key] + 1e-9, `${style.name}.${field} over delta cap`);
108
+ }
109
+ }
110
+ });
111
+
112
+ it("bias fingerprints are distinct across the whole table", () => {
113
+ const seen = new Set<string>();
114
+ for (const style of imageStyles()) {
115
+ const key = JSON.stringify([style.paletteBias, style.compositionBias, style.textureBias, style.motionBias]);
116
+ assert.ok(!seen.has(key), `${style.name} duplicates another style's bias fingerprint`);
117
+ seen.add(key);
118
+ }
119
+ assert.equal(seen.size, 15);
120
+ });
121
+
122
+ it("deterministic and fresh: two calls deep-equal, caller mutation is contained", () => {
123
+ const first = imageStyles();
124
+ const second = imageStyles();
125
+ assert.deepEqual(first, second);
126
+ first[0].tags.push("mutated");
127
+ first[0].paletteBias.chroma = 9;
128
+ assert.deepEqual(imageStyles(), second);
129
+ });
130
+ });
131
+
132
+ describe("imagestyles lookup", () => {
133
+ it("styleByName hits case-insensitively and returns table-equal data", () => {
134
+ const hit = styleByName(" Opaline ");
135
+ assert.equal(hit.name, "opaline");
136
+ assert.deepEqual(
137
+ hit,
138
+ imageStyles().find((style) => style.name === "opaline"),
139
+ );
140
+ assert.deepEqual(styleByName("TARNGLASS"), imageStyles()[13]);
141
+ });
142
+
143
+ it("unknown names throw StyleError with code style-unknown", () => {
144
+ for (const bad of ["nope", "", " ", "opal", "nocturne", "granfield"]) {
145
+ assert.throws(
146
+ () => styleByName(bad),
147
+ (error: unknown) =>
148
+ error instanceof Error &&
149
+ (error as StyleError).code === "style-unknown" &&
150
+ /style-unknown/.test(error.message),
151
+ `expected throw for ${JSON.stringify(bad)}`,
152
+ );
153
+ }
154
+ });
155
+ });
156
+
157
+ describe("imagestyles matching", () => {
158
+ it("styleForDescriptor is deterministic — same descriptor, same style, deep-equal", () => {
159
+ const probe = desc({ noisiness: 0.6, tempo: 0.7, energyDrive: 0.75, moodShadow: 0.4 });
160
+ assert.equal(styleForDescriptor(probe).name, styleForDescriptor(probe).name);
161
+ assert.deepEqual(styleForDescriptor(probe), styleForDescriptor(probe));
162
+ assert.deepEqual(
163
+ styleForDescriptor(probe),
164
+ styleForDescriptor(desc({ noisiness: 0.6, tempo: 0.7, energyDrive: 0.75, moodShadow: 0.4 }, "other-seed")),
165
+ );
166
+ });
167
+
168
+ it("a noise-wall descriptor lands on a high-grain style (grain density delta > 0)", () => {
169
+ const wall = desc({ noisiness: 0.95, flatness: 0.9, zeroCrossings: 0.8 });
170
+ const style = styleForDescriptor(wall);
171
+ assert.ok((style.textureBias.grainDensity ?? 0) > 0, `${style.name} should carry a positive grain delta`);
172
+ });
173
+
174
+ it("a dark descriptor (high moodShadow, low brightness) lands on a dark style", () => {
175
+ const dark = desc({
176
+ moodShadow: 0.95,
177
+ brightness: 0.1,
178
+ timbreBrightness: 0.1,
179
+ tempo: 0.4,
180
+ energyDrive: 0.4,
181
+ noisiness: 0.4,
182
+ });
183
+ assert.equal(styleForDescriptor(dark).tags[0], "dark");
184
+ });
185
+
186
+ it("a calm descriptor (low tempo, low energy) lands on a calm style", () => {
187
+ const calm = desc({
188
+ tempo: 0.05,
189
+ energyDrive: 0.05,
190
+ noisiness: 0.3,
191
+ brightness: 0.55,
192
+ timbreBrightness: 0.55,
193
+ moodShadow: 0.2,
194
+ });
195
+ assert.equal(styleForDescriptor(calm).tags[0], "calm");
196
+ });
197
+
198
+ it("a kinetic descriptor (high tempo, high energy) lands on a kinetic style", () => {
199
+ const kinetic = desc({ tempo: 0.9, energyDrive: 0.9, noisiness: 0.6 });
200
+ assert.equal(styleForDescriptor(kinetic).tags[0], "kinetic");
201
+ });
202
+
203
+ it("total over 40 LCG descriptors — always answers a table style", () => {
204
+ const rand = lcg(0x9e3779b9);
205
+ const names = new Set(imageStyles().map((style) => style.name));
206
+ for (let i = 0; i < 40; i++) {
207
+ const vector = Array.from({ length: 32 }, () => rand());
208
+ const style = styleForDescriptor({ version: 1, seed: `sweep-${i}`, vector, scalar: {} });
209
+ assert.ok(names.has(style.name), `${style.name} is not a table style`);
210
+ }
211
+ });
212
+
213
+ it("damaged descriptors coerce to the neutral mid and still answer deterministically", () => {
214
+ const broken: AudioDescriptor = {
215
+ version: 1,
216
+ seed: "bad",
217
+ vector: new Array<number>(32).fill(Number.NaN),
218
+ scalar: {},
219
+ };
220
+ const short: AudioDescriptor = { version: 1, seed: "short", vector: [0.1, 0.2], scalar: {} };
221
+ assert.deepEqual(styleForDescriptor(broken), styleForDescriptor(short));
222
+ assert.deepEqual(styleForDescriptor(broken), styleForDescriptor(desc()));
223
+ assert.ok(new Set(imageStyles().map((style) => style.name)).has(styleForDescriptor(broken).name));
224
+ });
225
+ });
226
+
227
+ describe("imagestyles fingerprint", () => {
228
+ it("stable across calls and copies, 8 lowercase hex digits", () => {
229
+ const fromTable = imageStyles().find((style) => style.name === "nocturn") as StyleSpec;
230
+ const print = styleFingerprint(fromTable);
231
+ assert.match(print, /^[0-9a-f]{8}$/);
232
+ assert.equal(print, styleFingerprint(styleByName("nocturn")));
233
+ assert.equal(print, styleFingerprint(imageStyles()[10]));
234
+ });
235
+
236
+ it("all 15 fingerprints are distinct", () => {
237
+ const prints = new Set(imageStyles().map(styleFingerprint));
238
+ assert.equal(prints.size, 15);
239
+ });
240
+
241
+ it("damaged styles still yield a deterministic fingerprint", () => {
242
+ const broken = { name: "x", mood: "y" } as unknown as StyleSpec;
243
+ assert.match(styleFingerprint(broken), /^[0-9a-f]{8}$/);
244
+ assert.equal(styleFingerprint(broken), styleFingerprint({ ...broken }));
245
+ });
246
+ });
247
+
248
+ describe("applyStyleBias", () => {
249
+ it("applies opaline's palette, density, hero pull and motion multipliers", () => {
250
+ const out = applyStyleBias(
251
+ { chroma: 0.5, lightness: 0.5, warmth: 0.5, density: 0.5, heroScale: 0.5, strength: 1, drift: 1 },
252
+ styleByName("opaline"),
253
+ );
254
+ assert.equal(out.chroma, 0); // 0.5 − 0.55 clamps at 0
255
+ assert.equal(out.lightness, 1); // 0.5 + 0.55 clamps at 1
256
+ near(out.warmth, 0.3);
257
+ near(out.density, 0.7);
258
+ near(out.heroScale, 0.44); // 0.5 + (0.38 − 0.5) · HERO_PULL
259
+ assert.equal(out.strength, 0.15); // 1 × tempo
260
+ assert.equal(out.drift, 0.2); // 1 × driftScale
261
+ });
262
+
263
+ it("pulls stressed heroScale bases back inside the 0.3-0.7 band", () => {
264
+ for (const base of [-5, 0, 0.9, 5, Number.NaN, Number.POSITIVE_INFINITY]) {
265
+ const out = applyStyleBias({ heroScale: base }, styleByName("percussa"));
266
+ assert.ok(
267
+ out.heroScale >= STYLE_RANGES.heroScale[0] - 1e-9 && out.heroScale <= STYLE_RANGES.heroScale[1] + 1e-9,
268
+ `base ${base} → ${out.heroScale}`,
269
+ );
270
+ }
271
+ });
272
+
273
+ it("texture deltas land inside TEXTURE_RANGES with whole glaze layers", () => {
274
+ const plated = applyStyleBias({ texture: { strokeWeight: 8, glazeLayers: 3 } }, styleByName("percussa"));
275
+ near(plated.texture.strokeWeight, 16);
276
+ assert.equal(plated.texture.glazeLayers, 3); // no delta → clamped base stays whole
277
+ const night = applyStyleBias({ texture: { grainDensity: 0.1, glazeLayers: 3.4 } }, styleByName("nocturn"));
278
+ assert.equal(night.texture.grainDensity, 0); // 0.1 − 0.15 clamps at 0
279
+ assert.equal(night.texture.glazeLayers, 5); // 3.4 + 2 → 5.4 → round 5
280
+ });
281
+
282
+ it("stress inputs never escape: every output finite, in range, coupling kept, layers whole", () => {
283
+ const stress: StyleBase = {
284
+ chroma: Number.NaN,
285
+ lightness: Number.POSITIVE_INFINITY,
286
+ warmth: Number.NEGATIVE_INFINITY,
287
+ density: Number.NaN,
288
+ heroScale: 1e9,
289
+ texture: {
290
+ grainDensity: Number.NaN,
291
+ grainSize: -1e9,
292
+ grainOpacity: Number.POSITIVE_INFINITY,
293
+ strokeSoftness: Number.NaN,
294
+ strokeWeight: -1e9,
295
+ strokeJitter: Number.POSITIVE_INFINITY,
296
+ glazeLayers: Number.NaN,
297
+ glazeOpacity: -1e9,
298
+ specular: Number.POSITIVE_INFINITY,
299
+ turbulence: Number.NaN,
300
+ },
301
+ strength: Number.NaN,
302
+ drift: Number.POSITIVE_INFINITY,
303
+ };
304
+ for (const style of imageStyles()) {
305
+ const out = applyStyleBias(stress, style);
306
+ for (const value of [
307
+ out.chroma,
308
+ out.lightness,
309
+ out.warmth,
310
+ out.density,
311
+ out.heroScale,
312
+ out.strength,
313
+ out.drift,
314
+ ]) {
315
+ assert.ok(Number.isFinite(value), `${style.name} leaked a non-finite scalar`);
316
+ }
317
+ assert.ok(out.chroma >= 0 && out.chroma <= 1 && out.lightness >= 0 && out.lightness <= 1);
318
+ assert.ok(out.warmth >= 0 && out.warmth <= 1 && out.density >= 0 && out.density <= 1);
319
+ assert.ok(out.strength >= 0 && out.strength <= 1 && out.drift >= 0 && out.drift <= 1);
320
+ for (const field of Object.keys(TEXTURE_RANGES) as (keyof TextureSpec)[]) {
321
+ const value = out.texture[field];
322
+ const range = TEXTURE_RANGES[field];
323
+ assert.ok(
324
+ Number.isFinite(value) && value >= range.min - 1e-9 && value <= range.max + 1e-9,
325
+ `${style.name}.${field} = ${value}`,
326
+ );
327
+ }
328
+ assert.ok(
329
+ out.texture.grainOpacity <= out.texture.grainDensity + GRAIN_COUPLING + 1e-9,
330
+ `${style.name} broke the grain coupling`,
331
+ );
332
+ assert.ok(Number.isInteger(out.texture.glazeLayers));
333
+ }
334
+ });
335
+
336
+ it("missing base fields take neutral defaults and the fold is deterministic", () => {
337
+ const style = styleByName("tarnglass");
338
+ const first = applyStyleBias({}, style);
339
+ const second = applyStyleBias({}, style);
340
+ assert.deepEqual(first, second);
341
+ near(first.chroma, 0.15); // 0.5 − 0.35
342
+ assert.equal(first.strength, 0.4); // neutral 1 × tempo
343
+ assert.equal(first.drift, 0.5); // neutral 1 × driftScale
344
+ assert.equal(first.texture.grainDensity, 0.2); // 0.5 − 0.3
345
+ assert.ok(first.texture.grainOpacity <= first.texture.grainDensity + GRAIN_COUPLING + 1e-9);
346
+ });
347
+ });
@@ -1,13 +1,13 @@
1
1
  // # masks.test — the mask plane algebra of the studio: the grid builders,
2
2
  // the add, subtract and intersect combination, the inversion, threshold
3
3
  // and boolean bridges, the bounds, the crop and the shape generators,
4
- // runnable with the node built-in runner (no dependencies, no install):
5
- // node --test tests/masks.test.ts
4
+ // runnable with vitest:
5
+ // pnpm vitest run tests/masks.test.ts
6
6
  // Every test watches the two invariants the module promises: the ops
7
7
  // fold planes cell by cell with clamped values and mismatched or empty
8
8
  // input answers null or the empty plane instead of throwing.
9
9
  import assert from "node:assert/strict";
10
- import { describe, it } from "node:test";
10
+ import { describe, it } from "vitest";
11
11
  import {
12
12
  alphatoboolean,
13
13
  booleantoalpha,
@@ -1,13 +1,13 @@
1
1
  // # matiz.test — the cadria pixel engine: the channel adjustments, the
2
2
  // grayscale and sepia tints, the 3x3 convolution, the box blur, the bilinear
3
- // resize and the composed pipeline, runnable with the node built-in runner
4
- // (no canvas, no dependencies, no install):
5
- // node --test tests/matiz.test.ts
3
+ // resize and the composed pipeline, runnable with vitest
4
+ // (no canvas):
5
+ // pnpm vitest run tests/matiz.test.ts
6
6
  // The fixture is a synthetic rgba buffer shaped like ImageData, and every
7
7
  // test also watches the one invariant the engine promises: the input never
8
8
  // mutates.
9
9
  import assert from "node:assert/strict";
10
- import { describe, it } from "node:test";
10
+ import { describe, it } from "vitest";
11
11
  import {
12
12
  adjustimage,
13
13
  boxblurimage,
@@ -138,7 +138,10 @@ describe("matiz convolution", () => {
138
138
 
139
139
  it("refuses a kernel that is not nine finite weights", () => {
140
140
  const image = solidImage(2, 2, [1, 2, 3, 255]);
141
- assert.throws(() => convolveimage(image, [1, 2, 3] as unknown as typeof matizkernels.identity), /nine finite weights/);
141
+ assert.throws(
142
+ () => convolveimage(image, [1, 2, 3] as unknown as typeof matizkernels.identity),
143
+ /nine finite weights/,
144
+ );
142
145
  });
143
146
  });
144
147
 
@@ -1,11 +1,11 @@
1
- // # mediaprobe.test — honest unit tests for the container probe, runnable with the
2
- // node built-in runner (no install, no dependencies):
3
- // node --test tests/mediaprobe.test.ts
1
+ // # mediaprobe.test — honest unit tests for the container probe, runnable
2
+ // with vitest:
3
+ // pnpm vitest run tests/mediaprobe.test.ts
4
4
  // The fixtures are synthetic MP4 (ISO-BMFF) and WebM (EBML) heads built byte by byte
5
5
  // below — no binary files, no canvas, no decoder.
6
6
  import assert from "node:assert/strict";
7
- import { describe, it } from "node:test";
8
- import { probeMedia, ProbeError } from "../mediaprobe.ts";
7
+ import { describe, it } from "vitest";
8
+ import { ProbeError, probeMedia } from "../mediaprobe.ts";
9
9
 
10
10
  /** big-endian u32 bytes. */
11
11
  function u32(value: number): number[] {
@@ -66,19 +66,36 @@ function mp4Fixture(): Uint8Array {
66
66
  const ftyp = mp4box("ftyp", ...ascii("isom"), ...u32(512), ...ascii("isom"), ...ascii("mp42"));
67
67
  const mvhd = mp4box(
68
68
  "mvhd",
69
- 0, 0, 0, 0, // version 0 + flags
70
- ...u32(0), ...u32(0), // creation, modification
71
- ...u32(1000), ...u32(3200), // timescale, duration → 3.2 s
69
+ 0,
70
+ 0,
71
+ 0,
72
+ 0, // version 0 + flags
73
+ ...u32(0),
74
+ ...u32(0), // creation, modification
75
+ ...u32(1000),
76
+ ...u32(3200), // timescale, duration → 3.2 s
72
77
  ...new Array<number>(80).fill(0), // rate/volume/matrix/predefined/next track id
73
78
  );
74
79
  const tkhd = (width: number, height: number) =>
75
80
  mp4box(
76
81
  "tkhd",
77
- 0, 0, 0, 0, // version 0 + flags
78
- ...u32(0), ...u32(0), ...u32(1), ...u32(0), ...u32(3200), // ids and duration
79
- ...new Array<number>(8).fill(0), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
82
+ 0,
83
+ 0,
84
+ 0,
85
+ 0, // version 0 + flags
86
+ ...u32(0),
87
+ ...u32(0),
88
+ ...u32(1),
89
+ ...u32(0),
90
+ ...u32(3200), // ids and duration
91
+ ...new Array<number>(8).fill(0),
92
+ ...u16(0),
93
+ ...u16(0),
94
+ ...u16(0),
95
+ ...u16(0),
80
96
  ...new Array<number>(36).fill(0), // matrix
81
- ...u32(width * 2 ** 16), ...u32(height * 2 ** 16), // fixed 16.16 extent
97
+ ...u32(width * 2 ** 16),
98
+ ...u32(height * 2 ** 16), // fixed 16.16 extent
82
99
  );
83
100
  const mdhd = (timescale: number, duration: number) =>
84
101
  mp4box("mdhd", 0, 0, 0, 0, ...u32(0), ...u32(0), ...u32(timescale), ...u32(duration), ...u16(0), ...u16(0));
@@ -86,26 +103,53 @@ function mp4Fixture(): Uint8Array {
86
103
  const stsd = (entry: number[]) => mp4box("stsd", 0, 0, 0, 0, ...u32(1), ...entry);
87
104
  const avc1 = mp4box(
88
105
  "avc1",
89
- ...new Array<number>(6).fill(0), ...u16(1), // reserved, data reference
90
- ...u16(0), ...u16(0), ...u32(0), ...u32(0), ...u32(0), // predefined/reserved
91
- ...u16(320), ...u16(240), // width, height
92
- ...u32(0x00480000), ...u32(0x00480000), ...u32(0), ...u16(1),
93
- ...new Array<number>(32).fill(0), ...u16(24), ...u16(0xffff),
106
+ ...new Array<number>(6).fill(0),
107
+ ...u16(1), // reserved, data reference
108
+ ...u16(0),
109
+ ...u16(0),
110
+ ...u32(0),
111
+ ...u32(0),
112
+ ...u32(0), // predefined/reserved
113
+ ...u16(320),
114
+ ...u16(240), // width, height
115
+ ...u32(0x00480000),
116
+ ...u32(0x00480000),
117
+ ...u32(0),
118
+ ...u16(1),
119
+ ...new Array<number>(32).fill(0),
120
+ ...u16(24),
121
+ ...u16(0xffff),
94
122
  );
95
123
  const mp4a = mp4box(
96
124
  "mp4a",
97
- ...new Array<number>(6).fill(0), ...u16(1),
98
- ...u16(0), ...u16(0), ...u32(0), // version, revision, vendor
99
- ...u16(2), ...u16(16), ...u16(0), ...u16(0), // channels, sample size
125
+ ...new Array<number>(6).fill(0),
126
+ ...u16(1),
127
+ ...u16(0),
128
+ ...u16(0),
129
+ ...u32(0), // version, revision, vendor
130
+ ...u16(2),
131
+ ...u16(16),
132
+ ...u16(0),
133
+ ...u16(0), // channels, sample size
100
134
  ...u32(48000 * 2 ** 16), // sample rate, 16.16 fixed
101
135
  );
102
136
  const trak = (extent: [number, number], timescale: number, duration: number, handler: string, entry: number[]) =>
103
137
  mp4box(
104
138
  "trak",
105
139
  ...tkhd(...extent),
106
- ...mp4box("mdia", ...mdhd(timescale, duration), ...hdlr(handler), ...mp4box("minf", ...mp4box("stbl", ...stsd(entry)))),
140
+ ...mp4box(
141
+ "mdia",
142
+ ...mdhd(timescale, duration),
143
+ ...hdlr(handler),
144
+ ...mp4box("minf", ...mp4box("stbl", ...stsd(entry))),
145
+ ),
107
146
  );
108
- const moov = mp4box("moov", ...mvhd, ...trak([320, 240], 1000, 3200, "vide", avc1), ...trak([0, 0], 48000, 153600, "soun", mp4a));
147
+ const moov = mp4box(
148
+ "moov",
149
+ ...mvhd,
150
+ ...trak([320, 240], 1000, 3200, "vide", avc1),
151
+ ...trak([0, 0], 48000, 153600, "soun", mp4a),
152
+ );
109
153
  return Uint8Array.from([...ftyp, ...moov]);
110
154
  }
111
155