@wenathlan/cadria 2.0.93 → 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.
Files changed (41) hide show
  1. package/README.md +33 -1
  2. package/docs/.gitkeep +0 -0
  3. package/package.json +25 -2
  4. package/tests/.gitkeep +0 -0
  5. package/tests/audiodecode.test.ts +406 -0
  6. package/tests/audiofeatures.test.ts +271 -0
  7. package/tests/audiopipeline.test.ts +193 -0
  8. package/tests/audiorhythm.test.ts +172 -0
  9. package/tests/audiospectrum.test.ts +257 -0
  10. package/tests/audiostructure.test.ts +292 -0
  11. package/tests/audiotimbre.test.ts +270 -0
  12. package/tests/audiotonality.test.ts +289 -0
  13. package/tests/blendmodes.test.ts +31 -4
  14. package/tests/colorconvert.test.ts +3 -4
  15. package/tests/easecurves.test.ts +3 -4
  16. package/tests/family.test.ts +73 -0
  17. package/tests/familyurl.test.ts +46 -0
  18. package/tests/gateway.test.ts +373 -0
  19. package/tests/generation.test.ts +228 -0
  20. package/tests/imageexport.test.ts +443 -0
  21. package/tests/imageproject.test.ts +372 -0
  22. package/tests/imagerender.test.ts +291 -0
  23. package/tests/imagerenderpaint.test.ts +119 -0
  24. package/tests/imagestyles.test.ts +347 -0
  25. package/tests/masks.test.ts +3 -3
  26. package/tests/matiz.test.ts +8 -5
  27. package/tests/mediaprobe.test.ts +66 -22
  28. package/tests/onboardingstate.test.ts +73 -0
  29. package/tests/shadercomp.test.ts +120 -0
  30. package/tests/synthcomposition.test.ts +257 -0
  31. package/tests/synthmotion.test.ts +372 -0
  32. package/tests/synthpalette.test.ts +247 -0
  33. package/tests/synthtexture.test.ts +256 -0
  34. package/tests/timelineedit.test.ts +3 -6
  35. package/tests/timelineview.test.ts +248 -0
  36. package/tests/timeticks.test.ts +4 -4
  37. package/tests/transcodeplan.test.ts +19 -8
  38. package/tests/versawaseanimate.test.ts +106 -0
  39. package/tests/versawasescheduler.test.ts +96 -0
  40. package/tests/versawaseslices.test.ts +75 -0
  41. package/tests/visualize.test.ts +6 -6
