| /* |
| * Copyright 2026 Rive |
| */ |
| |
| // Renders a tarnished SRIV next to its baseline and reports whether the two |
| // actually differ on screen. |
| // |
| // A silver test fails on any byte difference in the recorded command stream, |
| // which is stricter than "the frame changed": dropping a redundant rewind and |
| // addRawPath pair, or reordering equivalent calls, moves those bytes without |
| // moving a pixel. This replays both streams through the same renderer and |
| // compares the frames, so a rebaseline can be justified rather than trusted. |
| // |
| // Hidden by default (the leading dot in the tag). Run it directly against the |
| // built binary, NOT through test.sh, which wipes silvers/tarnished on startup: |
| // |
| // ./out/release/unit_tests "[.silverdiff]" |
| // |
| // SILVER_DIFF_NAME=<silver name> limits it to one. Differing frames are |
| // written to silvers/tarnished/diff/ as baseline, tarnished and a difference |
| // image. |
| |
| #include "rive/rive_types.hpp" |
| #include "rive_testing.hpp" |
| |
| #include <cstdio> |
| #include <cstdlib> |
| #include <string> |
| #include <vector> |
| |
| #if defined(RIVE_MACOSX) && !defined(RIVE_NO_FILESYSTEM) |
| |
| #include "common/write_png_file.hpp" |
| #include "cg_factory.hpp" |
| #include "cg_renderer.hpp" |
| #include "utils/serialized_replay.hpp" |
| |
| #include <CoreGraphics/CoreGraphics.h> |
| #include <algorithm> |
| #include <cstring> |
| #include <filesystem> |
| #include <set> |
| |
| using namespace rive; |
| |
| namespace |
| { |
| constexpr uint32_t kClearColor = 0x00000000; |
| |
| std::vector<uint8_t> readFile(const std::string& path) |
| { |
| std::vector<uint8_t> bytes; |
| FILE* fp = fopen(path.c_str(), "rb"); |
| if (fp == nullptr) |
| { |
| return bytes; |
| } |
| fseek(fp, 0, SEEK_END); |
| bytes.resize((size_t)ftell(fp)); |
| fseek(fp, 0, SEEK_SET); |
| if (fread(bytes.data(), 1, bytes.size(), fp) != bytes.size()) |
| { |
| bytes.clear(); |
| } |
| fclose(fp); |
| return bytes; |
| } |
| |
| uint64_t hashPixels(const std::vector<uint32_t>& pixels) |
| { |
| uint64_t hash = 0xcbf29ce484222325ull; |
| for (uint32_t pixel : pixels) |
| { |
| hash = (hash ^ pixel) * 0x100000001b3ull; |
| } |
| return hash; |
| } |
| |
| // One replay of a stream into a CoreGraphics bitmap. The renderer is CPU and |
| // deterministic, which is all this needs: both sides go through it, so the |
| // comparison is exact even where the rasterizer is approximate. |
| struct Replay |
| { |
| // Frames whose pixels the caller wants kept. Everything else is hashed and |
| // dropped -- a 2370x1680 stream is 16MB a frame. |
| std::set<int> keep; |
| |
| std::vector<uint64_t> hashes; |
| std::vector<std::vector<uint32_t>> kept; |
| uint32_t width = 0; |
| uint32_t height = 0; |
| int resizes = 0; |
| bool ok = false; |
| |
| bool run(const std::vector<uint8_t>& stream) |
| { |
| CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB(); |
| CGContextRef ctx = nullptr; |
| std::vector<uint32_t> pixels; |
| std::unique_ptr<CGRenderer> renderer; |
| CGFactory factory; |
| |
| auto clear = [&pixels]() { |
| std::fill(pixels.begin(), pixels.end(), kClearColor); |
| }; |
| |
| SerializedReplayHooks hooks; |
| hooks.onFrameSize = [&](uint32_t w, uint32_t h) { |
| if (w == width && h == height && ctx != nullptr) |
| { |
| return; |
| } |
| resizes++; |
| width = w; |
| height = h; |
| pixels.assign((size_t)w * h, kClearColor); |
| // ~CGRenderer restores a graphics state on its context, so the |
| // renderer has to go first or it restores through a freed one. |
| renderer.reset(); |
| if (ctx != nullptr) |
| { |
| CGContextRelease(ctx); |
| } |
| const uint32_t info = |
| static_cast<uint32_t>(kCGBitmapByteOrder32Big) | |
| static_cast<uint32_t>(kCGImageAlphaPremultipliedLast); |
| ctx = CGBitmapContextCreate(pixels.data(), |
| w, |
| h, |
| 8, |
| w * 4, |
| space, |
| info); |
| renderer = std::make_unique<CGRenderer>(ctx, (int)w, (int)h); |
| }; |
| hooks.onFrame = [&]() { |
| if (ctx == nullptr) |
| { |
| return; |
| } |
| CGContextFlush(ctx); |
| const int index = (int)hashes.size(); |
| hashes.push_back(hashPixels(pixels)); |
| if (keep.count(index) != 0) |
| { |
| kept.push_back(pixels); |
| } |
| clear(); |
| }; |
| |
| // The replayer takes one renderer for the whole stream, so the first |
| // frameSize has to arrive before any drawing. Silvers emit it up front. |
| Replay* self = this; |
| (void)self; |
| bool result = false; |
| { |
| // A renderer is needed up front; the stream's first op is the |
| // frame size, which builds it. Until then draws have nowhere to |
| // go, so replay in two steps: peek the size, then replay for real. |
| std::vector<uint32_t> probePixels(1); |
| const uint32_t info = |
| static_cast<uint32_t>(kCGBitmapByteOrder32Big) | |
| static_cast<uint32_t>(kCGImageAlphaPremultipliedLast); |
| CGContextRef probeCtx = CGBitmapContextCreate(probePixels.data(), |
| 1, |
| 1, |
| 8, |
| 4, |
| space, |
| info); |
| uint32_t w = 0, h = 0; |
| { |
| // Scoped so the probe renderer is destroyed, and restores its |
| // graphics state, before the context it holds is released. |
| CGRenderer probeRenderer(probeCtx, 1, 1); |
| SerializedReplayHooks sizeOnly; |
| sizeOnly.onFrameSize = [&w, &h](uint32_t width, |
| uint32_t height) { |
| if (w == 0) |
| { |
| w = width; |
| h = height; |
| } |
| }; |
| replaySerializedCommands( |
| Span<const uint8_t>(stream.data(), stream.size()), |
| &factory, |
| &probeRenderer, |
| sizeOnly); |
| } |
| CGContextRelease(probeCtx); |
| if (w == 0 || h == 0) |
| { |
| CGColorSpaceRelease(space); |
| return false; |
| } |
| hooks.onFrameSize(w, h); |
| result = replaySerializedCommands( |
| Span<const uint8_t>(stream.data(), stream.size()), |
| &factory, |
| renderer.get(), |
| hooks); |
| } |
| |
| renderer.reset(); |
| if (ctx != nullptr) |
| { |
| CGContextRelease(ctx); |
| } |
| CGColorSpaceRelease(space); |
| ok = result; |
| return result; |
| } |
| }; |
| |
| void writePNG(const std::string& path, |
| std::vector<uint32_t> pixels, |
| uint32_t w, |
| uint32_t h) |
| { |
| WritePNGFile(reinterpret_cast<uint8_t*>(pixels.data()), |
| (int)w, |
| (int)h, |
| false, |
| path.c_str(), |
| PNGCompression::fast_rle); |
| } |
| |
| // A raw difference of a few units out of 255 is invisible, and these are |
| // needles in a 4 megapixel haystack, so mark every differing pixel in red at |
| // an intensity that says how far off it was. Black means identical. |
| std::vector<uint32_t> differenceImage(const std::vector<uint32_t>& a, |
| const std::vector<uint32_t>& b) |
| { |
| std::vector<uint32_t> out(a.size()); |
| for (size_t i = 0; i < a.size(); i++) |
| { |
| const uint8_t* pa = reinterpret_cast<const uint8_t*>(&a[i]); |
| const uint8_t* pb = reinterpret_cast<const uint8_t*>(&b[i]); |
| uint8_t* po = reinterpret_cast<uint8_t*>(&out[i]); |
| int worst = 0; |
| for (int c = 0; c < 4; c++) |
| { |
| worst = std::max(worst, std::abs((int)pa[c] - (int)pb[c])); |
| } |
| po[0] = worst == 0 ? 0 : (uint8_t)std::min(255, 96 + worst * 8); |
| po[1] = 0; |
| po[2] = 0; |
| po[3] = 0xff; |
| } |
| return out; |
| } |
| |
| struct FrameDiff |
| { |
| size_t differingPixels = 0; |
| int maxChannelDelta = 0; |
| // Bounding box of the differing pixels, so the report says where. |
| uint32_t minX = UINT32_MAX, minY = UINT32_MAX, maxX = 0, maxY = 0; |
| bool any() const { return differingPixels != 0; } |
| }; |
| |
| FrameDiff compareFrames(const std::vector<uint32_t>& a, |
| const std::vector<uint32_t>& b, |
| uint32_t width) |
| { |
| FrameDiff diff; |
| for (size_t i = 0; i < a.size(); i++) |
| { |
| if (a[i] == b[i]) |
| { |
| continue; |
| } |
| diff.differingPixels++; |
| const uint32_t x = (uint32_t)(i % width); |
| const uint32_t y = (uint32_t)(i / width); |
| diff.minX = std::min(diff.minX, x); |
| diff.minY = std::min(diff.minY, y); |
| diff.maxX = std::max(diff.maxX, x); |
| diff.maxY = std::max(diff.maxY, y); |
| const uint8_t* pa = reinterpret_cast<const uint8_t*>(&a[i]); |
| const uint8_t* pb = reinterpret_cast<const uint8_t*>(&b[i]); |
| for (int c = 0; c < 4; c++) |
| { |
| diff.maxChannelDelta = std::max(diff.maxChannelDelta, |
| std::abs((int)pa[c] - (int)pb[c])); |
| } |
| } |
| return diff; |
| } |
| |
| // A close-up of the region that differs, padded so there is context around it. |
| // Without this the evidence is a handful of pixels in a 4 megapixel frame. |
| std::vector<uint32_t> crop(const std::vector<uint32_t>& pixels, |
| uint32_t width, |
| uint32_t height, |
| uint32_t x0, |
| uint32_t y0, |
| uint32_t w, |
| uint32_t h) |
| { |
| std::vector<uint32_t> out((size_t)w * h, 0xff000000); |
| for (uint32_t y = 0; y < h; y++) |
| { |
| if (y0 + y >= height) |
| { |
| break; |
| } |
| for (uint32_t x = 0; x < w; x++) |
| { |
| if (x0 + x >= width) |
| { |
| break; |
| } |
| out[(size_t)y * w + x] = |
| pixels[(size_t)(y0 + y) * width + (x0 + x)]; |
| } |
| } |
| return out; |
| } |
| |
| } // namespace |
| |
| TEST_CASE("tarnished silvers render the same as their baselines", |
| "[.silverdiff]") |
| { |
| const std::string tarnishedDir = "silvers/tarnished/"; |
| const std::string outDir = tarnishedDir + "diff/"; |
| if (!std::filesystem::exists(tarnishedDir)) |
| { |
| WARN("no silvers/tarnished directory -- run the suite first, and note " |
| "that test.sh wipes it on startup"); |
| return; |
| } |
| |
| const char* only = getenv("SILVER_DIFF_NAME"); |
| std::vector<std::string> names; |
| for (auto& entry : std::filesystem::directory_iterator(tarnishedDir)) |
| { |
| if (entry.path().extension() != ".sriv") |
| { |
| continue; |
| } |
| const std::string name = entry.path().stem().string(); |
| if (only == nullptr || name == only) |
| { |
| names.push_back(name); |
| } |
| } |
| std::sort(names.begin(), names.end()); |
| if (names.empty()) |
| { |
| WARN("no tarnished silvers to compare"); |
| return; |
| } |
| |
| size_t identical = 0, differing = 0, unreadable = 0; |
| size_t worstPixelsOverall = 0; |
| int worstChannelOverall = 0; |
| for (const auto& name : names) |
| { |
| auto baselineBytes = readFile("silvers/" + name + ".sriv"); |
| auto tarnishedBytes = readFile(tarnishedDir + name + ".sriv"); |
| if (baselineBytes.empty() || tarnishedBytes.empty()) |
| { |
| fprintf(stderr, |
| "[silverdiff] %-48s MISSING BASELINE\n", |
| name.c_str()); |
| unreadable++; |
| // Nothing was compared, so this cannot count as a pass. |
| CHECK(false); |
| continue; |
| } |
| |
| Replay baseline, tarnished; |
| const bool replayed = |
| baseline.run(baselineBytes) && tarnished.run(tarnishedBytes); |
| if (!replayed) |
| { |
| fprintf(stderr, "[silverdiff] %-48s REPLAY FAILED\n", name.c_str()); |
| unreadable++; |
| CHECK(replayed); |
| continue; |
| } |
| |
| if (baseline.width != tarnished.width || |
| baseline.height != tarnished.height || |
| baseline.hashes.size() != tarnished.hashes.size()) |
| { |
| fprintf(stderr, |
| "[silverdiff] %-48s SHAPE DIFFERS %ux%u/%zu frames vs " |
| "%ux%u/%zu\n", |
| name.c_str(), |
| baseline.width, |
| baseline.height, |
| baseline.hashes.size(), |
| tarnished.width, |
| tarnished.height, |
| tarnished.hashes.size()); |
| differing++; |
| CHECK(false); |
| continue; |
| } |
| |
| std::set<int> mismatched; |
| for (size_t i = 0; i < baseline.hashes.size(); i++) |
| { |
| if (baseline.hashes[i] != tarnished.hashes[i]) |
| { |
| mismatched.insert((int)i); |
| } |
| } |
| if (mismatched.empty()) |
| { |
| fprintf(stderr, |
| "[silverdiff] %-48s identical (%zu frames, %ux%u)\n", |
| name.c_str(), |
| baseline.hashes.size(), |
| baseline.width, |
| baseline.height); |
| identical++; |
| continue; |
| } |
| |
| // Second pass over the frames that differ, this time keeping pixels. |
| std::set<int> keep; |
| for (int index : mismatched) |
| { |
| if (keep.size() >= 3) |
| { |
| break; |
| } |
| keep.insert(index); |
| } |
| Replay baselinePixels, tarnishedPixels; |
| baselinePixels.keep = keep; |
| tarnishedPixels.keep = keep; |
| baselinePixels.run(baselineBytes); |
| tarnishedPixels.run(tarnishedBytes); |
| |
| std::filesystem::create_directories(outDir); |
| size_t worstPixels = 0; |
| int worstChannel = 0; |
| size_t k = 0; |
| for (int index : keep) |
| { |
| if (k >= baselinePixels.kept.size() || |
| k >= tarnishedPixels.kept.size()) |
| { |
| break; |
| } |
| const auto& a = baselinePixels.kept[k]; |
| const auto& b = tarnishedPixels.kept[k]; |
| const FrameDiff diff = compareFrames(a, b, baseline.width); |
| worstPixels = std::max(worstPixels, diff.differingPixels); |
| worstChannel = std::max(worstChannel, diff.maxChannelDelta); |
| |
| const std::string stem = |
| outDir + name + "-frame" + std::to_string(index); |
| writePNG(stem + "-baseline.png", |
| a, |
| baseline.width, |
| baseline.height); |
| writePNG(stem + "-tarnished.png", |
| b, |
| baseline.width, |
| baseline.height); |
| writePNG(stem + "-diff.png", |
| differenceImage(a, b), |
| baseline.width, |
| baseline.height); |
| |
| if (diff.any()) |
| { |
| // Pad the differing region out to at least 192px so there is |
| // something recognizable around it. |
| constexpr uint32_t kMin = 192; |
| uint32_t w = std::max(kMin, diff.maxX - diff.minX + 1); |
| uint32_t h = std::max(kMin, diff.maxY - diff.minY + 1); |
| uint32_t cx = (diff.minX + diff.maxX) / 2; |
| uint32_t cy = (diff.minY + diff.maxY) / 2; |
| uint32_t x0 = cx > w / 2 ? cx - w / 2 : 0; |
| uint32_t y0 = cy > h / 2 ? cy - h / 2 : 0; |
| writePNG(stem + "-crop-baseline.png", |
| crop(a, baseline.width, baseline.height, x0, y0, w, h), |
| w, |
| h); |
| writePNG(stem + "-crop-tarnished.png", |
| crop(b, baseline.width, baseline.height, x0, y0, w, h), |
| w, |
| h); |
| fprintf(stderr, |
| " frame %d: %zu pixels differ, max " |
| "channel delta %d, within (%u,%u)-(%u,%u)\n", |
| index, |
| diff.differingPixels, |
| diff.maxChannelDelta, |
| diff.minX, |
| diff.minY, |
| diff.maxX, |
| diff.maxY); |
| } |
| k++; |
| } |
| |
| fprintf(stderr, |
| "[silverdiff] %-48s DIFFERS on %zu/%zu frames, worst frame %zu " |
| "pixels, max channel delta %d -> %s\n", |
| name.c_str(), |
| mismatched.size(), |
| baseline.hashes.size(), |
| worstPixels, |
| worstChannel, |
| outDir.c_str()); |
| worstPixelsOverall = std::max(worstPixelsOverall, worstPixels); |
| worstChannelOverall = std::max(worstChannelOverall, worstChannel); |
| differing++; |
| CHECK(mismatched.empty()); |
| } |
| |
| fprintf(stderr, |
| "[silverdiff] %zu identical, %zu differing, %zu unreadable. Worst " |
| "frame anywhere: %zu pixels, max channel delta %d/255.\n", |
| identical, |
| differing, |
| unreadable, |
| worstPixelsOverall, |
| worstChannelOverall); |
| } |
| |
| #else |
| |
| TEST_CASE("tarnished silvers render the same as their baselines", |
| "[.silverdiff]") |
| { |
| WARN("silver visual diff needs macOS (CoreGraphics) and std::filesystem"); |
| } |
| |
| #endif |