| #include <rive/animation/state_machine_instance.hpp> | |
| #include <rive/artboard.hpp> | |
| #include <rive/file.hpp> | |
| #include <rive/node.hpp> | |
| #include <rive/watermark.hpp> | |
| #include <utils/no_op_renderer.hpp> | |
| #include "catch.hpp" | |
| #include "rive_file_reader.hpp" | |
| #include <algorithm> | |
| #include <chrono> | |
| #include <memory> | |
| #include <thread> | |
| #include <vector> | |
| using namespace rive; | |
| namespace | |
| { | |
| // The watermark refuses to advance faster than the wall clock unless the host | |
| // owns the timeline. Tests that want to drive time themselves — every one below | |
| // that counts frames — say so by taking this. | |
| struct DeterministicTime | |
| { | |
| DeterministicTime() { File::deterministicMode = true; } | |
| ~DeterministicTime() { File::deterministicMode = false; } | |
| }; | |
| // Records which RenderPaths reached the renderer. Every ArtboardInstance builds | |
| // its own paths, so path identity is what tells the watermark's artboard apart | |
| // from the host's. | |
| class PathRecordingRenderer : public NoOpRenderer | |
| { | |
| public: | |
| std::vector<RenderPath*> paths; | |
| void drawPath(RenderPath* path, RenderPaint*) override | |
| { | |
| paths.push_back(path); | |
| } | |
| bool sawAnyOf(const std::vector<RenderPath*>& others) const | |
| { | |
| for (auto path : paths) | |
| { | |
| if (std::find(others.begin(), others.end(), path) != others.end()) | |
| { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| }; | |
| std::vector<RenderPath*> recordDraw(Artboard* artboard) | |
| { | |
| PathRecordingRenderer renderer; | |
| artboard->draw(&renderer); | |
| return renderer.paths; | |
| } | |
| // drawInternal is never diverted, so this is how to see an artboard's own | |
| // content even while a watermark is attached to it. | |
| std::vector<RenderPath*> recordDrawInternal(Artboard* artboard) | |
| { | |
| PathRecordingRenderer renderer; | |
| artboard->drawInternal(&renderer); | |
| return renderer.paths; | |
| } | |
| // The fixture's state machine tweens a node's x, so the node positions are a | |
| // direct read on whether the artboard advanced. | |
| std::vector<float> nodePositions(Artboard* artboard) | |
| { | |
| std::vector<float> positions; | |
| for (auto node : artboard->find<Node>()) | |
| { | |
| positions.push_back(node->x()); | |
| positions.push_back(node->y()); | |
| } | |
| return positions; | |
| } | |
| // Builds a watermark out of a second instance of the same artboard. The two | |
| // instances render distinct paths, which is all the tests below need to tell | |
| // which one drew. | |
| std::unique_ptr<Watermark> makeWatermark(const File* file, | |
| std::vector<RenderPath*>* paths) | |
| { | |
| auto artboard = file->artboardDefault(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| if (paths != nullptr) | |
| { | |
| *paths = recordDraw(artboard.get()); | |
| } | |
| return std::make_unique<Watermark>(std::move(artboard), | |
| std::move(stateMachine)); | |
| } | |
| } // namespace | |
| TEST_CASE("a watermark holds back the artboard it is attached to", | |
| "[watermark]") | |
| { | |
| DeterministicTime deterministic; | |
| auto file = ReadRiveFile("assets/data_bind_keyframes_test.riv"); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| // Captured before the watermark is attached, while the host still draws | |
| // itself. | |
| auto hostPaths = recordDraw(artboard.get()); | |
| REQUIRE(!hostPaths.empty()); | |
| auto frozenPositions = nodePositions(artboard.get()); | |
| REQUIRE(!frozenPositions.empty()); | |
| std::vector<RenderPath*> watermarkPaths; | |
| artboard->watermark(makeWatermark(file.get(), &watermarkPaths)); | |
| REQUIRE(artboard->watermark() != nullptr); | |
| REQUIRE(!watermarkPaths.empty()); | |
| for (int i = 0; i < 10; i++) | |
| { | |
| // Never reports "settled" while the pre-roll runs: a false here would | |
| // stop the host's ticker mid watermark. | |
| REQUIRE(stateMachine->advanceAndApply(0.016f)); | |
| REQUIRE(artboard->watermark() != nullptr); | |
| CHECK(artboard->watermark()->isPlaying()); | |
| PathRecordingRenderer renderer; | |
| artboard->draw(&renderer); | |
| CHECK(!renderer.paths.empty()); | |
| CHECK(!renderer.sawAnyOf(hostPaths)); | |
| CHECK(nodePositions(artboard.get()) == frozenPositions); | |
| } | |
| } | |
| TEST_CASE("the artboard takes over once the watermark is done", "[watermark]") | |
| { | |
| DeterministicTime deterministic; | |
| auto file = ReadRiveFile("assets/data_bind_keyframes_test.riv"); | |
| auto artboard = file->artboardDefault(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto hostPaths = recordDraw(artboard.get()); | |
| auto frozenPositions = nodePositions(artboard.get()); | |
| artboard->watermark(makeWatermark(file.get(), nullptr)); | |
| // The same artboard played with no watermark at all, to compare against. | |
| auto control = file->artboardDefault(); | |
| auto controlStateMachine = control->stateMachineAt(0); | |
| REQUIRE(controlStateMachine != nullptr); | |
| controlStateMachine->advanceAndApply(0.0f); | |
| // Long enough to outrun the pre-roll's hard time cap even if the | |
| // watermark's state machine never settles on its own. | |
| for (int i = 0; i < 800 && artboard->watermark() != nullptr; i++) | |
| { | |
| stateMachine->advanceAndApply(0.016f); | |
| } | |
| // Released the moment it finishes, so the pre-roll can't play twice. | |
| REQUIRE(artboard->watermark() == nullptr); | |
| // Once the pre-roll is out of the way the artboard plays exactly what it | |
| // would have played without one: same frames, just delayed. The frame that | |
| // released the watermark is already the artboard's first real one. | |
| int hostFrames = 1; | |
| for (int i = 0; i < 60; i++) | |
| { | |
| stateMachine->advanceAndApply(0.016f); | |
| hostFrames++; | |
| } | |
| for (int i = 0; i < hostFrames; i++) | |
| { | |
| controlStateMachine->advanceAndApply(0.016f); | |
| } | |
| auto controlPositions = nodePositions(control.get()); | |
| REQUIRE(controlPositions != frozenPositions); // the fixture does animate | |
| CHECK(nodePositions(artboard.get()) == controlPositions); | |
| PathRecordingRenderer renderer; | |
| artboard->draw(&renderer); | |
| CHECK(renderer.sawAnyOf(hostPaths)); | |
| } | |
| TEST_CASE("a watermark that is never advanced does not hide the artboard", | |
| "[watermark]") | |
| { | |
| auto file = ReadRiveFile("assets/data_bind_keyframes_test.riv"); | |
| auto artboard = file->artboardDefault(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto hostPaths = recordDraw(artboard.get()); | |
| artboard->watermark(makeWatermark(file.get(), nullptr)); | |
| // Playback that never runs through a state machine never starts the | |
| // pre-roll, and must keep drawing the artboard rather than freeze on a | |
| // watermark that would never end. | |
| PathRecordingRenderer renderer; | |
| artboard->draw(&renderer); | |
| CHECK(renderer.sawAnyOf(hostPaths)); | |
| } | |
| TEST_CASE("drawInternal never consults the watermark", "[watermark]") | |
| { | |
| DeterministicTime deterministic; | |
| auto file = ReadRiveFile("assets/data_bind_keyframes_test.riv"); | |
| auto artboard = file->artboardDefault(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto hostPaths = recordDraw(artboard.get()); | |
| REQUIRE(!hostPaths.empty()); | |
| std::vector<RenderPath*> watermarkPaths; | |
| artboard->watermark(makeWatermark(file.get(), &watermarkPaths)); | |
| REQUIRE(stateMachine->advanceAndApply(0.016f)); | |
| REQUIRE(artboard->watermark()->isPlaying()); | |
| // Nested artboards and component lists render through drawInternal, which | |
| // is why the gate lives in draw(): drawing this way must still render the | |
| // artboard's own content even mid pre-roll. | |
| PathRecordingRenderer renderer; | |
| artboard->drawInternal(&renderer); | |
| CHECK(renderer.sawAnyOf(hostPaths)); | |
| CHECK(!renderer.sawAnyOf(watermarkPaths)); | |
| } | |
| TEST_CASE("a watermarked file pre-rolls the artboard it hands out", | |
| "[watermark]") | |
| { | |
| DeterministicTime deterministic; | |
| // created by watermark_export_test.dart in rive_core. | |
| auto file = ReadRiveFile("assets/watermark_playback_test.riv"); | |
| auto* manifest = file->manifest(); | |
| REQUIRE(manifest != nullptr); | |
| REQUIRE(manifest->hasWatermark()); | |
| // The exporter inlines the watermark after the host's artboards, and drags | |
| // in whatever that watermark itself nests, so the total count follows the | |
| // bundled artwork rather than being fixed. What this test needs is that the | |
| // host kept index 0 and the watermark landed right after it. | |
| REQUIRE(file->artboardCount() > 1); | |
| REQUIRE(manifest->watermarkArtboardIndex() == 1); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| REQUIRE(artboard->watermark() != nullptr); | |
| // The watermark artboard itself is still vended plain, so tooling can look | |
| // at it without triggering a pre-roll of itself. | |
| CHECK(file->artboardAt(1)->watermark() == nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto hostPaths = recordDrawInternal(artboard.get()); | |
| REQUIRE(!hostPaths.empty()); | |
| REQUIRE(stateMachine->advanceAndApply(0.016f)); | |
| REQUIRE(artboard->watermark()->isPlaying()); | |
| PathRecordingRenderer duringPreRoll; | |
| artboard->draw(&duringPreRoll); | |
| CHECK(!duringPreRoll.paths.empty()); | |
| CHECK(!duringPreRoll.sawAnyOf(hostPaths)); | |
| // The bundled watermark rests in a one-shot animation, so its state machine | |
| // settles well inside the pre-roll's hard time cap. | |
| for (int i = 0; i < 800 && artboard->watermark() != nullptr; i++) | |
| { | |
| stateMachine->advanceAndApply(0.016f); | |
| } | |
| REQUIRE(artboard->watermark() == nullptr); | |
| PathRecordingRenderer afterPreRoll; | |
| artboard->draw(&afterPreRoll); | |
| CHECK(afterPreRoll.sawAnyOf(hostPaths)); | |
| } | |
| TEST_CASE("the watermark cannot be advanced faster than real time", | |
| "[watermark]") | |
| { | |
| // Deliberately not DeterministicTime: this is the real-clock behaviour. | |
| REQUIRE(!File::deterministicMode); | |
| auto file = ReadRiveFile("assets/data_bind_keyframes_test.riv"); | |
| auto artboard = file->artboardDefault(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| artboard->watermark(makeWatermark(file.get(), nullptr)); | |
| // One enormous delta is the obvious way to seek past a pre-roll. It buys | |
| // only the wall time that has actually passed, which here is ~nothing. | |
| REQUIRE(stateMachine->advanceAndApply(1000.0f)); | |
| CHECK(artboard->watermark() != nullptr); | |
| // Neither does spinning: this loop is worth minutes of animation at face | |
| // value but runs in well under a frame of real time. | |
| for (int i = 0; i < 500; i++) | |
| { | |
| stateMachine->advanceAndApply(0.5f); | |
| } | |
| CHECK(artboard->watermark() != nullptr); | |
| CHECK(artboard->watermark()->isPlaying()); | |
| // Nor does spinning *underneath* the clock tolerance, which is the subtle | |
| // way through: while each delta was compared against the wall clock on its | |
| // own, a delta at or just below the tolerance was never clamped, and the | |
| // tolerance could be re-spent every call. This loop is worth far more than | |
| // the pre-roll's hard cap at face value and still costs no real time. | |
| for (int i = 0; i < 8000; i++) | |
| { | |
| stateMachine->advanceAndApply(0.004f); | |
| } | |
| CHECK(artboard->watermark() != nullptr); | |
| CHECK(artboard->watermark()->isPlaying()); | |
| // And the artboard is still being held back, not quietly playing behind it. | |
| PathRecordingRenderer renderer; | |
| artboard->draw(&renderer); | |
| CHECK(!renderer.paths.empty()); | |
| CHECK(!renderer.sawAnyOf(recordDrawInternal(artboard.get()))); | |
| } | |
| TEST_CASE("a file without a watermark section vends plain artboards", | |
| "[watermark]") | |
| { | |
| // This file carries a manifest (it has a string table) but no watermark | |
| // section, which is the shape of every file exported today. | |
| auto file = ReadRiveFile("assets/data_bind_keyframes_test.riv"); | |
| auto* manifest = file->manifest(); | |
| REQUIRE((manifest == nullptr || !manifest->hasWatermark())); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| CHECK(artboard->watermark() == nullptr); | |
| for (size_t i = 0; i < file->artboardCount(); i++) | |
| { | |
| CHECK(file->artboardAt(i)->watermark() == nullptr); | |
| } | |
| } | |
| // Idle time used to be bankable. The whole wall-clock gap went into the | |
| // budget, so an instance left alone -- a backgrounded tab, a file loaded and | |
| // not yet advanced -- funded a burst of advances that could drain the pre-roll | |
| // a frame at a time without ever showing it. The credit for one call is now | |
| // capped at the same ceiling a single advance may spend. | |
| TEST_CASE("idle time cannot be banked to drain the watermark", "[watermark]") | |
| { | |
| // Deliberately not DeterministicTime: this is the real-clock behaviour. | |
| REQUIRE(!File::deterministicMode); | |
| auto file = ReadRiveFile("assets/data_bind_keyframes_test.riv"); | |
| auto artboard = file->artboardDefault(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| artboard->watermark(makeWatermark(file.get(), nullptr)); | |
| // Sit idle for well over one frame's worth of real time... | |
| std::this_thread::sleep_for(std::chrono::milliseconds(400)); | |
| // ...then spend it as fast as possible. Every one of these is capped to a | |
| // frame, so banking the full gap would have let the burst consume all of | |
| // it rather than the one frame that has been earned. | |
| for (int i = 0; i < 20; i++) | |
| { | |
| stateMachine->advanceAndApply(1000.0f); | |
| } | |
| REQUIRE(artboard->watermark() != nullptr); | |
| CHECK(artboard->watermark()->isPlaying()); | |
| // One frame's credit plus the tolerance, not the whole idle gap. The bound | |
| // is deliberately loose: what matters is that it is nowhere near 0.4. | |
| CHECK(artboard->watermark()->elapsedSeconds() < 0.3f); | |
| } |