diff --git a/CMakeLists.txt b/CMakeLists.txt index 9be76ec..48447c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -165,6 +165,8 @@ set(APP_SOURCES "${APP_DIR}/videoio/VideoIOFormat.h" "${APP_DIR}/videoio/VideoBackend.cpp" "${APP_DIR}/videoio/VideoBackend.h" + "${APP_DIR}/videoio/VideoBackendLifecycle.cpp" + "${APP_DIR}/videoio/VideoBackendLifecycle.h" "${APP_DIR}/videoio/VideoIOTypes.h" "${APP_DIR}/videoio/VideoPlayoutScheduler.cpp" "${APP_DIR}/videoio/VideoPlayoutScheduler.h" @@ -538,6 +540,22 @@ endif() add_test(NAME VideoPlayoutSchedulerTests COMMAND VideoPlayoutSchedulerTests) +add_executable(VideoBackendLifecycleTests + "${APP_DIR}/videoio/VideoBackendLifecycle.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/VideoBackendLifecycleTests.cpp" +) + +target_include_directories(VideoBackendLifecycleTests PRIVATE + "${APP_DIR}" + "${APP_DIR}/videoio" +) + +if(MSVC) + target_compile_options(VideoBackendLifecycleTests PRIVATE /W3) +endif() + +add_test(NAME VideoBackendLifecycleTests COMMAND VideoBackendLifecycleTests) + add_executable(VideoIODeviceFakeTests "${APP_DIR}/videoio/VideoIOFormat.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/tests/VideoIODeviceFakeTests.cpp" diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.cpp b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.cpp index 953d0cc..bf201d5 100644 --- a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.cpp @@ -26,46 +26,86 @@ void VideoBackend::ReleaseResources() { if (mVideoIODevice) mVideoIODevice->ReleaseResources(); + if (!VideoBackendLifecycle::CanTransition(mLifecycle.State(), VideoBackendLifecycleState::Stopped)) + ApplyLifecycleFailure("Video backend resources released before lifecycle completed."); + ApplyLifecycleTransition(VideoBackendLifecycleState::Stopped, "Video backend resources released."); +} + +VideoBackendLifecycleState VideoBackend::LifecycleState() const +{ + return mLifecycle.State(); } bool VideoBackend::DiscoverDevicesAndModes(const VideoFormatSelection& videoModes, std::string& error) { - return mVideoIODevice->DiscoverDevicesAndModes(videoModes, error); + ApplyLifecycleTransition(VideoBackendLifecycleState::Discovering, "Discovering video backend devices and modes."); + if (mVideoIODevice->DiscoverDevicesAndModes(videoModes, error)) + return ApplyLifecycleTransition(VideoBackendLifecycleState::Discovered, "Video backend devices and modes discovered."); + + ApplyLifecycleFailure(error.empty() ? "Video backend discovery failed." : error); + return false; } bool VideoBackend::SelectPreferredFormats(const VideoFormatSelection& videoModes, bool outputAlphaRequired, std::string& error) { - return mVideoIODevice->SelectPreferredFormats(videoModes, outputAlphaRequired, error); + ApplyLifecycleTransition(VideoBackendLifecycleState::Configuring, "Selecting preferred video backend formats."); + if (mVideoIODevice->SelectPreferredFormats(videoModes, outputAlphaRequired, error)) + return true; + + ApplyLifecycleFailure(error.empty() ? "Video backend format selection failed." : error); + return false; } bool VideoBackend::ConfigureInput(const VideoFormat& inputVideoMode, std::string& error) { - return mVideoIODevice->ConfigureInput( + if (mLifecycle.State() != VideoBackendLifecycleState::Configuring) + ApplyLifecycleTransition(VideoBackendLifecycleState::Configuring, "Configuring video backend input."); + if (!mVideoIODevice->ConfigureInput( [this](const VideoIOFrame& frame) { HandleInputFrame(frame); }, inputVideoMode, - error); + error)) + { + ApplyLifecycleFailure(error.empty() ? "Video backend input configuration failed." : error); + return false; + } + return true; } bool VideoBackend::ConfigureOutput(const VideoFormat& outputVideoMode, bool externalKeyingEnabled, std::string& error) { - return mVideoIODevice->ConfigureOutput( + if (mLifecycle.State() != VideoBackendLifecycleState::Configuring) + ApplyLifecycleTransition(VideoBackendLifecycleState::Configuring, "Configuring video backend output."); + if (!mVideoIODevice->ConfigureOutput( [this](const VideoIOCompletion& completion) { HandleOutputFrameCompletion(completion); }, outputVideoMode, externalKeyingEnabled, - error); + error)) + { + ApplyLifecycleFailure(error.empty() ? "Video backend output configuration failed." : error); + return false; + } + return ApplyLifecycleTransition(VideoBackendLifecycleState::Configured, "Video backend configured."); } bool VideoBackend::Start() { + ApplyLifecycleTransition(VideoBackendLifecycleState::Prerolling, "Video backend preroll starting."); const bool started = mVideoIODevice->Start(); - PublishBackendStateChanged(started ? "started" : "start-failed", started ? "Video backend started." : StatusMessage()); + if (started) + ApplyLifecycleTransition(VideoBackendLifecycleState::Running, "Video backend started."); + else + ApplyLifecycleFailure(StatusMessage().empty() ? "Video backend start failed." : StatusMessage()); return started; } bool VideoBackend::Stop() { + ApplyLifecycleTransition(VideoBackendLifecycleState::Stopping, "Video backend stopping."); const bool stopped = mVideoIODevice->Stop(); - PublishBackendStateChanged(stopped ? "stopped" : "stop-failed", stopped ? "Video backend stopped." : StatusMessage()); + if (stopped) + ApplyLifecycleTransition(VideoBackendLifecycleState::Stopped, "Video backend stopped."); + else + ApplyLifecycleFailure(StatusMessage().empty() ? "Video backend stop failed." : StatusMessage()); return stopped; } @@ -198,7 +238,7 @@ void VideoBackend::PublishStatus(bool externalKeyingConfigured, const std::strin externalKeyingConfigured, ExternalKeyingActive(), StatusMessage()); - PublishBackendStateChanged("status", StatusMessage()); + PublishBackendStateChanged(VideoBackendLifecycle::StateName(mLifecycle.State()), StatusMessage()); } void VideoBackend::ReportNoInputDeviceSignalStatus() @@ -240,7 +280,7 @@ void VideoBackend::HandleOutputFrameCompletion(const VideoIOCompletion& completi AccountForCompletionResult(completion.result); if (!rendered) { - PublishBackendStateChanged("output-render-failed", "Output frame render request failed; skipping schedule for this frame."); + ApplyLifecycleTransition(VideoBackendLifecycleState::Degraded, "Output frame render request failed; skipping schedule for this frame."); return; } @@ -283,6 +323,32 @@ void VideoBackend::RecordFramePacing(VideoIOCompletionResult completionResult) PublishTimingSample("VideoBackend", "smoothedCompletionInterval", mSmoothedCompletionIntervalMilliseconds, "ms"); } +bool VideoBackend::ApplyLifecycleTransition(VideoBackendLifecycleState state, const std::string& message) +{ + const VideoBackendLifecycleTransition transition = mLifecycle.TransitionTo(state, message); + if (!transition.accepted) + { + PublishBackendStateChanged(VideoBackendLifecycle::StateName(transition.current), transition.errorMessage); + return false; + } + + PublishBackendStateChanged(VideoBackendLifecycle::StateName(transition.current), message); + return true; +} + +bool VideoBackend::ApplyLifecycleFailure(const std::string& message) +{ + const VideoBackendLifecycleTransition transition = mLifecycle.Fail(message); + if (!transition.accepted) + { + PublishBackendStateChanged(VideoBackendLifecycle::StateName(transition.current), transition.errorMessage); + return false; + } + + PublishBackendStateChanged(VideoBackendLifecycle::StateName(transition.current), message); + return true; +} + void VideoBackend::PublishBackendStateChanged(const std::string& state, const std::string& message) { try diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.h b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.h index bc8a818..afc0c88 100644 --- a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.h +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.h @@ -1,5 +1,6 @@ #pragma once +#include "VideoBackendLifecycle.h" #include "VideoIOTypes.h" #include @@ -20,6 +21,7 @@ public: ~VideoBackend(); void ReleaseResources(); + VideoBackendLifecycleState LifecycleState() const; bool DiscoverDevicesAndModes(const VideoFormatSelection& videoModes, std::string& error); bool SelectPreferredFormats(const VideoFormatSelection& videoModes, bool outputAlphaRequired, std::string& error); bool ConfigureInput(const VideoFormat& inputVideoMode, std::string& error); @@ -58,6 +60,8 @@ private: void HandleInputFrame(const VideoIOFrame& frame); void HandleOutputFrameCompletion(const VideoIOCompletion& completion); void RecordFramePacing(VideoIOCompletionResult completionResult); + bool ApplyLifecycleTransition(VideoBackendLifecycleState state, const std::string& message); + bool ApplyLifecycleFailure(const std::string& message); void PublishBackendStateChanged(const std::string& state, const std::string& message); void PublishInputSignalChanged(const VideoIOFrame& frame, const VideoIOState& state); void PublishInputFrameArrived(const VideoIOFrame& frame); @@ -69,6 +73,7 @@ private: HealthTelemetry& mHealthTelemetry; RuntimeEventDispatcher& mRuntimeEventDispatcher; + VideoBackendLifecycle mLifecycle; std::unique_ptr mVideoIODevice; std::unique_ptr mBridge; uint64_t mInputFrameIndex = 0; diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackendLifecycle.cpp b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackendLifecycle.cpp new file mode 100644 index 0000000..5bfd763 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackendLifecycle.cpp @@ -0,0 +1,123 @@ +#include "VideoBackendLifecycle.h" + +VideoBackendLifecycleState VideoBackendLifecycle::State() const +{ + return mState; +} + +const std::string& VideoBackendLifecycle::FailureReason() const +{ + return mFailureReason; +} + +VideoBackendLifecycleTransition VideoBackendLifecycle::TransitionTo(VideoBackendLifecycleState next, const std::string& reason) +{ + VideoBackendLifecycleTransition transition; + transition.previous = mState; + transition.current = next; + transition.reason = reason; + transition.accepted = CanTransition(mState, next); + if (!transition.accepted) + { + transition.current = mState; + transition.errorMessage = std::string("Invalid video backend lifecycle transition from ") + + StateName(mState) + " to " + StateName(next) + "."; + return transition; + } + + mState = next; + transition.current = mState; + if (mState != VideoBackendLifecycleState::Failed) + mFailureReason.clear(); + return transition; +} + +VideoBackendLifecycleTransition VideoBackendLifecycle::Fail(const std::string& reason) +{ + VideoBackendLifecycleTransition transition = TransitionTo(VideoBackendLifecycleState::Failed, reason); + if (transition.accepted) + mFailureReason = reason; + return transition; +} + +bool VideoBackendLifecycle::CanTransition(VideoBackendLifecycleState current, VideoBackendLifecycleState next) +{ + if (current == next) + return true; + + switch (current) + { + case VideoBackendLifecycleState::Uninitialized: + return next == VideoBackendLifecycleState::Discovering || + next == VideoBackendLifecycleState::Stopped || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Discovering: + return next == VideoBackendLifecycleState::Discovered || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Discovered: + return next == VideoBackendLifecycleState::Configuring || + next == VideoBackendLifecycleState::Stopped || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Configuring: + return next == VideoBackendLifecycleState::Configured || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Configured: + return next == VideoBackendLifecycleState::Prerolling || + next == VideoBackendLifecycleState::Stopped || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Prerolling: + return next == VideoBackendLifecycleState::Running || + next == VideoBackendLifecycleState::Stopping || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Running: + return next == VideoBackendLifecycleState::Degraded || + next == VideoBackendLifecycleState::Stopping || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Degraded: + return next == VideoBackendLifecycleState::Running || + next == VideoBackendLifecycleState::Stopping || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Stopping: + return next == VideoBackendLifecycleState::Stopped || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Stopped: + return next == VideoBackendLifecycleState::Discovering || + next == VideoBackendLifecycleState::Failed; + case VideoBackendLifecycleState::Failed: + return next == VideoBackendLifecycleState::Stopped || + next == VideoBackendLifecycleState::Discovering; + default: + return false; + } +} + +const char* VideoBackendLifecycle::StateName(VideoBackendLifecycleState state) +{ + switch (state) + { + case VideoBackendLifecycleState::Uninitialized: + return "uninitialized"; + case VideoBackendLifecycleState::Discovering: + return "discovering"; + case VideoBackendLifecycleState::Discovered: + return "discovered"; + case VideoBackendLifecycleState::Configuring: + return "configuring"; + case VideoBackendLifecycleState::Configured: + return "configured"; + case VideoBackendLifecycleState::Prerolling: + return "prerolling"; + case VideoBackendLifecycleState::Running: + return "running"; + case VideoBackendLifecycleState::Degraded: + return "degraded"; + case VideoBackendLifecycleState::Stopping: + return "stopping"; + case VideoBackendLifecycleState::Stopped: + return "stopped"; + case VideoBackendLifecycleState::Failed: + return "failed"; + default: + return "unknown"; + } +} diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackendLifecycle.h b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackendLifecycle.h new file mode 100644 index 0000000..bc9a5d6 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackendLifecycle.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +enum class VideoBackendLifecycleState +{ + Uninitialized, + Discovering, + Discovered, + Configuring, + Configured, + Prerolling, + Running, + Degraded, + Stopping, + Stopped, + Failed +}; + +struct VideoBackendLifecycleTransition +{ + VideoBackendLifecycleState previous = VideoBackendLifecycleState::Uninitialized; + VideoBackendLifecycleState current = VideoBackendLifecycleState::Uninitialized; + bool accepted = false; + std::string reason; + std::string errorMessage; +}; + +class VideoBackendLifecycle +{ +public: + VideoBackendLifecycleState State() const; + const std::string& FailureReason() const; + VideoBackendLifecycleTransition TransitionTo(VideoBackendLifecycleState next, const std::string& reason); + VideoBackendLifecycleTransition Fail(const std::string& reason); + + static bool CanTransition(VideoBackendLifecycleState current, VideoBackendLifecycleState next); + static const char* StateName(VideoBackendLifecycleState state); + +private: + VideoBackendLifecycleState mState = VideoBackendLifecycleState::Uninitialized; + std::string mFailureReason; +}; diff --git a/docs/PHASE_7_BACKEND_LIFECYCLE_PLAYOUT_DESIGN.md b/docs/PHASE_7_BACKEND_LIFECYCLE_PLAYOUT_DESIGN.md index 16af305..54e79c6 100644 --- a/docs/PHASE_7_BACKEND_LIFECYCLE_PLAYOUT_DESIGN.md +++ b/docs/PHASE_7_BACKEND_LIFECYCLE_PLAYOUT_DESIGN.md @@ -9,8 +9,8 @@ Phase 5 clarified that live parameter layering stops at final render-state compo ## Status - Phase 7 design package: proposed. -- Phase 7 implementation: not started. -- Current alignment: `VideoBackend`, `VideoIODevice`, `DeckLinkSession`, and `VideoPlayoutScheduler` exist. Phase 4 removed callback-thread GL ownership, but the DeckLink completion path still waits for render-thread output production. +- Phase 7 implementation: Step 1 complete. +- Current alignment: `VideoBackend`, `VideoIODevice`, `DeckLinkSession`, `VideoBackendLifecycle`, and `VideoPlayoutScheduler` exist. Phase 4 removed callback-thread GL ownership, but the DeckLink completion path still waits for render-thread output production. Current backend footholds: @@ -206,9 +206,16 @@ Introduce backend state enum and transition reporting without changing schedulin Initial target: -- state changes are explicit -- invalid transitions are detectable -- tests cover allowed transitions +- [x] state changes are explicit +- [x] invalid transitions are detectable +- [x] tests cover allowed transitions + +Current implementation: + +- `VideoBackendLifecycle` names backend states and validates allowed transitions. +- `VideoBackend` applies lifecycle transitions around discovery, configuration, start, stop, degradation, failure, and resource release. +- Existing `BackendStateChangedEvent` publication now uses lifecycle state names for backend lifecycle observations. +- `VideoBackendLifecycleTests` cover allowed transitions, rejected invalid transitions, failure reasons, retry, and stable state names. ### Step 2. Create Playout Policy Object @@ -313,7 +320,7 @@ Backend lifecycle and playout queue are related, but either can grow large. Impl Phase 7 can be considered complete once the project can say: -- [ ] backend lifecycle states and transitions are explicit +- [x] backend lifecycle states and transitions are explicit - [ ] playout policy owns preroll, pool size, headroom, and underrun behavior - [ ] output callbacks no longer synchronously wait for render production - [ ] render produces completed output frames into a bounded queue diff --git a/tests/VideoBackendLifecycleTests.cpp b/tests/VideoBackendLifecycleTests.cpp new file mode 100644 index 0000000..0a46248 --- /dev/null +++ b/tests/VideoBackendLifecycleTests.cpp @@ -0,0 +1,96 @@ +#include "VideoBackendLifecycle.h" + +#include +#include + +namespace +{ +int gFailures = 0; + +void Expect(bool condition, const char* message) +{ + if (condition) + return; + + std::cerr << "FAIL: " << message << "\n"; + ++gFailures; +} + +void TestAllowedLifecycleTransitions() +{ + VideoBackendLifecycle lifecycle; + Expect(lifecycle.State() == VideoBackendLifecycleState::Uninitialized, "lifecycle starts uninitialized"); + + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Discovering, "discover").accepted, + "uninitialized can transition to discovering"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Discovered, "discovered").accepted, + "discovering can transition to discovered"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Configuring, "configuring").accepted, + "discovered can transition to configuring"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Configured, "configured").accepted, + "configuring can transition to configured"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Prerolling, "preroll").accepted, + "configured can transition to prerolling"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Running, "running").accepted, + "prerolling can transition to running"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Degraded, "degraded").accepted, + "running can transition to degraded"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Running, "recovered").accepted, + "degraded can transition back to running"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Stopping, "stopping").accepted, + "running can transition to stopping"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Stopped, "stopped").accepted, + "stopping can transition to stopped"); +} + +void TestInvalidLifecycleTransitionIsRejected() +{ + VideoBackendLifecycle lifecycle; + const VideoBackendLifecycleTransition transition = + lifecycle.TransitionTo(VideoBackendLifecycleState::Running, "skip setup"); + Expect(!transition.accepted, "uninitialized cannot transition directly to running"); + Expect(lifecycle.State() == VideoBackendLifecycleState::Uninitialized, "invalid transition leaves state unchanged"); + Expect(transition.errorMessage.find("Invalid video backend lifecycle transition") != std::string::npos, + "invalid transition reports an error"); +} + +void TestFailureStateRecordsReasonAndCanRecover() +{ + VideoBackendLifecycle lifecycle; + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Discovering, "discover").accepted, + "lifecycle can start discovery"); + Expect(lifecycle.Fail("no device").accepted, "discovering can transition to failed"); + Expect(lifecycle.State() == VideoBackendLifecycleState::Failed, "failure transition sets failed state"); + Expect(lifecycle.FailureReason() == "no device", "failure reason is retained"); + Expect(lifecycle.TransitionTo(VideoBackendLifecycleState::Discovering, "retry").accepted, + "failed lifecycle can retry discovery"); + Expect(lifecycle.FailureReason().empty(), "successful non-failed transition clears failure reason"); +} + +void TestStateNamesAreStable() +{ + Expect(std::string(VideoBackendLifecycle::StateName(VideoBackendLifecycleState::Uninitialized)) == "uninitialized", + "uninitialized state name is stable"); + Expect(std::string(VideoBackendLifecycle::StateName(VideoBackendLifecycleState::Running)) == "running", + "running state name is stable"); + Expect(std::string(VideoBackendLifecycle::StateName(VideoBackendLifecycleState::Failed)) == "failed", + "failed state name is stable"); +} +} + +int main() +{ + TestAllowedLifecycleTransitions(); + TestInvalidLifecycleTransitionIsRejected(); + TestFailureStateRecordsReasonAndCanRecover(); + TestStateNamesAreStable(); + + if (gFailures != 0) + { + std::cerr << gFailures << " video backend lifecycle test failure(s).\n"; + return 1; + } + + std::cout << "VideoBackendLifecycle tests passed.\n"; + return 0; +}