From 858b97385824700d7e1f9b6764b142cf2653ee85 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 21:29:17 -0300 Subject: [PATCH] feat(#871): ENABLE_MOCAP build infra + VideoFrameSource (Qt Multimedia) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice B of epic #869 (performance capture). - New ENABLE_MOCAP CMake option (default OFF; requires ENABLE_ONNX, errors otherwise) pulling in Qt6::Multimedia for the app + UnitTests. - src/Mocap/VideoFrameSource.{h,cpp}: one frame-source abstraction delivering timestamped RGB888 MocapFrames from (a) a video file (QMediaPlayer + QVideoSink, targetFps decimation, playback-driven — faster-than-realtime decode is a documented follow-up), (b) a live camera (QCamera + QMediaCaptureSession, device enumeration for the GUI picker / MCP, permission-denied mapped to a human-readable error, latest-wins FrameMailbox for a slower inference consumer), (c) an image sequence (the synchronous headless test double / CLI --frames-dir path). - FrameDecimator + FrameMailbox are pure data and headless-tested; 13 new tests pass (emission order/timestamps, 60->30 decimation, latest-wins drop semantics incl. cross-thread, open() failure paths, device enumeration). - test_main.cpp gains QTMESH_TESTS_SKIP_OGRE_PREFLIGHT so pure-data suites can run on machines with no GL/WindowServer at all; CI never sets it. - NSCameraUsageDescription added to Info.plist.in. - CI: Linux unit-test lane installs the qtmultimedia module and configures with -DENABLE_MOCAP=ON. Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy.yml | 5 +- CMakeLists.txt | 23 +++ src/CMakeLists.txt | 11 ++ src/Info.plist.in | 3 + src/Mocap/VideoFrameSource.cpp | 282 ++++++++++++++++++++++++++++ src/Mocap/VideoFrameSource.h | 216 +++++++++++++++++++++ src/Mocap/VideoFrameSource_test.cpp | 200 ++++++++++++++++++++ src/test_main.cpp | 13 +- 8 files changed, 750 insertions(+), 3 deletions(-) create mode 100644 src/Mocap/VideoFrameSource.cpp create mode 100644 src/Mocap/VideoFrameSource.h create mode 100644 src/Mocap/VideoFrameSource_test.cpp 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);