@@ -0,0 +1,256 @@
1
+ // # synthtexture.test — the texture layer's contract, runnable with vitest:
2
+ // pnpm vitest run tests/synthtexture.test.ts
3
+ // The tests watch four promises: the timbre → texture ramps are monotonic in
4
+ // their documented drivers (noise densifies, warmth softens and thickens,
5
+ // brightness sheens, contrast agitates, flatness stacks, flux jitters, zcr
6
+ // refines), every answer stays finite inside TEXTURE_RANGES with the
7
+ // grainOpacity coupling intact, damaged input falls back to the neutral
8
+ // canvas preset, and everything — synthesis, scatter fields, presets,
9
+ // blends — is deterministic bit-for-bit.
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "vitest";
12
+ import type { AudioDescriptor } from "../audioattributes.ts";
13
+ import { DIM_ORDER } from "../audiofeatures.ts";
14
+ import type { TexturePresetName, TextureSpec } from "../synthtexture.ts";
15
+ import {
16
+ GRAIN_COUPLING,
17
+ JITTER_FIELD_CAP,
18
+ jitterField,
19
+ sanitizeTexture,
20
+ synthTexture,
21
+ synthTextureFromPreset,
22
+ TEXTURE_RANGES,
23
+ textureDistance,
24
+ texturePresets,
25
+ } from "../synthtexture.ts";
26
+
27
+ /** neutral base vector: mid-low everywhere so single-dim overrides read clean. */
28
+ const BASE = 0.3;
29
+
30
+ /** builds a descriptor by overriding contract positions (3, 4, 16-21). */
31
+ function descriptor(dims: Record<number, number>): AudioDescriptor {
32
+ const vector = new Array<number>(32).fill(BASE);
33
+ for (const [k, v] of Object.entries(dims)) vector[Number(k)] = v;
34
+ return { version: 1, seed: "feedface00ffaa11", vector, scalar: {} };
35
+ }
36
+
37
+ const WHITE_NOISE = descriptor({ 3: 0.95, 4: 0.9, 16: 0.98, 17: 0.3, 18: 0.85, 20: 0.45, 21: 0.75 });
38
+ const PURE_TONE = descriptor({ 3: 0.03, 4: 0.05, 16: 0.02, 17: 0.5, 18: 0.3, 20: 0.04, 21: 0.25 });
39
+ const WARM_DARK = descriptor({ 17: 0.9, 18: 0.08 });
40
+ const BRIGHT = descriptor({ 18: 0.95 });
41
+ /** every consumed dim broken at once — the whole descriptor is distrusted. */
42
+ const DAMAGED = descriptor({ 3: NaN, 4: 0.5, 16: 0.5, 17: 0.5, 18: 0.5, 20: 0.5, 21: 0.5 });
43
+
44
+ /** every documented bound holds (range, whole layers, grain coupling). */
45
+ function assertInRanges(spec: TextureSpec, label: string): void {
46
+ for (const key of Object.keys(TEXTURE_RANGES) as (keyof TextureSpec)[]) {
47
+ const { min, max } = TEXTURE_RANGES[key];
48
+ const v = spec[key];
49
+ assert.ok(Number.isFinite(v), `${label}.${key} finite`);
50
+ assert.ok(v >= min && v <= max, `${label}.${key} ${v} in [${min}, ${max}]`);
51
+ }
52
+ assert.ok(Number.isInteger(spec.glazeLayers), `${label}.glazeLayers whole`);
53
+ assert.ok(spec.grainOpacity <= spec.grainDensity + GRAIN_COUPLING + 1e-9, `${label} grain coupling`);
54
+ }
55
+
56
+ describe("timbre → texture ramps", () => {
57
+ it("noise walls speckle: noisiness densifies and coarsens the grain", () => {
58
+ const noise = synthTexture(WHITE_NOISE);
59
+ const pure = synthTexture(PURE_TONE);
60
+ assert.ok(noise.grainDensity > 0.7, `density ${noise.grainDensity}`);
61
+ assert.ok(noise.grainSize > 4, `size ${noise.grainSize}`);
62
+ assert.ok(pure.grainDensity < 0.2, `density ${pure.grainDensity}`);
63
+ assert.ok(pure.grainSize <= 1.5, `size ${pure.grainSize}`);
64
+ });
65
+ it("warm mixes paint soft: warmth lifts strokeSoftness", () => {
66
+ const soft = synthTexture(WARM_DARK);
67
+ assert.ok(soft.strokeSoftness > 0.7, `softness ${soft.strokeSoftness}`);
68
+ });
69
+ it("warm + dark goes soft with the sheen off", () => {
70
+ const spec = synthTexture(WARM_DARK);
71
+ assert.ok(spec.strokeSoftness > 0.7, `softness ${spec.strokeSoftness}`);
72
+ assert.ok(spec.specular < 0.2, `specular ${spec.specular}`);
73
+ });
74
+ it("bright mixes sheen: specular high and glazeOpacity lifts", () => {
75
+ const bright = synthTexture(BRIGHT);
76
+ const dark = synthTexture(WARM_DARK);
77
+ assert.ok(bright.specular > 0.8, `specular ${bright.specular}`);
78
+ assert.ok(bright.glazeOpacity > dark.glazeOpacity, "glazeOpacity climbs with brightness");
79
+ });
80
+ it("contrast agitates: turbulence climbs with dynamics range", () => {
81
+ const high = synthTexture(descriptor({ 21: 0.9 }));
82
+ const low = synthTexture(descriptor({ 21: 0.1 }));
83
+ assert.ok(high.turbulence > low.turbulence, `${high.turbulence} > ${low.turbulence}`);
84
+ });
85
+ it("flat spectra stack washes: flatness drives glazeLayers", () => {
86
+ const flat = synthTexture(descriptor({ 3: 0.95 }));
87
+ const tonal = synthTexture(descriptor({ 3: 0.03 }));
88
+ assert.equal(flat.glazeLayers, 6);
89
+ assert.equal(tonal.glazeLayers, 1);
90
+ });
91
+ it("spectral motion shakes the line: flux drives strokeJitter", () => {
92
+ const busy = synthTexture(descriptor({ 4: 0.9 }));
93
+ const still = synthTexture(descriptor({ 4: 0.05 }));
94
+ assert.ok(busy.strokeJitter > still.strokeJitter, "jitter climbs with flux");
95
+ assert.ok(synthTexture(WHITE_NOISE).strokeJitter > 0.7, "noise walls jitter hard");
96
+ });
97
+ it("high zero crossings refine the grain at fixed noisiness", () => {
98
+ const hissy = synthTexture(descriptor({ 16: 0.8, 20: 0.45 }));
99
+ const round = synthTexture(descriptor({ 16: 0.8, 20: 0.05 }));
100
+ assert.ok(hissy.grainSize < round.grainSize, `${hissy.grainSize} < ${round.grainSize}`);
101
+ });
102
+ it("strokeWeight grows with warmth", () => {
103
+ const light = synthTexture(descriptor({ 17: 0.2, 21: 0.3 }));
104
+ const mid = synthTexture(descriptor({ 17: 0.5, 21: 0.3 }));
105
+ const heavy = synthTexture(descriptor({ 17: 0.8, 21: 0.3 }));
106
+ assert.ok(light.strokeWeight < mid.strokeWeight && mid.strokeWeight < heavy.strokeWeight);
107
+ });
108
+ it("grainOpacity never outruns grainDensity + 0.2 across a sweep", () => {
109
+ const sweep = [
110
+ WHITE_NOISE,
111
+ PURE_TONE,
112
+ WARM_DARK,
113
+ BRIGHT,
114
+ descriptor({ 16: 0.5, 17: 0.5, 18: 0.5, 21: 0.5, 3: 0.5, 4: 0.5, 20: 0.5 }),
115
+ descriptor({ 16: 0.75, 20: 0.3 }),
116
+ descriptor({ 21: 1, 17: 1 }),
117
+ descriptor({}),
118
+ ];
119
+ for (const d of sweep) {
120
+ const spec = synthTexture(d);
121
+ assert.ok(spec.grainOpacity <= spec.grainDensity + GRAIN_COUPLING + 1e-9);
122
+ }
123
+ });
124
+ it("every synthesized spec is finite and inside TEXTURE_RANGES", () => {
125
+ assertInRanges(synthTexture(WHITE_NOISE), "white-noise");
126
+ assertInRanges(synthTexture(PURE_TONE), "pure-tone");
127
+ assertInRanges(synthTexture(WARM_DARK), "warm-dark");
128
+ assertInRanges(synthTexture(BRIGHT), "bright");
129
+ assertInRanges(synthTexture(descriptor({})), "neutral");
130
+ });
131
+ });
132
+
133
+ describe("damaged input", () => {
134
+ it("non-finite dims fall back to the neutral canvas preset", () => {
135
+ const canvas = texturePresets().canvas.spec;
136
+ assert.deepEqual(synthTexture(DAMAGED), canvas);
137
+ assert.deepEqual(synthTexture(descriptor({ 16: Infinity })), canvas);
138
+ assert.deepEqual(synthTexture(descriptor({ 21: -NaN })), canvas);
139
+ });
140
+ it("missing, short or non-object descriptors fall back to canvas", () => {
141
+ const canvas = texturePresets().canvas.spec;
142
+ assert.deepEqual(synthTexture(null as unknown as AudioDescriptor), canvas);
143
+ assert.deepEqual(synthTexture(undefined as unknown as AudioDescriptor), canvas);
144
+ assert.deepEqual(synthTexture({ version: 1, seed: "x", vector: [0, 0, 0], scalar: {} }), canvas);
145
+ });
146
+ it("sanitizeTexture repairs wild specs into the documented ranges", () => {
147
+ const wild = {
148
+ grainDensity: 0.5,
149
+ grainSize: -2,
150
+ grainOpacity: 0.9,
151
+ strokeSoftness: NaN,
152
+ strokeWeight: -10,
153
+ strokeJitter: 3,
154
+ glazeLayers: 0.4,
155
+ glazeOpacity: 1.5,
156
+ specular: Infinity,
157
+ turbulence: -1,
158
+ } as TextureSpec;
159
+ const fixed = sanitizeTexture(wild);
160
+ assertInRanges(fixed, "wild");
161
+ assert.equal(fixed.grainOpacity, fixed.grainDensity + GRAIN_COUPLING); // coupling clamps it down
162
+ assert.equal(fixed.grainSize, 1); // damaged → range floor
163
+ });
164
+ });
165
+
166
+ describe("jitterField", () => {
167
+ it("is deterministic for the same seed", () => {
168
+ assert.deepEqual(jitterField("feedface00ffaa11", 64), jitterField("feedface00ffaa11", 64));
169
+ });
170
+ it("stays in [-1, 1] and swings both signs", () => {
171
+ const field = jitterField("determinism", 256);
172
+ assert.equal(field.length, 256);
173
+ for (const v of field) assert.ok(v >= -1 && v <= 1, `value ${v}`);
174
+ assert.ok(
175
+ field.some((v) => v > 0.1),
176
+ "has positives",
177
+ );
178
+ assert.ok(
179
+ field.some((v) => v < -0.1),
180
+ "has negatives",
181
+ );
182
+ });
183
+ it("differs across seeds and prefixes across counts", () => {
184
+ const a = jitterField("seed-a", 32);
185
+ const b = jitterField("seed-b", 32);
186
+ assert.notDeepEqual(a, b);
187
+ assert.deepEqual(jitterField("seed-a", 128).slice(0, 32), a); // prefix property
188
+ });
189
+ it("guards the count: floors, rejects damage, caps runaway asks", () => {
190
+ assert.equal(jitterField("x", NaN).length, 0);
191
+ assert.equal(jitterField("x", -5).length, 0);
192
+ assert.equal(jitterField("x", 4.9).length, 4);
193
+ assert.equal(jitterField("x", JITTER_FIELD_CAP + 1000).length, JITTER_FIELD_CAP);
194
+ });
195
+ });
196
+
197
+ describe("presets + blending", () => {
198
+ it("ships the three named feels, all finite, in range and coupled", () => {
199
+ const presets = texturePresets();
200
+ assert.deepEqual(Object.keys(presets).sort(), ["canvas", "glass", "static"]);
201
+ assertInRanges(presets.glass.spec, "glass");
202
+ assertInRanges(presets.canvas.spec, "canvas");
203
+ assertInRanges(presets.static.spec, "static");
204
+ });
205
+ it("frozen fields hold their ground under blending", () => {
206
+ for (const preset of Object.values(texturePresets())) {
207
+ const blended = synthTextureFromPreset(preset.name, WHITE_NOISE);
208
+ for (const key of preset.frozen) {
209
+ assert.equal(blended[key], preset.spec[key], `${preset.name}.${String(key)} frozen`);
210
+ }
211
+ }
212
+ });
213
+ it("preset blends stay inside every documented range", () => {
214
+ for (const name of ["glass", "canvas", "static"] as const) {
215
+ assertInRanges(synthTextureFromPreset(name, WHITE_NOISE), `${name} × noise`);
216
+ assertInRanges(synthTextureFromPreset(name, PURE_TONE), `${name} × pure`);
217
+ assertInRanges(synthTextureFromPreset(name, DAMAGED), `${name} × damaged`);
218
+ }
219
+ });
220
+ it("pure tones sit closer to glass than to static", () => {
221
+ const target = synthTexture(PURE_TONE);
222
+ const glass = synthTextureFromPreset("glass", PURE_TONE);
223
+ const statik = synthTextureFromPreset("static", PURE_TONE);
224
+ assert.equal(textureDistance(target, target), 0);
225
+ assert.ok(
226
+ textureDistance(glass, target) < textureDistance(statik, target),
227
+ `${textureDistance(glass, target)} < ${textureDistance(statik, target)}`,
228
+ );
229
+ });
230
+ it("noise walls sit closer to static than to glass", () => {
231
+ const target = synthTexture(WHITE_NOISE);
232
+ const glass = synthTextureFromPreset("glass", WHITE_NOISE);
233
+ const statik = synthTextureFromPreset("static", WHITE_NOISE);
234
+ assert.ok(textureDistance(statik, target) < textureDistance(glass, target));
235
+ });
236
+ it("unknown preset names fall back to the canvas base", () => {
237
+ const viaCanvas = synthTextureFromPreset("canvas", WARM_DARK);
238
+ const viaGarbage = synthTextureFromPreset("velvet" as TexturePresetName, WARM_DARK);
239
+ assert.deepEqual(viaGarbage, viaCanvas);
240
+ });
241
+ it("the whole pipeline is deterministic", () => {
242
+ assert.deepEqual(synthTexture(WHITE_NOISE), synthTexture(WHITE_NOISE));
243
+ assert.deepEqual(synthTextureFromPreset("glass", WARM_DARK), synthTextureFromPreset("glass", WARM_DARK));
244
+ assert.deepEqual(sanitizeTexture(synthTexture(PURE_TONE)), synthTexture(PURE_TONE));
245
+ });
246
+ it("reads the contract dims by documented name", () => {
247
+ assert.equal(DIM_ORDER.length, 32);
248
+ assert.equal(DIM_ORDER[3], "flatness");
249
+ assert.equal(DIM_ORDER[4], "flux");
250
+ assert.equal(DIM_ORDER[16], "noisiness");
251
+ assert.equal(DIM_ORDER[17], "warmth");
252
+ assert.equal(DIM_ORDER[18], "timbreBrightness");
253
+ assert.equal(DIM_ORDER[20], "zeroCrossings");
254
+ assert.equal(DIM_ORDER[21], "contrast");
255
+ });
256
+ });
@@ -1,15 +1,14 @@
1
1
  // # timelineedit.test — the editing algebra of the timeline: the
