Skip to content

Commit 4881e47

Browse files
authored
Test video frame cache eviction (#71)
* Test video frame cache eviction * Restore unset decoder cache override * Exclude runtime keys from saved settings
1 parent 0e56574 commit 4881e47

6 files changed

Lines changed: 215 additions & 8 deletions

File tree

core/include/settings_manager.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class SettingsManager : public QObject {
2222
Q_INVOKABLE void save();
2323

2424
Q_INVOKABLE void setValue(const QString &key, const QVariant &value);
25+
void removeValue(const QString &key);
2526
Q_INVOKABLE QVariant value(const QString &key, const QVariant &defaultValue = QVariant()) const;
2627
Q_INVOKABLE QVariantMap shortcuts() const;
2728
Q_INVOKABLE QString shortcut(const QString &actionId, const QString &fallbackValue = QString()) const;
@@ -37,4 +38,4 @@ class SettingsManager : public QObject {
3738
QVariantMap m_settings;
3839
};
3940

40-
} // namespace AviQtl::Core
41+
} // namespace AviQtl::Core

core/src/settings_manager.cpp

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,15 @@ void SettingsManager::save() {
265265
return;
266266
}
267267

268-
QJsonObject obj = QJsonObject::fromVariantMap(m_settings);
268+
QVariantMap persistentSettings = m_settings;
269+
for (auto it = persistentSettings.begin(); it != persistentSettings.end();) {
270+
if (it.key().startsWith(QStringLiteral("_"))) {
271+
it = persistentSettings.erase(it);
272+
} else {
273+
++it;
274+
}
275+
}
276+
QJsonObject obj = QJsonObject::fromVariantMap(persistentSettings);
269277
QJsonDocument doc(obj);
270278
const QByteArray payload = doc.toJson();
271279
qint64 written = file.write(payload);
@@ -295,6 +303,15 @@ void SettingsManager::setValue(const QString &key, const QVariant &value) {
295303
}
296304
}
297305

306+
void SettingsManager::removeValue(const QString &key) {
307+
if (m_settings.remove(key) > 0) {
308+
emit settingsChanged();
309+
if (!key.startsWith(QStringLiteral("_"))) {
310+
save();
311+
}
312+
}
313+
}
314+
298315
auto SettingsManager::value(const QString &key, const QVariant &defaultValue) const -> QVariant { return m_settings.value(key, defaultValue); }
299316

300317
auto SettingsManager::shortcuts() const -> QVariantMap { return m_settings.value(QStringLiteral("shortcuts")).toMap(); }

core/src/video_decoder.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,16 @@ void VideoDecoder::decodeTask(int targetFrame, double fps) { // NOLINT(bugprone-
770770
}
771771

