910 lines
46 KiB
Plaintext
910 lines
46 KiB
Plaintext
// The importer's pieces on their own (app/import.jai, modules/Image_Decode): what kind of file bytes
|
|
// are, EXIF, turning an image by its orientation, the dates found in EXIF and in file names, and the
|
|
// folder: search; videos and animations, and the .mcap file read and written with its original left on disk.
|
|
|
|
#scope_file
|
|
|
|
checks_run := 0;
|
|
checks_failed := 0;
|
|
|
|
MAX_GROUPS :: 16;
|
|
group_names : [MAX_GROUPS] string;
|
|
group_checks : [MAX_GROUPS] int;
|
|
group_fails : [MAX_GROUPS] int;
|
|
group_count := 0;
|
|
|
|
output_builder: *String_Builder;
|
|
|
|
emit :: (format_string: string, args: ..Any) {
|
|
if output_builder print_to_builder(output_builder, format_string, ..args);
|
|
else print(format_string, ..args);
|
|
} @PrintLike
|
|
|
|
group :: (name: string) {
|
|
assert(group_count < MAX_GROUPS, "raise MAX_GROUPS");
|
|
group_names[group_count] = name;
|
|
group_count += 1;
|
|
emit("\n-- % --\n", name);
|
|
}
|
|
|
|
expect :: (condition: bool, format: string, args: ..Any, loc := #caller_location) {
|
|
checks_run += 1;
|
|
if group_count group_checks[group_count-1] += 1;
|
|
if condition return;
|
|
|
|
checks_failed += 1;
|
|
if group_count group_fails[group_count-1] += 1;
|
|
emit(" FAIL [%:%] %\n", group_names[max(group_count-1, 0)], loc.line_number, tprint(format, ..args));
|
|
}
|
|
|
|
report :: () -> bool {
|
|
emit("\n");
|
|
for 0..group_count-1 {
|
|
if group_fails[it] emit(" % -- % checks, % FAILED\n", group_names[it], group_checks[it], group_fails[it]);
|
|
else emit(" % -- % checks\n", group_names[it], group_checks[it]);
|
|
}
|
|
if checks_failed emit("\n% OF % IMPORT CHECKS FAILED\n", checks_failed, checks_run);
|
|
else emit("\nALL IMPORT TESTS PASSED (% checks over % groups)\n", checks_run, group_count);
|
|
return checks_failed == 0;
|
|
}
|
|
|
|
kind_tests :: () {
|
|
group("kind");
|
|
kind :: (bytes: string) -> Image_Kind { return image_kind(cast([] u8) bytes); }
|
|
expect(kind("\x89PNG\r\n\x1A\n....") == .PNG, "PNG");
|
|
expect(kind("\xFF\xD8\xFF\xE1") == .JPEG, "JPEG");
|
|
expect(kind("GIF89a......") == .GIF, "GIF");
|
|
expect(kind("RIFF\0\0\0\0WEBPVP8 ") == .WEBP, "WebP");
|
|
expect(kind("II*\0\x08\0\0\0") == .TIFF, "TIFF, little-endian");
|
|
expect(kind("MM\0*\0\0\0\x08") == .TIFF, "TIFF, big-endian");
|
|
expect(kind("\0\0\0\x18ftypheic\0\0\0\0mif1heic") == .HEIF, "HEIC");
|
|
expect(kind("\0\0\0\x1Cftypmif1\0\0\0\0mif1avifmiaf") == .AVIF, "AVIF under the general mif1 brand");
|
|
expect(kind("\0\0\0\x18ftypisom\0\0\0\0isommp41") == .VIDEO, "MP4");
|
|
expect(kind("\0\0\0\x14ftypqt \0\0\0\0qt ") == .VIDEO, "QuickTime");
|
|
expect(kind("\0\0\0\x18ftypcrx \0\0\0\x01crx isom") == .RAW, "Canon CR3");
|
|
expect(kind("\x1A\x45\xDF\xA3\x01\0\0\0") == .VIDEO, "Matroska / WebM");
|
|
expect(kind("RIFF\0\0\0\0AVI LIST") == .VIDEO, "AVI");
|
|
expect(kind("\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9") == .VIDEO, "WMV");
|
|
expect(kind("\0\0\x01\xBA\x44\0\x04") == .VIDEO, "MPEG program stream");
|
|
expect(kind("\0\0\0\x08wide\0\0\0\0mdat") == .VIDEO, "old QuickTime");
|
|
expect(video_format_name(cast([] u8) "\0\0\0\x14ftypqt \0\0\0\0qt ", "IMG_7011.MOV") == "QuickTime", "MOV's format");
|
|
expect(video_format_name(cast([] u8) "\0\0\0\x18ftypisom\0\0\0\0isommp41", "a.mp4") == "MP4", "MP4's format");
|
|
expect(video_format_name(cast([] u8) "\x1A\x45\xDF\xA3", "clip.webm") == "WebM", "WebM's format, by its name");
|
|
expect(video_format_name(cast([] u8) "\x1A\x45\xDF\xA3", "clip.mkv") == "Matroska", "Matroska's format");
|
|
expect(kind("hello") == .UNKNOWN, "text");
|
|
expect(kind("") == .UNKNOWN, "nothing");
|
|
expect(image_kind_from_name(.TIFF, "IMG_0001.DNG") == .RAW, "a DNG is RAW");
|
|
expect(image_kind_from_name(.TIFF, "scan.tif") == .TIFF, "a .tif stays TIFF");
|
|
expect(image_kind_from_name(.UNKNOWN, "x.tga") == .TGA, "TGA by its name");
|
|
}
|
|
|
|
// A TIFF block with Orientation, Make, Model, an Exif directory with DateTimeOriginal, and GPS.
|
|
make_tiff :: (little: bool) -> [] u8 {
|
|
out: [..] u8;
|
|
out.allocator = temp;
|
|
u16_ :: (out: *[..] u8, v: u16, little: bool) {
|
|
if little { array_add(out, cast(u8) (v & 0xFF)); array_add(out, cast(u8) (v >> 8)); }
|
|
else { array_add(out, cast(u8) (v >> 8)); array_add(out, cast(u8) (v & 0xFF)); }
|
|
}
|
|
u32_ :: (out: *[..] u8, v: u32, little: bool) {
|
|
if little { u16_(out, cast(u16) (v & 0xFFFF), little); u16_(out, cast(u16) (v >> 16), little); }
|
|
else { u16_(out, cast(u16) (v >> 16), little); u16_(out, cast(u16) (v & 0xFFFF), little); }
|
|
}
|
|
entry :: (out: *[..] u8, tag: u16, type: u16, count: u32, value: u32, little: bool) {
|
|
u16_(out, tag, little); u16_(out, type, little); u32_(out, count, little);
|
|
if type == 3 && count == 1 { u16_(out, cast(u16) value, little); u16_(out, 0, little); }
|
|
else u32_(out, value, little);
|
|
}
|
|
// Layout: header 8; IFD0 at 8 (5 entries: 2 + 60 + 4 = 66) ends at 74; strings at 74 ("Pixel\0" 6, "Pixel 8 Pro\0" 12);
|
|
// Exif IFD at 92 (1 entry, 18) ends at 110; date at 110 (20); GPS IFD at 130 (4 entries, 54) ends at 184; rationals at 184 (48).
|
|
array_add(*out, ..cast([] u8) ifx little then "II" else "MM");
|
|
u16_(*out, 42, little);
|
|
u32_(*out, 8, little);
|
|
u16_(*out, 5, little);
|
|
entry(*out, 0x010F, 2, 6, 74, little);
|
|
entry(*out, 0x0110, 2, 12, 80, little);
|
|
entry(*out, 0x0112, 3, 1, 6, little);
|
|
entry(*out, 0x8769, 4, 1, 92, little);
|
|
entry(*out, 0x8825, 4, 1, 130, little);
|
|
u32_(*out, 0, little);
|
|
array_add(*out, ..cast([] u8) "Pixel\0");
|
|
array_add(*out, ..cast([] u8) "Pixel 8 Pro\0");
|
|
u16_(*out, 1, little);
|
|
entry(*out, 0x9003, 2, 20, 110, little);
|
|
u32_(*out, 0, little);
|
|
array_add(*out, ..cast([] u8) "2024:05:12 10:15:30\0");
|
|
u16_(*out, 4, little);
|
|
entry(*out, 1, 2, 2, ifx little then cast(u32) #char "S" else (cast(u32) #char "S") << 24, little);
|
|
entry(*out, 2, 5, 3, 184, little);
|
|
entry(*out, 3, 2, 2, ifx little then cast(u32) #char "W" else (cast(u32) #char "W") << 24, little);
|
|
entry(*out, 4, 5, 3, 208, little);
|
|
u32_(*out, 0, little);
|
|
for u32.[33, 1, 52, 1, 3000, 100, 151, 1, 12, 1, 0, 1] u32_(*out, it, little);
|
|
return out;
|
|
}
|
|
|
|
exif_tests :: () {
|
|
group("exif");
|
|
for little: bool.[true, false] {
|
|
tiff := make_tiff(little);
|
|
exif := exif_parse_tiff(tiff);
|
|
order := ifx little then "little-endian" else "big-endian";
|
|
expect(exif.found, "% TIFF found", order);
|
|
expect(exif.orientation == 6, "% orientation: %", order, exif.orientation);
|
|
expect(exif.make == "Pixel" && exif.model == "Pixel 8 Pro", "% make and model: \"%\" \"%\"", order, exif.make, exif.model);
|
|
expect(exif.date_original == "2024:05:12 10:15:30", "% DateTimeOriginal: \"%\"", order, exif.date_original);
|
|
expect(exif.has_gps && abs(exif.latitude - (-(33 + 52.0 / 60 + 30.0 / 3600))) < 0.000000001 && abs(exif.longitude - (-(151 + 12.0 / 60))) < 0.000000001,
|
|
"% GPS, south and west negative: %, %", order, exif.latitude, exif.longitude);
|
|
|
|
// The same block inside a JPEG's APP1 segment.
|
|
jpeg: [..] u8;
|
|
jpeg.allocator = temp;
|
|
array_add(*jpeg, 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00); // an APP0 first
|
|
length := tiff.count + 8;
|
|
array_add(*jpeg, 0xFF, 0xE1, cast(u8) (length >> 8), cast(u8) (length & 0xFF));
|
|
array_add(*jpeg, ..cast([] u8) "Exif\0\0");
|
|
array_add(*jpeg, ..tiff);
|
|
array_add(*jpeg, 0xFF, 0xDA);
|
|
from_jpeg := exif_read(jpeg);
|
|
expect(from_jpeg.orientation == 6 && from_jpeg.model == "Pixel 8 Pro", "% EXIF from a JPEG's APP1", order);
|
|
}
|
|
expect(!exif_parse_tiff(cast([] u8) "not a tiff").found, "garbage is no EXIF");
|
|
expect(exif_read(cast([] u8) "\xFF\xD8\xFF\xDA").orientation == 1, "a JPEG with none is upright");
|
|
}
|
|
|
|
// heif_info: a HEIF's primary item's size, rotation and mirroring, from a meta box made here.
|
|
heif_tests :: () {
|
|
group("heif");
|
|
box :: (type: string, contents: [] u8) -> [] u8 {
|
|
out: [..] u8;
|
|
out.allocator = temp;
|
|
size := contents.count + 8;
|
|
array_add(*out, cast(u8) (size >> 24), cast(u8) (size >> 16), cast(u8) (size >> 8), cast(u8) size);
|
|
array_add(*out, ..cast([] u8) type);
|
|
array_add(*out, ..contents);
|
|
return out;
|
|
}
|
|
join :: (parts: ..[] u8) -> [] u8 {
|
|
out: [..] u8;
|
|
out.allocator = temp;
|
|
for parts array_add(*out, ..it);
|
|
return out;
|
|
}
|
|
for turns: 0..3 {
|
|
ispe := box("ispe", u8.[0,0,0,0, 0,0,0,64, 0,0,0,48]);
|
|
irot := box("irot", u8.[cast(u8) turns]);
|
|
imir := box("imir", u8.[1]);
|
|
ipco := box("ipco", join(ispe, irot, imir));
|
|
ipma := box("ipma", u8.[0,0,0,0, 0,0,0,1, 0,1, 3, 0x81, 0x82, 0x03]); // item 1: properties 1-3
|
|
pitm := box("pitm", u8.[0,0,0,0, 0,1]);
|
|
meta := box("meta", join(u8.[0,0,0,0], pitm, box("iprp", join(ipco, ipma))));
|
|
heif := heif_info(join(box("ftyp", cast([] u8) "heic\0\0\0\0mif1heic"), meta));
|
|
expect(heif.width == 64 && heif.height == 48, "irot %: the size, % x %", turns, heif.width, heif.height);
|
|
expect(heif.rotation == turns * 90, "irot %: % degrees (expected %)", turns, heif.rotation, turns * 90);
|
|
expect(heif.mirror == 1, "irot %: imir read", turns);
|
|
}
|
|
}
|
|
|
|
orientation_tests :: () {
|
|
group("orientation");
|
|
// 3 x 2, pixels numbered by where they are: 0 1 2 / 3 4 5. Each orientation's result, row by row.
|
|
expected :: u32.[
|
|
0, 1, 2, 3, 4, 5, // 1
|
|
2, 1, 0, 5, 4, 3, // 2 mirrored
|
|
5, 4, 3, 2, 1, 0, // 3 half a turn
|
|
3, 4, 5, 0, 1, 2, // 4 flipped
|
|
0, 3, 1, 4, 2, 5, // 5 transposed (2 x 3)
|
|
3, 0, 4, 1, 5, 2, // 6 a quarter turn clockwise
|
|
5, 2, 4, 1, 3, 0, // 7 transversed
|
|
2, 5, 1, 4, 0, 3, // 8 a quarter turn anticlockwise
|
|
];
|
|
for orientation: 1..8 {
|
|
pixels := cast(*u32) alloc(6 * 4,, context.default_allocator);
|
|
for 0..5 pixels[it] = cast(u32) it;
|
|
image := Image.{pixels = cast(*u8) pixels, width = 3, height = 2};
|
|
image_orient(*image, orientation);
|
|
swapped := orientation >= 5;
|
|
expect(image.width == (ifx swapped then 2 else 3) && image.height == (ifx swapped then 3 else 2), "orientation %: % x %", orientation, image.width, image.height);
|
|
got: [6] u32;
|
|
for 0..5 got[it] = (cast(*u32) image.pixels)[it];
|
|
wanted := array_view(expected, (orientation - 1) * 6, 6);
|
|
same := true;
|
|
for 0..5 if got[it] != wanted[it] same = false;
|
|
expect(same, "orientation %: % (wanted %)", orientation, got, wanted);
|
|
image_free(*image);
|
|
}
|
|
}
|
|
|
|
date_tests :: () {
|
|
group("dates");
|
|
expect(import_exif_date("2024:05:12 10:15:30") == "2024-05-12 10:15:30", "EXIF's date");
|
|
expect(import_exif_date("0000:00:00 00:00:00") == "", "a camera with no clock set");
|
|
expect(import_exif_date(" : : : : ") == "", "blanks");
|
|
expect(import_exif_date("") == "", "nothing");
|
|
|
|
Name_Date :: struct { name, date: string; }
|
|
names :: Name_Date.[
|
|
.{"IMG_20240512_101530.jpg", "2024-05-12 10:15:30"},
|
|
.{"PXL_20240512_101530123.jpg", "2024-05-12 10:15:30"},
|
|
.{"Screenshot_20240512-101530.png", "2024-05-12 10:15:30"},
|
|
.{"IMG-20240512-WA0001.jpg", "2024-05-12 00:00:00"},
|
|
.{"Screenshot 2024-05-12 at 10.15.30.png", "2024-05-12 10:15:30"},
|
|
.{"Screenshot_2024-05-12-10-15-30.png", "2024-05-12 10:15:30"},
|
|
.{"2024-05-12_10-15-30.snagx", "2024-05-12 10:15:30"},
|
|
.{"Screenshot 2024-05-12 101530.png", "2024-05-12 10:15:30"},
|
|
.{"IMG_0001.JPG", ""},
|
|
.{"scan_12345678.png", ""},
|
|
.{"DSC_20241399.jpg", ""},
|
|
];
|
|
for names {
|
|
got := import_name_date(it.name);
|
|
expect(got == it.date, "% -> \"%\" (wanted \"%\")", it.name, got, it.date);
|
|
}
|
|
expect(import_name_is_valid("SnagitLibraryWork") && import_name_is_valid("Phone 2024"), "folder names");
|
|
expect(!import_name_is_valid("a/b") && !import_name_is_valid("..") && !import_name_is_valid(".hidden") && !import_name_is_valid("x:") && !import_name_is_valid("trailing."), "not folder names");
|
|
expect(import_text("Canon\0\0") == "Canon", "EXIF text loses its trailing nulls");
|
|
expect(import_text("caf\xE9") == "caf?", "Latin-1 bytes are not UTF-8");
|
|
}
|
|
|
|
folder_tests :: () {
|
|
group("folder search");
|
|
terms := search_parse("folder:SnagitLibraryWork kicad");
|
|
expect(terms.count == 2 && terms[0].field == .FOLDER && terms[0].text == "snagitlibrarywork", "folder: prefix: %", terms);
|
|
entry: Catalog_Entry;
|
|
entry.find_folder = "snagitlibrarywork";
|
|
entry.find_title = "kicad";
|
|
expect(search_matches(*entry, terms), "in the folder");
|
|
expect(search_matches(*entry, search_parse("folder:work")), "part of the folder's name");
|
|
expect(!search_matches(*entry, search_parse("folder:personal")), "another folder");
|
|
expect(!search_matches(*entry, search_parse("snagitlibrarywork")), "a plain word does not match the folder");
|
|
}
|
|
|
|
video_tests :: () {
|
|
group("video and animation");
|
|
|
|
// Rotation from a display matrix (16.16 fixed point): an iPhone held upright stores its frames a
|
|
// quarter turn anticlockwise, so they are turned 90 degrees clockwise.
|
|
ONE :: 0x10000;
|
|
expect(display_matrix_rotation(.[ONE, 0, 0, 0, ONE, 0, 0, 0, 0x40000000]) == 0, "no turn");
|
|
expect(display_matrix_rotation(.[0, ONE, 0, -ONE, 0, 0, 0, 0, 0x40000000]) == 90, "portrait: 90 clockwise");
|
|
expect(display_matrix_rotation(.[-ONE, 0, 0, 0, -ONE, 0, 0, 0, 0x40000000]) == 180, "upside down");
|
|
expect(display_matrix_rotation(.[0, -ONE, 0, ONE, 0, 0, 0, 0, 0x40000000]) == 270, "270 clockwise");
|
|
expect(display_matrix_rotation(.[0, 0, 0, 0, 0, 0, 0, 0, 0]) == 0, "an empty matrix");
|
|
|
|
latitude, longitude, altitude, has_altitude, ok := iso6709_parse("+51.5072-000.1276+011.000/");
|
|
expect(ok && abs(latitude - 51.5072) < 0.000001 && abs(longitude + 0.1276) < 0.000001 && has_altitude && abs(altitude - 11) < 0.000001, "Apple's location: % % %", latitude, longitude, altitude);
|
|
latitude, longitude, altitude, has_altitude, ok = iso6709_parse("+37.7749-122.4194/");
|
|
expect(ok && abs(latitude - 37.7749) < 0.000001 && abs(longitude + 122.4194) < 0.000001 && !has_altitude, "Android's location, with no altitude");
|
|
_, _, _, _, ok = iso6709_parse("");
|
|
expect(!ok, "no location");
|
|
_, _, _, _, ok = iso6709_parse("+95.0+10.0/");
|
|
expect(!ok, "a latitude past the pole");
|
|
|
|
date, offset := import_video_local_date("2024-05-12T10:15:30+0100");
|
|
expect(date == "2024-05-12 10:15:30" && offset == "+01:00", "Apple's creationdate: % %", date, offset);
|
|
date, offset = import_video_local_date("2024-05-12T10:15:30-07:00");
|
|
expect(date == "2024-05-12 10:15:30" && offset == "-07:00", "with a colon in its offset: % %", date, offset);
|
|
date, offset = import_video_local_date("nonsense");
|
|
expect(date == "" && offset == "", "not a date");
|
|
expect(import_video_utc_date("1904-01-01T00:00:00.000000Z") == "", "a camera with no clock");
|
|
utc := import_video_utc_date("2024-05-12T09:15:30.000000Z");
|
|
expect(utc.count == 19 && begins_with(utc, "2024-05-"), "creation_time as local time: %", utc);
|
|
|
|
// A GIF of three frames, 20, 0 (shown for 100 ms) and 50 hundredths of a second, with no colour tables.
|
|
gif: [..] u8;
|
|
gif.allocator = temp;
|
|
add :: (gif: *[..] u8, bytes: string) { array_add(gif, ..cast([] u8) bytes); }
|
|
add(*gif, "GIF89a\x02\0\x02\0\0\0\0");
|
|
for delay: u8.[20, 0, 50] {
|
|
add(*gif, "\x21\xF9\x04\0");
|
|
array_add(*gif, delay, 0, 0, 0);
|
|
add(*gif, "\x2C\0\0\0\0\x02\0\x02\0\0"); // a frame, 2 x 2
|
|
add(*gif, "\x02\x02\x44\x01\0"); // LZW code size, one data block, the end
|
|
}
|
|
add(*gif, "\x3B");
|
|
frames, duration_ms, gif_ok := gif_info(gif);
|
|
expect(gif_ok && frames == 3 && duration_ms == 200 + 100 + 500, "an animated GIF: % frames, % ms", frames, duration_ms);
|
|
frames, duration_ms, gif_ok = gif_info(array_view(gif, 0, 13 + 8 + 10 + 5));
|
|
expect(gif_ok && frames == 1, "one cut short counts its whole frames: %", frames);
|
|
_, _, gif_ok = gif_info(cast([] u8) "\x89PNG\r\n\x1A\n");
|
|
expect(!gif_ok, "not a GIF");
|
|
}
|
|
|
|
// The .mcap file, version 4, and reading and writing it with its original left on disk.
|
|
mcap_tests :: () {
|
|
group("mcap version 4");
|
|
#if OS == .WINDOWS folder := tprint("%/mcap-tests", windows_expand_environment("%TEMP%"));
|
|
else folder := "/tmp/mcap-tests";
|
|
make_directory_if_it_does_not_exist(folder, recursive = true);
|
|
|
|
m: Mcap;
|
|
m.text = "[[file \"metadata.json\"]]\nWindowName: \"clip.mp4\"\n";
|
|
m.preview = .{width = 2, height = 1, png = cast([] u8) "preview"};
|
|
m.original_name = "clip.mp4";
|
|
original := NewArray(3 * 1024 * 1024 + 17, u8,, temp); // across more than one read of the copy
|
|
for * original it.* = cast,trunc(u8) (it_index * 7 + 3);
|
|
m.original = original;
|
|
m.rendered_kind = .OWN;
|
|
m.rendered = cast([] u8) "a frame";
|
|
m.back_chunks = .[.{tag = mcap_tag("mask"), key = "model", data = cast([] u8) "large"}];
|
|
|
|
bytes, serialized := mcap_serialize(m,, temp);
|
|
expect(serialized, "serialized");
|
|
back, parsed, error := mcap_parse(bytes);
|
|
expect(parsed && back.version == 4, "parses as version 4: %", error);
|
|
expect(cast(string) back.original == cast(string) original && cast(string) back.rendered == "a frame" && back.back_chunks.count == 1, "every part back");
|
|
|
|
// Version 3: the original's size a u32.
|
|
v3: [..] u8;
|
|
v3.allocator = temp;
|
|
array_add(*v3, ..array_view(bytes, 0, 8));
|
|
array_add(*v3, 3, 0, 0, 0);
|
|
head_end := bytes.count - original.count - 8 - (1 + 4 + 7 + mcap_chunks_size_for_test(back.back_chunks));
|
|
array_add(*v3, ..array_view(bytes, 12, head_end - 12));
|
|
size := cast(u32) original.count;
|
|
array_add(*v3, ..cast([] u8) string.{4, cast(*u8) *size});
|
|
array_add(*v3, ..array_view(bytes, head_end + 8));
|
|
old, old_parsed, old_error := mcap_parse(v3);
|
|
expect(old_parsed && old.version == 3 && cast(string) old.original == cast(string) original, "version 3 still reads: %", old_error);
|
|
|
|
// Streamed: the same bytes as serialized, with the original copied from a file.
|
|
source := tprint("%/source.bin", folder);
|
|
write_entire_file(source, original.data, original.count);
|
|
path := tprint("%/streamed.mcap", folder);
|
|
without := m;
|
|
without.original = .[];
|
|
written, write_error := mcap_write_streamed(path, without, .{source, 0, original.count});
|
|
data, read_ok := read_entire_file(path,, temp);
|
|
expect(written && read_ok && data == cast(string) bytes, "streamed is byte for byte serialized: %", write_error);
|
|
|
|
// Read without the original: the ref finds it.
|
|
m2, ref, ok, read_error := mcap_read_without_original(path,, temp);
|
|
expect(ok && m2.original_name == "clip.mp4" && ref.size == original.count && cast(string) m2.rendered == "a frame" && m2.back_chunks.count == 1, "read without the original: %", read_error);
|
|
read_back, range_ok := mcap_read_original(ref,, temp);
|
|
expect(range_ok && cast(string) read_back == cast(string) original, "its original where the ref says");
|
|
_, shown, which, shown_ok := mcap_read_shown_image(path,, temp);
|
|
expect(shown_ok && which == "rendered" && cast(string) shown == "a frame", "shown: the rendered image");
|
|
|
|
// Storing a scan's chunk rewrites the front and copies the original across unchanged.
|
|
stored, store_error := mcap_store(path, front = .[.{tag = mcap_tag("labl"), key = "model", data = cast([] u8) "labels"}]);
|
|
after, after_parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp));
|
|
expect(stored && after_parsed && cast(string) after.original == cast(string) original && after.front_chunks.count == 1 && after.back_chunks.count == 1, "stored, the original kept: %", store_error);
|
|
erased, erase_error, changed := mcap_erase(path, tags = .["labl"]);
|
|
expect(erased && changed, "erased: %", erase_error);
|
|
|
|
// An image with no rendered one is shown as its original.
|
|
image := m;
|
|
image.rendered_kind = .NONE;
|
|
image.original = cast([] u8) "an image";
|
|
image_bytes := mcap_serialize(image,, temp);
|
|
image_path := tprint("%/image.mcap", folder);
|
|
mcap_write(image_path, image_bytes);
|
|
_, shown, which, shown_ok = mcap_read_shown_image(image_path,, temp);
|
|
expect(shown_ok && which == "original" && cast(string) shown == "an image", "shown: the original, when there is no rendered image");
|
|
|
|
// Extracted, as the Capture window does to open it.
|
|
extracted := tprint("%/extracted.mp4", folder);
|
|
extract_ok := mcap_extract_original(ref, extracted);
|
|
extracted_data := read_entire_file(extracted,, temp);
|
|
expect(extract_ok && extracted_data == cast(string) original, "extracted as it was");
|
|
|
|
// import_hash_file is import_hash, read a piece at a time.
|
|
hash, hashed := import_hash_file(.{source, 0, original.count});
|
|
expect(hashed && hash == import_hash(original), "a file's hash is its bytes' hash");
|
|
|
|
for string.[source, path, image_path, extracted] file_delete(it);
|
|
}
|
|
|
|
// The segmentation chunks (modules/Capture_File/segment_chunks.jai): segs text, the mask's run-length
|
|
// encoding, and storing both in one rewrite.
|
|
segment_chunk_tests :: () {
|
|
group("segment chunks");
|
|
|
|
s: Mcap_Segments;
|
|
s.engine = "onnxruntime 1.30.0, CUDA";
|
|
s.date = "2026-10-12T09:30:00Z";
|
|
s.image = "rendered";
|
|
s.image_width, s.image_height = 4032, 3024;
|
|
s.classes = "5c1e9a0d77b2f413";
|
|
s.segments.allocator = temp;
|
|
array_add(*s.segments, .{1, 0.981, 0.2140, 1210, 380, 900, 2210, "person"});
|
|
array_add(*s.segments, .{2, 0.5, 0.0001, 0, 0, 1, 1, "a \"quoted\" class"});
|
|
text := mcap_segments_text(s);
|
|
back, ok, error := mcap_segments_parse(text,, temp);
|
|
expect(ok && back.engine == s.engine && back.date == s.date && back.image == "rendered" && back.image_width == 4032 && back.image_height == 3024 && back.classes == s.classes, "segs header round trip: %", error);
|
|
expect(back.segments.count == 2, "both segments back: %", back.segments.count);
|
|
if back.segments.count == 2 {
|
|
a := back.segments[0];
|
|
expect(a.id == 1 && abs(a.score - 0.981) < 0.0005 && abs(a.area - 0.214) < 0.00005 && a.x == 1210 && a.y == 380 && a.width == 900 && a.height == 2210 && a.class == "person", "a segment's values: %", a);
|
|
expect(back.segments[1].class == "a \"quoted\" class", "a class with quotes: %", back.segments[1].class);
|
|
}
|
|
_, newer_ok := mcap_segments_parse("@version 2\nsegment: 1 0.9 0.1 0 0 1 1 \"x\"\n",, temp);
|
|
expect(!newer_ok, "a later version is refused");
|
|
junk, junk_ok := mcap_segments_parse("@version 1\nsegment: 1 0.9 \"too few numbers\"\nsegment: 0 0.9 0.1 0 0 1 1 \"id 0\"\nsegment: 3 0.9 0.1 0 0 1 1 \"fine\"\nfuture: kept quiet\n",, temp);
|
|
expect(junk_ok && junk.segments.count == 1 && junk.segments[0].class == "fine", "lines that cannot be read are skipped");
|
|
|
|
// Masks: the edge cases of the runs.
|
|
round_trip :: (w: s32, h: s32, map: [] u8, what: string, loc := #caller_location) -> s64 {
|
|
bytes := mcap_mask_bytes(w, h, map);
|
|
mask, ok, error := mcap_mask_parse(bytes,, temp);
|
|
expect(ok && mask.width == w && mask.height == h && cast(string) mask.map == cast(string) map, "%: %", what, error, loc = loc);
|
|
return bytes.count;
|
|
}
|
|
W, H :: 1024, 768;
|
|
everything := NewArray(W * H, u8,, temp);
|
|
for * everything it.* = 7;
|
|
size := round_trip(W, H, everything, "one segment over everything");
|
|
expect(size == 6 + 1 + 3, "one run of 786432 is 10 bytes, its length in 3: %", size);
|
|
nothing := NewArray(W * H, u8,, temp);
|
|
round_trip(W, H, nothing, "no segments");
|
|
stripes := NewArray(W * H, u8,, temp); // runs that cross rows: three rows and a bit each
|
|
for * stripes it.* = cast(u8) ((it_index / (W * 3 + 5)) % 256);
|
|
round_trip(W, H, stripes, "runs wrapping across rows, every id to 255");
|
|
noise := NewArray(37 * 11, u8,, temp); // runs of 1, and a map with odd sides
|
|
for * noise it.* = cast,trunc(u8) (it_index * 2654435761 >> 7);
|
|
round_trip(37, 11, noise, "runs of one");
|
|
one := u8.[255];
|
|
round_trip(1, 1, one, "one pixel, the largest id");
|
|
|
|
long := NewArray(127 + 128 + 16384 + 1, u8,, temp);
|
|
for * long it.* = ifx it_index < 127 then cast(u8) 1 else ifx it_index < 255 then cast(u8) 2 else ifx it_index < 255 + 16384 then cast(u8) 3 else cast(u8) 4;
|
|
round_trip(cast(s32) long.count, 1, long, "runs of 127, 128 and 16384");
|
|
|
|
// Bad masks are refused, not misread.
|
|
good := mcap_mask_bytes(37, 11, noise);
|
|
bad := array_copy(good,, temp);
|
|
bad[0] = 2;
|
|
_, ok = mcap_mask_parse(bad,, temp);
|
|
expect(!ok, "an unknown format");
|
|
_, ok = mcap_mask_parse(array_view(good, 0, good.count - 1),, temp);
|
|
expect(!ok, "cut short");
|
|
short := array_copy(good,, temp);
|
|
short[1] = 38; // a wider map than the runs cover
|
|
_, ok = mcap_mask_parse(short,, temp);
|
|
expect(!ok, "runs that do not cover it");
|
|
over := array_copy(good,, temp);
|
|
over[1] = 36; // narrower: the runs overflow it
|
|
_, ok = mcap_mask_parse(over,, temp);
|
|
expect(!ok, "runs that overflow it");
|
|
|
|
// Stored together with mcap_store, in one rewrite, and erased together.
|
|
#if OS == .WINDOWS folder := tprint("%/mcap-tests", windows_expand_environment("%TEMP%"));
|
|
else folder := "/tmp/mcap-tests";
|
|
make_directory_if_it_does_not_exist(folder, recursive = true);
|
|
path := tprint("%/segments.mcap", folder);
|
|
m: Mcap;
|
|
m.text = "[[file \"metadata.json\"]]\nWindowName: \"photo.jpg\"\n";
|
|
m.preview = .{width = 2, height = 1, png = cast([] u8) "preview"};
|
|
m.original_name = "photo.jpg";
|
|
m.original = cast([] u8) "the photo's bytes, as they were";
|
|
m.front_chunks = .[.{tag = mcap_tag("labl"), key = "siglip", data = cast([] u8) "labels"}];
|
|
m.back_chunks = .[.{tag = mcap_tag("zzzz"), key = "later", data = cast([] u8) "a chunk this build does not know"}];
|
|
bytes := mcap_serialize(m,, temp);
|
|
mcap_write(path, bytes);
|
|
|
|
MODEL :: "mask2former-swin-base-coco-panoptic";
|
|
map := NewArray(37 * 11, u8,, temp);
|
|
for * map it.* = cast(u8) (it_index % 3);
|
|
stored, store_error := mcap_store(path, front = .[mcap_segments_chunk(MODEL, s)], back = .[mcap_mask_chunk(MODEL, 37, 11, map)]);
|
|
after, parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp));
|
|
expect(stored && parsed, "stored: %", store_error);
|
|
segs_chunk := mcap_find_chunk(after.front_chunks, mcap_tag(MCAP_SEGMENTS_TAG), MODEL);
|
|
mask_chunk := mcap_find_chunk(after.back_chunks, mcap_tag(MCAP_MASK_TAG), MODEL);
|
|
expect(segs_chunk && mask_chunk, "segs in front and mask in back");
|
|
expect(cast(string) after.original == cast(string) m.original && after.front_chunks.count == 2 && after.back_chunks.count == 2, "the original and every other chunk kept");
|
|
expect(mcap_find_chunk(after.back_chunks, mcap_tag("zzzz"), "later") != null, "a chunk this build does not know is kept");
|
|
if mask_chunk {
|
|
mask, mask_ok := mcap_mask_parse(mask_chunk.data,, temp);
|
|
expect(mask_ok && cast(string) mask.map == cast(string) map, "the stored mask reads back");
|
|
}
|
|
// Again: replaced, not added.
|
|
mcap_store(path, front = .[mcap_segments_chunk(MODEL, s)], back = .[mcap_mask_chunk(MODEL, 37, 11, map)]);
|
|
again, _ := mcap_parse(cast([] u8) read_entire_file(path,, temp));
|
|
expect(again.front_chunks.count == 2 && again.back_chunks.count == 2, "storing again replaces them");
|
|
erased, erase_error, changed := mcap_erase(path, tags = .[MCAP_SEGMENTS_TAG, MCAP_MASK_TAG]);
|
|
gone, _ := mcap_parse(cast([] u8) read_entire_file(path,, temp));
|
|
expect(erased && changed && gone.front_chunks.count == 1 && gone.back_chunks.count == 1, "erased together, the rest kept: %", erase_error);
|
|
file_delete(path);
|
|
}
|
|
|
|
// .library-folders (app/library_folders.jai): each folder's options, read and written back with what
|
|
// this build does not know kept.
|
|
library_folders_tests :: () {
|
|
group("library folders");
|
|
TEXT :: #string END
|
|
@version 1
|
|
# a comment
|
|
|
|
[folder "Photos 2014"]
|
|
segment: true
|
|
ocr: false
|
|
|
|
[folder "My ""quoted"" folder"]
|
|
segment: true
|
|
|
|
[folder "Screens"]
|
|
segment: false
|
|
|
|
[folder ""]
|
|
segment: true
|
|
END;
|
|
library_folders_parse(TEXT, "test");
|
|
expect(library_folder_segments("Photos 2014"), "a folder set to segment");
|
|
expect(library_folder_segments("My \"quoted\" folder"), "a quoted name");
|
|
expect(!library_folder_segments("Screens"), "a folder set not to");
|
|
expect(library_folder_segments(""), "the library's top");
|
|
expect(!library_folder_segments("Not listed"), "a folder not listed is off");
|
|
|
|
// Written back: the unknown key kept, under its folder; a folder with nothing on left out.
|
|
text := library_folders_text();
|
|
expect(contains(text, "[folder \"My \"\"quoted\"\" folder\"]"), "the quoted name written back quoted");
|
|
expect(contains(text, "[folder \"Photos 2014\"]\nsegment: true\nocr: false\n"), "a key this build does not know kept: %", text);
|
|
expect(!contains(text, "Screens"), "a folder with no option on is left out");
|
|
library_folders_parse(text, "test");
|
|
expect(library_folder_segments("Photos 2014") && library_folder_segments("My \"quoted\" folder") && library_folder_segments(""), "it reads back the same");
|
|
expect(library_folders_text() == text, "and writes the same again");
|
|
|
|
library_folders_parse("@version 2\n[folder \"Photos\"]\nsegment: true\n", "test");
|
|
expect(!library_folder_segments("Photos"), "a later version is not read: every option off");
|
|
library_folders_parse("[folder \"Photos\"]\nsegment: true\n", "test");
|
|
expect(!library_folder_segments("Photos"), "no @version: every option off");
|
|
library_folders_parse("@version 1\nsegment: true\n[folder \"Photos\"]\nsegment: maybe\n", "test");
|
|
expect(!library_folder_segments("Photos"), "a key outside a folder, or a value not true or false, sets nothing");
|
|
library_folders_parse("", "test");
|
|
expect(!library_folders.folders.count, "no file: no options");
|
|
}
|
|
|
|
// The Capture window's segments overlay (app/segment_ui.jai): the pixels it draws, and reading a file's
|
|
// segments for it.
|
|
segment_overlay_tests :: () {
|
|
group("segment overlay");
|
|
// A 4 x 3 mask: two people (ids 1 and 2) side by side on the left, a dog (3) on the right, a gap.
|
|
map := u8.[1, 1, 2, 3,
|
|
1, 1, 2, 3,
|
|
0, 0, 0, 3];
|
|
overlay: Segment_Overlay;
|
|
overlay.width, overlay.height = 4, 3;
|
|
overlay.map = map;
|
|
segments := Mcap_Segment.[.{id = 1, class = "person"}, .{id = 2, class = "person"}, .{id = 3, class = "dog"}];
|
|
overlay.segments = segments;
|
|
pixel :: (pixels: [] u8, x: s64, y: s64) -> [4] u8 {
|
|
p: [4] u8;
|
|
memcpy(p.data, pixels.data + (y * 4 + x) * 4, 4);
|
|
return p;
|
|
}
|
|
|
|
pixels := segment_overlay_pixels(*overlay, .[], 0.5);
|
|
expect(pixels.count == 4 * 3 * 4, "RGBA at the mask's size");
|
|
expect(pixel(pixels, 0, 2)[3] == 0, "no segment: clear");
|
|
a, b := pixel(pixels, 0, 0), pixel(pixels, 2, 0);
|
|
expect(a[3] > 0 && b[3] > 0, "segments drawn");
|
|
expect(a[0] != b[0] || a[1] != b[1] || a[2] != b[2], "two people side by side differ in shade");
|
|
inner, rim := pixel(pixels, 0, 0), pixel(pixels, 1, 0); // the first person's left column, and its edge with the second
|
|
expect(rim[3] > inner[3] && rim[0] <= inner[0], "edges stronger and darker than the inside: % and %", rim, inner);
|
|
same :: (a: [3] u8, b: [3] u8) -> bool { return a[0] == b[0] && a[1] == b[1] && a[2] == b[2]; }
|
|
dog := segment_class_colour("dog", 0);
|
|
expect(same(segment_class_colour("dog", 0), dog) && !same(segment_class_colour("person", 0), dog), "a class's colour is its own, the same each time");
|
|
|
|
// A colour chosen in the legend: the class's base, its instances shades of it; then back to its own.
|
|
own := segment_class_colour("dog", 0);
|
|
segment_colour_set("dog", .[200, 100, 50], save = false);
|
|
expect(same(segment_class_colour("dog", 0), .[200, 100, 50]), "a chosen colour is the class's");
|
|
second := segment_class_colour("dog", 1);
|
|
expect(second[0] < 200 && second[0] > 100 && second[1] < 100, "its next instance a darker shade of it: %", second);
|
|
expect(same(segment_class_colour("person", 0), segment_class_default_colour("person")), "other classes keep their own");
|
|
segment_colour_set("dog", .[0, 0, 0], default = true, save = false);
|
|
expect(same(segment_class_colour("dog", 0), own), "back to its own colour");
|
|
// Only dog's choice is gone: the colours chosen in the app's own data folder are loaded too.
|
|
dog_chosen := false;
|
|
for segment_ui.overlay_state.colours if it.class == "dog" dog_chosen = true;
|
|
expect(!dog_chosen, "and its choice is gone");
|
|
|
|
hidden := segment_overlay_pixels(*overlay, .["person"], 0.5);
|
|
expect(pixel(hidden, 0, 0)[3] == 0 && pixel(hidden, 2, 1)[3] == 0, "a hidden class is clear");
|
|
expect(pixel(hidden, 3, 0)[3] > 0, "the others still drawn");
|
|
faint := segment_overlay_pixels(*overlay, .[], 0.1);
|
|
expect(pixel(faint, 0, 0)[3] < inner[3], "opacity sets the alpha");
|
|
|
|
// Read from a file as a viewer worker reads it.
|
|
#if OS == .WINDOWS folder := tprint("%/mcap-tests", windows_expand_environment("%TEMP%"));
|
|
else folder := "/tmp/mcap-tests";
|
|
make_directory_if_it_does_not_exist(folder, recursive = true);
|
|
path := tprint("%/overlay.mcap", folder);
|
|
m: Mcap;
|
|
m.text = "[[file \"metadata.json\"]]\nWindowName: \"photo.jpg\"\n";
|
|
m.preview = .{width = 2, height = 1, png = cast([] u8) "preview"};
|
|
m.original_name = "photo.jpg";
|
|
m.original = cast([] u8) "the photo";
|
|
mcap_write(path, mcap_serialize(m,, temp));
|
|
s: Mcap_Segments;
|
|
s.image = "original";
|
|
s.image_width, s.image_height = 8, 6;
|
|
s.segments.allocator = temp;
|
|
array_add(*s.segments, .{id = 3, score = 0.9, area = 0.25, class = "dog"});
|
|
array_add(*s.segments, .{id = 1, score = 0.8, area = 0.2, class = "person"});
|
|
mcap_store(path, front = .[mcap_segments_chunk(SEGMENT_MODEL, s)], back = .[mcap_mask_chunk(SEGMENT_MODEL, 4, 3, map)]);
|
|
read := segment_overlay_read(path);
|
|
expect(!read.error && read.width == 4 && read.height == 3 && cast(string) read.map == cast(string) cast([] u8) map, "the mask read back: %", read.error);
|
|
expect(read.segments.count == 2 && read.by_id[3] == 0 && read.by_id[1] == 1 && read.by_id[2] == -1, "segments found by id");
|
|
segment_overlay_free(read);
|
|
bare := tprint("%/bare.mcap", folder);
|
|
mcap_write(bare, mcap_serialize(m,, temp));
|
|
none := segment_overlay_read(bare);
|
|
expect(none.error.count > 0, "a file not segmented says so: %", none.error);
|
|
segment_overlay_free(none);
|
|
file_delete(path);
|
|
file_delete(bare);
|
|
}
|
|
|
|
// A screen capture written into the library (app/screen_capture.jai): a PNG of its pixels, a preview,
|
|
// and the window it came from where search reads it.
|
|
screen_capture_tests :: () {
|
|
group("screen capture");
|
|
#if OS == .WINDOWS library := tprint("%/mcap-tests/library", windows_expand_environment("%TEMP%"));
|
|
else library := "/tmp/mcap-tests/library";
|
|
saved_library := app.library_folder;
|
|
app.library_folder = library;
|
|
defer app.library_folder = saved_library;
|
|
|
|
WIDTH :: 700;
|
|
HEIGHT :: 300;
|
|
shot: Screen_Shot;
|
|
shot.width, shot.height = WIDTH, HEIGHT;
|
|
shot.left, shot.top = -1920, 40; // on a screen left of the primary one
|
|
shot.pixels = NewArray(WIDTH * HEIGHT * 4, u8,, temp);
|
|
for y: 0..HEIGHT - 1 for x: 0..WIDTH - 1 {
|
|
p := shot.pixels.data + (y * WIDTH + x) * 4;
|
|
p[0], p[1], p[2], p[3] = cast,trunc(u8) x, cast,trunc(u8) y, 200, 255;
|
|
}
|
|
shot.window_title = "Assign Footprints \x01- KiCad"; // a control character, as titles can hold
|
|
shot.app_name = "KiCad";
|
|
shot.app_path = "C:/Program Files/KiCad/bin/kicad.exe";
|
|
|
|
path, error := screen_capture_write(shot, "2026-10-10 14:05:09");
|
|
expect(!error, "written: %", error);
|
|
expect(begins_with(path, tprint("%/%/2026-10-10_14-05-09_", library, SCREEN_CAPTURE_FOLDER)), "named by its date, in its folder: %", path);
|
|
again, again_error := screen_capture_write(shot, "2026-10-10 14:05:09");
|
|
expect(!again_error && again != path && ends_with(again, "_2.mcap"), "the same again gets a name of its own: %", again);
|
|
|
|
m, parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp));
|
|
expect(parsed, "it reads back");
|
|
if parsed {
|
|
expect(m.rendered_kind == .NONE && ends_with(m.original_name, ".png"), "the original is a PNG: %", m.original_name);
|
|
image, decoded := image_decode(m.original);
|
|
expect(decoded && image.width == WIDTH && image.height == HEIGHT, "at its own size");
|
|
if decoded {
|
|
expect(memcmp(image.pixels, shot.pixels.data, shot.pixels.count) == 0, "every pixel as it was");
|
|
image_free(*image);
|
|
}
|
|
expect(max(m.preview.width, m.preview.height) == MCAP_PREVIEW_MAX_SIDE, "a preview: % x %", m.preview.width, m.preview.height);
|
|
|
|
text :: (object: Capture_Value, key: string) -> string {
|
|
value := capture_object_find(*object, key);
|
|
return ifx value && value.kind == .STRING then value.text else "";
|
|
}
|
|
number :: (object: Capture_Value, key: string) -> float64 {
|
|
value := capture_object_find(*object, key);
|
|
return ifx value && value.kind == .NUMBER then value.number else 0;
|
|
}
|
|
doc: Capture_Document;
|
|
capture_document_init(*doc, temp);
|
|
expect(capture_text_parse(*doc, path, m.text), "its Capture Text reads");
|
|
metadata := capture_find_file(*doc, "metadata.json");
|
|
capture := capture_find_file(*doc, "capture.json");
|
|
expect(metadata && capture, "metadata.json and capture.json");
|
|
if metadata {
|
|
expect(text(metadata.root, "WindowName") == "Assign Footprints - KiCad", "WindowName: %", text(metadata.root, "WindowName"));
|
|
expect(text(metadata.root, "AppName") == "KiCad", "AppName");
|
|
expect(text(metadata.root, "CaptureDate") == "2026-10-10 14:05:09", "CaptureDate");
|
|
}
|
|
if capture {
|
|
expect(number(capture.root, "Left") == -1920 && number(capture.root, "Top") == 40, "where on the desktop");
|
|
expect(text(capture.root, "Region") == "selection", "a selection");
|
|
}
|
|
}
|
|
file_delete(path);
|
|
file_delete(again);
|
|
}
|
|
|
|
// A screen recording written into the library (app/screen_record.jai): the MP4 as its original, a
|
|
// frame from it as its rendered image, and capture.json saying it is a video, as the catalog reads it.
|
|
// The MP4 is made here with modules/Screen_Record's encoder, from frames of a changing colour.
|
|
screen_record_tests :: () {
|
|
group("screen recording");
|
|
#if OS == .WINDOWS {
|
|
expect(record_size_allowed(240, 180), "240 x 180 may be recorded");
|
|
expect(!record_size_allowed(239, 180) && !record_size_allowed(240, 179), "smaller may not");
|
|
expect(record_size_allowed(3840, 2160), "a 4K screen may");
|
|
|
|
folder := tprint("%/mcap-tests", windows_expand_environment("%TEMP%"));
|
|
library := tprint("%/library", folder);
|
|
saved_library := app.library_folder;
|
|
app.library_folder = library;
|
|
defer app.library_folder = saved_library;
|
|
make_directory_if_it_does_not_exist(folder, recursive = true);
|
|
|
|
WIDTH :: 320;
|
|
HEIGHT :: 240;
|
|
FRAMES :: 30;
|
|
mp4 := tprint("%/recording-test.mp4", folder);
|
|
encoder: Encoder;
|
|
opened, open_error := encoder_open(*encoder, mp4, WIDTH, HEIGHT, 30, "");
|
|
expect(opened, "an encoder opened: %", open_error);
|
|
if !opened return;
|
|
pixels := NewArray(WIDTH * HEIGHT * 4, u8,, temp);
|
|
for frame: 0..FRAMES - 1 {
|
|
for i: 0..WIDTH * HEIGHT - 1 {
|
|
pixels[i * 4 + 0] = cast,trunc(u8) (frame * 8);
|
|
pixels[i * 4 + 1] = cast,trunc(u8) (i % WIDTH);
|
|
pixels[i * 4 + 2] = 128;
|
|
pixels[i * 4 + 3] = 255;
|
|
}
|
|
encoder_write(*encoder, pixels.data, frame * 3000); // 30 a second, in 90 kHz ticks
|
|
}
|
|
encoder_name := copy_string(encoder.name,, temp);
|
|
closed, close_error := encoder_close(*encoder);
|
|
expect(closed, "the MP4 finished: %", close_error);
|
|
_, mp4_size, _ := file_modtime_and_size(mp4);
|
|
|
|
job: Screen_Capture_Job;
|
|
job.video = true;
|
|
job.recording = mp4;
|
|
job.date = "2026-10-10 15:20:00";
|
|
job.encoder = encoder_name;
|
|
job.source = "DXGI DuplicateOutput1 on a test";
|
|
job.shot.width, job.shot.height = WIDTH, HEIGHT;
|
|
job.shot.left, job.shot.top = 100, 200;
|
|
job.shot.window_title = "Recording test - KiCad";
|
|
job.shot.app_name = "KiCad";
|
|
path, error := screen_record_write(*job);
|
|
expect(!error, "written: %", error);
|
|
if error return;
|
|
expect(begins_with(path, tprint("%/%/2026-10-10_15-20-00_", library, SCREEN_CAPTURE_FOLDER)), "named by its date, in its folder: %", path);
|
|
expect(!file_exists(mp4), "the MP4 is deleted once it is in the library");
|
|
|
|
m, parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp));
|
|
expect(parsed, "it reads back");
|
|
if parsed {
|
|
// Recorded fragmented, it goes in a regular MP4: the same frames, its header first.
|
|
original := cast(string) m.original;
|
|
expect(ends_with(m.original_name, ".mp4") && slice(original, 4, 4) == "ftyp" && find_index_from_left(original, "moof") < 0 && find_index_from_left(original, "moov") < find_index_from_left(original, "mdat"),
|
|
"the MP4, made regular, is its original: % (% bytes, % recorded)", m.original_name, m.original.count, mp4_size);
|
|
expect(m.rendered_kind == .OWN, "a frame of it is its rendered image");
|
|
poster, decoded := image_decode(m.rendered);
|
|
expect(decoded && poster.width == WIDTH && poster.height == HEIGHT, "at the recording's size");
|
|
if decoded image_free(*poster);
|
|
}
|
|
|
|
entry, entry_error := catalog_load(path);
|
|
expect(entry != null, "catalogued: %", entry_error);
|
|
if entry {
|
|
expect(entry.media == .VIDEO, "a video, from capture.json");
|
|
expect(entry.video_codec == "h264", "H.264: %", entry.video_codec);
|
|
expect(entry.duration > 0.9 && entry.duration < 1.1, "about a second: %", entry.duration);
|
|
expect(entry.title == "Recording test - KiCad" && entry.app == "KiCad", "the window it was recorded from");
|
|
catalog_entry_free(entry);
|
|
}
|
|
file_delete(path);
|
|
|
|
// A recording a crash left behind: its date from its name, and no window, size or encoder known.
|
|
expect(screen_record_leftover_date("C:/Temp/mcap/recording-2026-10-10_07-12-43.mp4") == "2026-10-10 07:12:43", "a leftover's date from its name");
|
|
expect(screen_record_leftover_date("C:/Temp/mcap/recording-2026-10-10_07-12-43_2.mp4") == "2026-10-10 07:12:43", "and from a second one's");
|
|
encoder = .{};
|
|
opened, open_error = encoder_open(*encoder, mp4, WIDTH, HEIGHT, 30, "");
|
|
for frame: 0..9 encoder_write(*encoder, pixels.data, frame * 3000);
|
|
encoder_close(*encoder);
|
|
leftover: Screen_Capture_Job;
|
|
leftover.video = true;
|
|
leftover.recording = mp4;
|
|
leftover.date = "2026-10-10 07:12:43";
|
|
leftover_path, leftover_error := screen_record_write(*leftover);
|
|
expect(!leftover_error, "a leftover is written: %", leftover_error);
|
|
if !leftover_error {
|
|
leftover_entry := catalog_load(leftover_path);
|
|
expect(leftover_entry && leftover_entry.media == .VIDEO, "as a video");
|
|
if leftover_entry catalog_entry_free(leftover_entry);
|
|
m2, parsed2 := mcap_parse(cast([] u8) read_entire_file(leftover_path,, temp));
|
|
if parsed2 {
|
|
doc2: Capture_Document;
|
|
capture_document_init(*doc2, temp);
|
|
capture_text_parse(*doc2, leftover_path, m2.text);
|
|
info2 := capture_find_file(*doc2, "capture.json");
|
|
width2 := ifx info2 then capture_object_find(*info2.root, "Width") else null;
|
|
encoder2 := ifx info2 then capture_object_find(*info2.root, "Encoder") else null;
|
|
expect(width2 && width2.kind == .NUMBER && width2.number == WIDTH && !encoder2, "its size from its frames, and no encoder claimed");
|
|
}
|
|
file_delete(leftover_path);
|
|
}
|
|
|
|
// With sound: an AAC stream beside the video, kept through the remux, and capture.json says so.
|
|
encoder = .{};
|
|
opened, open_error = encoder_open(*encoder, mp4, WIDTH, HEIGHT, 30, "", audio = true);
|
|
expect(opened && encoder.audio != null, "an encoder with sound opened: %", open_error);
|
|
if opened {
|
|
tone := NewArray(encoder.audio_frame_size * 2, float,, temp);
|
|
for frame: 0..29 encoder_write(*encoder, pixels.data, frame * 3000);
|
|
for block: 0..46 { // about a second of 440 Hz
|
|
for i: 0..encoder.audio_frame_size - 1 {
|
|
v := sin(cast(float) (block * encoder.audio_frame_size + i) * 2 * PI * 440 / 48000) * 0.5;
|
|
tone[i * 2], tone[i * 2 + 1] = v, v;
|
|
}
|
|
encoder_write_audio(*encoder, tone.data, block * encoder.audio_frame_size);
|
|
}
|
|
encoder_close(*encoder);
|
|
sounding: Screen_Capture_Job;
|
|
sounding.video = true;
|
|
sounding.recording = mp4;
|
|
sounding.date = "2026-10-10 08:30:00";
|
|
sounding.audio = "Speakers (test)";
|
|
sounding.shot.width, sounding.shot.height = WIDTH, HEIGHT;
|
|
sounding_path, sounding_error := screen_record_write(*sounding);
|
|
expect(!sounding_error, "a recording with sound is written: %", sounding_error);
|
|
if !sounding_error {
|
|
m4, parsed4 := mcap_parse(cast([] u8) read_entire_file(sounding_path,, temp));
|
|
expect(parsed4 && find_index_from_left(cast(string) m4.original, "mp4a") >= 0, "its MP4 has the AAC stream (mp4a)");
|
|
expect(parsed4 && contains(m4.text, "Speakers (test)"), "capture.json names the sound's source");
|
|
file_delete(sounding_path);
|
|
}
|
|
}
|
|
|
|
// One a crash cut short: a frame every 100 ms for 5 s makes fragments at 0, 2 and 4 s (a
|
|
// keyframe each 2 s, however few the frames); cut at the last, as a crash would, it goes into the
|
|
// library as a regular MP4 (header first, no fragments) of the 4 s before the cut.
|
|
encoder = .{};
|
|
encoder_open(*encoder, mp4, WIDTH, HEIGHT, 30, "");
|
|
for frame: 0..49 encoder_write(*encoder, pixels.data, frame * 9000);
|
|
encoder_close(*encoder);
|
|
whole := read_entire_file(mp4,, temp);
|
|
last_fragment := -1;
|
|
for 4..whole.count - 4 if whole[it] == #char "m" && slice(whole, it, 4) == "moof" last_fragment = it - 4;
|
|
expect(last_fragment > 0, "the recording is fragmented");
|
|
write_entire_file(mp4, slice(whole, 0, last_fragment));
|
|
crashed: Screen_Capture_Job;
|
|
crashed.video = true;
|
|
crashed.recording = mp4;
|
|
crashed.date = "2026-10-10 07:48:52";
|
|
crashed_path, crashed_error := screen_record_write(*crashed);
|
|
expect(!crashed_error, "a cut recording is written: %", crashed_error);
|
|
if !crashed_error {
|
|
m3, parsed3 := mcap_parse(cast([] u8) read_entire_file(crashed_path,, temp));
|
|
original := cast(string) m3.original;
|
|
moov := find_index_from_left(original, "moov");
|
|
mdat := find_index_from_left(original, "mdat");
|
|
expect(parsed3 && moov > 0 && moov < mdat && find_index_from_left(original, "moof") < 0, "a regular MP4, its header first");
|
|
crashed_entry := catalog_load(crashed_path);
|
|
expect(crashed_entry && crashed_entry.duration > 3.5 && crashed_entry.duration < 4.5, "the 4 s before the cut: %", ifx crashed_entry then crashed_entry.duration else -1);
|
|
if crashed_entry catalog_entry_free(crashed_entry);
|
|
file_delete(crashed_path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// What mcap_chunks_size counts, here where it cannot be seen.
|
|
mcap_chunks_size_for_test :: (chunks: [] Mcap_Chunk) -> s64 {
|
|
size: s64 = 4;
|
|
for chunks size += 4 + 4 + 2 + it.key.count + it.data.count;
|
|
return size;
|
|
}
|
|
|
|
#scope_export
|
|
|
|
run_import_tests :: (builder: *String_Builder = null) -> bool {
|
|
output_builder = builder;
|
|
emit("=== import test suite ===\n");
|
|
push_allocator(context.default_allocator);
|
|
kind_tests();
|
|
exif_tests();
|
|
heif_tests();
|
|
orientation_tests();
|
|
date_tests();
|
|
folder_tests();
|
|
video_tests();
|
|
mcap_tests();
|
|
segment_chunk_tests();
|
|
library_folders_tests();
|
|
segment_overlay_tests();
|
|
screen_capture_tests();
|
|
screen_record_tests();
|
|
return report();
|
|
}
|