2
2
  // overwrite, insert, lift and extract spans, the split with the fresh
3
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
4
+ // slide and rate stretch, runnable with vitest:
5
+ // pnpm vitest run tests/timelineedit.test.ts
7
6
  // Every test watches the two invariants the module promises: edits
8
7
  // never throw — every refusal rides the outcome type — and the media
9
8
  // window of a piece only moves through the source in point its speed
10
9
  // computes.
11
10
  import assert from "node:assert/strict";
12
- import { describe, it } from "node:test";
11
+ import { describe, it } from "vitest";
13
12
  import {
14
13
  type EditItem,
15
14
  type EditTrack,
@@ -21,13 +20,11 @@ import {
21
20
  overwrite,
22
21
  ratestretch,
23
22
  roll,
24
- shapeof,
25
23
  slide,
26
24
  slip,
27
25
  sorttrack,
28
26
  split,
29
27
  syncconflict,
30
- trackof,
31
28
  trim,
32
29
  } from "../timelineedit.ts";
33
30
 
@@ -0,0 +1,248 @@
1
+ // # timelineview.test — the pure view-model of the edit surface, runnable
2
+ // with vitest:
3
+ // pnpm vitest run tests/timelineview.test.ts
4
+ // Every test watches the three promises the module makes: the tick↔pixel
5
+ // mapping never invents time (pixels floor, ticks saturate), the ruler picks
6
+ // the smallest readable step deterministically and labels with the exact
7
+ // SMPTE display, and the playhead helpers (edit points, magnet, quantized
8
+ // advance) only ever land on boundaries the algebra itself knows about.
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "vitest";
11
+ import type { EditIssue, EditItem, EditTrack } from "../timelineedit.ts";
12
+ import {
13
+ advanceplayhead,
14
+ clipgeometry,
15
+ DEFAULT_ZOOM_INDEX,
16
+ editpoints,
17
+ mediawindow,
18
+ pxtotick,
19
+ refusalnote,
20
+ rulerticks,
21
+ SLATE_FACES,
22
+ sequenceend,
23
+ slateface,
24
+ snapedit,
25
+ stockbin,
26
+ ticktopx,
27
+ visiblewindow,
28
+ ZOOM_LEVELS,
29
+ } from "../timelineview.ts";
30
+ import { frameduration, MAX_TICKS, TICKS_PER_SECOND, tickofframe } from "../timeticks.ts";
31
+
32
+ const R24 = { num: 24, den: 1 };
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("timelineview mapping", () => {
43
+ it("converts ticks to px and back without inventing time", () => {
44
+ const px = 64;
45
+ assert.equal(ticktopx(TICKS_PER_SECOND, px), px);
46
+ // a floored round trip: px→tick floors, so tick→px→tick lands on the same tick
47
+ const t = 2 * TICKS_PER_SECOND + 12345;
48
+ assert.equal(pxtotick(ticktopx(t, 64), 64), t);
49
+ // half a second at 64 px/s
50
+ assert.equal(ticktopx(TICKS_PER_SECOND / 2, 64), 32);
51
+ assert.equal(pxtotick(32, 64), TICKS_PER_SECOND / 2);
52
+ });
53
+
54
+ it("answers zero on damaged input instead of throwing", () => {
55
+ assert.equal(ticktopx(Number.NaN, 64), 0);
56
+ assert.equal(ticktopx(TICKS_PER_SECOND, 0), 0);
57
+ assert.equal(pxtotick(Number.NaN, 64), 0);
58
+ assert.equal(pxtotick(100, Number.NaN), 0);
59
+ assert.equal(pxtotick(-50, 64), 0);
60
+ });
61
+
62
+ it("floors pixels coarser than ticks and saturates at the tick ceiling", () => {
63
+ assert.equal(pxtotick(1, 64), Math.floor((1 / 64) * TICKS_PER_SECOND));
64
+ // an absurd pixel offset saturates at the tick ceiling instead of overflowing
65
+ assert.equal(pxtotick(1e9, 64), Number.MAX_SAFE_INTEGER);
66
+ });
67
+ });
68
+
69
+ describe("timelineview ruler", () => {
70
+ it("picks the smallest step whose majors clear 72 px", () => {
71
+ // 64 px/s: 0.5s = 32px (too tight), 1s = 64px (too tight), 2s = 128px ✓
72
+ const ticks = rulerticks(0, 10 * TICKS_PER_SECOND, R24, 64);
73
+ const majors = ticks.filter((entry) => entry.major);
74
+ assert.equal(majors.length, 6); // 0,2,4,6,8,10 — inclusive window
75
+ assert.equal(majors[0].label, "00:00:00:00");
76
+ assert.equal(majors[1].label, "00:00:02:00");
77
+ });
78
+
79
+ it("widens the step as the zoom drops and labels with exact timecode", () => {
80
+ // 8 px/s: even 60s = 480px, 0.5s = 4px… majors = 8s? no: 8*8=64 <72, 10s = 80 ✓
81
+ const ticks = rulerticks(0, 30 * TICKS_PER_SECOND, R24, 8);
82
+ const majors = ticks.filter((entry) => entry.major);
83
+ assert.equal(majors[0].label, "00:00:00:00");
84
+ assert.equal(majors[1].label, "00:00:10:00");
85
+ });
86
+
87
+ it("stays bounded and empty on damaged windows", () => {
88
+ assert.deepEqual(rulerticks(5, 5, R24, 64), []);
89
+ assert.deepEqual(rulerticks(10, 0, R24, 64), []);
90
+ const bounded = rulerticks(0, Number.MAX_SAFE_INTEGER, R24, 64);
91
+ assert.ok(bounded.length <= 1600);
92
+ });
93
+ });
94
+
95
+ describe("timelineview geometry", () => {
96
+ it("places clips and floors the width at 2 px", () => {
97
+ const one = item(1, TICKS_PER_SECOND, TICKS_PER_SECOND / 2);
98
+ assert.deepEqual(clipgeometry(one, 64), { left: 64, width: 32 });
99
+ const sliver = item(2, 0, 1); // one tick — invisible, but grabbable
100
+ assert.deepEqual(clipgeometry(sliver, 64), { left: 0, width: 2 });
101
+ });
102
+
103
+ it("maps the media window of a clip against its source", () => {
104
+ const clipped = item(1, 0, TICKS_PER_SECOND, 2 * TICKS_PER_SECOND, 1);
105
+ assert.deepEqual(mediawindow(clipped, 4 * TICKS_PER_SECOND), { from: 0.5, to: 0.75 });
106
+ // unknown source: the full window (the face paints whole)
107
+ assert.deepEqual(mediawindow(clipped, null), { from: 0, to: 1 });
108
+ assert.deepEqual(mediawindow(clipped, 0), { from: 0, to: 1 });
109
+ // over-speed reading clamps to the source end
110
+ const fast = item(2, 0, 4 * TICKS_PER_SECOND, 2 * TICKS_PER_SECOND, 4);
111
+ assert.equal(mediawindow(fast, 4 * TICKS_PER_SECOND).to, 1);
112
+ });
113
+ });
114
+
115
+ describe("timelineview sequence + playhead", () => {
116
+ it("answers the sequence end and the sorted edit points", () => {
117
+ const trk = track([item(1, 10, 5), item(2, 0, 5), item(3, 20, 5)]);
118
+ assert.equal(sequenceend(trk), 25);
119
+ assert.deepEqual(editpoints(trk), [0, 5, 10, 15, 20, 25]);
120
+ assert.deepEqual(editpoints(track([])), [0]);
121
+ });
122
+
123
+ it("magnet-snaps the playhead to the nearest edit point inside the threshold", () => {
124
+ const trk = track([item(1, 0, 10), item(2, 10, 10)]);
125
+ assert.equal(snapedit(trk, 9, 2), 10);
126
+ assert.equal(snapedit(trk, 9, 0.5), null);
127
+ // a tie answers the earlier point — the cut behind the playhead wins
128
+ assert.equal(snapedit(trk, 15, 5), 10);
129
+ assert.equal(snapedit(trk, 14.5, 5), 10);
130
+ assert.equal(snapedit(trk, 15, 0), null);
131
+ });
132
+
133
+ it("advances the playhead by wall clock, quantized to frames when asked", () => {
134
+ const frame = frameduration(R24);
135
+ const start = tickofframe(R24, 12);
136
+ const plain = advanceplayhead(R24, start, 1000, false);
137
+ assert.equal(plain, start + TICKS_PER_SECOND);
138
+ const quantized = advanceplayhead(R24, start + Math.round(frame * 0.4), 1000, true);
139
+ assert.equal(quantized, tickofframe(R24, 36)); // 12 frames + 1 s (24 frames), snapped nearest
140
+ // non-advance answers the (quantized) input
141
+ assert.equal(advanceplayhead(R24, start, 0, true), start);
142
+ assert.equal(advanceplayhead(R24, start, -5, false), start);
143
+ });
144
+
145
+ it("answers the visible tick window for a scroll offset", () => {
146
+ const win = visiblewindow(128, 64, 64);
147
+ assert.deepEqual(win, { from: 2 * TICKS_PER_SECOND, to: 3 * TICKS_PER_SECOND });
148
+ assert.deepEqual(visiblewindow(-10, 0, 64).from, 0);
149
+ });
150
+ });
151
+
152
+ describe("timelineview stock bin", () => {
153
+ it("ships deterministic slates with exact tick durations", () => {
154
+ const bin = stockbin();
155
+ assert.equal(bin.length, 5);
156
+ assert.equal(bin[0].id, "slate-01");
157
+ assert.equal(bin[0].duration, 4 * TICKS_PER_SECOND);
158
+ assert.equal(bin[4].duration, 2 * TICKS_PER_SECOND);
159
+ // every slate names itself and carries a detail line
160
+ for (const slate of bin) {
161
+ assert.ok(slate.name.length > 0);
162
+ assert.ok(slate.detail.length > 0);
163
+ }
164
+ });
165
+
166
+ it("wraps the rose face ramp for any bin index", () => {
167
+ assert.equal(slateface(0), "#f472b6");
168
+ assert.equal(slateface(5), slateface(0));
169
+ assert.equal(slateface(-1), slateface(4));
170
+ });
171
+ });
172
+
173
+ describe("timelineview refusal vocabulary", () => {
174
+ /** the full issue vocabulary of the algebra — the surface must answer every key. */
175
+ const ISSUES: readonly EditIssue[] = [
176
+ "locked",
177
+ "no-item",
178
+ "sync-lock-conflict",
179
+ "no-handles",
180
+ "too-short",
181
+ "overlap",
182
+ "nothing",
183
+ ];
184
+
185
+ it("answers an honest one-line note for every issue the algebra can return", () => {
186
+ const notes = new Set<string>();
187
+ for (const issue of ISSUES) {
188
+ const note = refusalnote(issue);
189
+ assert.ok(typeof note === "string" && note.length > 0, `${issue} must carry a note`);
190
+ assert.ok(!note.includes("undefined"), `${issue} must not leak undefined`);
191
+ notes.add(note);
192
+ }
193
+ // every note is distinct — the readout never blurs two refusals together
194
+ assert.equal(notes.size, ISSUES.length);
195
+ });
196
+
197
+ it("keeps the refusals honest — they say what was kept and why", () => {
198
+ assert.match(refusalnote("locked"), /unlock/);
199
+ assert.match(refusalnote("no-handles"), /master handles/);
200
+ assert.match(refusalnote("too-short"), /one frame/);
201
+ assert.match(refusalnote("overlap"), /neighbour/);
202
+ assert.match(refusalnote("nothing"), /nothing/);
203
+ });
204
+ });
205
+
206
+ describe("timelineview zoom ladder", () => {
207
+ it("keeps the ladder ascending with a sane default index", () => {
208
+ assert.ok(ZOOM_LEVELS.length >= 4);
209
+ for (let i = 1; i < ZOOM_LEVELS.length; i++) assert.ok(ZOOM_LEVELS[i] > ZOOM_LEVELS[i - 1]);
210
+ assert.ok(DEFAULT_ZOOM_INDEX >= 0 && DEFAULT_ZOOM_INDEX < ZOOM_LEVELS.length);
211
+ assert.equal(ZOOM_LEVELS[DEFAULT_ZOOM_INDEX], 64);
212
+ });
213
+ });
214
+
215
+ describe("timelineview saturation + honesty", () => {
216
+ const BANNED = new Set(["#3b82f6", "#2563eb", "#f97316"]);
217
+ const MASTER_TICKS = 20 * TICKS_PER_SECOND;
218
+
219
+ it("saturates the playhead at the tick ceiling instead of overflowing", () => {
220
+ // a wall clock absurdity (a tab left playing for years) must clamp, never wrap
221
+ assert.equal(advanceplayhead(R24, 0, Number.MAX_SAFE_INTEGER, false), Number.MAX_SAFE_INTEGER);
222
+ assert.equal(advanceplayhead(R24, 0, Number.MAX_SAFE_INTEGER, true), MAX_TICKS);
223
+ // the quantized ceiling is a real frame boundary, not NaN
224
+ assert.equal(advanceplayhead(R24, MAX_TICKS, 1000, true), MAX_TICKS);
225
+ });
226
+
227
+ it("keeps the stock bin deterministic and every cut inside the 20 s master", () => {
228
+ const bin = stockbin();
229
+ assert.deepEqual(bin, stockbin());
230
+ // the surface trims against a 20 s master: slate i reads from 2i s, so the
231
+ // furthest source out must clear the handles for every slate
232
+ for (let i = 0; i < bin.length; i++) {
233
+ const sourceOut = i * 2 * TICKS_PER_SECOND + bin[i].duration;
234
+ assert.ok(sourceOut <= MASTER_TICKS, `${bin[i].name} exceeds the master's handles`);
235
+ }
236
+ });
237
+
238
+ it("paints the clip faces in the house rose ramp only", () => {
239
+ assert.deepEqual(SLATE_FACES, ["#f472b6", "#ec4899", "#f9a8d4", "#db2777", "#f78dc3"]);
240
+ for (const face of SLATE_FACES) {
241
+ assert.ok(!BANNED.has(face), `a slate face rides a banned default: ${face}`);
242
+ }
243
+ // any index (even absurd ones) answers a face from the ramp — never invented
244
+ for (const index of [0, 1, 7, 999, -3, Number.NaN]) {
245
+ assert.ok(SLATE_FACES.includes(slateface(index)), `slateface(${index}) left the ramp`);
246
+ }
247
+ });
248
+ });
@@ -1,12 +1,12 @@
1
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
2
+ // rational container conversions and SMPTE drop-frame timecode, runnable
3
+ // with vitest:
4
+ // pnpm vitest run tests/timeticks.test.ts
5
5
  // Every test watches the two invariants the module promises: conversions are
6
6
  // exact inside the safe-integer range (no float drift) and nothing ever
7
7
  // throws — damaged input answers the default or null.
8
8
  import assert from "node:assert/strict";
9
- import { describe, it } from "node:test";
9
+ import { describe, it } from "vitest";
10
10
  import {
11
11
  addticks,
12
12
  clampticks,
@@ -1,17 +1,17 @@
1
- // # transcodeplan.test — honest unit tests for the transcode planner, runnable with
2
- // the node built-in runner (no install, no dependencies):
3
- // node --test tests/transcodeplan.test.ts
1
+ // # transcodeplan.test — honest unit tests for the transcode planner, runnable
2
+ // with vitest:
3
+ // pnpm vitest run tests/transcodeplan.test.ts
4
4
  import assert from "node:assert/strict";
5
- import { describe, it } from "node:test";
5
+ import { describe, it } from "vitest";
6
6
  import {
7
7
  buildTranscodePlan,
8
8
  estimateVariantBytes,
9
9
  evenDimension,
10
10
  parseBitrate,
11
- TranscodeError,
12
- validateRung,
13
11
  type SourceMedia,
12
+ TranscodeError,
14
13
  type TranscodeRung,
14
+ validateRung,
15
15
  } from "../transcodeplan.ts";
16
16
 
17
17
  /** The probed source the plan tests build from (the mediaprobe.ts shape). */
@@ -28,7 +28,15 @@ const SOURCE: SourceMedia = {
28
28
  const LADDER: TranscodeRung[] = [
29
29
  { id: "2160p", height: 2160, videoCodec: "avc1", audioCodec: "mp4a", videoBitrate: "16M", audioBitrate: "192K" },
30
30
  { id: "1080p", height: 1080, videoCodec: "avc1", audioCodec: "mp4a", videoBitrate: "4M", audioBitrate: "128K" },
31
- { id: "720p", height: 720, videoCodec: "avc1", audioCodec: "mp4a", videoBitrate: "2500K", audioBitrate: "96K", fps: 24 },
31
+ {
32
+ id: "720p",
33
+ height: 720,
34
+ videoCodec: "avc1",
35
+ audioCodec: "mp4a",
36
+ videoBitrate: "2500K",
37
+ audioBitrate: "96K",
38
+ fps: 24,
39
+ },
32
40
  { id: "480p", height: 480, videoCodec: "vp09", audioCodec: "opus", videoBitrate: "1M", audioBitrate: "64K" },
33
41
  ];
34
42
 
@@ -116,7 +124,10 @@ describe("transcodeplan planner", () => {
116
124
  it("estimates the bytes per variant and the plan total", () => {
117
125
  const plan = buildTranscodePlan(SOURCE, LADDER);
118
126
  assert.equal(plan.variants[0].estimatedBytes, 30_960_000);
119
- assert.equal(plan.estimatedBytes, plan.variants.reduce((sum, variant) => sum + variant.estimatedBytes, 0));
127
+ assert.equal(
128
+ plan.estimatedBytes,
129
+ plan.variants.reduce((sum, variant) => sum + variant.estimatedBytes, 0),
130
+ );
120
131
  });
121
132
 
122
133
  it("keeps the top of the ladder under maxRungs", () => {