blob: 584fe8d2b7881307b6b9f7d77b0480a30153d239 [file]
/*
* 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