772772
void VideoDecoder::updateCacheSize() {
773+
// Runtime-only diagnostic override for deterministic tests and measurements.
774+
// Settings keys prefixed with "_" are intentionally never persisted.
775+
const QVariant overrideValue = SettingsManager::instance().value(QStringLiteral("_videoDecoderFrameCacheMaxBytes"));
776+
if (overrideValue.isValid()) {
777+
const qsizetype overrideBytes = overrideValue.toLongLong();
778+
if (overrideBytes > 0) {
779+
m_frameCache.setMaxCost(overrideBytes);
780+
return;
781+
}
782+
}
773783
int sizeMB = SettingsManager::instance().settings().value(QStringLiteral("cacheSize"), 512).toInt();
774784
int minSizeMB = SettingsManager::instance().value(QStringLiteral("videoDecoderMinCacheMB"), 64).toInt();
775785
sizeMB = std::max(sizeMB, minSizeMB);

docs/LARGE_PROJECT_PERFORMANCE.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,24 @@ the two oldest blocks, and served the final repeated frame from the larger
9595
frame cache. These counts are deterministic assertions; the tiny generated
9696
media is not used to claim representative seek latency.
9797

98+
## Frame Cache Eviction
99+
100+
The decoder fixture also generates 240 frames across 20 fixed GOPs and applies
101+
a runtime-only 2 MiB frame-cache budget. Descending seeks fill every GOP while
102+
forcing the frame cache and three-block GOP LRU beyond capacity. The test
103+
verifies bounded frame-cache cost, a frame-cache hit for a recent frame no
104+
longer present in the GOP LRU, and fresh decoding when an early evicted frame is
105+
requested again.
106+
107+
Cold-seek timings and the two re-seek timings are printed for comparison but do
108+
not use wall-clock pass/fail limits. The low-resolution generated video makes
109+
cache lifecycle behavior deterministic; it is not representative evidence for
110+
seek latency on production media.
111+
98112
## Next Measurements
99113

100114
The fixture now covers the model/controller baseline, real QML delegate
101-
virtualization, continuous viewport interaction, and the decoder GOP-cache
102-
lifecycle. Separate measurements are still needed for long-video seek latency
103-
and frame-cache memory eviction, long-audio decoding, and plugin scanning.
115+
virtualization, continuous viewport interaction, the decoder GOP-cache
116+
lifecycle, and bounded frame-cache eviction. Separate measurements are still
117+
needed for long-video seek latency with representative media, long-audio
118+
decoding, and plugin scanning.

tests/test_settings_manager.cpp

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
#include "settings_manager.hpp"
2+
#include <QCoreApplication>
3+
#include <QFile>
4+
#include <QFileInfo>
5+
#include <QJsonDocument>
6+
#include <QScopeGuard>
27
#include <QSignalSpy>
38
#include <QTest>
49

@@ -24,6 +29,57 @@ class TestSettingsManager : public QObject {
2429
QCOMPARE(SettingsManager::instance().value(QStringLiteral("_test.integer")).toInt(), 42);
2530
}
2631

32+
void removeValue() {
33+
SettingsManager::instance().setValue(QStringLiteral("_test.removed"), 42);
34+
SettingsManager::instance().removeValue(QStringLiteral("_test.removed"));
35+
QVERIFY(!SettingsManager::instance().settings().contains(QStringLiteral("_test.removed")));
36+
}
37+
38+
void runtimeValuesAreNeverPersisted() {
39+
SettingsManager &settings = SettingsManager::instance();
40+
const QVariantMap originalSettings = settings.settings();
41+
const QString settingsPath = QCoreApplication::applicationDirPath() + QStringLiteral("/aviqtl_settings.json");
42+
QVERIFY(QFileInfo(QCoreApplication::applicationDirPath()).isWritable());
43+
44+
QFile originalFile(settingsPath);
45+
const bool originalFileExisted = originalFile.exists();
46+
QByteArray originalPayload;
47+
if (originalFileExisted) {
48+
QVERIFY(originalFile.open(QIODevice::ReadOnly));
49+
originalPayload = originalFile.readAll();
50+
originalFile.close();
51+
}
52+
const auto restoreSettings = qScopeGuard([&settings, originalSettings, settingsPath, originalFileExisted, originalPayload]() -> void {
53+
settings.setSettings(originalSettings);
54+
if (originalFileExisted) {
55+
QFile file(settingsPath);
56+
if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
57+
file.write(originalPayload);
58+
}
59+
} else {
60+
QFile::remove(settingsPath);
61+
}
62+
});
63+
64+
const QString runtimeKey = QStringLiteral("_test.runtimeOnly");
65+
const QString persistentKey = QStringLiteral("showConfirmOnClose");
66+
const bool persistentValue = !settings.value(persistentKey).toBool();
67+
settings.setValue(runtimeKey, QStringLiteral("must-not-persist"));
68+
settings.setValue(persistentKey, persistentValue);
69+
70+
QFile persistedFile(settingsPath);
71+
QVERIFY(persistedFile.open(QIODevice::ReadOnly));
72+
const QJsonDocument persistedDocument = QJsonDocument::fromJson(persistedFile.readAll());
73+
QVERIFY(persistedDocument.isObject());
74+
QVERIFY(!persistedDocument.object().contains(runtimeKey));
75+
QCOMPARE(persistedDocument.object().value(persistentKey).toBool(), persistentValue);
76+
77+
settings.removeValue(runtimeKey);
78+
settings.load();
79+
QVERIFY(!settings.settings().contains(runtimeKey));
80+
QCOMPARE(settings.value(persistentKey).toBool(), persistentValue);
81+
}
82+
2783
void valueWithDefault() {
2884
QVariant val = SettingsManager::instance().value(QStringLiteral("_test.nonexistent"), QStringLiteral("fallback"));
2985
QCOMPARE(val.toString(), QStringLiteral("fallback"));

tests/test_video_decoder.cpp

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
#include "settings_manager.hpp"
12
#include "video_decoder.hpp"
23
#include "video_encoder.hpp"
34
#include "video_frame_store.hpp"
45
#include <QElapsedTimer>
56
#include <QFile>
7+
#include <QScopeGuard>
68
#include <QSignalSpy>
79
#include <QTemporaryDir>
810
#include <QTest>
@@ -20,6 +22,8 @@ namespace {
2022
constexpr int kTestFrameCount = 60;
2123
constexpr int kTestGopSize = 12;
2224
constexpr int kColdFrame = 48;
25+
constexpr int kEvictionFrameCount = 240;
26+
constexpr qsizetype kEvictionCacheBytes = 2 * 1024 * 1024;
2327
} // namespace
2428

2529
class TestVideoDecoder : public QObject {
@@ -28,13 +32,14 @@ class TestVideoDecoder : public QObject {
2832
private slots:
2933
void initialStateIsEmpty();
3034
void decodesEncodedFramesThroughVideoSink();
35+
void frameCacheEvictionStaysWithinBudget();
3136

3237
private:
33-
static bool createTestVideo(const QString &path);
38+
static bool createTestVideo(const QString &path, int frameCount = kTestFrameCount);
3439
static QVector3D averageColor(const QImage &image);
3540
};
3641

37-
bool TestVideoDecoder::createTestVideo(const QString &path) {
42+
bool TestVideoDecoder::createTestVideo(const QString &path, int frameCount) {
3843
VideoEncoder encoder;
3944
VideoEncoder::Config config;
4045
config.width = 96;
@@ -50,7 +55,7 @@ bool TestVideoDecoder::createTestVideo(const QString &path) {
5055
return false;
5156

5257
const std::array<QColor, 4> colors = {QColor(Qt::red), QColor(Qt::yellow), QColor(Qt::cyan), QColor(Qt::blue)};
53-
for (int frame = 0; frame < kTestFrameCount; ++frame) {
58+
for (int frame = 0; frame < frameCount; ++frame) {
5459
QImage image(config.width, config.height, QImage::Format_RGBA8888);
5560
image.fill(colors[static_cast<std::size_t>(frame) % colors.size()]);
5661
if (!encoder.pushFrame(image, frame)) {
@@ -214,5 +219,108 @@ void TestVideoDecoder::decodesEncodedFramesThroughVideoSink() {
214219
QCOMPARE(burstFrameSpy.last().first().toInt(), 36);
215220
}
216221

222+
void TestVideoDecoder::frameCacheEvictionStaysWithinBudget() {
223+
SettingsManager &settings = SettingsManager::instance();
224+
const QVariant previousOverride = settings.value(QStringLiteral("_videoDecoderFrameCacheMaxBytes"));
225+
settings.setValue(QStringLiteral("_videoDecoderFrameCacheMaxBytes"), kEvictionCacheBytes);
226+
const auto restoreCacheOverride = qScopeGuard([&settings, previousOverride]() -> void {
227+
if (previousOverride.isValid()) {
228+
settings.setValue(QStringLiteral("_videoDecoderFrameCacheMaxBytes"), previousOverride);
229+
} else {
230+
settings.removeValue(QStringLiteral("_videoDecoderFrameCacheMaxBytes"));
231+
}
232+
});
233+
234+
QTemporaryDir dir;
235+
QVERIFY(dir.isValid());
236+
const QString videoPath = dir.filePath(QStringLiteral("decoder-eviction.mp4"));
237+
QVERIFY(createTestVideo(videoPath, kEvictionFrameCount));
238+
239+
constexpr int clipId = 8;
240+
VideoFrameStore store;
241+
QVideoSink sink;
242+
store.registerSink(QString::number(clipId), &sink);
243+
244+
VideoDecoder decoder(clipId, QUrl::fromLocalFile(videoPath), &store);
245+
QSignalSpy readySpy(&decoder, &MediaDecoder::ready);
246+
decoder.scheduleStart();
247+
QTRY_COMPARE_WITH_TIMEOUT(readySpy.count(), 1, 10'000);
248+
QVERIFY(decoder.isReady());
249+
QCOMPARE(decoder.totalFrameCount(), kEvictionFrameCount);
250+
251+
const std::array<QColor, 4> expectedColors = {QColor(Qt::red), QColor(Qt::yellow), QColor(Qt::cyan), QColor(Qt::blue)};
252+
auto seekAndVerify = [&decoder, &sink, &expectedColors](int frame) -> qint64 {
253+
QSignalSpy frameSpy(&decoder, &MediaDecoder::frameReady);
254+
QElapsedTimer timer;
255+
timer.start();
256+
decoder.seekToFrame(frame, decoder.sourceFps());
257+
if (frameSpy.isEmpty() && !frameSpy.wait(10'000)) {
258+
return -1;
259+
}
260+
if (frameSpy.count() != 1 || frameSpy.takeFirst().at(0).toInt() != frame) {
261+
return -1;
262+
}
263+
const QImage image = sink.videoFrame().toImage();
264+
if (image.isNull()) {
265+
return -1;
266+
}
267+
const QVector3D actualColor = averageColor(image);
268+
const QColor expectedColor = expectedColors[static_cast<std::size_t>(frame) % expectedColors.size()];
269+
const QVector3D expectedColorVector(static_cast<float>(expectedColor.red()), static_cast<float>(expectedColor.green()), static_cast<float>(expectedColor.blue()));
270+
if ((actualColor - expectedColorVector).length() >= 80.0F) {
271+
return -1;
272+
}
273+
return timer.elapsed();
274+
};
275+
276+
QVector<qint64> coldSeekTimes;
277+
constexpr int gopCount = kEvictionFrameCount / kTestGopSize;
278+
coldSeekTimes.reserve(gopCount);
279+
for (int gop = gopCount - 1; gop >= 0; --gop) {
280+
const int frame = (gop * kTestGopSize) + (((gop * 5) + 1) % kTestGopSize);
281+
const qint64 elapsedMs = seekAndVerify(frame);
282+
QVERIFY2(elapsedMs >= 0, qPrintable(QStringLiteral("cold seek to frame %1 failed").arg(frame)));
283+
const int completedSeeks = gopCount - gop;
284+
QTRY_COMPARE_WITH_TIMEOUT(decoder.cacheStats().decodedFrames, static_cast<quint64>(completedSeeks * kTestGopSize), 10'000);
285+
QTRY_COMPARE_WITH_TIMEOUT(decoder.cacheStats().gopEvictions, static_cast<quint64>(std::max(0, completedSeeks - 3)), 10'000);
286+
coldSeekTimes.append(elapsedMs);
287+
}
288+
289+
const VideoDecoder::CacheStats sweepStats = decoder.cacheStats();
290+
std::ranges::sort(coldSeekTimes);
291+
const qint64 medianColdSeekMs = coldSeekTimes.at(coldSeekTimes.size() / 2);
292+
QTextStream(stdout) << "video_decoder eviction_sweep seeks=" << gopCount << " median_ms=" << medianColdSeekMs << " misses=" << sweepStats.misses << " decoded_frames=" << sweepStats.decodedFrames << " gop_blocks=" << sweepStats.gopBlocks
293+
<< " gop_evictions=" << sweepStats.gopEvictions << " frame_entries=" << sweepStats.frameEntries << " frame_cost=" << sweepStats.frameCost << " frame_max_cost=" << sweepStats.frameMaxCost << Qt::endl;
294+
QCOMPARE(sweepStats.misses, quint64{gopCount});
295+
QCOMPARE(sweepStats.decodedFrames, quint64{kEvictionFrameCount});
296+
QCOMPARE(sweepStats.gopBlocks, 3);
297+
QCOMPARE(sweepStats.gopEvictions, quint64{gopCount - 3});
298+
QCOMPARE(sweepStats.frameMaxCost, kEvictionCacheBytes);
299+
QVERIFY(sweepStats.frameCost <= sweepStats.frameMaxCost);
300+
QVERIFY(sweepStats.frameEntries < kEvictionFrameCount);
301+
302+
constexpr int frameCacheHitTarget = 47;
303+
const qint64 frameCacheHitMs = seekAndVerify(frameCacheHitTarget);
304+
QVERIFY(frameCacheHitMs >= 0);
305+
const VideoDecoder::CacheStats hitStats = decoder.cacheStats();
306+
QTextStream(stdout) << "video_decoder frame_cache_reseek frame=" << frameCacheHitTarget << " elapsed_ms=" << frameCacheHitMs << " misses=" << hitStats.misses << " gop_hits=" << hitStats.gopHits << " frame_hits=" << hitStats.frameHits << " decoded_frames=" << hitStats.decodedFrames << Qt::endl;
307+
QCOMPARE(hitStats.misses, sweepStats.misses);
308+
QCOMPARE(hitStats.gopHits, sweepStats.gopHits);
309+
QCOMPARE(hitStats.frameHits, sweepStats.frameHits + 1);
310+
QCOMPARE(hitStats.decodedFrames, sweepStats.decodedFrames);
311+
312+
constexpr int evictedFrameTarget = kEvictionFrameCount - 1;
313+
const qint64 evictedReseekMs = seekAndVerify(evictedFrameTarget);
314+
QVERIFY(evictedReseekMs >= 0);
315+
QTRY_COMPARE_WITH_TIMEOUT(decoder.cacheStats().decodedFrames, hitStats.decodedFrames + kTestGopSize, 10'000);
316+
const VideoDecoder::CacheStats evictionStats = decoder.cacheStats();
317+
QTextStream(stdout) << "video_decoder evicted_reseek frame=" << evictedFrameTarget << " elapsed_ms=" << evictedReseekMs << " misses=" << evictionStats.misses << " frame_hits=" << evictionStats.frameHits << " decoded_frames=" << evictionStats.decodedFrames << " frame_entries=" << evictionStats.frameEntries
318+
<< " frame_cost=" << evictionStats.frameCost << Qt::endl;
319+
QCOMPARE(evictionStats.misses, hitStats.misses + 1);
320+
QCOMPARE(evictionStats.frameHits, hitStats.frameHits);
321+
QCOMPARE(evictionStats.decodedFrames, hitStats.decodedFrames + kTestGopSize);
322+
QVERIFY(evictionStats.frameCost <= evictionStats.frameMaxCost);
323+
}
324+
217325
QTEST_MAIN(TestVideoDecoder)
218326
#include "test_video_decoder.moc"

0 commit comments

Comments
 (0)