@wenathlan/cadria 2.0.80 → 2.0.84
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/media.probe.test.ts +210 -0
- package/tests/transcode.plan.test.ts +136 -0
- package/tests/visualize.test.ts +179 -0
package/package.json
CHANGED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// # media.probe.test — honest unit tests for the container probe, runnable with the
|
|
2
|
+
// node built-in runner (no install, no dependencies):
|
|
3
|
+
// node --test tests/media.probe.test.ts
|
|
4
|
+
// The fixtures are synthetic MP4 (ISO-BMFF) and WebM (EBML) heads built byte by byte
|
|
5
|
+
// below — no binary files, no canvas, no decoder.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
import { probeMedia, ProbeError } from "../media.probe.ts";
|
|
9
|
+
|
|
10
|
+
/** big-endian u32 bytes. */
|
|
11
|
+
function u32(value: number): number[] {
|
|
12
|
+
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** big-endian u16 bytes. */
|
|
16
|
+
function u16(value: number): number[] {
|
|
17
|
+
return [(value >> 8) & 0xff, value & 0xff];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** the ascii bytes of a fourcc / CodecID / DocType. */
|
|
21
|
+
function ascii(text: string): number[] {
|
|
22
|
+
return [...text].map((char) => char.charCodeAt(0));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** one ISO-BMFF box: size, type, body. */
|
|
26
|
+
function mp4box(type: string, ...body: number[]): number[] {
|
|
27
|
+
return [...u32(body.length + 8), ...ascii(type), ...body];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** big-endian float64 bytes (EBML Duration / SamplingFrequency). */
|
|
31
|
+
function f64(value: number): number[] {
|
|
32
|
+
const view = new DataView(new ArrayBuffer(8));
|
|
33
|
+
view.setFloat64(0, value);
|
|
34
|
+
return [...new Uint8Array(view.buffer)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** one EBML element: id (with marker), size vint (marker dropped), body. */
|
|
38
|
+
function ebml(id: number[], ...body: number[]): number[] {
|
|
39
|
+
const size = body.length;
|
|
40
|
+
const sizeVint = size < 127 ? [0x80 | size] : [0x40 | (size >> 8), size & 0xff];
|
|
41
|
+
return [...id, ...sizeVint, ...body];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** EBML element ids the probe walks. */
|
|
45
|
+
const ID = {
|
|
46
|
+
ebml: [0x1a, 0x45, 0xdf, 0xa3],
|
|
47
|
+
docType: [0x42, 0x82],
|
|
48
|
+
segment: [0x18, 0x53, 0x80, 0x67],
|
|
49
|
+
info: [0x15, 0x49, 0xa9, 0x66],
|
|
50
|
+
timecodeScale: [0x2a, 0xd7, 0xb1],
|
|
51
|
+
duration: [0x44, 0x89],
|
|
52
|
+
tracks: [0x16, 0x54, 0xae, 0x6b],
|
|
53
|
+
trackEntry: [0xae],
|
|
54
|
+
trackType: [0x83],
|
|
55
|
+
codecId: [0x86],
|
|
56
|
+
video: [0xe0],
|
|
57
|
+
pixelWidth: [0xb0],
|
|
58
|
+
pixelHeight: [0xba],
|
|
59
|
+
audio: [0xe1],
|
|
60
|
+
samplingFrequency: [0xb5],
|
|
61
|
+
channels: [0x9f],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** builds a minimal MP4 head: ftyp + moov(mvhd, video trak, audio trak). */
|
|
65
|
+
function mp4Fixture(): Uint8Array {
|
|
66
|
+
const ftyp = mp4box("ftyp", ...ascii("isom"), ...u32(512), ...ascii("isom"), ...ascii("mp42"));
|
|
67
|
+
const mvhd = mp4box(
|
|
68
|
+
"mvhd",
|
|
69
|
+
0, 0, 0, 0, // version 0 + flags
|
|
70
|
+
...u32(0), ...u32(0), // creation, modification
|
|
71
|
+
...u32(1000), ...u32(3200), // timescale, duration → 3.2 s
|
|
72
|
+
...new Array<number>(80).fill(0), // rate/volume/matrix/predefined/next track id
|
|
73
|
+
);
|
|
74
|
+
const tkhd = (width: number, height: number) =>
|
|
75
|
+
mp4box(
|
|
76
|
+
"tkhd",
|
|
77
|
+
0, 0, 0, 0, // version 0 + flags
|
|
78
|
+
...u32(0), ...u32(0), ...u32(1), ...u32(0), ...u32(3200), // ids and duration
|
|
79
|
+
...new Array<number>(8).fill(0), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
|
|
80
|
+
...new Array<number>(36).fill(0), // matrix
|
|
81
|
+
...u32(width * 2 ** 16), ...u32(height * 2 ** 16), // fixed 16.16 extent
|
|
82
|
+
);
|
|
83
|
+
const mdhd = (timescale: number, duration: number) =>
|
|
84
|
+
mp4box("mdhd", 0, 0, 0, 0, ...u32(0), ...u32(0), ...u32(timescale), ...u32(duration), ...u16(0), ...u16(0));
|
|
85
|
+
const hdlr = (handler: string) => mp4box("hdlr", 0, 0, 0, 0, ...u32(0), ...ascii(handler), 0);
|
|
86
|
+
const stsd = (entry: number[]) => mp4box("stsd", 0, 0, 0, 0, ...u32(1), ...entry);
|
|
87
|
+
const avc1 = mp4box(
|
|
88
|
+
"avc1",
|
|
89
|
+
...new Array<number>(6).fill(0), ...u16(1), // reserved, data reference
|
|
90
|
+
...u16(0), ...u16(0), ...u32(0), ...u32(0), ...u32(0), // predefined/reserved
|
|
91
|
+
...u16(320), ...u16(240), // width, height
|
|
92
|
+
...u32(0x00480000), ...u32(0x00480000), ...u32(0), ...u16(1),
|
|
93
|
+
...new Array<number>(32).fill(0), ...u16(24), ...u16(0xffff),
|
|
94
|
+
);
|
|
95
|
+
const mp4a = mp4box(
|
|
96
|
+
"mp4a",
|
|
97
|
+
...new Array<number>(6).fill(0), ...u16(1),
|
|
98
|
+
...u16(0), ...u16(0), ...u32(0), // version, revision, vendor
|
|
99
|
+
...u16(2), ...u16(16), ...u16(0), ...u16(0), // channels, sample size
|
|
100
|
+
...u32(48000 * 2 ** 16), // sample rate, 16.16 fixed
|
|
101
|
+
);
|
|
102
|
+
const trak = (extent: [number, number], timescale: number, duration: number, handler: string, entry: number[]) =>
|
|
103
|
+
mp4box(
|
|
104
|
+
"trak",
|
|
105
|
+
...tkhd(...extent),
|
|
106
|
+
...mp4box("mdia", ...mdhd(timescale, duration), ...hdlr(handler), ...mp4box("minf", ...mp4box("stbl", ...stsd(entry)))),
|
|
107
|
+
);
|
|
108
|
+
const moov = mp4box("moov", ...mvhd, ...trak([320, 240], 1000, 3200, "vide", avc1), ...trak([0, 0], 48000, 153600, "soun", mp4a));
|
|
109
|
+
return Uint8Array.from([...ftyp, ...moov]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** builds a minimal WebM head: EBML(DocType) + Segment(Info, Tracks). */
|
|
113
|
+
function webmFixture(): Uint8Array {
|
|
114
|
+
const header = ebml(ID.ebml, ...ebml(ID.docType, ...ascii("webm")));
|
|
115
|
+
const info = ebml(ID.info, ...ebml(ID.timecodeScale, ...u32(1_000_000)), ...ebml(ID.duration, ...f64(3200)));
|
|
116
|
+
const videoTrack = ebml(
|
|
117
|
+
ID.trackEntry,
|
|
118
|
+
...ebml(ID.trackType, 0x01),
|
|
119
|
+
...ebml(ID.codecId, ...ascii("V_VP9")),
|
|
120
|
+
...ebml(ID.video, ...ebml(ID.pixelWidth, ...u16(320)), ...ebml(ID.pixelHeight, ...u16(240))),
|
|
121
|
+
);
|
|
122
|
+
const audioTrack = ebml(
|
|
123
|
+
ID.trackEntry,
|
|
124
|
+
...ebml(ID.trackType, 0x02),
|
|
125
|
+
...ebml(ID.codecId, ...ascii("A_OPUS")),
|
|
126
|
+
...ebml(ID.audio, ...ebml(ID.samplingFrequency, ...f64(48000)), ...ebml(ID.channels, 0x02)),
|
|
127
|
+
);
|
|
128
|
+
const segment = ebml(ID.segment, ...info, ...ebml(ID.tracks, ...videoTrack, ...audioTrack));
|
|
129
|
+
return Uint8Array.from([...header, ...segment]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe("media.probe mp4", () => {
|
|
133
|
+
const probe = probeMedia(mp4Fixture());
|
|
134
|
+
|
|
135
|
+
it("answers the container, brands and movie duration", () => {
|
|
136
|
+
assert.equal(probe.container, "mp4");
|
|
137
|
+
assert.equal(probe.brand, "isom");
|
|
138
|
+
assert.deepEqual(probe.brands, ["isom", "mp42"]);
|
|
139
|
+
assert.equal(probe.durationSeconds, 3.2);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("answers one row per stream with codec and layout", () => {
|
|
143
|
+
assert.equal(probe.tracks.length, 2);
|
|
144
|
+
const [video, audio] = probe.tracks;
|
|
145
|
+
assert.equal(video.kind, "video");
|
|
146
|
+
assert.equal(video.codec, "avc1");
|
|
147
|
+
assert.equal(video.width, 320);
|
|
148
|
+
assert.equal(video.height, 240);
|
|
149
|
+
assert.equal(video.durationSeconds, 3.2);
|
|
150
|
+
assert.equal(audio.kind, "audio");
|
|
151
|
+
assert.equal(audio.codec, "mp4a");
|
|
152
|
+
assert.equal(audio.channels, 2);
|
|
153
|
+
assert.equal(audio.sampleRate, 48000);
|
|
154
|
+
assert.equal(audio.durationSeconds, 3.2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("feeds the transcode planner as a source", async () => {
|
|
158
|
+
const { buildTranscodePlan } = await import("../transcode.plan.ts");
|
|
159
|
+
const source = {
|
|
160
|
+
width: probe.tracks[0].width ?? 0,
|
|
161
|
+
height: probe.tracks[0].height ?? 0,
|
|
162
|
+
durationSeconds: probe.durationSeconds,
|
|
163
|
+
fps: 30,
|
|
164
|
+
videoCodec: probe.tracks[0].codec,
|
|
165
|
+
audioCodec: probe.tracks[1].codec,
|
|
166
|
+
};
|
|
167
|
+
const plan = buildTranscodePlan(source, [
|
|
168
|
+
{ id: "240p", height: 240, videoCodec: "avc1", audioCodec: "mp4a", videoBitrate: "400K", audioBitrate: "64K" },
|
|
169
|
+
]);
|
|
170
|
+
assert.equal(plan.variants[0].width, 320);
|
|
171
|
+
assert.equal(plan.variants[0].height, 240);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("media.probe webm", () => {
|
|
176
|
+
const probe = probeMedia(webmFixture());
|
|
177
|
+
|
|
178
|
+
it("answers the container, doctype and clock math", () => {
|
|
179
|
+
assert.equal(probe.container, "webm");
|
|
180
|
+
assert.equal(probe.brand, "webm");
|
|
181
|
+
assert.equal(probe.durationSeconds, 3.2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("answers both tracks with their EBML codecs and layouts", () => {
|
|
185
|
+
assert.equal(probe.tracks.length, 2);
|
|
186
|
+
const [video, audio] = probe.tracks;
|
|
187
|
+
assert.equal(video.codec, "V_VP9");
|
|
188
|
+
assert.equal(video.width, 320);
|
|
189
|
+
assert.equal(video.height, 240);
|
|
190
|
+
assert.equal(audio.codec, "A_OPUS");
|
|
191
|
+
assert.equal(audio.sampleRate, 48000);
|
|
192
|
+
assert.equal(audio.channels, 2);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("media.probe errors", () => {
|
|
197
|
+
it("rejects buffers too small to probe", () => {
|
|
198
|
+
assert.throws(() => probeMedia(new Uint8Array(8)), ProbeError);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("rejects unknown containers with a typed error", () => {
|
|
202
|
+
assert.throws(() => probeMedia(new Uint8Array(64)), ProbeError);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("rejects truncated box structure", () => {
|
|
206
|
+
const ftyp = mp4box("ftyp", ...ascii("isom"), ...u32(512));
|
|
207
|
+
const truncated = Uint8Array.from([...ftyp.slice(0, 10)]);
|
|
208
|
+
assert.throws(() => probeMedia(truncated), ProbeError);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// # transcode.plan.test — honest unit tests for the transcode planner, runnable with
|
|
2
|
+
// the node built-in runner (no install, no dependencies):
|
|
3
|
+
// node --test tests/transcode.plan.test.ts
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
buildTranscodePlan,
|
|
8
|
+
estimateVariantBytes,
|
|
9
|
+
evenDimension,
|
|
10
|
+
parseBitrate,
|
|
11
|
+
TranscodeError,
|
|
12
|
+
validateRung,
|
|
13
|
+
type SourceMedia,
|
|
14
|
+
type TranscodeRung,
|
|
15
|
+
} from "../transcode.plan.ts";
|
|
16
|
+
|
|
17
|
+
/** The probed source the plan tests build from (the media.probe.ts shape). */
|
|
18
|
+
const SOURCE: SourceMedia = {
|
|
19
|
+
width: 1920,
|
|
20
|
+
height: 1080,
|
|
21
|
+
durationSeconds: 60,
|
|
22
|
+
fps: 30,
|
|
23
|
+
videoCodec: "avc1",
|
|
24
|
+
audioCodec: "mp4a",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A mixed ladder: one too tall for the source, one vp9 webm rung, an fps cap. */
|
|
28
|
+
const LADDER: TranscodeRung[] = [
|
|
29
|
+
{ id: "2160p", height: 2160, videoCodec: "avc1", audioCodec: "mp4a", videoBitrate: "16M", audioBitrate: "192K" },
|
|
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 },
|
|
32
|
+
{ id: "480p", height: 480, videoCodec: "vp09", audioCodec: "opus", videoBitrate: "1M", audioBitrate: "64K" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
describe("transcode.plan bitrates", () => {
|
|
36
|
+
it("parses the renderer spellings into bps", () => {
|
|
37
|
+
assert.equal(parseBitrate("4M"), 4_000_000);
|
|
38
|
+
assert.equal(parseBitrate("2500K"), 2_500_000);
|
|
39
|
+
assert.equal(parseBitrate("800k"), 800_000);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects bare numbers, missing suffixes and junk", () => {
|
|
43
|
+
assert.throws(() => parseBitrate(4_000_000 as unknown as string), TranscodeError);
|
|
44
|
+
assert.throws(() => parseBitrate("4000000"), TranscodeError);
|
|
45
|
+
assert.throws(() => parseBitrate("4G"), TranscodeError);
|
|
46
|
+
assert.throws(() => parseBitrate("0M"), TranscodeError);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("floors dimensions onto even values the MPEG encoders demand", () => {
|
|
50
|
+
assert.equal(evenDimension(853.33), 852);
|
|
51
|
+
assert.equal(evenDimension(1920), 1920);
|
|
52
|
+
assert.equal(evenDimension(1), 2);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("transcode.plan validation", () => {
|
|
57
|
+
it("names the failing rung in every thrown error", () => {
|
|
58
|
+
assert.throws(
|
|
59
|
+
() => validateRung({ ...LADDER[1], height: 0 }),
|
|
60
|
+
(error: unknown) => error instanceof TranscodeError && error.message.includes('"1080p"'),
|
|
61
|
+
);
|
|
62
|
+
assert.throws(
|
|
63
|
+
() => validateRung({ ...LADDER[1], videoBitrate: "4" }),
|
|
64
|
+
(error: unknown) => error instanceof TranscodeError && error.message.includes('"1080p"'),
|
|
65
|
+
);
|
|
66
|
+
assert.throws(
|
|
67
|
+
() => validateRung({ ...LADDER[1], fps: -1 }),
|
|
68
|
+
(error: unknown) => error instanceof TranscodeError && error.message.includes('"1080p"'),
|
|
69
|
+
);
|
|
70
|
+
assert.throws(
|
|
71
|
+
() => validateRung({ ...LADDER[1], videoCodec: "not a codec" }),
|
|
72
|
+
(error: unknown) => error instanceof TranscodeError && error.message.includes('"1080p"'),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects broken sources and ladders before any variant is built", () => {
|
|
77
|
+
assert.throws(() => buildTranscodePlan({ ...SOURCE, height: 0 }, LADDER), TranscodeError);
|
|
78
|
+
assert.throws(() => buildTranscodePlan({ ...SOURCE, durationSeconds: 0 }, LADDER), TranscodeError);
|
|
79
|
+
assert.throws(() => buildTranscodePlan(SOURCE, []), TranscodeError);
|
|
80
|
+
assert.throws(() => buildTranscodePlan(SOURCE, [LADDER[1], { ...LADDER[1], height: 540 }]), TranscodeError);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("answers the size estimate from the bitrates and the duration", () => {
|
|
84
|
+
assert.equal(estimateVariantBytes("4M", "128K", 60), 30_960_000);
|
|
85
|
+
assert.equal(estimateVariantBytes("1M", "64K", 10), 1_330_000);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("transcode.plan planner", () => {
|
|
90
|
+
it("builds the runnable variants: never upscales, orders highest-first", () => {
|
|
91
|
+
const plan = buildTranscodePlan(SOURCE, LADDER);
|
|
92
|
+
assert.deepEqual(
|
|
93
|
+
plan.variants.map((variant) => variant.rung.id),
|
|
94
|
+
["1080p", "720p", "480p"],
|
|
95
|
+
);
|
|
96
|
+
assert.equal(plan.variants.length, 3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("keeps the aspect, even dimensions and the codec-native container", () => {
|
|
100
|
+
const plan = buildTranscodePlan(SOURCE, LADDER);
|
|
101
|
+
const [top, , low] = plan.variants;
|
|
102
|
+
assert.equal(top.width, 1920);
|
|
103
|
+
assert.equal(top.height, 1080);
|
|
104
|
+
assert.equal(top.container, "mp4");
|
|
105
|
+
assert.equal(low.width, 852, "1920×480/1080 floors onto the even 852");
|
|
106
|
+
assert.equal(low.height, 480);
|
|
107
|
+
assert.equal(low.container, "webm", "vp09 defaults to webm");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("caps the fps by the rung and clamps by the source fps", () => {
|
|
111
|
+
const plan = buildTranscodePlan(SOURCE, LADDER);
|
|
112
|
+
assert.equal(plan.variants[0].fps, 30);
|
|
113
|
+
assert.equal(plan.variants[1].fps, 24);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("estimates the bytes per variant and the plan total", () => {
|
|
117
|
+
const plan = buildTranscodePlan(SOURCE, LADDER);
|
|
118
|
+
assert.equal(plan.variants[0].estimatedBytes, 30_960_000);
|
|
119
|
+
assert.equal(plan.estimatedBytes, plan.variants.reduce((sum, variant) => sum + variant.estimatedBytes, 0));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps the top of the ladder under maxRungs", () => {
|
|
123
|
+
const plan = buildTranscodePlan(SOURCE, LADDER, { maxRungs: 2 });
|
|
124
|
+
assert.deepEqual(
|
|
125
|
+
plan.variants.map((variant) => variant.rung.id),
|
|
126
|
+
["1080p", "720p"],
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("throws a traceable error when no rung fits the source", () => {
|
|
131
|
+
assert.throws(
|
|
132
|
+
() => buildTranscodePlan({ ...SOURCE, height: 360 }, [LADDER[0], LADDER[1]]),
|
|
133
|
+
(error: unknown) => error instanceof TranscodeError && error.message.includes("no rung fits"),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// # visualize.test — honest unit tests for the visualize library, runnable with the
|
|
2
|
+
// node built-in runner (no canvas, no dependencies, no install):
|
|
3
|
+
// node --test tests/visualize.test.ts
|
|
4
|
+
// The fixture is a synthetic grayscale gradient buffer shaped like ImageData.
|
|
5
|
+
import assert from "node:assert/strict";
|
|
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";
|
|
10
|
+
import { visualize, VisualizeError, type ImageInput } from "../visualize.ts";
|
|
11
|
+
|
|
12
|
+
/** builds a w×h grayscale horizontal ramp (left black, right white, alpha full). */
|
|
13
|
+
function gradientImage(width: number, height: number): ImageInput {
|
|
14
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
15
|
+
for (let y = 0; y < height; y++) {
|
|
16
|
+
for (let x = 0; x < width; x++) {
|
|
17
|
+
const index = (y * width + x) * 4;
|
|
18
|
+
const value = Math.round((255 * x) / (width - 1));
|
|
19
|
+
data[index] = value;
|
|
20
|
+
data[index + 1] = value;
|
|
21
|
+
data[index + 2] = value;
|
|
22
|
+
data[index + 3] = 255;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { width, height, data };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("visualize pipeline", () => {
|
|
29
|
+
const image = gradientImage(8, 6);
|
|
30
|
+
|
|
31
|
+
it("counts vertices and indices for a decimated grid", () => {
|
|
32
|
+
const result = visualize(image, { depth: 10, step: 2, colors: false, normals: false });
|
|
33
|
+
const { columns, rows } = gridDimensions(8, 6, 2);
|
|
34
|
+
assert.equal(columns, 4);
|
|
35
|
+
assert.equal(rows, 3);
|
|
36
|
+
assert.equal(result.mesh.vertices.length, columns * rows * 3);
|
|
37
|
+
assert.equal(result.mesh.vertices.length / 3, 12);
|
|
38
|
+
assert.equal(result.mesh.indices.length, (columns - 1) * (rows - 1) * 6);
|
|
39
|
+
assert.equal(result.mesh.indices.length / 3, 12);
|
|
40
|
+
assert.equal(result.mesh.depth, 10);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("extrudes heights by depth and keeps image-space x/y", () => {
|
|
44
|
+
const result = visualize(image, { depth: 10, step: 2 });
|
|
45
|
+
const lastVertex = (result.mesh.vertices.length / 3 - 1) * 3;
|
|
46
|
+
// bottom-right vertex: x = 6, y = 4, z = luminance(6/7) * 10 — within 8-bit
|
|
47
|
+
// quantization of the synthetic ramp (byte 219 instead of 218.57)
|
|
48
|
+
assert.equal(result.mesh.vertices[lastVertex], 6);
|
|
49
|
+
assert.equal(result.mesh.vertices[lastVertex + 1], 4);
|
|
50
|
+
assert.ok(Math.abs(result.mesh.vertices[lastVertex + 2] - (6 / 7) * 10) < 0.05);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("exports OBJ that starts with a vertex and carries faces", () => {
|
|
54
|
+
const result = visualize(image, { depth: 10, step: 2 });
|
|
55
|
+
const obj = result.toObj();
|
|
56
|
+
assert.ok(obj.startsWith("v "), `expected OBJ to start with "v ", got: ${obj.slice(0, 12)}`);
|
|
57
|
+
assert.ok(obj.includes("\nf "), "expected OBJ to contain face lines");
|
|
58
|
+
const faceLines = obj.split("\n").filter((line) => line.startsWith("f "));
|
|
59
|
+
assert.equal(faceLines.length, 12);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("exports OBJ with vertex colors in the v lines when colors are on", () => {
|
|
63
|
+
const result = visualize(image, { depth: 10, step: 2 });
|
|
64
|
+
const first = result.toObj().split("\n")[0];
|
|
65
|
+
assert.ok(first.startsWith("v "));
|
|
66
|
+
assert.equal(first.split(" ").length, 7, `expected "v x y z r g b", got: ${first}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("exports STL ASCII with facet normals", () => {
|
|
70
|
+
const result = visualize(image, { depth: 10, step: 2 });
|
|
71
|
+
const stl = result.toStl();
|
|
72
|
+
assert.ok(stl.startsWith("solid cadria-visualize"));
|
|
73
|
+
assert.ok(stl.includes("facet normal"));
|
|
74
|
+
assert.ok(stl.includes("vertex"));
|
|
75
|
+
assert.ok(stl.endsWith("endsolid cadria-visualize"));
|
|
76
|
+
assert.equal(stl.split("facet normal").length - 1, 12);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("exports binary STL with the documented byte layout", () => {
|
|
80
|
+
const result = visualize(image, { depth: 10, step: 2, colors: false, normals: false });
|
|
81
|
+
const bytes = result.toStlBinary();
|
|
82
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
83
|
+
assert.equal(bytes.byteLength, 84 + 12 * 50);
|
|
84
|
+
assert.equal(view.getUint32(80, true), 12);
|
|
85
|
+
const normalY = view.getFloat32(84 + 4, true);
|
|
86
|
+
assert.ok(Math.abs(normalY) <= 1, "facet normal must be normalized");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("computes unit-length vertex normals", () => {
|
|
90
|
+
const result = visualize(image, { depth: 10, step: 2 });
|
|
91
|
+
const normals = result.normals;
|
|
92
|
+
assert.ok(normals, "expected normals by default");
|
|
93
|
+
assert.equal(normals.length, result.mesh.vertices.length);
|
|
94
|
+
for (let offset = 0; offset < normals.length; offset += 3) {
|
|
95
|
+
const length = Math.hypot(normals[offset], normals[offset + 1], normals[offset + 2]);
|
|
96
|
+
assert.ok(Math.abs(length - 1) < 1e-6, `normal ${offset / 3} is not unit: ${length}`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("keeps vertex colors within [0, 1]", () => {
|
|
101
|
+
const result = visualize(image, { depth: 10, step: 2 });
|
|
102
|
+
const colors = result.colors;
|
|
103
|
+
assert.ok(colors, "expected colors by default");
|
|
104
|
+
assert.equal(colors.length, 12 * 3);
|
|
105
|
+
for (const channel of colors) {
|
|
106
|
+
assert.ok(channel >= 0 && channel <= 1, `color channel out of range: ${channel}`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("minHeight carves holes into the low end of the gradient", () => {
|
|
111
|
+
const full = visualize(image, { depth: 10, colors: false, normals: false });
|
|
112
|
+
const cut = visualize(image, { depth: 10, minHeight: 0.5, colors: false, normals: false });
|
|
113
|
+
assert.ok(cut.mesh.indices.length > 0, "high half of the gradient must survive");
|
|
114
|
+
assert.ok(cut.mesh.indices.length < full.mesh.indices.length, "low half must be carved away");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("blurRadius changes the heightmap", () => {
|
|
118
|
+
const sharp = visualize(image, { depth: 10, colors: false, normals: false });
|
|
119
|
+
const blurred = visualize(image, { depth: 10, blurRadius: 2, colors: false, normals: false });
|
|
120
|
+
let differs = false;
|
|
121
|
+
for (let i = 0; i < sharp.mesh.vertices.length; i++) {
|
|
122
|
+
if (sharp.mesh.vertices[i] !== blurred.mesh.vertices[i]) {
|
|
123
|
+
differs = true;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
assert.ok(differs, "blur must move vertices");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("reports stats with counts and material summary", () => {
|
|
131
|
+
const result = visualize(image, { depth: 10, step: 2 });
|
|
132
|
+
const stats = result.stats();
|
|
133
|
+
assert.equal(stats.vertices, 12);
|
|
134
|
+
assert.equal(stats.triangles, 12);
|
|
135
|
+
assert.equal(stats.columns, 4);
|
|
136
|
+
assert.equal(stats.rows, 3);
|
|
137
|
+
assert.equal(stats.hasColors, true);
|
|
138
|
+
assert.equal(stats.hasNormals, true);
|
|
139
|
+
assert.ok(stats.material);
|
|
140
|
+
assert.equal(stats.material.minLuminance, 0);
|
|
141
|
+
assert.ok(Math.abs(stats.material.maxLuminance - 1) < 0.01);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rejects buffers that do not match the dimensions", () => {
|
|
145
|
+
assert.throws(() => visualize({ width: 2, height: 2, data: new Uint8ClampedArray(4) }), VisualizeError);
|
|
146
|
+
assert.throws(() => visualize(image, { step: 0 }), VisualizeError);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("survives a decimation step larger than the image", () => {
|
|
150
|
+
const result = visualize(image, { depth: 10, step: 100, colors: false, normals: false });
|
|
151
|
+
assert.equal(result.stats().vertices, 1);
|
|
152
|
+
assert.equal(result.stats().triangles, 0);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("visualize geometry and material helpers", () => {
|
|
157
|
+
const image = gradientImage(8, 6);
|
|
158
|
+
|
|
159
|
+
it("averages adjacent face normals into vertex normals", () => {
|
|
160
|
+
const result = visualize(image, { depth: 10, step: 2, normals: false });
|
|
161
|
+
const normals = vertexNormals(result.mesh);
|
|
162
|
+
const flat = faceNormal(result.mesh.vertices, 0, 1, result.mesh.columns + 1);
|
|
163
|
+
assert.ok(flat);
|
|
164
|
+
// the first vertex only sees up-facing triangles of the same slope family
|
|
165
|
+
assert.ok(Math.abs(normals[2] - flat[2]) < 0.2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("bounding box, center scale and color grid agree with the mesh", () => {
|
|
169
|
+
const result = visualize(image, { depth: 10, step: 2 });
|
|
170
|
+
const box = boundingBox(result.mesh.vertices);
|
|
171
|
+
assert.equal(box.min[0], 0);
|
|
172
|
+
assert.equal(box.max[0], 6);
|
|
173
|
+
assert.ok(normalizedScale(result.mesh.vertices, 2) > 0);
|
|
174
|
+
const colors = vertexColors(image, result.mesh.columns, result.mesh.rows, 2);
|
|
175
|
+
assert.equal(colors.length, result.mesh.vertices.length);
|
|
176
|
+
const summary = materialSummary(image);
|
|
177
|
+
assert.ok(summary.average[0] > 0.4 && summary.average[0] < 0.6, "ramp average sits near mid gray");
|
|
178
|
+
});
|
|
179
|
+
});
|