mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-27 22:07:07 +01:00
505 lines
27 KiB
C++
505 lines
27 KiB
C++
#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 <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <string>
|
|
#include <thread>
|
|
|
|
namespace dingosdk::capture { bool depth_mask(const PixelView&, Rect, Image&); }
|
|
|
|
// 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<std::string> chunks(const std::vector<std::uint8_t>& png) {
|
|
std::vector<std::string> out;
|
|
for (std::size_t at = 8; at + 12 <= png.size();) {
|
|
const auto size = be32(png.data() + at);
|
|
out.emplace_back(reinterpret_cast<const char*>(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<float>(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<float>(x) + 0.5f, fy = static_cast<float>(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<std::uint8_t>(40 + y * 200 / size);
|
|
p[1] = 140;
|
|
p[2] = static_cast<std::uint8_t>(220 - x * 100 / size);
|
|
const bool band = std::abs(fy - c) < n * 0.05f;
|
|
p[3] = static_cast<std::uint8_t>(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<std::uint8_t> composite(const Image& image, std::array<float, 3> background) {
|
|
std::vector<std::uint8_t> 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::uint8_t>(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<int>(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<Image> frames, truth;
|
|
for (std::uint32_t f = 0; f < turn_frames; ++f) {
|
|
const float angle = 6.2831853f * static_cast<float>(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<float>(x) + 0.5f, fy = static_cast<float>(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::uint8_t>(std::lround(deck[c] * cover + (bg + static_cast<float>(c)) * (1 - cover)));
|
|
p[3] = 255;
|
|
want.at(x, y)[3] = static_cast<std::uint8_t>(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<double>(ghost) / static_cast<double>(background),
|
|
100.0 * static_cast<double>(lost) / static_cast<double>(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<float>(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<std::uint8_t> first_frame_indices(const std::vector<std::uint8_t>& 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<std::uint8_t> data;
|
|
while (gif[at]) {
|
|
data.insert(data.end(), gif.begin() + static_cast<std::ptrdiff_t>(at + 1), gif.begin() + static_cast<std::ptrdiff_t>(at + 1 + gif[at]));
|
|
at += gif[at] + 1u;
|
|
}
|
|
const int clear = 1 << min_bits, end = clear + 1;
|
|
std::vector<std::vector<std::uint8_t>> table;
|
|
const auto reset = [&] {
|
|
table.assign(static_cast<std::size_t>(end) + 1, {});
|
|
for (int i = 0; i < clear; ++i) table[static_cast<std::size_t>(i)] = {static_cast<std::uint8_t>(i)};
|
|
};
|
|
reset();
|
|
int bits = min_bits + 1, previous = -1;
|
|
std::size_t bit = 0;
|
|
std::vector<std::uint8_t> out;
|
|
while (bit + static_cast<std::size_t>(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<std::uint8_t> entry;
|
|
if (code < static_cast<int>(table.size())) entry = table[static_cast<std::size_t>(code)];
|
|
else if (previous >= 0) entry = table[static_cast<std::size_t>(previous)], entry.push_back(table[static_cast<std::size_t>(previous)][0]);
|
|
else return {};
|
|
out.insert(out.end(), entry.begin(), entry.end());
|
|
if (previous >= 0 && table.size() < 4096) {
|
|
auto added = table[static_cast<std::size_t>(previous)];
|
|
added.push_back(entry[0]);
|
|
table.push_back(std::move(added));
|
|
}
|
|
if (static_cast<int>(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<std::uint8_t>(seed >> 24), noise.rgba[i + 1] = static_cast<std::uint8_t>(seed >> 16);
|
|
noise.rgba[i + 2] = static_cast<std::uint8_t>(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<float>(i) * 0.4f));
|
|
const auto webm = encode_webm(animation);
|
|
const auto has = [&](std::initializer_list<std::uint8_t> 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<std::uint8_t, 2> 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::uint8_t>(std::istreambuf_iterator<char>(in), {});
|
|
};
|
|
const auto brush = load("PermanentMarker-Regular.ttf"), heading = load("Montserrat-ExtraBold.ttf"), body = load("Montserrat-SemiBold.ttf");
|
|
const std::vector<Part> 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<const char*>(png.data()), static_cast<std::streamsize>(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<std::uint8_t> last_;
|
|
std::vector<std::uint8_t> 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<std::uint8_t>((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<float>(drawn) * 0.21f) : 0.0f); }
|
|
float half_width() const { return 60 * std::abs(std::cos(static_cast<float>(turn) * 6.2831853f)) + 3; }
|
|
std::vector<std::uint8_t> draw() const {
|
|
constexpr std::uint32_t w = 1280, h = 720;
|
|
std::vector<std::uint8_t> 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<float>(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<float>(x) + 0.5f, fy = static_cast<float>(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::uint8_t>(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<float> 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<float> 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<float>(x) + 0.5f - cx) < half_w && std::abs(static_cast<float>(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::uint8_t>(std::istreambuf_iterator<char>(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<float>{};
|
|
recorder.take_frame({pixels.data(), 1280, 720, 1280 * 4, true, with_depth ? reinterpret_cast<const std::uint8_t*>(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::uint32_t>(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");
|
|
}
|
|
|
|
// The cut by the game's depth: a skater standing on a floor that curves away at the sides. Shoes only
|
|
// 3% nearer than the floor under them are kept (they went missing, 2026-10-24); the curved floor is
|
|
// not taken for the skater.
|
|
void test_depth_cut() {
|
|
constexpr std::uint32_t w = 200, h = 150;
|
|
std::vector<float> depth(std::size_t{w} * h);
|
|
std::vector<std::uint8_t> pixels(std::size_t{w} * h * 4, 90);
|
|
const auto floor_at = [&](std::uint32_t x) {
|
|
const float t = (2.0f * x + 1) / w - 1;
|
|
return 100.0f - 30.0f * t * t; // nearer at the sides, as a curved backdrop is
|
|
};
|
|
for (std::uint32_t y = 0; y < h; ++y)
|
|
for (std::uint32_t x = 0; x < w; ++x) {
|
|
float distance = floor_at(x);
|
|
if (x >= 80 && x < 120 && y >= 20 && y < 130) distance *= 0.6f; // the skater
|
|
else if (x >= 85 && x < 115 && y >= 130 && y < 140) distance *= 0.97f; // their shoes
|
|
depth[std::size_t{y} * w + x] = 1.0f / distance;
|
|
}
|
|
const PixelView frame{pixels.data(), w, h, w * 4, true, reinterpret_cast<const std::uint8_t*>(depth.data()), w * 4};
|
|
Image picture(w, h);
|
|
expect(depth_mask(frame, {0, 0, w, h}, picture), "the depth shows something in front");
|
|
std::size_t shoes = 0, floor = 0;
|
|
for (std::uint32_t y = 130; y < 140; ++y)
|
|
for (std::uint32_t x = 87; x < 113; ++x) shoes += picture.at(x, y)[3] == 255;
|
|
for (std::uint32_t y = 0; y < h; ++y)
|
|
for (std::uint32_t x = 0; x < w; ++x)
|
|
if ((x < 70 || x >= 130) && picture.at(x, y)[3]) ++floor;
|
|
expect(shoes == 26 * 10, "shoes just in front of the floor are kept");
|
|
expect(floor == 0, "a backdrop curving away is not taken for the subject");
|
|
}
|
|
|
|
// The cut by the game's depth: a skater standing on a floor that curves away at the sides. Shoes only
|
|
// 3% nearer than the floor under them are kept (they went missing, 2026-10-24); the curved floor is
|
|
// not taken for the skater.
|
|
void test_depth_cut_contact() {
|
|
constexpr std::uint32_t w = 200, h = 150;
|
|
std::vector<float> depth(std::size_t{w} * h);
|
|
std::vector<std::uint8_t> pixels(std::size_t{w} * h * 4, 90);
|
|
const auto floor_at = [&](std::uint32_t x) {
|
|
const float t = (2.0f * x + 1) / w - 1;
|
|
return 100.0f - 30.0f * t * t; // nearer at the sides, as a curved backdrop is
|
|
};
|
|
for (std::uint32_t y = 0; y < h; ++y)
|
|
for (std::uint32_t x = 0; x < w; ++x) {
|
|
float distance = floor_at(x);
|
|
if (x >= 80 && x < 120 && y >= 20 && y < 130) distance *= 0.6f; // the skater
|
|
else if (x >= 85 && x < 115 && y >= 130 && y < 134) distance *= 0.995f; // their shoes
|
|
depth[std::size_t{y} * w + x] = 1.0f / distance;
|
|
}
|
|
const PixelView frame{pixels.data(), w, h, w * 4, true, reinterpret_cast<const std::uint8_t*>(depth.data()), w * 4};
|
|
Image picture(w, h);
|
|
expect(depth_mask(frame, {0, 0, w, h}, picture), "the depth shows something in front");
|
|
std::size_t shoes = 0, floor = 0;
|
|
for (std::uint32_t y = 130; y < 134; ++y)
|
|
for (std::uint32_t x = 87; x < 113; ++x) shoes += picture.at(x, y)[3] == 255;
|
|
for (std::uint32_t y = 0; y < h; ++y)
|
|
for (std::uint32_t x = 0; x < w; ++x)
|
|
if ((x < 70 || x >= 130) && picture.at(x, y)[3]) ++floor;
|
|
expect(shoes == 26 * 4, "near-contact soles extend the depth silhouette without colour contrast");
|
|
expect(floor == 0, "a backdrop curving away is not taken for the subject");
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
test_depth_cut_contact();
|
|
test_depth_cut();
|
|
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;
|
|
}
|