@wenathlan/cadria 2.0.84 → 2.0.93

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/cadria",
3
- "version": "2.0.84",
3
+ "version": "2.0.93",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/wenathlan/devthink"
@@ -0,0 +1,93 @@
1
+ // # blendmodes.test — the separable blend formulas of the compositing
2
+ // standard over the 0-1 and 0-255 domains, the non-separable luminance
3
+ // modes and the W3C alpha composite, runnable with the node built-in
4
+ // runner (no dependencies, no install):
5
+ // node --test tests/blendmodes.test.ts
6
+ // Every test watches the two invariants the module promises: the channel
7
+ // formulas match the published equations exactly and clamped inputs
8
+ // never throw — damaged input answers the clamped or source value.
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "node:test";
11
+ import {
12
+ alphacomposite,
13
+ blendchannel,
14
+ blendchannel01,
15
+ blendpixel,
16
+ blendrgb,
17
+ blendrgb01,
18
+ isseparable,
19
+ lumaof,
20
+ } from "../blendmodes.ts";
21
+
22
+ describe("blendmodes", () => {
23
+ it("answers the exact separable formulas in the 0-1 domain", () => {
24
+ assert.equal(blendchannel01("multiply", 0.5, 0.5), 0.25);
25
+ assert.ok(Math.abs(blendchannel01("screen", 0.5, 0.5) - 0.75) < 1e-12);
26
+ assert.equal(blendchannel01("difference", 0.8, 0.3), 0.5);
27
+ assert.ok(Math.abs(blendchannel01("exclusion", 0.5, 0.4) - 0.5) < 1e-12);
28
+ assert.equal(blendchannel01("darken", 0.7, 0.2), 0.2);
29
+ assert.equal(blendchannel01("lighten", 0.7, 0.2), 0.7);
30
+ assert.equal(blendchannel01("normal", 0.3, 0.9), 0.9);
31
+ });
32
+
33
+ it("answers the dodge and burn edge cases of the published equations", () => {
34
+ assert.equal(blendchannel01("colordodge", 0.5, 0.5), 1);
35
+ assert.equal(blendchannel01("colordodge", 0, 0.5), 0);
36
+ assert.equal(blendchannel01("colordodge", 0.25, 1), 1);
37
+ assert.equal(blendchannel01("colorburn", 0.5, 0.5), 0);
38
+ assert.equal(blendchannel01("colorburn", 1, 0.5), 1);
39
+ assert.equal(blendchannel01("colorburn", 0.5, 0), 0);
40
+ });
41
+
42
+ it("clamps damaged inputs instead of throwing", () => {
43
+ assert.equal(blendchannel01("multiply", 2, -1), 0);
44
+ assert.equal(blendchannel01("multiply", 5, 5), 1);
45
+ assert.ok(Number.isNaN(blendchannel01("multiply", Number.NaN, 0.5)));
46
+ assert.equal(blendchannel("multiply", Number.NaN, 128), 0);
47
+ });
48
+
49
+ it("rounds the 0-255 channel form of the same formulas", () => {
50
+ assert.equal(blendchannel("multiply", 255, 255), 255);
51
+ assert.equal(blendchannel("multiply", 128, 0), 0);
52
+ assert.equal(blendchannel("normal", 10, 200), 200);
53
+ assert.equal(blendchannel("screen", 128, 128), 192);
54
+ assert.equal(blendchannel("multiply", 128, 128), 64);
55
+ });
56
+
57
+ it("blends rgb triples per channel in both domains", () => {
58
+ assert.deepEqual(blendrgb01("multiply", [0.2, 0.4, 0.6], [0.5, 0.5, 0.5]), [0.1, 0.2, 0.3]);
59
+ assert.deepEqual(blendrgb("screen", [0, 0, 0], [255, 255, 255]), [255, 255, 255]);
60
+ assert.deepEqual(blendrgb("multiply", [255, 128, 0], [255, 128, 0]), [255, 64, 0]);
61
+ });
62
+
63
+ it("separates the separable modes from the luminance modes", () => {
64
+ assert.equal(isseparable("multiply"), true);
65
+ assert.equal(isseparable("softlight"), true);
66
+ assert.equal(isseparable("hue"), false);
67
+ assert.equal(isseparable("luminosity"), false);
68
+ });
69
+
70
+ it("keeps the classic luma weights of the compositing standard", () => {
71
+ assert.ok(Math.abs(lumaof([1, 0, 0]) - 0.3) < 1e-12);
72
+ assert.ok(Math.abs(lumaof([0, 1, 0]) - 0.59) < 1e-12);
73
+ assert.ok(Math.abs(lumaof([0, 0, 1]) - 0.11) < 1e-12);
74
+ assert.ok(Math.abs(lumaof([1, 1, 1]) - 1) < 1e-12);
75
+ });
76
+
77
+ it("composites with the W3C alpha weighting", () => {
78
+ const opaque = alphacomposite([1, 0, 0], [0, 1, 0], [0, 0, 1], 1, 1);
79
+ assert.ok(Math.abs(opaque.alpha - 1) < 1e-12);
80
+ assert.deepEqual(opaque.color, [1, 0, 0]);
81
+ const transparent = alphacomposite([1, 0, 0], [0, 1, 0], [0, 0, 1], 0, 1);
82
+ assert.ok(Math.abs(transparent.alpha - 1) < 1e-12);
83
+ assert.deepEqual(transparent.color, [0, 0, 1]);
84
+ const empty = alphacomposite([1, 0, 0], [0, 1, 0], [0, 0, 1], 0, 0);
85
+ assert.equal(empty.alpha, 0);
86
+ assert.deepEqual(empty.color, [0, 0, 0]);
87
+ });
88
+
89
+ it("blends one pixel under per-layer alpha", () => {
90
+ assert.deepEqual(blendpixel("multiply", [200, 200, 200], [100, 100, 100]), [78, 78, 78]);
91
+ assert.deepEqual(blendpixel("normal", [200, 200, 200], [10, 20, 30]), [10, 20, 30]);
92
+ });
93
+ });
@@ -0,0 +1,124 @@
1
+ // # colorconvert.test — the color space conversions of the studio: the
2
+ // sRGB transfer curve through the decode table, the HSL and HSV cylinders,
3
+ // the OKLab and OKLCH spheres, the studio-swing YCbCr families and the
4
+ // hex round trip, runnable with the node built-in runner (no dependencies,
5
+ // no install):
6
+ // node --test tests/colorconvert.test.ts
7
+ // Every test watches the two invariants the module promises: conversions
8
+ // round trip inside one byte of drift and clamped or damaged input never
9
+ // throws — the channel clamp answers the nearest legal byte.
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+ import {
13
+ clamp01,
14
+ clamprgb,
15
+ hexfromrgb,
16
+ hsltorgb,
17
+ hsvtorgb,
18
+ lineartosrgb,
19
+ mixrgb,
20
+ oklabfromrgb,
21
+ relativeluma601,
22
+ relativeluma709,
23
+ rgbfromhex,
24
+ rgbfromoklab,
25
+ rgbfromycbcr,
26
+ rgbtohsl,
27
+ rgbtohsv,
28
+ srgbtolinear,
29
+ YCBCR_601,
30
+ YCBCR_709,
31
+ ycbcrfromrgb,
32
+ } from "../colorconvert.ts";
33
+
34
+ describe("colorconvert", () => {
35
+ it("clamps to the legal ranges without throwing", () => {
36
+ assert.equal(clamp01(2), 1);
37
+ assert.equal(clamp01(-1), 0);
38
+ assert.equal(clamp01(0.5), 0.5);
39
+ assert.deepEqual(clamprgb([300, -5, 128]), [255, 0, 128]);
40
+ });
41
+
42
+ it("rides the sRGB transfer curve through the decode table", () => {
43
+ assert.equal(srgbtolinear(0), 0);
44
+ assert.equal(srgbtolinear(255), 1);
45
+ assert.ok(Math.abs(srgbtolinear(128) - 0.2158) < 0.002);
46
+ assert.equal(lineartosrgb(0), 0);
47
+ assert.equal(lineartosrgb(1), 255);
48
+ assert.ok(Math.abs(lineartosrgb(0.5) - 188) < 2);
49
+ assert.ok(Math.abs(lineartosrgb(srgbtolinear(128)) - 128) <= 1);
50
+ assert.equal(srgbtolinear(Number.NaN), 0);
51
+ });
52
+
53
+ it("converts the red primary across the HSL cylinder", () => {
54
+ const red = rgbtohsl(255, 0, 0);
55
+ assert.equal(red.h, 0);
56
+ assert.equal(red.s, 1);
57
+ assert.equal(red.l, 0.5);
58
+ assert.deepEqual(hsltorgb(0, 1, 0.5), [255, 0, 0]);
59
+ assert.deepEqual(hsltorgb(120, 1, 0.5), [0, 255, 0]);
60
+ assert.deepEqual(hsltorgb(240, 1, 0.5), [0, 0, 255]);
61
+ const gray = rgbtohsl(128, 128, 128);
62
+ assert.equal(gray.s, 0);
63
+ });
64
+
65
+ it("converts the red primary across the HSV cylinder", () => {
66
+ assert.deepEqual(rgbtohsv(255, 0, 0), { h: 0, s: 1, v: 1 });
67
+ assert.deepEqual(hsvtorgb(120, 1, 1), [0, 255, 0]);
68
+ assert.deepEqual(hsvtorgb(0, 0, 1), [255, 255, 255]);
69
+ assert.deepEqual(hsvtorgb(0, 1, 0), [0, 0, 0]);
70
+ });
71
+
72
+ it("round trips OKLab inside a small drift", () => {
73
+ const lab = oklabfromrgb(255, 128, 0);
74
+ assert.ok(lab.l > 0.5 && lab.l < 0.9);
75
+ const back = rgbfromoklab(lab.l, lab.a, lab.b);
76
+ assert.ok(Math.abs(back[0] - 255) <= 2);
77
+ assert.ok(Math.abs(back[1] - 128) <= 2);
78
+ assert.ok(Math.abs(back[2] - 0) <= 2);
79
+ });
80
+
81
+ it("maps the studio-swing YCbCr of the 601 family", () => {
82
+ const red = ycbcrfromrgb(255, 0, 0, YCBCR_601);
83
+ assert.equal(red.y, 81);
84
+ assert.equal(red.cb, 90);
85
+ assert.equal(red.cr, 240);
86
+ const green = ycbcrfromrgb(0, 255, 0, YCBCR_601);
87
+ assert.equal(green.y, 145);
88
+ assert.equal(green.cr, 34);
89
+ const white = ycbcrfromrgb(255, 255, 255, YCBCR_601);
90
+ assert.equal(white.y, 235);
91
+ assert.equal(white.cb, 128);
92
+ assert.equal(white.cr, 128);
93
+ });
94
+
95
+ it("round trips YCbCr back into the byte cube", () => {
96
+ const forward = ycbcrfromrgb(60, 120, 200, YCBCR_601);
97
+ const back = rgbfromycbcr(forward.y, forward.cb, forward.cr, YCBCR_601);
98
+ assert.ok(Math.abs(back[0] - 60) <= 2);
99
+ assert.ok(Math.abs(back[1] - 120) <= 2);
100
+ assert.ok(Math.abs(back[2] - 200) <= 2);
101
+ });
102
+
103
+ it("declares the 709 coefficients beside the 601 family", () => {
104
+ assert.ok(Math.abs(YCBCR_709.kr - 0.2126) < 1e-12);
105
+ assert.ok(Math.abs(YCBCR_709.kb - 0.0722) < 1e-12);
106
+ assert.ok(Math.abs(relativeluma709(0, 255, 0) - 0.7152) < 0.001);
107
+ assert.equal(relativeluma601(0, 0, 0), 0);
108
+ assert.ok(Math.abs(relativeluma601(255, 255, 255) - 1) < 1e-9);
109
+ });
110
+
111
+ it("parses and renders the hex form", () => {
112
+ assert.equal(hexfromrgb([255, 0, 0]), "#ff0000");
113
+ assert.equal(hexfromrgb([16, 32, 64]), "#102040");
114
+ assert.deepEqual(rgbfromhex("#102040"), [16, 32, 64]);
115
+ assert.equal(rgbfromhex("not a color"), null);
116
+ });
117
+
118
+ it("mixes two colors along the gamma-aware path", () => {
119
+ const mid = mixrgb([0, 0, 0], [255, 255, 255], 0.5);
120
+ assert.deepEqual(mid, [99, 99, 99]);
121
+ const clamped = mixrgb([0, 0, 0], [255, 0, 0], 2);
122
+ assert.deepEqual(clamped, [255, 0, 0]);
123
+ });
124
+ });
@@ -0,0 +1,113 @@
1
+ // # easecurves.test — the influence and speed easing model: the handle
2
+ // algebra of the presets, the cubic bezier solver, the linear detection
3
+ // and the key track evaluation with hold segments, runnable with the
4
+ // node built-in runner (no dependencies, no install):
5
+ // node --test tests/easecurves.test.ts
6
+ // Every test watches the two invariants the module promises: the linear
7
+ // preset answers the identity of its parameter and damaged curves or
8
+ // times answer null or the nearest key instead of throwing.
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "node:test";
11
+ import {
12
+ bezierprogress,
13
+ curvefromhandles,
14
+ curvehandles,
15
+ curveprogress,
16
+ EASE_INOUT,
17
+ EASE_LINEAR,
18
+ type EaseKey,
19
+ evaluatekeys,
20
+ evaluatesegment,
21
+ islinearcurve,
22
+ samplecurve,
23
+ sanitizecurve,
24
+ timeatprogress,
25
+ } from "../easecurves.ts";
26
+
27
+ describe("easecurves", () => {
28
+ it("renders the linear preset as the identity handles", () => {
29
+ const handles = curvehandles(EASE_LINEAR);
30
+ assert.ok(Math.abs(handles[0] - 1 / 3) < 1e-9);
31
+ assert.ok(Math.abs(handles[1] - 1 / 3) < 1e-9);
32
+ assert.ok(Math.abs(handles[2] - 2 / 3) < 1e-9);
33
+ assert.ok(Math.abs(handles[3] - 2 / 3) < 1e-9);
34
+ assert.equal(islinearcurve(EASE_LINEAR), true);
35
+ assert.equal(islinearcurve(EASE_INOUT), false);
36
+ });
37
+
38
+ it("rebuilds the curve from the handles of the preset", () => {
39
+ const rebuilt = curvefromhandles(curvehandles(EASE_LINEAR));
40
+ assert.ok(rebuilt !== null);
41
+ assert.equal(islinearcurve(rebuilt as NonNullable<typeof rebuilt>), true);
42
+ assert.equal(curvefromhandles([Number.NaN, 0, 1, 1]), null);
43
+ });
44
+
45
+ it("solves the degenerate linear bezier as the identity", () => {
46
+ assert.ok(Math.abs(bezierprogress(0, 0, 1, 1, 0) - 0) < 1e-9);
47
+ assert.ok(Math.abs(bezierprogress(0, 0, 1, 1, 0.5) - 0.5) < 1e-9);
48
+ assert.ok(Math.abs(bezierprogress(0, 0, 1, 1, 1) - 1) < 1e-9);
49
+ });
50
+
51
+ it("answers the parameter of the linear preset along the progress", () => {
52
+ assert.ok(Math.abs((curveprogress(EASE_LINEAR, 0.25) as number) - 0.25) < 0.02);
53
+ assert.ok(Math.abs((curveprogress(EASE_LINEAR, 0.75) as number) - 0.75) < 0.02);
54
+ assert.ok(Math.abs((curveprogress(EASE_INOUT, 0.5) as number) - 0.5) < 0.01);
55
+ assert.equal(curveprogress(EASE_LINEAR, Number.NaN), 0);
56
+ });
57
+
58
+ it("inverts the progress of the linear preset back into time", () => {
59
+ assert.ok(Math.abs((timeatprogress(EASE_LINEAR, 0.5) as number) - 0.5) < 0.02);
60
+ assert.ok(Math.abs(timeatprogress(EASE_LINEAR, Number.NaN) as number) < 0.001);
61
+ });
62
+
63
+ it("samples the linear preset across the unit range", () => {
64
+ const samples = samplecurve(EASE_LINEAR, 4) as number[];
65
+ const expected = [0, 0.25, 0.5, 0.75, 1];
66
+ assert.equal(samples.length, 5);
67
+ for (let i = 0; i < expected.length; i += 1) assert.ok(Math.abs(samples[i] - expected[i]) < 0.02, `sample ${i}`);
68
+ assert.equal(samplecurve(EASE_LINEAR, 0), null);
69
+ });
70
+
71
+ it("evaluates the key track with linear segments and edge clamps", () => {
72
+ const keys: EaseKey[] = [
73
+ { time: 0, value: 0 },
74
+ { time: 10, value: 100 },
75
+ ];
76
+ assert.ok(Math.abs((evaluatekeys(keys, 5) as number) - 50) < 0.5);
77
+ assert.equal(evaluatekeys(keys, -1), 0);
78
+ assert.equal(evaluatekeys(keys, 11), 100);
79
+ assert.equal(evaluatekeys([], 5), null);
80
+ assert.equal(evaluatekeys(keys, Number.NaN), null);
81
+ });
82
+
83
+ it("holds the value of a hold segment until the next key", () => {
84
+ const keys: EaseKey[] = [
85
+ { time: 0, value: 1, holdOut: true },
86
+ { time: 10, value: 2 },
87
+ ];
88
+ assert.equal(evaluatekeys(keys, 5), 1);
89
+ assert.equal(evaluatekeys(keys, 10), 2);
90
+ });
91
+
92
+ it("evaluates one segment through the bezier of its curves", () => {
93
+ const value = evaluatesegment({ time: 0, value: 0 }, { time: 1, value: 10 }, 0.5);
94
+ assert.ok(Math.abs(value - 5) < 0.3);
95
+ const held = evaluatesegment({ time: 0, value: 3, holdOut: true }, { time: 1, value: 9 }, 0.5);
96
+ assert.equal(held, 3);
97
+ });
98
+
99
+ it("sanitizes curves into the legal ranges", () => {
100
+ const clean = sanitizecurve(EASE_LINEAR);
101
+ assert.ok(clean !== null);
102
+ assert.ok(Math.abs((clean as NonNullable<typeof clean>).outInfluence - 100 / 3) < 1e-9);
103
+ assert.equal(
104
+ sanitizecurve({
105
+ outInfluence: Number.NaN,
106
+ outSpeed: 1,
107
+ inInfluence: 100 / 3,
108
+ inSpeed: 1,
109
+ }),
110
+ null,
111
+ );
112
+ });
113
+ });
@@ -0,0 +1,127 @@
1
+ // # masks.test — the mask plane algebra of the studio: the grid builders,
2
+ // the add, subtract and intersect combination, the inversion, threshold
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
6
+ // Every test watches the two invariants the module promises: the ops
7
+ // fold planes cell by cell with clamped values and mismatched or empty
8
+ // input answers null or the empty plane instead of throwing.
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "node:test";
11
+ import {
12
+ alphatoboolean,
13
+ booleantoalpha,
14
+ combinemasks,
15
+ cropmask,
16
+ ellipsemask,
17
+ invertmask,
18
+ makeboolean,
19
+ makeopacity,
20
+ maskbounds,
21
+ maskcoverage,
22
+ maskstack,
23
+ rectmask,
24
+ thresholdmask,
25
+ } from "../masks.ts";
26
+
27
+ describe("masks", () => {
28
+ it("builds opacity and boolean grids with clamped fills", () => {
29
+ const plane = makeopacity(2, 3, 1);
30
+ assert.equal(plane.width, 2);
31
+ assert.equal(plane.height, 3);
32
+ assert.equal(plane.data.length, 6);
33
+ for (const value of plane.data) assert.equal(value, 1);
34
+ const hot = makeopacity(2, 2, 5);
35
+ for (const value of hot.data) assert.equal(value, 1);
36
+ const bits = makeboolean(3, 2, 7);
37
+ assert.equal(bits.data.length, 6);
38
+ for (const value of bits.data) assert.equal(value, 1);
39
+ const off = makeboolean(2, 2, 0);
40
+ for (const value of off.data) assert.equal(value, 0);
41
+ });
42
+
43
+ it("combines planes with the add, subtract and intersect ops", () => {
44
+ const left = makeopacity(2, 1, 0);
45
+ left.data[0] = 1;
46
+ const right = makeopacity(2, 1, 0);
47
+ right.data[1] = 1;
48
+ const added = combinemasks(left, right, "add") as NonNullable<ReturnType<typeof combinemasks>>;
49
+ assert.equal(added.data[0], 1);
50
+ assert.equal(added.data[1], 1);
51
+ const full = makeopacity(2, 1, 1);
52
+ const cut = makeopacity(2, 1, 0);
53
+ cut.data[0] = 1;
54
+ const subtracted = combinemasks(full, cut, "subtract") as NonNullable<ReturnType<typeof combinemasks>>;
55
+ assert.equal(subtracted.data[0], 0);
56
+ assert.equal(subtracted.data[1], 1);
57
+ const half = makeopacity(2, 1, 0);
58
+ half.data[0] = 0.5;
59
+ const intersected = combinemasks(full, half, "intersect") as NonNullable<ReturnType<typeof combinemasks>>;
60
+ assert.ok(Math.abs(intersected.data[0] - 0.5) < 1e-6);
61
+ assert.equal(intersected.data[1], 0);
62
+ assert.equal(combinemasks(makeopacity(2, 1), makeopacity(3, 1), "add"), null);
63
+ });
64
+
65
+ it("inverts a plane cell by cell", () => {
66
+ const plane = makeopacity(2, 1, 0);
67
+ plane.data[0] = 0.2;
68
+ plane.data[1] = 0.8;
69
+ const flipped = invertmask(plane);
70
+ assert.ok(Math.abs(flipped.data[0] - 0.8) < 1e-6);
71
+ assert.ok(Math.abs(flipped.data[1] - 0.2) < 1e-6);
72
+ });
73
+
74
+ it("crops to the bounds of the painted rectangle", () => {
75
+ const rect = rectmask(4, 4, 1, 1, 2, 2);
76
+ assert.equal(maskcoverage(rect), 4 / 16);
77
+ const bounds = maskbounds(rect) as NonNullable<ReturnType<typeof maskbounds>>;
78
+ assert.equal(bounds.minX, 1);
79
+ assert.equal(bounds.minY, 1);
80
+ assert.equal(bounds.maxX, 2);
81
+ assert.equal(bounds.maxY, 2);
82
+ const cropped = cropmask(rect, bounds) as NonNullable<ReturnType<typeof cropmask>>;
83
+ assert.equal(cropped.width, 2);
84
+ assert.equal(cropped.height, 2);
85
+ for (const value of cropped.data) assert.equal(value, 1);
86
+ assert.equal(maskbounds(makeopacity(2, 2, 0)), null);
87
+ assert.equal(cropmask(rect, { minX: 3, minY: 3, maxX: 0, maxY: 0 }), null);
88
+ });
89
+
90
+ it("bridges the boolean and opacity domains through the threshold", () => {
91
+ const plane = makeopacity(2, 1, 0);
92
+ plane.data[0] = 0.9;
93
+ plane.data[1] = 0.1;
94
+ const bits = thresholdmask(plane);
95
+ assert.equal(bits.data[0], 1);
96
+ assert.equal(bits.data[1], 0);
97
+ const back = booleantoalpha(bits);
98
+ assert.equal(back.data[0], 1);
99
+ assert.equal(back.data[1], 0);
100
+ const again = alphatoboolean(back);
101
+ assert.equal(again.data[0], 1);
102
+ assert.equal(again.data[1], 0);
103
+ });
104
+
105
+ it("paints the rectangle and the ellipse shapes", () => {
106
+ const rect = rectmask(3, 3, 0, 0, 3, 3);
107
+ assert.equal(maskcoverage(rect), 1);
108
+ const partial = rectmask(3, 3, 1, 1, 1, 1);
109
+ assert.ok(Math.abs(maskcoverage(partial) - 1 / 9) < 1e-6);
110
+ const clipped = rectmask(3, 3, 2, 2, 8, 8);
111
+ assert.equal(maskcoverage(clipped), 1 / 9);
112
+ const disc = ellipsemask(5, 5, 2, 2, 2, 2);
113
+ assert.equal(disc.data[2 * 5 + 2], 1);
114
+ assert.equal(disc.data[0], 0);
115
+ });
116
+
117
+ it("folds the layer stack over the base plane", () => {
118
+ const base = makeopacity(2, 2, 0);
119
+ const layer = rectmask(2, 2, 0, 0, 1, 1);
120
+ const stacked = maskstack([{ grid: layer, op: "add" }], base) as NonNullable<ReturnType<typeof maskstack>>;
121
+ assert.equal(stacked.data[0], 1);
122
+ assert.equal(stacked.data[3], 0);
123
+ const alone = maskstack([{ grid: layer, op: "add" }]) as NonNullable<ReturnType<typeof maskstack>>;
124
+ assert.equal(alone.data[0], 1);
125
+ assert.equal(maskstack([]), null);
126
+ });
127
+ });
@@ -0,0 +1,221 @@
1
+ // # matiz.test — the cadria pixel engine: the channel adjustments, the
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
6
+ // The fixture is a synthetic rgba buffer shaped like ImageData, and every
7
+ // test also watches the one invariant the engine promises: the input never
8
+ // mutates.
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "node:test";
11
+ import {
12
+ adjustimage,
13
+ boxblurimage,
14
+ convolveimage,
15
+ type MatizImage,
16
+ matizkernels,
17
+ resizeimage,
18
+ runmatiz,
19
+ tintimage,
20
+ } from "../matiz.ts";
21
+
22
+ /** builds a w×h rgba buffer where every pixel carries the same color. */
23
+ function solidImage(width: number, height: number, color: [number, number, number, number]): MatizImage {
24
+ const data = new Uint8ClampedArray(width * height * 4);
25
+ for (let at = 0; at < data.length; at += 4) {
26
+ data[at] = color[0];
27
+ data[at + 1] = color[1];
28
+ data[at + 2] = color[2];
29
+ data[at + 3] = color[3];
30
+ }
31
+ return { width, height, data };
32
+ }
33
+
34
+ /** builds a w×h grayscale horizontal ramp (left black, right white, alpha full). */
35
+ function rampImage(width: number, height: number): MatizImage {
36
+ const data = new Uint8ClampedArray(width * height * 4);
37
+ for (let y = 0; y < height; y += 1) {
38
+ for (let x = 0; x < width; x += 1) {
39
+ const at = (y * width + x) * 4;
40
+ const value = Math.round((255 * x) / (width - 1));
41
+ data[at] = value;
42
+ data[at + 1] = value;
43
+ data[at + 2] = value;
44
+ data[at + 3] = 255;
45
+ }
46
+ }
47
+ return { width, height, data };
48
+ }
49
+
50
+ /** the buffer snapshot used to prove no stage mutates its input. */
51
+ function snapshot(image: MatizImage): Uint8ClampedArray {
52
+ return new Uint8ClampedArray(image.data);
53
+ }
54
+
55
+ describe("matiz adjustments", () => {
56
+ it("keeps the image unchanged when every amount answers one", () => {
57
+ const image = solidImage(3, 2, [40, 80, 160, 255]);
58
+ const before = snapshot(image);
59
+ const out = adjustimage(image);
60
+ assert.deepEqual([out.data[0], out.data[1], out.data[2], out.data[3]], [40, 80, 160, 255]);
61
+ assert.deepEqual(image.data, before);
62
+ });
63
+
64
+ it("drives brightness to zero and multiplies honestly", () => {
65
+ const image = solidImage(2, 2, [100, 50, 25, 255]);
66
+ const dark = adjustimage(image, { brightness: 0 });
67
+ assert.deepEqual([dark.data[0], dark.data[1], dark.data[2]], [0, 0, 0]);
68
+ const twice = adjustimage(image, { brightness: 2 });
69
+ assert.deepEqual([twice.data[0], twice.data[1], twice.data[2]], [200, 100, 50]);
70
+ });
71
+
72
+ it("swings contrast around the 128 midpoint", () => {
73
+ const image = solidImage(2, 2, [128, 178, 78, 255]);
74
+ const out = adjustimage(image, { contrast: 2 });
75
+ assert.deepEqual([out.data[0], out.data[1], out.data[2]], [128, 228, 28]);
76
+ });
77
+
78
+ it("answers the luma when saturation reaches zero", () => {
79
+ const image = solidImage(2, 2, [100, 150, 200, 255]);
80
+ const out = adjustimage(image, { saturation: 0 });
81
+ const gray = out.data[0];
82
+ assert.equal(out.data[1], gray);
83
+ assert.equal(out.data[2], gray);
84
+ assert.equal(gray, Math.round(0.2126 * 100 + 0.7152 * 150 + 0.0722 * 200));
85
+ });
86
+
87
+ it("refuses negative or non-finite amounts", () => {
88
+ const image = solidImage(2, 2, [10, 20, 30, 255]);
89
+ assert.throws(() => adjustimage(image, { brightness: -1 }), /adjustments must be finite/);
90
+ assert.throws(() => adjustimage(image, { contrast: Number.NaN }), /adjustments must be finite/);
91
+ });
92
+ });
93
+
94
+ describe("matiz tints", () => {
95
+ it("answers the rec 709 luma on every channel for grayscale", () => {
96
+ const image = solidImage(2, 2, [90, 120, 30, 255]);
97
+ const out = tintimage(image, "grayscale");
98
+ const gray = Math.round(0.2126 * 90 + 0.7152 * 120 + 0.0722 * 30);
99
+ assert.deepEqual([out.data[0], out.data[1], out.data[2]], [gray, gray, gray]);
100
+ });
101
+
102
+ it("warms a neutral gray toward sepia and clamps at the ceiling", () => {
103
+ const white = solidImage(2, 2, [255, 255, 255, 255]);
104
+ const out = tintimage(white, "sepia");
105
+ assert.deepEqual([out.data[0], out.data[1], out.data[2]], [255, 255, 239]);
106
+ });
107
+
108
+ it("answers a copy for the none tint", () => {
109
+ const image = solidImage(2, 2, [12, 34, 56, 255]);
110
+ const out = tintimage(image, "none");
111
+ assert.deepEqual(new Uint8ClampedArray(out.data), image.data);
112
+ assert.notEqual(out.data, image.data);
113
+ });
114
+ });
115
+
116
+ describe("matiz convolution", () => {
117
+ it("answers the input colors for the identity kernel", () => {
118
+ const image = solidImage(3, 3, [30, 60, 90, 255]);
119
+ const out = convolveimage(image, matizkernels.identity);
120
+ assert.deepEqual(new Uint8ClampedArray(out.data), image.data);
121
+ });
122
+
123
+ it("keeps the alpha untouched and the dimensions stable", () => {
124
+ const image = rampImage(4, 4);
125
+ const before = snapshot(image);
126
+ const out = convolveimage(image, matizkernels.sharpen);
127
+ assert.equal(out.width, 4);
128
+ assert.equal(out.height, 4);
129
+ for (let at = 3; at < image.data.length; at += 4) assert.equal(out.data[at], image.data[at]);
130
+ assert.deepEqual(image.data, before);
131
+ });
132
+
133
+ it("replicates the clamped edges instead of reading outside", () => {
134
+ const image = solidImage(2, 2, [100, 100, 100, 255]);
135
+ const out = convolveimage(image, matizkernels.identity);
136
+ assert.deepEqual(new Uint8ClampedArray(out.data), image.data);
137
+ });
138
+
139
+ it("refuses a kernel that is not nine finite weights", () => {
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/);
142
+ });
143
+ });
144
+
145
+ describe("matiz box blur", () => {
146
+ it("answers a solid image unchanged", () => {
147
+ const image = solidImage(4, 4, [80, 120, 200, 255]);
148
+ const out = boxblurimage(image, 1);
149
+ assert.deepEqual(new Uint8ClampedArray(out.data), image.data);
150
+ });
151
+
152
+ it("pulls a lone bright pixel toward its dark neighborhood", () => {
153
+ const image = solidImage(5, 5, [0, 0, 0, 255]);
154
+ const center = (2 * 5 + 2) * 4;
155
+ image.data[center] = 255;
156
+ image.data[center + 1] = 255;
157
+ image.data[center + 2] = 255;
158
+ const out = boxblurimage(image, 1);
159
+ assert.equal(out.data[center], 28, "the white center falls toward its dark ring");
160
+ assert.equal(out.data[0], 0, "the far corner stays dark");
161
+ });
162
+
163
+ it("answers a copy for radius zero and refuses bad radii", () => {
164
+ const image = solidImage(2, 2, [9, 8, 7, 255]);
165
+ assert.deepEqual(new Uint8ClampedArray(boxblurimage(image, 0).data), image.data);
166
+ assert.throws(() => boxblurimage(image, -1), /integer >= 0/);
167
+ assert.throws(() => boxblurimage(image, 1.5), /integer >= 0/);
168
+ });
169
+ });
170
+
171
+ describe("matiz resize", () => {
172
+ it("answers the same color field for a solid image at any size", () => {
173
+ const image = solidImage(3, 3, [10, 200, 30, 255]);
174
+ const out = resizeimage(image, { width: 7, height: 5 });
175
+ assert.equal(out.width, 7);
176
+ assert.equal(out.height, 5);
177
+ for (let at = 0; at < out.data.length; at += 4)
178
+ assert.deepEqual([out.data[at], out.data[at + 1], out.data[at + 2], out.data[at + 3]], [10, 200, 30, 255]);
179
+ });
180
+
181
+ it("keeps a downsampled ramp monotonic", () => {
182
+ const image = rampImage(8, 2);
183
+ const out = resizeimage(image, { width: 4, height: 1 });
184
+ assert.ok(out.data[0] < out.data[4] && out.data[4] < out.data[8] && out.data[8] < out.data[12]);
185
+ });
186
+
187
+ it("refuses zero or fractional dimensions", () => {
188
+ const image = solidImage(2, 2, [1, 1, 1, 255]);
189
+ assert.throws(() => resizeimage(image, { width: 0, height: 2 }), /positive integer/);
190
+ assert.throws(() => resizeimage(image, { width: 2.5, height: 2 }), /positive integer/);
191
+ });
192
+ });
193
+
194
+ describe("matiz pipeline", () => {
195
+ it("runs the documented order: adjust, tint, convolve, blur, resize", () => {
196
+ const image = rampImage(6, 4);
197
+ const before = snapshot(image);
198
+ const out = runmatiz(image, {
199
+ adjustments: { brightness: 1.2, contrast: 1.1, saturation: 0.8 },
200
+ tint: "grayscale",
201
+ kernel: matizkernels.identity,
202
+ blurRadius: 1,
203
+ resize: { width: 3, height: 2 },
204
+ });
205
+ assert.equal(out.width, 3);
206
+ assert.equal(out.height, 2);
207
+ assert.deepEqual(image.data, before, "the pipeline never mutates the input");
208
+ });
209
+
210
+ it("answers a copy with no options", () => {
211
+ const image = solidImage(2, 2, [5, 6, 7, 255]);
212
+ const out = runmatiz(image);
213
+ assert.deepEqual(new Uint8ClampedArray(out.data), image.data);
214
+ assert.notEqual(out.data, image.data);
215
+ });
216
+
217
+ it("refuses an image whose buffer does not answer for its size", () => {
218
+ assert.throws(() => runmatiz({ width: 2, height: 2, data: new Uint8ClampedArray(4) }), /needs 16/);
219
+ assert.throws(() => runmatiz({ width: 0, height: 2, data: new Uint8ClampedArray(0) }), /positive integer/);
220
+ });
221
+ });
@@ -1,11 +1,11 @@
1
- // # media.probe.test — honest unit tests for the container probe, runnable with the
1
+ // # mediaprobe.test — honest unit tests for the container probe, runnable with the
2
2
  // node built-in runner (no install, no dependencies):
