#include "Extension/Rendering/Capture/capture_encode.h" #include "Extension/Rendering/Capture/capture_matte.h" #include "Extension/Rendering/Capture/capture_panel.h" #include "Extension/Rendering/Capture/capture_recorder.h" #include #include #include #include #include #include #include #include #include #include // Captures without the game (reskate.capture): matting, resizing, the card, the file formats, and a // recording of a turning board or skater found, cut out and saved. // Run with the folder of the menu fonts (External/fonts) as the first argument to test the card too, // and a folder as the second to save a picture of it there. namespace { using namespace dingosdk::capture; int failures = 0; void expect(bool condition, const char* what) { if (condition) return; ++failures; std::printf("FAIL: %s\n", what); } std::uint32_t be32(const std::uint8_t* p) { return std::uint32_t{p[0]} << 24 | std::uint32_t{p[1]} << 16 | std::uint32_t{p[2]} << 8 | p[3]; } // The chunk types of a PNG, in order. std::vector chunks(const std::vector& png) { std::vector out; for (std::size_t at = 8; at + 12 <= png.size();) { const auto size = be32(png.data() + at); out.emplace_back(reinterpret_cast(png.data() + at + 4), 4); at += 12 + size; } return out; } // A board-ish test picture: an opaque rounded bar with a soft edge, a see-through middle band and a // fully transparent background. Image board(std::uint32_t size, float turn) { Image image(size, size); const auto n = static_cast(size); const float c = n / 2.0f, half_w = n * 0.18f * std::abs(std::cos(turn)) + 2, half_h = n * 0.42f; for (std::uint32_t y = 0; y < size; ++y) for (std::uint32_t x = 0; x < size; ++x) { const float fx = static_cast(x) + 0.5f, fy = static_cast(y) + 0.5f; const float dx = std::abs(fx - c) - half_w, dy = std::abs(fy - c) - half_h; const float edge = std::clamp(0.5f - std::max(dx, dy), 0.0f, 1.0f); auto* p = image.at(x, y); p[0] = static_cast(40 + y * 200 / size); p[1] = 140; p[2] = static_cast(220 - x * 100 / size); const bool band = std::abs(fy - c) < n * 0.05f; p[3] = static_cast(std::lround(edge * (band ? 120 : 255))); } return image; } // `image` over a background, then "graded" the way the game's post-processing shifts both captures. std::vector composite(const Image& image, std::array background) { std::vector out(image.rgba.size()); for (std::size_t i = 0; i < image.rgba.size(); i += 4) { const float a = image.rgba[i + 3] / 255.0f; for (int c = 0; c < 3; ++c) { const float v = image.rgba[i + c] * a + background[c] * (1 - a); out[i + 2 - c] = static_cast(std::lround(std::clamp(v * 0.9f + 6.0f, 0.0f, 255.0f))); // BGRA } out[i + 3] = 255; } return out; } void test_matte() { const auto truth = board(96, 0.3f); const auto dark = composite(truth, {10, 12, 14}), light = composite(truth, {235, 236, 240}); const PixelView d{dark.data(), 96, 96, 96 * 4, true}, l{light.data(), 96, 96, 96 * 4, true}; const Rect all{0, 0, 96, 96}; const auto backgrounds = measure_backgrounds(d, l, all); expect(std::abs(backgrounds.dark[0] - (10 * 0.9f + 6)) < 1 && std::abs(backgrounds.light[2] - (240 * 0.9f + 6)) < 1, "backgrounds are measured from the edge"); const auto cut = matte_pair(d, l, all, backgrounds, 0.0f); int worst_alpha = 0, worst_colour = 0; for (std::uint32_t y = 0; y < 96; ++y) for (std::uint32_t x = 0; x < 96; ++x) { const auto* want = truth.at(x, y); const auto* got = cut.at(x, y); worst_alpha = std::max(worst_alpha, std::abs(want[3] - got[3])); if (want[3] > 60) for (int c = 0; c < 3; ++c) worst_colour = std::max(worst_colour, std::abs(static_cast(want[c] * 0.9f + 6) - got[c])); } expect(worst_alpha <= 3, "matting recovers alpha, soft edges and the see-through band included"); expect(worst_colour <= 6, "matting recovers the board's colour without the background in it"); expect(content_bounds(cut).width < 96, "the cut-out has transparent space around the board"); const auto same = matte_pair(d, d, all, measure_backgrounds(d, d, all)); expect(same.at(0, 0)[3] == 255, "identical captures are taken as opaque, not empty"); } // A whole turn of a board with a black grip over a dark radial vignette, the Board screen's worst // case: where the board has been, the backdrop must come back see-through, not as a dark ghost. void test_backdrop_key(bool light_both_sides) { constexpr std::uint32_t w = 300, h = 500, turn_frames = 120; std::vector frames, truth; for (std::uint32_t f = 0; f < turn_frames; ++f) { const float angle = 6.2831853f * static_cast(f) / turn_frames; const float half_w = 55 * std::abs(std::cos(angle)) + 3, half_h = 200; const bool grip = std::cos(angle) < 0; Image frame(w, h), want(w, h); for (std::uint32_t y = 0; y < h; ++y) for (std::uint32_t x = 0; x < w; ++x) { const float fx = static_cast(x) + 0.5f, fy = static_cast(y) + 0.5f; const float rx = (fx - 120) / 300, ry = (fy - 230) / 400; // A vignette no simple surface fits: a soft spot, a kink and a slow ripple. const float bg = 24 + 30 * std::exp(-(rx * rx + ry * ry) * 5) + 8 * std::max(0.0f, 0.4f - std::abs(rx)) + 5 * std::sin(fy / 37); const float dx = std::abs(fx - 150) - half_w, dy = std::abs(fy - 250) - half_h; const float cover = std::clamp(0.5f - std::max(dx, dy), 0.0f, 1.0f); // A white deck with a black grip; or one light on both sides (a white grip), which covers // the middle for most of the turn: there the backdrop is seen least. const float back = light_both_sides ? 200.0f : 12.0f; const float deck[3] = {grip ? back : 226.0f, grip ? back : 220.0f, grip ? back + 1 : 208.0f}; auto* p = frame.at(x, y); for (int c = 0; c < 3; ++c) p[c] = static_cast(std::lround(deck[c] * cover + (bg + static_cast(c)) * (1 - cover))); p[3] = 255; want.at(x, y)[3] = static_cast(std::lround(cover * 255)); } frames.push_back(std::move(frame)); truth.push_back(std::move(want)); } key_against_backdrop(frames); std::uint64_t ghost = 0, lost = 0, background = 0, board = 0; for (std::uint32_t f = 0; f < turn_frames; ++f) for (std::uint32_t y = 0; y < h; ++y) for (std::uint32_t x = 0; x < w; ++x) { const auto want = truth[f].at(x, y)[3], got = frames[f].at(x, y)[3]; if (want == 0) ++background, ghost += got > 64; if (want == 255) ++board, lost += got < 192; } std::printf("backdrop key (%s): %.3f%% ghost, %.3f%% lost\n", light_both_sides ? "light both sides" : "black grip", 100.0 * static_cast(ghost) / static_cast(background), 100.0 * static_cast(lost) / static_cast(board)); expect(ghost * 1000 < background, "where the board has been comes back see-through"); expect(lost * 100 < board, "a black grip on the dark backdrop stays board"); } void test_geometry() { const auto square = padded_square({10, 20, 30, 50}, 5, 200, 100); expect(square.width == 60 && square.height == 60, "padded_square grows and squares"); expect(square.x + square.width <= 200 && square.y + square.height <= 100, "padded_square stays inside"); const auto u = united({0, 0, 10, 10}, {20, 5, 5, 20}); expect(u.x == 0 && u.y == 0 && u.width == 25 && u.height == 25, "united covers both"); const auto small = fitted(board(200, 0), 50); expect(small.width == 50 && small.height == 50, "fitted keeps a square square"); expect(small.at(25, 2)[3] == 0 && small.at(25, 15)[3] > 200, "fitted keeps the background clear and the board solid"); // Premultiplied filtering: an opaque red pixel next to transparent green does not turn olive. Image edge(2, 1); edge.rgba = {255, 0, 0, 255, 0, 255, 0, 0}; const auto halved = fitted(edge, 1); expect(halved.at(0, 0)[0] == 255 && halved.at(0, 0)[1] == 0 && halved.at(0, 0)[3] == 128, "fitted does not bleed transparent colour"); const auto flat = over_matte(halved, {0, 0, 255}); expect(flat.at(0, 0)[3] == 255 && flat.at(0, 0)[0] == 128 && flat.at(0, 0)[2] == 127, "over_matte flattens"); } void test_png() { Animation animation; animation.fps = 24; for (int i = 0; i < 6; ++i) animation.frames.push_back(board(64, static_cast(i) * 0.5f)); const auto apng = encode_apng(animation); expect(apng.size() > 8 && std::memcmp(apng.data(), "\x89PNG\r\n\x1a\n", 8) == 0, "APNG signature"); const auto types = chunks(apng); const auto count = [&](const char* t) { return std::count(types.begin(), types.end(), std::string(t)); }; expect(types.front() == "IHDR" && types[1] == "acTL" && types.back() == "IEND", "APNG chunk order"); expect(count("fcTL") == 6 && count("IDAT") == 1 && count("fdAT") == 5, "APNG has one control and data chunk per frame"); const auto still = encode_png(animation.frames.front()); expect(chunks(still).size() == 3, "PNG is IHDR, IDAT, IEND"); } // Decodes a GIF's first frame's LZW data back to indices, to check the encoder's code-size steps. std::vector first_frame_indices(const std::vector& gif, std::size_t pixels) { std::size_t at = 13 + 768; while (at < gif.size() && gif[at] == 0x21) { // extensions at += 2; while (gif[at]) at += gif[at] + 1u; ++at; } at += 10; // image descriptor const int min_bits = gif[at++]; std::vector data; while (gif[at]) { data.insert(data.end(), gif.begin() + static_cast(at + 1), gif.begin() + static_cast(at + 1 + gif[at])); at += gif[at] + 1u; } const int clear = 1 << min_bits, end = clear + 1; std::vector> table; const auto reset = [&] { table.assign(static_cast(end) + 1, {}); for (int i = 0; i < clear; ++i) table[static_cast(i)] = {static_cast(i)}; }; reset(); int bits = min_bits + 1, previous = -1; std::size_t bit = 0; std::vector out; while (bit + static_cast(bits) <= data.size() * 8 && out.size() < pixels) { int code = 0; for (int k = 0; k < bits; ++k, ++bit) code |= ((data[bit / 8] >> (bit % 8)) & 1) << k; if (code == clear) { reset(), bits = min_bits + 1, previous = -1; continue; } if (code == end) break; std::vector entry; if (code < static_cast(table.size())) entry = table[static_cast(code)]; else if (previous >= 0) entry = table[static_cast(previous)], entry.push_back(table[static_cast(previous)][0]); else return {}; out.insert(out.end(), entry.begin(), entry.end()); if (previous >= 0 && table.size() < 4096) { auto added = table[static_cast(previous)]; added.push_back(entry[0]); table.push_back(std::move(added)); } if (static_cast(table.size()) >= (1 << bits) && bits < 12) ++bits; previous = code; } return out; } void test_gif() { // Noise fills the LZW table several times over, so clears and every code width get used. Image noise(300, 200); std::uint32_t seed = 7; for (std::size_t i = 0; i < noise.rgba.size(); i += 4) { seed = seed * 1664525u + 1013904223u; noise.rgba[i] = static_cast(seed >> 24), noise.rgba[i + 1] = static_cast(seed >> 16); noise.rgba[i + 2] = static_cast(seed >> 8), noise.rgba[i + 3] = (seed >> 7) & 1 ? 255 : 0; } Animation animation; animation.frames = {noise, noise}; const auto gif = encode_gif(animation, {.fringe = {0, 0, 0}, .dither = false}); expect(std::memcmp(gif.data(), "GIF89a", 6) == 0 && gif.back() == 0x3b, "GIF header and trailer"); const auto indices = first_frame_indices(gif, 300 * 200); expect(indices.size() == 300 * 200, "GIF LZW data decodes to every pixel"); bool transparent_right = indices.size() == 300 * 200; for (std::size_t i = 0; transparent_right && i < indices.size(); ++i) transparent_right = (indices[i] == 255) == (noise.rgba[i * 4 + 3] < 128); expect(transparent_right, "GIF cuts out exactly the transparent pixels"); Animation timing; timing.fps = 30; timing.frames.assign(3, board(16, 0)); const auto timed = encode_gif(timing); int total = 0; for (std::size_t i = 0; i + 5 < timed.size(); ++i) if (timed[i] == 0x21 && timed[i + 1] == 0xf9) total += timed[i + 4] | timed[i + 5] << 8; expect(total == 10, "GIF delays add up to the clip's length"); } void test_webm() { Animation animation; animation.fps = 30; for (int i = 0; i < 4; ++i) animation.frames.push_back(board(48, static_cast(i) * 0.4f)); const auto webm = encode_webm(animation); const auto has = [&](std::initializer_list bytes) { return std::search(webm.begin(), webm.end(), bytes.begin(), bytes.end()) != webm.end(); }; expect(webm.size() > 4 && webm[0] == 0x1a && webm[1] == 0x45 && webm[2] == 0xdf && webm[3] == 0xa3, "WebM starts with an EBML header"); expect(has({'V', '_', 'V', 'P', '8'}), "WebM track is VP8"); expect(has({0x53, 0xc0}), "a see-through WebM marks its track AlphaMode"); expect(has({0x9d, 0x01, 0x2a}), "WebM frames are VP8 key frames"); Animation opaque; opaque.frames = {over_matte(board(48, 0), {0, 0, 0})}; const auto flat = encode_webm(opaque); const std::array alpha_mode{0x53, 0xc0}; expect(std::search(flat.begin(), flat.end(), alpha_mode.begin(), alpha_mode.end()) == flat.end(), "an opaque WebM has no alpha"); } void test_panel(const std::filesystem::path& fonts, const char* out) { const auto load = [&](const char* name) { std::ifstream in(fonts / name, std::ios::binary); return std::vector(std::istreambuf_iterator(in), {}); }; const auto brush = load("PermanentMarker-Regular.ttf"), heading = load("Montserrat-ExtraBold.ttf"), body = load("Montserrat-SemiBold.ttf"); const std::vector parts{{"Deck", "ReSkate Scrolling Pattern"}, {"Grip tape", "A grip tape with a name long enough that it has to wrap onto a second line and then some more"}, {"Trucks", "Silver"}, {"Wheels", ""}}; const auto card = render_parts_panel("Board", parts, 512, {heading, body, brush}); expect(card.height == 512 && card.width > 300, "the parts card is the height asked for"); expect(card.at(0, 0)[3] < 40 && card.at(card.width / 2, card.height - 4)[3] > 200, "the card has rounded corners and a solid body"); int bright = 0; for (std::size_t i = 0; i < card.rgba.size(); i += 4) bright += card.rgba[i] > 200; expect(bright > 500, "the card has text on it"); const auto shown = side_by_side(fitted(board(300, 0.2f), 512), card); expect(shown.width == 512 + card.width, "side_by_side lays the card beside the board"); if (out) { // a folder to save the card in, to look at it const auto png = encode_png(shown); std::ofstream(std::filesystem::path(out) / "parts_card.png", std::ios::binary).write(reinterpret_cast(png.data()), static_cast(png.size())); } } } // namespace // A stand-in for the game: a 1280x720 frame with a board that turns a little every frame on the right // of the menu (or a skater in the middle, swaying as they stand), a piece of UI that never moves, and // the game's depth when asked for. struct FakeGame { double turn{}, spin{1.0 / 37.3}; bool skater{}, with_depth{}; unsigned drawn{}; std::vector last_; std::vector frame() { turn += spin; ++drawn; auto now = draw(); // As the game's temporal smoothing does, a frame keeps a trace of the last one. if (!last_.empty()) for (std::size_t i = 0; i < now.size(); ++i) now[i] = static_cast((now[i] * 3 + last_[i] + 2) / 4); last_ = now; return now; } float centre_x() const { return 1280 * (skater ? 0.5f : 0.76f) + (skater ? 3.0f * std::sin(static_cast(drawn) * 0.21f) : 0.0f); } float half_width() const { return 60 * std::abs(std::cos(static_cast(turn) * 6.2831853f)) + 3; } std::vector draw() const { constexpr std::uint32_t w = 1280, h = 720; std::vector out(std::size_t{w} * h * 4); const float cx = centre_x(), cy = 720 * 0.57f, half_w = half_width(), half_h = 230; const bool back = std::cos(static_cast(turn) * 6.2831853f) < 0; for (std::uint32_t y = 0; y < h; ++y) for (std::uint32_t x = 0; x < w; ++x) { const float fx = static_cast(x) + 0.5f, fy = static_cast(y) + 0.5f; const float bg = 28.0f + 10.0f * fy / h; float rgb[3] = {bg, bg, bg + 4}; if (x > 860 && x < 900 && y > 140 && y < 170) rgb[0] = rgb[1] = rgb[2] = 200; // UI that never moves const float dx = std::abs(fx - cx) - half_w, dy = std::abs(fy - cy) - half_h; const float cover = std::clamp(0.5f - std::max(dx, dy), 0.0f, 1.0f); const float deck[3] = {back ? 12.0f : 40.0f, back ? 12.0f : 150.0f + 80 * fy / h, back ? 13.0f : 220.0f}; for (int c = 0; c < 3; ++c) rgb[c] = deck[c] * cover + rgb[c] * (1 - cover); auto* p = out.data() + (std::size_t{y} * w + x) * 4; for (int c = 0; c < 3; ++c) p[2 - c] = static_cast(std::clamp(rgb[c] * 0.92f + 5, 0.0f, 255.0f)); p[3] = 255; } return out; } // The game's depth for the frame drawn last: the board a quarter as far as the backdrop. std::vector depth() const { constexpr std::uint32_t w = 1280, h = 720; const float cx = centre_x(), cy = 720 * 0.57f, half_w = half_width(), half_h = 230; std::vector out(std::size_t{w} * h, 1.0f / 400); for (std::uint32_t y = 0; y < h; ++y) for (std::uint32_t x = 0; x < w; ++x) if (std::abs(static_cast(x) + 0.5f - cx) < half_w && std::abs(static_cast(y) + 0.5f - cy) < half_h) out[std::size_t{y} * w + x] = 1.0f / 100; return out; } }; // A recording of the board (or skater) turning, as a plugin makes one: its frames compared to find // the turn, the frames evenly spaced through it saved. void test_recording(const std::filesystem::path& fonts, Format format, bool skater, bool with_depth, std::uint32_t frames) { const auto load = [&](const char* name) { std::ifstream in(fonts / name, std::ios::binary); return std::vector(std::istreambuf_iterator(in), {}); }; static const auto brush = load("PermanentMarker-Regular.ttf"), heading = load("Montserrat-ExtraBold.ttf"), body = load("Montserrat-SemiBold.ttf"); FakeGame game; game.skater = skater; game.with_depth = with_depth; Recorder recorder; RecordRequest request; request.area = skater ? Area{0.27f, 0.14f, 0.735f, 0.985f, true} : Area{0.655f, 0.185f, 0.86f, 0.985f, true}; request.depth = with_depth; request.height = 480; request.skip = 3; request.most = 120; std::string why; expect(recorder.start(request, why), "a recording starts"); for (int i = 0; i < 400 && recorder.status().state == State::recording; ++i) { const bool wanted = recorder.wants_frame(); const auto pixels = game.frame(); if (!wanted) continue; const auto depth = with_depth ? game.depth() : std::vector{}; recorder.take_frame({pixels.data(), 1280, 720, 1280 * 4, true, with_depth ? reinterpret_cast(depth.data()) : nullptr, 1280 * 4}); } const auto taken = recorder.status(); expect(taken.state == State::stopped && taken.taken == 120, "it stops by itself at the frames asked for"); expect(taken.depth == with_depth, "it says whether every frame was cut out by depth"); // Comes round: about 37 frames on it looks as it started, half way it does not. const float round = recorder.compare(0, 37), half = recorder.compare(0, 19); expect(round >= 0 && half > 3 * round + 1, "a whole turn on, a frame looks like the first again; half way it does not"); expect(recorder.compare(0, 500) < 0, "a frame not taken is not compared"); const auto kept = recorder.kept(); expect(kept.size() == 120 && kept.front() == 0 && kept.back() == 119, "every frame is kept while there is room"); SaveRequest save; for (std::uint32_t j = 0; j < frames; ++j) save.frames.push_back(static_cast(std::lround(37.3 * j / frames))); save.format = format; save.size = 256; save.solid = !skater; save.card_title = skater ? "Outfit" : "Board"; save.card = {{"Deck", "Test Deck"}, {"Wheels", "Round"}}; save.fonts = {heading, body, brush}; save.file = std::filesystem::temp_directory_path() / "capture_tests" / (std::string("turn.") + extension(format)); expect(recorder.save(save, why), "it saves"); std::string second; expect(!recorder.save(save, second), "one save at a time"); for (int i = 0; i < 600 && recorder.status().state == State::saving; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(50)); const auto saved = recorder.status(); if (saved.state != State::saved) std::printf("recording (%s, %s, %s): %s\n", extension(format), skater ? "skater" : "board", with_depth ? "depth" : "colour", saved.message.c_str()); expect(saved.state == State::saved, "the recording is saved"); std::error_code error; expect(std::filesystem::file_size(saved.file, error) > 1000, "its file is written"); if (with_depth) expect(saved.message.find("by depth") != std::string::npos, "cut out by the game's depth when it had one"); else expect(saved.message.find("by colour") != std::string::npos, "else by colour"); recorder.discard(); expect(recorder.status().state == State::idle && recorder.kept().empty(), "discarded, nothing is held"); } int main(int argc, char** argv) { test_matte(); test_backdrop_key(false); test_backdrop_key(true); test_geometry(); test_png(); test_gif(); test_webm(); if (argc > 1) { test_panel(argv[1], argc > 2 ? argv[2] : nullptr); test_recording(argv[1], Format::apng, false, false, 16); test_recording(argv[1], Format::gif, false, true, 24); test_recording(argv[1], Format::webm, true, false, 16); test_recording(argv[1], Format::png, true, true, 1); } else std::printf("skipped: the card and recordings (pass the fonts folder)\n"); if (!failures) std::printf("capture tests passed\n"); return failures ? 1 : 0; }