diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 5d721cd73..4c9717a6c 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -958,6 +958,8 @@ jobs:
host: 'linux'
target: 'desktop'
arch: 'linux_gcc_64'
+ # qtmultimedia: performance capture (ENABLE_MOCAP, epic #869)
+ modules: 'qtmultimedia'
- name: change folder permissions
run: |
@@ -1074,7 +1076,8 @@ jobs:
-DBUILD_QT_MESH_EDITOR=OFF \
-DENABLE_SENTRY=OFF \
-DENABLE_PS1_RIP=ON \
- -DENABLE_ONNX=ON
+ -DENABLE_ONNX=ON \
+ -DENABLE_MOCAP=ON
- name: Run build-wrapper
env:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f04b48990..d68fb1c48 100755
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -324,6 +324,29 @@ else()
message(STATUS "Alembic vertex-animation import disabled (VAT_POSE playback still available)")
endif()
##############################################################
+# Performance capture — video/webcam mocap (epic #869)
+##############################################################
+# Slice B (#871): brings in Qt Multimedia (camera + video decode) and the
+# src/Mocap/ frame-source layer. Default OFF: Qt Multimedia is a new runtime
+# dependency (FFmpeg backend) that packaging has to carry per platform. The
+# ONNX predictors (Slices C/E) additionally require ENABLE_ONNX; requiring it
+# here keeps a single "mocap build" configuration instead of a half-working
+# one. Non-mocap builds print "rebuild with -DENABLE_MOCAP" on every surface.
+option(ENABLE_MOCAP "Enable performance capture (video/webcam mocap)" OFF)
+
+if(ENABLE_MOCAP)
+ if(NOT ENABLE_ONNX)
+ message(FATAL_ERROR "ENABLE_MOCAP requires ENABLE_ONNX (the face/pose "
+ "predictors run on ONNX Runtime). Configure with "
+ "-DENABLE_ONNX=ON -DENABLE_MOCAP=ON.")
+ endif()
+ find_package(Qt6 REQUIRED COMPONENTS Multimedia)
+ add_definitions(-DENABLE_MOCAP)
+ message(STATUS "Performance capture enabled (Qt Multimedia ${Qt6Multimedia_VERSION})")
+else()
+ message(STATUS "Performance capture disabled (rebuild with -DENABLE_MOCAP=ON -DENABLE_ONNX=ON)")
+endif()
+##############################################################
# PS1 runtime geometry extraction (experimental)
##############################################################
option(ENABLE_PS1_RIP "Enable experimental PS1 runtime geometry extraction" OFF)
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a06f61f83..81a3aa044 100755
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -161,6 +161,7 @@ NodeAnimationManager.cpp
PoseLibrary.cpp
VertexAnimationManager.cpp
AlembicImporter.cpp
+Mocap/VideoFrameSource.cpp
ApplyAtlas.cpp
EmbeddedTextureCache.cpp
NormalMapGenerator.cpp
@@ -753,6 +754,11 @@ if(ENABLE_PS1_RIP AND TARGET Qt6::Gamepad)
target_link_libraries(${CMAKE_PROJECT_NAME} Qt6::Gamepad)
endif()
+# Performance capture (epic #869): Qt Multimedia for the frame sources
+if(ENABLE_MOCAP)
+ target_link_libraries(${CMAKE_PROJECT_NAME} Qt6::Multimedia)
+endif()
+
if(ENABLE_AUTO_UPDATER AND TARGET qtmesh-relauncher)
add_dependencies(${CMAKE_PROJECT_NAME} qtmesh-relauncher)
if(APPLE)
@@ -898,6 +904,11 @@ if(BUILD_TESTS)
ADD_DEPENDENCIES(UnitTests ui)
+ # Performance capture (epic #869): Qt Multimedia for the frame sources
+ if(ENABLE_MOCAP)
+ target_link_libraries(UnitTests Qt6::Multimedia)
+ endif()
+
if(ENABLE_PS1_RIP)
add_dependencies(UnitTests qtmesh_ps1core_stub)
if(TARGET qtmesh_ps1core_libretro)
diff --git a/src/Info.plist.in b/src/Info.plist.in
index 886eab2ca..ba8de8099 100644
--- a/src/Info.plist.in
+++ b/src/Info.plist.in
@@ -43,6 +43,9 @@
NSSupportsAutomaticGraphicsSwitching
+ NSCameraUsageDescription
+ QtMeshEditor uses the camera for live performance capture.
+
CFBundleDocumentTypes
diff --git a/src/Mocap/VideoFrameSource.cpp b/src/Mocap/VideoFrameSource.cpp
new file mode 100644
index 000000000..3769ad417
--- /dev/null
+++ b/src/Mocap/VideoFrameSource.cpp
@@ -0,0 +1,282 @@
+#ifdef ENABLE_MOCAP
+
+#include "VideoFrameSource.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+// Queued frameReady connections (capture thread -> worker) need the metatype.
+struct MocapMetaTypeRegistrar {
+ MocapMetaTypeRegistrar() { qRegisterMetaType("MocapFrame"); }
+};
+const MocapMetaTypeRegistrar registrar;
+} // namespace
+
+QImage mocapFrameToRgb888(const QImage& image)
+{
+ if (image.format() == QImage::Format_RGB888)
+ return image;
+ return image.convertToFormat(QImage::Format_RGB888);
+}
+
+// ---------------------------------------------------------------------------
+// ImageSequenceFrameSource
+// ---------------------------------------------------------------------------
+
+ImageSequenceFrameSource::ImageSequenceFrameSource(const QStringList& imagePaths,
+ double fps, double targetFps,
+ QObject* parent)
+ : VideoFrameSource(parent),
+ m_paths(imagePaths),
+ m_fps(fps > 0.0 ? fps : 30.0),
+ m_decimator(targetFps)
+{
+}
+
+bool ImageSequenceFrameSource::open(QString* error)
+{
+ if (m_paths.isEmpty()) {
+ if (error) *error = tr("image sequence is empty");
+ return false;
+ }
+ for (const QString& p : m_paths) {
+ if (!QFileInfo::exists(p)) {
+ if (error) *error = tr("image not found: %1").arg(p);
+ return false;
+ }
+ }
+ return true;
+}
+
+void ImageSequenceFrameSource::start()
+{
+ m_stopped = false;
+ m_decimator.reset();
+ for (qint64 i = 0; i < m_paths.size() && !m_stopped; ++i) {
+ const double t = static_cast(i) / m_fps;
+ if (!m_decimator.shouldEmit(t))
+ continue;
+ QImage img(m_paths.at(i));
+ if (img.isNull()) {
+ emit errorOccurred(tr("failed to load image: %1").arg(m_paths.at(i)));
+ return;
+ }
+ MocapFrame frame;
+ frame.image = mocapFrameToRgb888(img);
+ frame.timeSec = t;
+ frame.frameIndex = i;
+ emit frameReady(frame);
+ }
+ if (!m_stopped)
+ emit finished();
+}
+
+void ImageSequenceFrameSource::stop()
+{
+ m_stopped = true;
+}
+
+// ---------------------------------------------------------------------------
+// FileFrameSource
+// ---------------------------------------------------------------------------
+
+FileFrameSource::FileFrameSource(const QString& filePath, double targetFps,
+ QObject* parent)
+ : VideoFrameSource(parent), m_path(filePath), m_decimator(targetFps)
+{
+}
+
+FileFrameSource::~FileFrameSource()
+{
+ stop();
+}
+
+bool FileFrameSource::open(QString* error)
+{
+ if (!QFileInfo::exists(m_path)) {
+ if (error) *error = tr("video file not found: %1").arg(m_path);
+ return false;
+ }
+ m_player = std::make_unique();
+ m_sink = std::make_unique();
+ m_player->setVideoSink(m_sink.get());
+
+ connect(m_sink.get(), &QVideoSink::videoFrameChanged, this,
+ &FileFrameSource::handleVideoFrame);
+ connect(m_player.get(), &QMediaPlayer::mediaStatusChanged, this,
+ [this](QMediaPlayer::MediaStatus status) {
+ if (status == QMediaPlayer::EndOfMedia && !m_finishedEmitted) {
+ m_finishedEmitted = true;
+ emit finished();
+ }
+ if (status == QMediaPlayer::LoadedMedia) {
+ const auto rate = m_player->metaData()
+ .value(QMediaMetaData::VideoFrameRate);
+ if (rate.isValid())
+ m_nativeFps = rate.toDouble();
+ }
+ });
+ connect(m_player.get(), &QMediaPlayer::errorOccurred, this,
+ [this](QMediaPlayer::Error, const QString& message) {
+ emit errorOccurred(tr("video decode error: %1").arg(message));
+ });
+
+ m_player->setSource(QUrl::fromLocalFile(m_path));
+ return true;
+}
+
+void FileFrameSource::start()
+{
+ if (!m_player) {
+ emit errorOccurred(tr("start() before open()"));
+ return;
+ }
+ m_decimator.reset();
+ m_frameIndex = 0;
+ m_finishedEmitted = false;
+ m_player->play();
+}
+
+void FileFrameSource::stop()
+{
+ if (m_player)
+ m_player->stop();
+}
+
+void FileFrameSource::handleVideoFrame()
+{
+ const QVideoFrame vf = m_sink->videoFrame();
+ if (!vf.isValid())
+ return;
+ const qint64 index = m_frameIndex++;
+ // Prefer the frame's own timestamp; fall back to the player clock.
+ double t = vf.startTime() >= 0
+ ? vf.startTime() / 1e6
+ : (m_player ? m_player->position() / 1e3 : 0.0);
+ if (!m_decimator.shouldEmit(t))
+ return;
+ MocapFrame frame;
+ frame.image = mocapFrameToRgb888(vf.toImage());
+ frame.timeSec = t;
+ frame.frameIndex = index;
+ if (!frame.image.isNull())
+ emit frameReady(frame);
+}
+
+// ---------------------------------------------------------------------------
+// CameraFrameSource
+// ---------------------------------------------------------------------------
+
+QList CameraFrameSource::availableDevices()
+{
+ QList out;
+ const auto devices = QMediaDevices::videoInputs();
+ for (const QCameraDevice& d : devices)
+ out.append({QString::fromUtf8(d.id()), d.description()});
+ return out;
+}
+
+CameraFrameSource::CameraFrameSource(const QString& deviceId, QObject* parent)
+ : VideoFrameSource(parent), m_deviceId(deviceId)
+{
+}
+
+CameraFrameSource::~CameraFrameSource()
+{
+ stop();
+}
+
+bool CameraFrameSource::open(QString* error)
+{
+ QCameraDevice device;
+ const auto devices = QMediaDevices::videoInputs();
+ if (m_deviceId.isEmpty()) {
+ device = QMediaDevices::defaultVideoInput();
+ } else {
+ for (const QCameraDevice& d : devices) {
+ if (QString::fromUtf8(d.id()) == m_deviceId) {
+ device = d;
+ break;
+ }
+ }
+ }
+ if (device.isNull()) {
+ if (error)
+ *error = devices.isEmpty()
+ ? tr("no camera available")
+ : tr("camera not found: %1").arg(m_deviceId);
+ return false;
+ }
+
+ m_camera = std::make_unique(device);
+ m_session = std::make_unique();
+ m_sink = std::make_unique();
+ m_session->setCamera(m_camera.get());
+ m_session->setVideoSink(m_sink.get());
+ m_clock = std::make_unique();
+
+ const auto formats = device.videoFormats();
+ if (!formats.isEmpty())
+ m_nativeFps = formats.first().maxFrameRate();
+
+ connect(m_sink.get(), &QVideoSink::videoFrameChanged, this,
+ &CameraFrameSource::handleVideoFrame);
+ connect(m_camera.get(), &QCamera::errorOccurred, this,
+ [this](QCamera::Error err, const QString& message) {
+ if (err == QCamera::CameraError && message.contains(
+ QStringLiteral("permission"), Qt::CaseInsensitive)) {
+ emit errorOccurred(tr("camera permission denied — allow "
+ "camera access for QtMeshEditor in "
+ "the system settings"));
+ } else {
+ emit errorOccurred(tr("camera error: %1").arg(message));
+ }
+ });
+ return true;
+}
+
+void CameraFrameSource::start()
+{
+ if (!m_camera) {
+ emit errorOccurred(tr("start() before open()"));
+ return;
+ }
+ m_frameIndex = 0;
+ m_clock->start();
+ m_camera->start();
+}
+
+void CameraFrameSource::stop()
+{
+ if (m_camera)
+ m_camera->stop();
+}
+
+void CameraFrameSource::handleVideoFrame()
+{
+ const QVideoFrame vf = m_sink->videoFrame();
+ if (!vf.isValid())
+ return;
+ MocapFrame frame;
+ frame.image = mocapFrameToRgb888(vf.toImage());
+ if (frame.image.isNull())
+ return;
+ frame.timeSec = m_clock->isValid() ? m_clock->elapsed() / 1e3 : 0.0;
+ frame.frameIndex = m_frameIndex++;
+ // Latest-wins for the inference consumer; the signal serves lightweight
+ // observers (preview HUD) that keep up with the camera.
+ m_mailbox.put(frame);
+ emit frameReady(frame);
+}
+
+#endif // ENABLE_MOCAP
diff --git a/src/Mocap/VideoFrameSource.h b/src/Mocap/VideoFrameSource.h
new file mode 100644
index 000000000..eab852d06
--- /dev/null
+++ b/src/Mocap/VideoFrameSource.h
@@ -0,0 +1,216 @@
+#ifndef VIDEOFRAMESOURCE_H
+#define VIDEOFRAMESOURCE_H
+
+// Performance capture frame sources (epic #869, Slice B #871).
+//
+// One abstraction delivers timestamped RGB888 frames from (a) a video file,
+// (b) a live camera, (c) an image sequence (the headless test double / the
+// CLI --frames-dir debug path) — so the predictors and controllers never
+// touch Qt Multimedia directly. Everything in this header is compiled only
+// under ENABLE_MOCAP (src/CMakeLists.txt adds the .cpp behind the flag).
+
+#ifdef ENABLE_MOCAP
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+class QCamera;
+class QElapsedTimer;
+class QMediaCaptureSession;
+class QMediaPlayer;
+class QVideoSink;
+
+// One decoded frame. image is guaranteed Format_RGB888.
+struct MocapFrame {
+ QImage image;
+ double timeSec = 0.0; // media timestamp (file/sequence) or wall-clock since start (camera)
+ qint64 frameIndex = 0; // source frame counter (pre-decimation)
+};
+Q_DECLARE_METATYPE(MocapFrame)
+
+// Drops frames so a source delivering at native fps emits ~targetFps.
+// Pure data — unit-tested headless. targetFps <= 0 disables decimation.
+class FrameDecimator {
+public:
+ explicit FrameDecimator(double targetFps = 0.0) : m_targetFps(targetFps) {}
+
+ // Called with each frame's timestamp (monotonically increasing); returns
+ // true when the frame should be emitted. The first frame always passes.
+ // Tolerates timestamps landing half a source-frame early so 60 -> 30 fps
+ // emits exactly every other frame instead of every third.
+ bool shouldEmit(double timeSec)
+ {
+ if (m_targetFps <= 0.0)
+ return true;
+ const double interval = 1.0 / m_targetFps;
+ if (m_hasEmitted && timeSec - m_lastEmitted < interval * 0.75)
+ return false;
+ m_hasEmitted = true;
+ m_lastEmitted = timeSec;
+ return true;
+ }
+
+ void reset() { m_hasEmitted = false; m_lastEmitted = 0.0; }
+
+private:
+ double m_targetFps;
+ double m_lastEmitted = 0.0;
+ bool m_hasEmitted = false;
+};
+
+// Latest-wins single-slot mailbox between the capture thread and a (slower)
+// inference consumer: a new frame REPLACES any undelivered pending frame, so
+// live inference never falls behind the camera. Thread-safe; unit-tested.
+class FrameMailbox {
+public:
+ void put(const MocapFrame& frame)
+ {
+ std::lock_guard lock(m_mutex);
+ if (m_hasPending)
+ ++m_dropped;
+ m_pending = frame;
+ m_hasPending = true;
+ }
+
+ // Takes the newest pending frame, if any. Returns false when empty.
+ bool take(MocapFrame* out)
+ {
+ std::lock_guard lock(m_mutex);
+ if (!m_hasPending)
+ return false;
+ *out = m_pending;
+ m_pending = MocapFrame{}; // release the QImage
+ m_hasPending = false;
+ return true;
+ }
+
+ // Frames overwritten before a consumer took them (diagnostics/HUD).
+ qint64 droppedCount() const
+ {
+ std::lock_guard lock(m_mutex);
+ return m_dropped;
+ }
+
+private:
+ mutable std::mutex m_mutex;
+ MocapFrame m_pending;
+ bool m_hasPending = false;
+ qint64 m_dropped = 0;
+};
+
+// Guarantees Format_RGB888 (the input contract of every mocap predictor).
+QImage mocapFrameToRgb888(const QImage& image);
+
+class VideoFrameSource : public QObject {
+ Q_OBJECT
+public:
+ explicit VideoFrameSource(QObject* parent = nullptr) : QObject(parent) {}
+ ~VideoFrameSource() override = default;
+
+ // Prepare the source. Returns false and fills *error on failure.
+ virtual bool open(QString* error) = 0;
+ virtual void start() = 0;
+ virtual void stop() = 0;
+ virtual bool isLive() const = 0;
+ virtual double nativeFps() const = 0; // 0 if unknown
+
+signals:
+ void frameReady(const MocapFrame& frame);
+ void finished(); // file/sequence sources: end of media
+ void errorOccurred(const QString& message);
+};
+
+// (c) Image sequence — synchronous test double. Emits every image on start()
+// with timestamps i/fps, honouring targetFps decimation, then finished().
+class ImageSequenceFrameSource : public VideoFrameSource {
+ Q_OBJECT
+public:
+ ImageSequenceFrameSource(const QStringList& imagePaths, double fps,
+ double targetFps = 0.0, QObject* parent = nullptr);
+
+ bool open(QString* error) override;
+ void start() override;
+ void stop() override;
+ bool isLive() const override { return false; }
+ double nativeFps() const override { return m_fps; }
+
+private:
+ QStringList m_paths;
+ double m_fps;
+ FrameDecimator m_decimator;
+ bool m_stopped = false;
+};
+
+// (a) Video file — QMediaPlayer + QVideoSink. Playback-driven (real-time;
+// faster-than-realtime decode is a known follow-up, QVideoSink is fed by the
+// player clock). targetFps decimates delivery for offline capture.
+class FileFrameSource : public VideoFrameSource {
+ Q_OBJECT
+public:
+ explicit FileFrameSource(const QString& filePath, double targetFps = 0.0,
+ QObject* parent = nullptr);
+ ~FileFrameSource() override;
+
+ bool open(QString* error) override;
+ void start() override;
+ void stop() override;
+ bool isLive() const override { return false; }
+ double nativeFps() const override { return m_nativeFps; }
+
+private:
+ void handleVideoFrame();
+
+ QString m_path;
+ FrameDecimator m_decimator;
+ std::unique_ptr m_player;
+ std::unique_ptr m_sink;
+ double m_nativeFps = 0.0;
+ qint64 m_frameIndex = 0;
+ bool m_finishedEmitted = false;
+};
+
+// (b) Live camera — QCamera + QMediaCaptureSession + QVideoSink. Frames are
+// emitted as frameReady AND written into mailbox() (latest-wins) for a
+// slower inference consumer. deviceId empty = default camera.
+class CameraFrameSource : public VideoFrameSource {
+ Q_OBJECT
+public:
+ struct DeviceInfo {
+ QString id;
+ QString description;
+ };
+ // Enumerates video inputs (feeds the GUI picker + MCP list_capture_devices).
+ static QList availableDevices();
+
+ explicit CameraFrameSource(const QString& deviceId = {},
+ QObject* parent = nullptr);
+ ~CameraFrameSource() override;
+
+ bool open(QString* error) override;
+ void start() override;
+ void stop() override;
+ bool isLive() const override { return true; }
+ double nativeFps() const override { return m_nativeFps; }
+
+ FrameMailbox& mailbox() { return m_mailbox; }
+
+private:
+ void handleVideoFrame();
+
+ QString m_deviceId;
+ std::unique_ptr m_camera;
+ std::unique_ptr m_session;
+ std::unique_ptr m_sink;
+ std::unique_ptr m_clock;
+ FrameMailbox m_mailbox;
+ double m_nativeFps = 0.0;
+ qint64 m_frameIndex = 0;
+};
+
+#endif // ENABLE_MOCAP
+#endif // VIDEOFRAMESOURCE_H
diff --git a/src/Mocap/VideoFrameSource_test.cpp b/src/Mocap/VideoFrameSource_test.cpp
new file mode 100644
index 000000000..e8161599c
--- /dev/null
+++ b/src/Mocap/VideoFrameSource_test.cpp
@@ -0,0 +1,200 @@
+#ifdef ENABLE_MOCAP
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include "Mocap/VideoFrameSource.h"
+
+namespace {
+
+QStringList writeTestImages(const QString& dir, int count, int size = 8)
+{
+ QStringList paths;
+ for (int i = 0; i < count; ++i) {
+ QImage img(size, size, QImage::Format_ARGB32);
+ img.fill(QColor(i * 10 % 255, 0, 0));
+ const QString p = dir + QStringLiteral("/frame_%1.png").arg(i, 3, 10, QChar('0'));
+ img.save(p);
+ paths << p;
+ }
+ return paths;
+}
+
+} // namespace
+
+TEST(FrameDecimator, PassesEverythingWhenDisabled)
+{
+ FrameDecimator d(0.0);
+ for (int i = 0; i < 10; ++i)
+ EXPECT_TRUE(d.shouldEmit(i / 60.0));
+}
+
+TEST(FrameDecimator, HalvesSixtyToThirty)
+{
+ FrameDecimator d(30.0);
+ int emitted = 0;
+ for (int i = 0; i < 60; ++i)
+ if (d.shouldEmit(i / 60.0))
+ ++emitted;
+ EXPECT_GE(emitted, 28);
+ EXPECT_LE(emitted, 32);
+}
+
+TEST(FrameDecimator, FirstFrameAlwaysPasses)
+{
+ FrameDecimator d(1.0);
+ EXPECT_TRUE(d.shouldEmit(0.0));
+ EXPECT_FALSE(d.shouldEmit(0.1));
+ d.reset();
+ EXPECT_TRUE(d.shouldEmit(0.1));
+}
+
+TEST(FrameMailbox, LatestWinsDropsIntermediates)
+{
+ FrameMailbox box;
+ for (int i = 0; i < 3; ++i) {
+ MocapFrame f;
+ f.frameIndex = i;
+ box.put(f);
+ }
+ MocapFrame out;
+ ASSERT_TRUE(box.take(&out));
+ EXPECT_EQ(out.frameIndex, 2); // only the newest survives
+ EXPECT_FALSE(box.take(&out)); // and only once
+ EXPECT_EQ(box.droppedCount(), 2); // the two overwritten frames
+}
+
+TEST(FrameMailbox, ThreadSafePutTake)
+{
+ FrameMailbox box;
+ std::thread producer([&box] {
+ for (int i = 0; i < 1000; ++i) {
+ MocapFrame f;
+ f.frameIndex = i;
+ box.put(f);
+ }
+ });
+ qint64 last = -1;
+ for (int i = 0; i < 2000; ++i) {
+ MocapFrame out;
+ if (box.take(&out)) {
+ EXPECT_GT(out.frameIndex, last); // monotone: never re-deliver older
+ last = out.frameIndex;
+ }
+ }
+ producer.join();
+ MocapFrame out;
+ while (box.take(&out))
+ last = out.frameIndex;
+ EXPECT_EQ(last, 999); // the final frame is never lost
+}
+
+TEST(ImageSequenceFrameSource, EmitsAllFramesInOrderWithTimestamps)
+{
+ QTemporaryDir tmp;
+ ASSERT_TRUE(tmp.isValid());
+ const QStringList paths = writeTestImages(tmp.path(), 5);
+
+ ImageSequenceFrameSource src(paths, 10.0);
+ QString error;
+ ASSERT_TRUE(src.open(&error)) << error.toStdString();
+
+ std::vector frames;
+ bool done = false;
+ QObject::connect(&src, &VideoFrameSource::frameReady,
+ [&frames](const MocapFrame& f) { frames.push_back(f); });
+ QObject::connect(&src, &VideoFrameSource::finished, [&done] { done = true; });
+ src.start();
+
+ ASSERT_TRUE(done);
+ ASSERT_EQ(frames.size(), 5u);
+ for (size_t i = 0; i < frames.size(); ++i) {
+ EXPECT_EQ(frames[i].frameIndex, static_cast(i));
+ EXPECT_DOUBLE_EQ(frames[i].timeSec, i / 10.0);
+ EXPECT_EQ(frames[i].image.format(), QImage::Format_RGB888);
+ EXPECT_FALSE(frames[i].image.isNull());
+ }
+}
+
+TEST(ImageSequenceFrameSource, DecimatesToTargetFps)
+{
+ QTemporaryDir tmp;
+ ASSERT_TRUE(tmp.isValid());
+ const QStringList paths = writeTestImages(tmp.path(), 60);
+
+ ImageSequenceFrameSource src(paths, 60.0, /*targetFps=*/30.0);
+ QString error;
+ ASSERT_TRUE(src.open(&error)) << error.toStdString();
+
+ int emitted = 0;
+ QObject::connect(&src, &VideoFrameSource::frameReady,
+ [&emitted](const MocapFrame&) { ++emitted; });
+ src.start();
+ EXPECT_GE(emitted, 28);
+ EXPECT_LE(emitted, 32);
+}
+
+TEST(ImageSequenceFrameSource, OpenFailsOnMissingFile)
+{
+ ImageSequenceFrameSource src({QStringLiteral("/nonexistent/frame.png")}, 30.0);
+ QString error;
+ EXPECT_FALSE(src.open(&error));
+ EXPECT_FALSE(error.isEmpty());
+}
+
+TEST(ImageSequenceFrameSource, OpenFailsOnEmptyList)
+{
+ ImageSequenceFrameSource src({}, 30.0);
+ QString error;
+ EXPECT_FALSE(src.open(&error));
+ EXPECT_FALSE(error.isEmpty());
+}
+
+TEST(FileFrameSource, OpenFailsOnMissingFile)
+{
+ FileFrameSource src(QStringLiteral("/nonexistent/video.mp4"));
+ QString error;
+ EXPECT_FALSE(src.open(&error));
+ EXPECT_FALSE(error.isEmpty());
+}
+
+// Real camera tests are impossible in CI; enumeration must not crash headless.
+TEST(CameraFrameSource, AvailableDevicesDoesNotCrash)
+{
+ const auto devices = CameraFrameSource::availableDevices();
+ for (const auto& d : devices) {
+ EXPECT_FALSE(d.id.isEmpty());
+ }
+}
+
+TEST(CameraFrameSource, OpenFailsOnBogusDeviceId)
+{
+ if (!qEnvironmentVariableIsSet("QTMESH_MOCAP_CAMERA_TESTS")
+ && CameraFrameSource::availableDevices().isEmpty()) {
+ // headless CI: also exercises the no-camera error path
+ }
+ CameraFrameSource src(QStringLiteral("definitely-not-a-camera-id"));
+ QString error;
+ EXPECT_FALSE(src.open(&error));
+ EXPECT_FALSE(error.isEmpty());
+}
+
+TEST(MocapFrameToRgb888, ConvertsAndPassesThrough)
+{
+ QImage argb(4, 4, QImage::Format_ARGB32);
+ argb.fill(Qt::green);
+ const QImage converted = mocapFrameToRgb888(argb);
+ EXPECT_EQ(converted.format(), QImage::Format_RGB888);
+
+ const QImage same = mocapFrameToRgb888(converted);
+ EXPECT_EQ(same.format(), QImage::Format_RGB888);
+}
+
+#endif // ENABLE_MOCAP
diff --git a/src/test_main.cpp b/src/test_main.cpp
index 8ec3dd95b..23df3646c 100644
--- a/src/test_main.cpp
+++ b/src/test_main.cpp
@@ -139,12 +139,21 @@ int main(int argc, char **argv)
// Prove headless GL works on this runner, then tear down: many suites
// (Assimp processors, etc.) construct their own Ogre::Root and cannot
// coexist with a live Manager singleton from a prior init.
- if (!tryInitOgre()) {
+ // QTMESH_TESTS_SKIP_OGRE_PREFLIGHT=1 skips the proof so PURE-DATA suites
+ // can run (with --gtest_filter) on machines with no GL/WindowServer at
+ // all (remote shells, containers without Xvfb). Ogre-dependent fixtures
+ // will still fail under it — this only moves the failure from "no test
+ // ran" to per-fixture. CI never sets it.
+ if (qEnvironmentVariableIsSet("QTMESH_TESTS_SKIP_OGRE_PREFLIGHT")) {
+ fprintf(stderr, "UnitTests: skipping Ogre GL preflight "
+ "(QTMESH_TESTS_SKIP_OGRE_PREFLIGHT set)\n");
+ } else if (!tryInitOgre()) {
fprintf(stderr,
"UnitTests FATAL: tryInitOgre() failed — need working DISPLAY / Xvfb for GL.\n");
return 1;
+ } else {
+ Manager::kill();
}
- Manager::kill();
if (QCoreApplication::instance())
QCoreApplication::processEvents();
QThread::msleep(50);