3
- // node --test tests/media.probe.test.ts
3
+ // node --test 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
7
  import { describe, it } from "node:test";
8
- import { probeMedia, ProbeError } from "../media.probe.ts";
8
+ import { probeMedia, ProbeError } from "../mediaprobe.ts";
9
9
 
10
10
  /** big-endian u32 bytes. */
11
11
  function u32(value: number): number[] {
@@ -129,7 +129,7 @@ function webmFixture(): Uint8Array {
129
129
  return Uint8Array.from([...header, ...segment]);
130
130
  }
131
131
 
132
- describe("media.probe mp4", () => {
132
+ describe("mediaprobe mp4", () => {
133
133
  const probe = probeMedia(mp4Fixture());
134
134
 
135
135
  it("answers the container, brands and movie duration", () => {
@@ -155,7 +155,7 @@ describe("media.probe mp4", () => {
155
155
  });
156
156
 
157
157
  it("feeds the transcode planner as a source", async () => {
158
- const { buildTranscodePlan } = await import("../transcode.plan.ts");
158
+ const { buildTranscodePlan } = await import("../transcodeplan.ts");
159
159
  const source = {
160
160
  width: probe.tracks[0].width ?? 0,
161
161
  height: probe.tracks[0].height ?? 0,
@@ -172,7 +172,7 @@ describe("media.probe mp4", () => {
172
172
  });
173
173
  });
174
174
 
175
- describe("media.probe webm", () => {
175
+ describe("mediaprobe webm", () => {
176
176
  const probe = probeMedia(webmFixture());
177
177
 
178
178
  it("answers the container, doctype and clock math", () => {
@@ -193,7 +193,7 @@ describe("media.probe webm", () => {
193
193
  });
194
194
  });
195
195
 
196
- describe("media.probe errors", () => {
196
+ describe("mediaprobe errors", () => {
197
197
  it("rejects buffers too small to probe", () => {
198
198
  assert.throws(() => probeMedia(new Uint8Array(8)), ProbeError);
199
199
  });
@@ -0,0 +1,212 @@
1
+ // # timelineedit.test — the editing algebra of the timeline: the
2
+ // overwrite, insert, lift and extract spans, the split with the fresh
3
+ // right id, the trims with handles and neighbours, the roll, slip,
4
+ // slide and rate stretch, runnable with the node built-in runner (no
5
+ // dependencies, no install):
6
+ // node --test tests/timelineedit.test.ts
7
+ // Every test watches the two invariants the module promises: edits
8
+ // never throw — every refusal rides the outcome type — and the media
9
+ // window of a piece only moves through the source in point its speed
10
+ // computes.
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import {
14
+ type EditItem,
15
+ type EditTrack,
16
+ extract,
17
+ insert,
18
+ itemat,
19
+ itemend,
20
+ lift,
21
+ overwrite,
22
+ ratestretch,
23
+ roll,
24
+ shapeof,
25
+ slide,
26
+ slip,
27
+ sorttrack,
28
+ split,
29
+ syncconflict,
30
+ trackof,
31
+ trim,
32
+ } from "../timelineedit.ts";
33
+
34
+ function item(id: number, start: number, duration: number, sourceIn = 0, speed = 1): EditItem {
35
+ return { id, start, duration, sourceIn, speed };
36
+ }
37
+
38
+ function track(items: EditItem[]): EditTrack {
39
+ return { id: 1, locked: false, syncLock: false, items };
40
+ }
41
+
42
+ describe("timelineedit", () => {
43
+ it("answers the exclusive end and the item under a tick", () => {
44
+ const one = item(1, 10, 5);
45
+ assert.equal(itemend(one), 15);
46
+ const trk = track([one, item(2, 0, 5)]);
47
+ assert.equal(itemat(trk, 12)?.id, 1);
48
+ assert.equal(itemat(trk, 5), null);
49
+ assert.equal(itemat(trk, 4)?.id, 2);
50
+ });
51
+
52
+ it("sorts a track by start and then id", () => {
53
+ const trk = sorttrack(track([item(2, 10, 5), item(1, 0, 5), item(3, 10, 5)]));
54
+ assert.deepEqual(
55
+ trk.items.map((it) => it.id),
56
+ [1, 2, 3],
57
+ );
58
+ });
59
+
60
+ it("detects the sync-lock conflict of a range", () => {
61
+ const trk = track([item(1, 0, 10)]);
62
+ assert.equal(syncconflict(trk, { start: 5, duration: 2 }), true);
63
+ assert.equal(syncconflict(trk, { start: 10, duration: 2 }), false);
64
+ });
65
+
66
+ it("overwrites the covered span and trims the partials", () => {
67
+ const trk = track([item(1, 0, 10, 0), item(2, 10, 10, 0)]);
68
+ const done = overwrite(trk, item(3, 5, 10, 100));
69
+ assert.ok(done.ok);
70
+ if (!done.ok) return;
71
+ const left = done.value.items.find((it) => it.id === 1) as EditItem;
72
+ const right = done.value.items.find((it) => it.id === 2) as EditItem;
73
+ assert.equal(left.duration, 5);
74
+ assert.equal(right.start, 15);
75
+ assert.equal(right.duration, 5);
76
+ assert.equal(right.sourceIn, 5);
77
+ assert.ok(done.value.items.some((it) => it.id === 3));
78
+ });
79
+
80
+ it("inserts at the in point and pushes the rest right", () => {
81
+ const trk = track([item(1, 0, 10, 0)]);
82
+ const done = insert(trk, item(2, 5, 4, 50));
83
+ assert.ok(done.ok);
84
+ if (!done.ok) return;
85
+ assert.equal(done.value.items.length, 3);
86
+ const head = done.value.items.find((it) => it.start === 0) as EditItem;
87
+ assert.equal(head.duration, 5);
88
+ const tail = done.value.items.find((it) => it.start === 9) as EditItem;
89
+ assert.equal(tail.duration, 5);
90
+ assert.equal(tail.sourceIn, 5);
91
+ const fresh = done.value.items.find((it) => it.id === 2) as EditItem;
92
+ assert.equal(fresh.start, 5);
93
+ });
94
+
95
+ it("lifts the span and keeps the gap", () => {
96
+ const trk = track([item(1, 0, 10, 0), item(2, 10, 10, 0)]);
97
+ const done = lift(trk, { start: 8, duration: 4 });
98
+ assert.ok(done.ok);
99
+ if (!done.ok) return;
100
+ assert.equal(done.value.items.length, 2);
101
+ assert.equal(done.value.items[0].duration, 8);
102
+ assert.equal(done.value.items[1].start, 12);
103
+ assert.equal(done.value.items[1].duration, 8);
104
+ assert.equal(done.value.items[1].sourceIn, 2);
105
+ assert.equal(lift(trk, { start: 50, duration: 4 }).ok, false);
106
+ });
107
+
108
+ it("extracts the span and closes the gap", () => {
109
+ const trk = track([item(1, 0, 10, 0), item(2, 10, 10, 0)]);
110
+ const done = extract(trk, { start: 8, duration: 4 });
111
+ assert.ok(done.ok);
112
+ if (!done.ok) return;
113
+ assert.equal(done.value.items[1].start, 8);
114
+ assert.equal(done.value.items[1].duration, 8);
115
+ });
116
+
117
+ it("splits the containing item with a fresh right id", () => {
118
+ const trk = track([item(1, 0, 10, 0, 2)]);
119
+ const done = split(trk, 6);
120
+ assert.ok(done.ok);
121
+ if (!done.ok) return;
122
+ assert.equal(done.value.track.items.length, 2);
123
+ assert.equal(done.value.track.items[0].duration, 6);
124
+ const right = done.value.track.items[1];
125
+ assert.equal(right.start, 6);
126
+ assert.equal(right.duration, 4);
127
+ assert.equal(right.sourceIn, 12);
128
+ assert.equal(right.speed, 2);
129
+ assert.equal(done.value.rightId, 2);
130
+ assert.equal(split(trk, 20).ok, false);
131
+ });
132
+
133
+ it("trims the in edge across the media handles", () => {
134
+ const trk = track([item(1, 0, 10, 20)]);
135
+ const done = trim(trk, 1, "in", 2, { mediaStart: 0 });
136
+ assert.ok(done.ok);
137
+ if (!done.ok) return;
138
+ const moved = done.value.items[0];
139
+ assert.equal(moved.start, 2);
140
+ assert.equal(moved.duration, 8);
141
+ assert.equal(moved.sourceIn, 22);
142
+ assert.equal(trim(trk, 1, "in", 30, { mediaStart: 0 }).ok, false);
143
+ assert.equal(trim(trk, 1, "in", 0).ok, false);
144
+ });
145
+
146
+ it("trims the out edge against the media duration", () => {
147
+ const trk = track([item(1, 0, 10, 0, 1)]);
148
+ const done = trim(trk, 1, "out", 5, { mediaStart: 0, mediaDuration: 20 });
149
+ assert.ok(done.ok);
150
+ if (!done.ok) return;
151
+ assert.equal(done.value.items[0].duration, 15);
152
+ assert.equal(trim(trk, 1, "out", 5, { mediaStart: 0, mediaDuration: 12 }).ok, false);
153
+ });
154
+
155
+ it("rolls the cut between two joined items", () => {
156
+ const trk = track([item(1, 0, 10, 0), item(2, 10, 10, 0)]);
157
+ const done = roll(trk, 1, 3);
158
+ assert.ok(done.ok);
159
+ if (!done.ok) return;
160
+ const left = done.value.items.find((it) => it.id === 1) as EditItem;
161
+ const right = done.value.items.find((it) => it.id === 2) as EditItem;
162
+ assert.equal(left.duration, 13);
163
+ assert.equal(right.start, 13);
164
+ assert.equal(right.duration, 7);
165
+ assert.equal(right.sourceIn, 3);
166
+ assert.equal(roll(trk, 1, 0).ok, false);
167
+ });
168
+
169
+ it("slips the media window without moving the span", () => {
170
+ const trk = track([item(1, 0, 10, 20)]);
171
+ const done = slip(trk, 1, 5);
172
+ assert.ok(done.ok);
173
+ if (!done.ok) return;
174
+ assert.equal(done.value.items[0].start, 0);
175
+ assert.equal(done.value.items[0].duration, 10);
176
+ assert.equal(done.value.items[0].sourceIn, 25);
177
+ assert.equal(slip(trk, 1, -30, { mediaStart: 0 }).ok, false);
178
+ });
179
+
180
+ it("slides the item between its neighbours", () => {
181
+ const trk = track([item(1, 0, 5, 0), item(2, 10, 5, 0), item(3, 20, 5, 0)]);
182
+ const done = slide(trk, 2, 3);
183
+ assert.ok(done.ok);
184
+ if (!done.ok) return;
185
+ assert.equal(done.value.items[1].start, 13);
186
+ assert.equal(slide(trk, 2, 6).ok, false);
187
+ assert.equal(slide(trk, 2, -8).ok, false);
188
+ });
189
+
190
+ it("rate stretches the item over a new duration", () => {
191
+ const trk = track([item(1, 0, 10, 0, 1)]);
192
+ const done = ratestretch(trk, 1, 20);
193
+ assert.ok(done.ok);
194
+ if (!done.ok) return;
195
+ assert.equal(done.value.items[0].duration, 20);
196
+ assert.equal(done.value.items[0].speed, 0.5);
197
+ const back = ratestretch(done.value, 1, 5);
198
+ assert.ok(back.ok);
199
+ if (!back.ok) return;
200
+ assert.equal(back.value.items[0].speed, 2);
201
+ assert.equal(ratestretch(trk, 1, 0, { minDuration: 1 }).ok, false);
202
+ });
203
+
204
+ it("refuses every edit on a locked track", () => {
205
+ const trk = track([item(1, 0, 10)]);
206
+ trk.locked = true;
207
+ assert.equal(overwrite(trk, item(2, 0, 5)).ok, false);
208
+ assert.equal(insert(trk, item(2, 0, 5)).ok, false);
209
+ assert.equal(lift(trk, { start: 0, duration: 5 }).ok, false);
210
+ assert.equal(split(trk, 5).ok, false);
211
+ });
212
+ });
@@ -0,0 +1,127 @@
1
+ // # timeticks.test — exact tick time: frame-rate math, sample clocks, the
2
+ // rational container conversions and SMPTE drop-frame timecode, runnable with
3
+ // the node built-in runner (no dependencies, no install):
4
+ // node --test tests/timeticks.test.ts
5
+ // Every test watches the two invariants the module promises: conversions are
6
+ // exact inside the safe-integer range (no float drift) and nothing ever
7
+ // throws — damaged input answers the default or null.
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import {
11
+ addticks,
12
+ clampticks,
13
+ formattimecode,
14
+ frameat,
15
+ frameduration,
16
+ framelabel,
17
+ framerate,
18
+ frameratefromf64,
19
+ framestofields,
20
+ makerange,
21
+ parsetimecode,
22
+ rangeintersect,
23
+ rangeoverlaps,
24
+ scaleticks,
25
+ snaptick,
26
+ subticks,
27
+ TICKS_PER_SECOND,
28
+ tickofframe,
29
+ ticksfromrational,
30
+ ticksfromseconds,
31
+ ticksfromunits,
32
+ tickstorationalround,
33
+ tickstoseconds,
34
+ tickstounitsfloor,
35
+ timecodeparams,
36
+ } from "../timeticks.ts";
37
+
38
+ const R24 = { num: 24, den: 1 };
39
+ const R2997 = { num: 30000, den: 1001 };
40
+
41
+ describe("timeticks rates", () => {
42
+ it("keeps the tick rate and exact frame durations", () => {
43
+ assert.equal(TICKS_PER_SECOND, 254016000000);
44
+ assert.equal(frameduration(R24), 10584000000);
45
+ assert.equal(frameduration({ num: 24000, den: 1001 }), 10594584000);
46
+ });
47
+ it("reduces rational rates and falls back to the default on damaged input", () => {
48
+ assert.deepEqual(framerate(24, 48), { num: 1, den: 2 });
49
+ assert.deepEqual(framerate(0, 10), { num: 24000, den: 1001 });
50
+ assert.equal(framelabel({ num: 24000, den: 1001 }), "23.976");
51
+ assert.equal(framelabel({ num: 25, den: 1 }), "25");
52
+ });
53
+ it("matches the closest standard rate for a container average", () => {
54
+ assert.deepEqual(frameratefromf64(29.97), R2997);
55
+ assert.deepEqual(frameratefromf64(23.976), { num: 24000, den: 1001 });
56
+ assert.deepEqual(frameratefromf64(23.5), { num: 47, den: 2 });
57
+ });
58
+ });
59
+
60
+ describe("timeticks frame math", () => {
61
+ it("floors frames and answers exact frame starts", () => {
62
+ assert.equal(frameat(R24, frameduration(R24) + 1), 1);
63
+ assert.equal(tickofframe(R24, 2), 2 * frameduration(R24));
64
+ assert.equal(frameat(R24, 0), 0);
65
+ assert.equal(tickofframe(R24, 0), 0);
66
+ });
67
+ it("snaps to floor, nearest and ceil boundaries", () => {
68
+ const t = frameduration(R24) + frameduration(R24) * 0.6;
69
+ assert.equal(snaptick(R24, t, "floor"), frameduration(R24));
70
+ assert.equal(snaptick(R24, t, "nearest"), 2 * frameduration(R24));
71
+ assert.equal(snaptick(R24, t, "ceil"), 2 * frameduration(R24));
72
+ assert.equal(snaptick(R24, frameduration(R24), "ceil"), frameduration(R24));
73
+ });
74
+ });
75
+
76
+ describe("timeticks conversions", () => {
77
+ it("converts seconds both ways", () => {
78
+ assert.equal(ticksfromseconds(1), TICKS_PER_SECOND);
79
+ assert.equal(tickstoseconds(TICKS_PER_SECOND), 1);
80
+ assert.equal(ticksfromseconds(1.5), 381024000000);
81
+ });
82
+ it("aligns audio samples without drift", () => {
83
+ assert.equal(ticksfromunits(48000, 48000), TICKS_PER_SECOND);
84
+ assert.equal(tickstounitsfloor(TICKS_PER_SECOND, 44100), 44100);
85
+ assert.equal(tickstounitsfloor(ticksfromunits(1000, 44100), 44100), 1000);
86
+ });
87
+ it("converts rational container timestamps exactly", () => {
88
+ assert.equal(ticksfromrational(1, 1, 1000), 254016000);
89
+ assert.equal(tickstorationalround(TICKS_PER_SECOND / 3, 1, 1000), 333);
90
+ assert.equal(tickstorationalround((TICKS_PER_SECOND * 2) / 3, 1, 1000), 667);
91
+ assert.equal(scaleticks(TICKS_PER_SECOND, 1, 2), TICKS_PER_SECOND / 2);
92
+ });
93
+ it("does the integer arithmetic with saturation and crossed-bound clamps", () => {
94
+ assert.equal(addticks(TICKS_PER_SECOND, 1), TICKS_PER_SECOND + 1);
95
+ assert.equal(subticks(10, 4), 6);
96
+ assert.equal(clampticks(5, 10, 20), 10);
97
+ assert.equal(clampticks(5, 20, 10), 20);
98
+ });
99
+ });
100
+
101
+ describe("timeticks timecode", () => {
102
+ it("applies the drop-frame skip on NTSC rates only", () => {
103
+ assert.equal(timecodeparams(R2997).dropCapable, true);
104
+ assert.equal(timecodeparams(R24).dropCapable, false);
105
+ assert.deepEqual(framestofields(1800, R2997, true), { neg: false, hours: 0, minutes: 1, seconds: 0, frames: 2 });
106
+ assert.deepEqual(framestofields(17982, R2997, true), { neg: false, hours: 0, minutes: 10, seconds: 0, frames: 0 });
107
+ });
108
+ it("formats and parses timecodes (round trip, drop separator, garbage)", () => {
109
+ const t = tickofframe(R24, 4320);
110
+ assert.equal(formattimecode(t, R24), "00:03:00:00");
111
+ assert.equal(formattimecode(tickofframe(R2997, 1800), R2997, true), "00:01:00;02");
112
+ assert.equal(parsetimecode("00:03:00:00", R24), t);
113
+ assert.equal(parsetimecode("nope", R24), null);
114
+ assert.equal(parsetimecode("00:00:00:30", R24), null);
115
+ });
116
+ });
117
+
118
+ describe("timeticks ranges", () => {
119
+ it("overlaps and intersects half-open ranges", () => {
120
+ const a = makerange(10, 5);
121
+ assert.equal(a.duration, 5);
122
+ assert.ok(rangeoverlaps(a, makerange(12, 10)));
123
+ assert.deepEqual(rangeintersect(a, makerange(12, 10)), { start: 12, duration: 3 });
124
+ assert.equal(rangeintersect(a, makerange(20, 5)), null);
125
+ assert.equal(makerange(0, -5).duration, 0);
126
+ });
127
+ });
@@ -1,6 +1,6 @@
1
- // # transcode.plan.test — honest unit tests for the transcode planner, runnable with
1
+ // # transcodeplan.test — honest unit tests for the transcode planner, runnable with
2
2
  // the node built-in runner (no install, no dependencies):
3
- // node --test tests/transcode.plan.test.ts
3
+ // node --test tests/transcodeplan.test.ts
4
4
  import assert from "node:assert/strict";
5
5
  import { describe, it } from "node:test";
6
6
  import {
@@ -12,9 +12,9 @@ import {
12
12
  validateRung,
13
13
  type SourceMedia,
14
14
  type TranscodeRung,
15
- } from "../transcode.plan.ts";
15
+ } from "../transcodeplan.ts";
16
16
 
17
- /** The probed source the plan tests build from (the media.probe.ts shape). */
17
+ /** The probed source the plan tests build from (the mediaprobe.ts shape). */
18
18
  const SOURCE: SourceMedia = {
19
19
  width: 1920,
20
20
  height: 1080,
@@ -32,7 +32,7 @@ const LADDER: TranscodeRung[] = [
32
32
  { id: "480p", height: 480, videoCodec: "vp09", audioCodec: "opus", videoBitrate: "1M", audioBitrate: "64K" },
33
33
  ];
34
34
 
35
- describe("transcode.plan bitrates", () => {
35
+ describe("transcodeplan bitrates", () => {
36
36
  it("parses the renderer spellings into bps", () => {
37
37
  assert.equal(parseBitrate("4M"), 4_000_000);
38
38
  assert.equal(parseBitrate("2500K"), 2_500_000);
@@ -53,7 +53,7 @@ describe("transcode.plan bitrates", () => {
53
53
  });
54
54
  });
55
55
 
56
- describe("transcode.plan validation", () => {
56
+ describe("transcodeplan validation", () => {
57
57
  it("names the failing rung in every thrown error", () => {
58
58
  assert.throws(
59
59
  () => validateRung({ ...LADDER[1], height: 0 }),
@@ -86,7 +86,7 @@ describe("transcode.plan validation", () => {
86
86
  });
87
87
  });
88
88
 
89
- describe("transcode.plan planner", () => {
89
+ describe("transcodeplan planner", () => {
90
90
  it("builds the runnable variants: never upscales, orders highest-first", () => {
91
91
  const plan = buildTranscodePlan(SOURCE, LADDER);
92
92
  assert.deepEqual(
@@ -4,9 +4,9 @@
4
4
  // The fixture is a synthetic grayscale gradient buffer shaped like ImageData.
5
5
  import assert from "node:assert/strict";
6
6
  import { describe, it } from "node:test";
7
- import { gridDimensions } from "../visualize.mesh.ts";
8
- import { boundingBox, faceNormal, normalizedScale, vertexNormals } from "../visualize.normals.ts";
9
- import { materialSummary, vertexColors } from "../visualize.material.ts";
7
+ import { gridDimensions } from "../visualizemesh.ts";
8
+ import { boundingBox, faceNormal, normalizedScale, vertexNormals } from "../visualizenormals.ts";
9
+ import { materialSummary, vertexColors } from "../visualizematerial.ts";
10
10
  import { visualize, VisualizeError, type ImageInput } from "../visualize.ts";
11
11
 
12
12
  /** builds a w×h grayscale horizontal ramp (left black, right white, alpha full). */