@wenathlan/cadria 2.0.88 → 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 +1 -1
- package/tests/blendmodes.test.ts +93 -0
- package/tests/colorconvert.test.ts +124 -0
- package/tests/easecurves.test.ts +113 -0
- package/tests/masks.test.ts +127 -0
- package/tests/timelineedit.test.ts +212 -0
- package/tests/timeticks.test.ts +127 -0
package/package.json
CHANGED
|
@@ -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,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
|
+
});
|