@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,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
|
+
});
|
package/tests/masks.test.ts
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
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 "
|
|
10
|
+
import { describe, it } from "vitest";
|
|
11
11
|
import {
|
|
12
12
|
alphatoboolean,
|
|
13
13
|
booleantoalpha,
|
package/tests/matiz.test.ts
CHANGED
|
@@ -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
|
|
4
|
-
// (no canvas
|
|
5
|
-
//
|
|
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 "
|
|
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(
|
|
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
|
|
package/tests/mediaprobe.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// # mediaprobe.test — honest unit tests for the container probe, runnable
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 "
|
|
8
|
-
import {
|
|
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,
|
|
70
|
-
|
|
71
|
-
|
|
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,
|
|
78
|
-
|
|
79
|
-
|
|
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),
|
|
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),
|
|
90
|
-
...u16(
|
|
91
|
-
...u16(
|
|
92
|
-
...
|
|
93
|
-
...
|
|
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),
|
|
98
|
-
...u16(
|
|
99
|
-
...u16(
|
|
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(
|
|
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(
|
|
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
|
|