From 318d1813b8daf58f2b6a31a8d7b85d9dbb75d1f5 Mon Sep 17 00:00:00 2001 From: "zhengxuan.zhang" Date: Thu, 7 May 2026 15:12:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=95=E5=B1=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Pipeline/OperatorToolboxViewModelTests.cs | 5 +- .../ViewportRecordingPropertyTests.cs | 180 ++++++ XplorePlane.Tests/XplorePlane.Tests.csproj | 2 + XplorePlane/App.xaml.cs | 20 + XplorePlane/Assets/Icons/record.png | Bin 0 -> 454 bytes .../Events/RequestCaptureTargetEvent.cs | 14 + .../Recording/IViewportRecordingService.cs | 53 ++ .../Recording/ViewportRecordingService.cs | 538 ++++++++++++++++++ XplorePlane/ViewModels/Main/MainViewModel.cs | 149 ++++- .../ViewModels/Main/ViewportPanelViewModel.cs | 43 +- .../ImageProcessing/PipelineEditorView.xaml | 6 + XplorePlane/Views/Main/MainWindow.xaml | 20 + XplorePlane/Views/Main/ViewportPanelView.xaml | 21 +- .../Views/Main/ViewportPanelView.xaml.cs | 6 + 14 files changed, 1053 insertions(+), 4 deletions(-) create mode 100644 XplorePlane.Tests/Services/ViewportRecordingPropertyTests.cs create mode 100644 XplorePlane/Assets/Icons/record.png create mode 100644 XplorePlane/Events/RequestCaptureTargetEvent.cs create mode 100644 XplorePlane/Services/Recording/IViewportRecordingService.cs create mode 100644 XplorePlane/Services/Recording/ViewportRecordingService.cs diff --git a/XplorePlane.Tests/Pipeline/OperatorToolboxViewModelTests.cs b/XplorePlane.Tests/Pipeline/OperatorToolboxViewModelTests.cs index e5ad1c4..37883ea 100644 --- a/XplorePlane.Tests/Pipeline/OperatorToolboxViewModelTests.cs +++ b/XplorePlane.Tests/Pipeline/OperatorToolboxViewModelTests.cs @@ -1,4 +1,6 @@ using System; +using Moq; +using Prism.Events; using XplorePlane.Tests.Helpers; using XplorePlane.ViewModels; using Xunit; @@ -13,7 +15,8 @@ namespace XplorePlane.Tests.Pipeline private OperatorToolboxViewModel CreateVm(string[]? keys = null) { var mock = TestHelpers.CreateMockImageService(keys); - return new OperatorToolboxViewModel(mock.Object); + var mockEventAggregator = new Mock(); + return new OperatorToolboxViewModel(mock.Object, mockEventAggregator.Object); } [Fact] diff --git a/XplorePlane.Tests/Services/ViewportRecordingPropertyTests.cs b/XplorePlane.Tests/Services/ViewportRecordingPropertyTests.cs new file mode 100644 index 0000000..c7ea89d --- /dev/null +++ b/XplorePlane.Tests/Services/ViewportRecordingPropertyTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; +using Emgu.CV; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using XplorePlane.Services.Recording; + +namespace XplorePlane.Tests.Services +{ + /// + /// FsCheck property-based tests for ViewportRecordingService static helpers. + /// + public class ViewportRecordingPropertyTests + { + // ── Property 1: Timer formatting correctness ───────────────────────── + + // Feature: viewport-recording, Property 1: Timer formatting correctness + /// + /// **Validates: Requirements 2.2** + /// For any elapsed time value in seconds between 0 and 3599 (inclusive), + /// FormatElapsedTime SHALL produce a string in the format "MM:SS" where + /// MM is zero-padded minutes (00–59) and SS is zero-padded seconds (00–59), + /// and the numeric value represented equals the input seconds. + /// + [Property(MaxTest = 100)] + public Property FormatElapsedTime_ProducesCorrectMMSS_ForAllValidSeconds() + { + var secondsGen = Gen.Choose(0, 3599); + + return Prop.ForAll( + secondsGen.ToArbitrary(), + totalSeconds => + { + string result = ViewportRecordingService.FormatElapsedTime(totalSeconds); + + // Verify format is exactly "MM:SS" (5 chars, colon at index 2) + bool correctLength = result.Length == 5; + bool hasColon = result[2] == ':'; + bool matchesPattern = Regex.IsMatch(result, @"^\d{2}:\d{2}$"); + + // Parse back and verify numeric correctness + int parsedMinutes = int.Parse(result.Substring(0, 2)); + int parsedSeconds = int.Parse(result.Substring(3, 2)); + + bool minutesInRange = parsedMinutes >= 0 && parsedMinutes <= 59; + bool secondsInRange = parsedSeconds >= 0 && parsedSeconds <= 59; + + // Verify the numeric value equals the input + int reconstructed = parsedMinutes * 60 + parsedSeconds; + bool valueCorrect = reconstructed == totalSeconds; + + return correctLength && hasColon && matchesPattern + && minutesInRange && secondsInRange && valueCorrect; + }); + } + + // ── Property 2: Recording filename generation ──────────────────────── + + // Feature: viewport-recording, Property 2: Recording filename generation + /// + /// **Validates: Requirements 3.2** + /// For any valid DateTime value, GenerateFileName SHALL produce a string + /// matching the pattern REC_yyyyMMdd_HHmmss.mp4, and parsing the timestamp + /// portion back to a DateTime SHALL yield the same year, month, day, hour, + /// minute, and second as the input. + /// + [Property(MaxTest = 100)] + public Property GenerateFileName_MatchesPattern_AndTimestampIsReversible() + { + var dateTimeGen = ArbMap.Default.GeneratorFor(); + + return Prop.ForAll( + dateTimeGen.ToArbitrary(), + (DateTime dateTime) => + { + var fileName = ViewportRecordingService.GenerateFileName(dateTime); + + // Verify pattern: REC_yyyyMMdd_HHmmss.mp4 + var pattern = @"^REC_\d{8}_\d{6}\.mp4$"; + bool matchesPattern = Regex.IsMatch(fileName, pattern); + + // Extract timestamp portion and parse back + var timestampStr = fileName.Substring(4, 15); // "yyyyMMdd_HHmmss" + bool canParse = DateTime.TryParseExact( + timestampStr, + "yyyyMMdd_HHmmss", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTime parsed); + + // Verify reversibility: year, month, day, hour, minute, second match + bool isReversible = canParse + && parsed.Year == dateTime.Year + && parsed.Month == dateTime.Month + && parsed.Day == dateTime.Day + && parsed.Hour == dateTime.Hour + && parsed.Minute == dateTime.Minute + && parsed.Second == dateTime.Second; + + return matchesPattern && isReversible; + }); + } + + // ── Property 3: VIDEO folder path construction ─────────────────────── + + // Feature: viewport-recording, Property 3: VIDEO folder path construction + /// + /// **Validates: Requirements 4.2** + /// For any non-empty string rootPath, GetVideoFolderPath SHALL produce a path + /// equal to Path.Combine(rootPath, "VIDEO"), ensuring the VIDEO subfolder + /// is always constructed relative to the given root path. + /// + [Property(MaxTest = 100)] + public Property GetVideoFolderPath_AlwaysReturnsVideoSubfolder() + { + // Generate non-empty strings (filter out empty/whitespace) + var nonEmptyStringGen = ArbMap.Default.GeneratorFor(); + + return Prop.ForAll( + nonEmptyStringGen.ToArbitrary(), + (NonEmptyString rootPath) => + { + string result = ViewportRecordingService.GetVideoFolderPath(rootPath.Get); + + // Verify the result equals Path.Combine(rootPath, "VIDEO") + string expected = System.IO.Path.Combine(rootPath.Get, "VIDEO"); + bool equalsExpected = result == expected; + + // Verify the result ends with "VIDEO" segment + bool endsWithVideo = result.EndsWith("VIDEO") || result.EndsWith("VIDEO/") || result.EndsWith("VIDEO\\"); + + return equalsExpected && endsWithVideo; + }); + } + + // ── Property 4: Null-source frame produces black frame ─────────────── + + // Feature: viewport-recording, Property 4: Null-source frame produces black frame + /// + /// **Validates: Requirements 6.3** + /// For any positive integer dimensions (width, height), CreateBlackFrame SHALL + /// produce a Mat with the correct dimensions, 3 channels (BGR24), and all pixel + /// values equal to zero (pure black). + /// + [Property(MaxTest = 50)] + public Property CreateBlackFrame_ProducesCorrectBlackFrame_ForAnyPositiveDimensions() + { + // Generate positive integers for width and height (1..512 to keep tests fast) + var widthGen = Gen.Choose(1, 512); + var heightGen = Gen.Choose(1, 512); + var dimensionsGen = widthGen.SelectMany(w => heightGen.Select(h => (w, h))); + + return Prop.ForAll( + dimensionsGen.ToArbitrary(), + ((int width, int height) dims) => + { + using var frame = ViewportRecordingService.CreateBlackFrame(dims.width, dims.height); + + // Verify dimensions + bool correctWidth = frame.Width == dims.width; + bool correctHeight = frame.Height == dims.height; + + // Verify 3 channels (BGR24) + bool correctChannels = frame.NumberOfChannels == 3; + + // Verify all pixels are zero (black) + // Get raw byte data and check all zeros + int totalBytes = dims.width * dims.height * 3; + byte[] pixelData = new byte[totalBytes]; + System.Runtime.InteropServices.Marshal.Copy(frame.DataPointer, pixelData, 0, totalBytes); + bool allZero = System.Array.TrueForAll(pixelData, b => b == 0); + + return correctWidth && correctHeight && correctChannels && allZero; + }); + } + } +} diff --git a/XplorePlane.Tests/XplorePlane.Tests.csproj b/XplorePlane.Tests/XplorePlane.Tests.csproj index c55372f..c84fe89 100644 --- a/XplorePlane.Tests/XplorePlane.Tests.csproj +++ b/XplorePlane.Tests/XplorePlane.Tests.csproj @@ -10,6 +10,8 @@ + + diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index a0cf2c2..cb1d149 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -40,6 +40,7 @@ using XplorePlane.Services.MainViewport; using XplorePlane.Services.Matrix; using XplorePlane.Services.Measurement; using XplorePlane.Services.Recipe; +using XplorePlane.Services.Recording; using XplorePlane.Services.Storage; using XplorePlane.ViewModels; using XplorePlane.ViewModels.Cnc; @@ -217,6 +218,22 @@ namespace XplorePlane Log.Error(ex, "数据库资源释放失败,忽略该错误继续退出 | Database resource release failed, ignoring error and continuing exit"); } + // 释放录制服务资源 + try + { + var bootstrapper = AppBootstrapper.Instance; + if (bootstrapper != null) + { + var recordingService = bootstrapper.Container.Resolve(); + recordingService?.Dispose(); + Log.Information("录制服务资源已释放"); + } + } + catch (Exception ex) + { + Log.Error(ex, "录制服务资源释放失败"); + } + Log.CloseAndFlush(); base.OnExit(e); } @@ -427,6 +444,9 @@ namespace XplorePlane new CameraFactory().CreateController("Basler")); containerRegistry.RegisterSingleton(); + // ── 录制服务(单例)── + containerRegistry.RegisterSingleton(); + Log.Information("依赖注入容器配置完成"); } diff --git a/XplorePlane/Assets/Icons/record.png b/XplorePlane/Assets/Icons/record.png new file mode 100644 index 0000000000000000000000000000000000000000..6e3a20dbb6fc46c70ee8488bba3e6c2605e9f659 GIT binary patch literal 454 zcmV;%0XhDOP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0b5B#K~z{r?Uo@= z#6S>*Pa=^>{=s&pxdeCo1SArPL?UrWBoc|_Cm@k%x@}gTL?R)tf#mwSy>|De38G)} zTxVW8J2TtWYGEOhO7+Z_<@v7o8FOITh&*}K&sz1Ha#D)3ywXAuwI|kA}?$> zqSp`S3eJ^GTWXPQ#9&mYNG)>7IVXd2<)m54d5PfMY3`S^P>JN@$T;&cy|c$4W~J}z zioK#>lp?{0OXQ8cdW6m9sO?@3HWGS^Jv(6RILK6E6M-$(3o=xtdc&R_Dv@grvXmFO zW6usK5eHeSDvDe7>@ed9{%QXx2Ir0=L1vn?5pk1oppgQ5MVYY?@7Ze*?CwZ{Y~f + /// 请求 ViewportPanelView 提供 captureTarget 用于录制 + /// + public class RequestCaptureTargetEvent : PubSubEvent { } + + /// + /// ViewportPanelView 响应录制请求,提供 captureTarget + /// + public class ProvideCaptureTargetEvent : PubSubEvent { } +} diff --git a/XplorePlane/Services/Recording/IViewportRecordingService.cs b/XplorePlane/Services/Recording/IViewportRecordingService.cs new file mode 100644 index 0000000..a31603d --- /dev/null +++ b/XplorePlane/Services/Recording/IViewportRecordingService.cs @@ -0,0 +1,53 @@ +#nullable enable + +using System; +using System.Windows; + +namespace XplorePlane.Services.Recording +{ + public enum RecordingState + { + Idle, + Recording, + Saving + } + + public class RecordingStateChangedEventArgs : EventArgs + { + public RecordingState OldState { get; } + public RecordingState NewState { get; } + public string? ErrorMessage { get; } + public string? SavedFilePath { get; } + + public RecordingStateChangedEventArgs( + RecordingState oldState, + RecordingState newState, + string? errorMessage = null, + string? savedFilePath = null) + { + OldState = oldState; + NewState = newState; + ErrorMessage = errorMessage; + SavedFilePath = savedFilePath; + } + } + + public interface IViewportRecordingService : IDisposable + { + RecordingState CurrentState { get; } + TimeSpan ElapsedTime { get; } + + event EventHandler StateChanged; + + /// + /// 开始录制。需要传入用于 RenderTargetBitmap 捕获的 Visual 引用。 + /// 返回 true 表示录制成功启动,false 表示前置条件不满足。 + /// + bool StartRecording(FrameworkElement captureTarget); + + /// + /// 停止录制并开始保存。 + /// + void StopRecording(); + } +} diff --git a/XplorePlane/Services/Recording/ViewportRecordingService.cs b/XplorePlane/Services/Recording/ViewportRecordingService.cs new file mode 100644 index 0000000..65f418a --- /dev/null +++ b/XplorePlane/Services/Recording/ViewportRecordingService.cs @@ -0,0 +1,538 @@ +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Emgu.CV; +using Emgu.CV.CvEnum; +using Emgu.CV.Structure; +using XP.Common.Logging.Interfaces; +using XplorePlane.Services.Storage; + +namespace XplorePlane.Services.Recording +{ + public sealed class ViewportRecordingService : IViewportRecordingService + { + // ── Dependencies ───────────────────────────────────────────────────── + private readonly IXpDataPathService _xpDataPathService; + private readonly ILoggerService _logger; + + // ── State ──────────────────────────────────────────────────────────── + private RecordingState _currentState = RecordingState.Idle; + private DateTime _recordingStartTime; + private DispatcherTimer? _captureTimer; + private FrameworkElement? _captureTarget; + private int _lockedWidth; + private int _lockedHeight; + private string? _currentFilePath; + + // ── Encoding pipeline (producer-consumer) ──────────────────────────── + private BlockingCollection? _frameQueue; + private Task? _encoderTask; + private VideoWriter? _videoWriter; + private CancellationTokenSource? _encoderCts; + + // ── Constants ──────────────────────────────────────────────────────── + private const int TargetFps = 15; + private const int CaptureIntervalMs = 66; // ~15fps + private const int MinDiskSpaceMB = 100; + private const int MaxRecordingMinutes = 60; + private const int MaxFrameQueueSize = 60; // ~4 seconds buffer + + // ── Constructor ────────────────────────────────────────────────────── + + public ViewportRecordingService( + IXpDataPathService xpDataPathService, + ILoggerService logger) + { + _xpDataPathService = xpDataPathService ?? throw new ArgumentNullException(nameof(xpDataPathService)); + _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); + } + + // ── IViewportRecordingService Implementation ───────────────────────── + + public RecordingState CurrentState => _currentState; + + public TimeSpan ElapsedTime => + _currentState == RecordingState.Recording + ? DateTime.Now - _recordingStartTime + : TimeSpan.Zero; + + public event EventHandler? StateChanged; + + public bool StartRecording(FrameworkElement captureTarget) + { + if (_currentState != RecordingState.Idle) + { + _logger.Warn("StartRecording called while state is {0}", _currentState); + return false; + } + + string videoFolder = GetVideoFolderPath(_xpDataPathService.RootPath); + + if (!ValidatePreConditions(captureTarget, videoFolder)) + { + return false; + } + + // Lock the current resolution of the viewport + _lockedWidth = (int)captureTarget.ActualWidth; + _lockedHeight = (int)captureTarget.ActualHeight; + + // Generate file path + _recordingStartTime = DateTime.Now; + _currentFilePath = Path.Combine(videoFolder, GenerateFileName(_recordingStartTime)); + _captureTarget = captureTarget; + + // Initialize encoding pipeline + if (!InitializeEncodingPipeline()) + { + return false; + } + + // Start frame capture timer on UI thread + StartCaptureTimer(); + + // Transition state + var oldState = _currentState; + _currentState = RecordingState.Recording; + RaiseStateChanged(oldState, _currentState); + + _logger.Info("录制已开始: {0} ({1}x{2} @{3}fps)", + _currentFilePath, _lockedWidth, _lockedHeight, TargetFps); + + return true; + } + + public void StopRecording() + { + // Full implementation in task 2.3 + if (_currentState != RecordingState.Recording) + { + return; + } + + var oldState = _currentState; + _currentState = RecordingState.Saving; + RaiseStateChanged(oldState, _currentState); + + // Stop capture timer + _captureTimer?.Stop(); + _captureTimer = null; + + // Signal encoder to finish + _frameQueue?.CompleteAdding(); + + // Wait for encoder to drain remaining frames + try + { + _encoderTask?.Wait(TimeSpan.FromSeconds(30)); + } + catch (AggregateException ex) + { + _logger.Error(ex, "编码线程异常"); + } + + // Release resources + CleanupEncodingResources(); + + var savedPath = _currentFilePath; + _currentState = RecordingState.Idle; + RaiseStateChanged(RecordingState.Saving, RecordingState.Idle, savedFilePath: savedPath); + + _logger.Info("录制已保存: {0}", savedPath); + } + + public void Dispose() + { + if (_currentState == RecordingState.Recording) + { + StopRecording(); + } + else if (_currentState == RecordingState.Saving) + { + // Wait for encoder to finish draining, then clean up + try + { + _frameQueue?.CompleteAdding(); + _encoderTask?.Wait(TimeSpan.FromSeconds(5)); + } + catch { /* best effort */ } + + CleanupEncodingResources(); + _currentState = RecordingState.Idle; + } + + CleanupEncodingResources(); + } + + // ── Static Helpers (internal for testing) ──────────────────────────── + + /// + /// 构造 VIDEO 文件夹完整路径 + /// + internal static string GetVideoFolderPath(string rootPath) + { + return Path.Combine(rootPath, "VIDEO"); + } + + /// + /// 将总秒数格式化为 "MM:SS" 计时器显示文本 + /// + internal static string FormatElapsedTime(int totalSeconds) + { + int minutes = totalSeconds / 60; + int seconds = totalSeconds % 60; + return $"{minutes:D2}:{seconds:D2}"; + } + + /// + /// 根据录制开始时间生成文件名,格式为 REC_yyyyMMdd_HHmmss.mp4 + /// + internal static string GenerateFileName(DateTime startTime) + { + return $"REC_{startTime:yyyyMMdd_HHmmss}.mp4"; + } + + /// + /// 创建指定分辨率的纯黑帧 (BGR24, 所有像素值为 0) + /// + internal static Mat CreateBlackFrame(int width, int height) + { + // Create a BGR24 Mat initialized to zeros (black) + var mat = new Mat(height, width, DepthType.Cv8U, 3); + mat.SetTo(new MCvScalar(0, 0, 0)); + return mat; + } + + // ── Private Methods ────────────────────────────────────────────────── + + private bool InitializeEncodingPipeline() + { + try + { + _frameQueue = new BlockingCollection(MaxFrameQueueSize); + _encoderCts = new CancellationTokenSource(); + + // Initialize VideoWriter with H.264 codec + int fourcc = VideoWriter.Fourcc('a', 'v', 'c', '1'); + _videoWriter = new VideoWriter( + _currentFilePath!, + fourcc, + TargetFps, + new System.Drawing.Size(_lockedWidth, _lockedHeight), + true); // isColor = true for BGR + + if (!_videoWriter.IsOpened) + { + _logger.Error(new InvalidOperationException("VideoWriter failed to open"), + "无法初始化视频编码器: {0}", _currentFilePath); + RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, + errorMessage: "无法初始化视频编码器"); + CleanupEncodingResources(); + return false; + } + + // Start background encoder thread + var ct = _encoderCts.Token; + _encoderTask = Task.Factory.StartNew( + () => EncoderLoop(ct), + ct, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + + return true; + } + catch (Exception ex) + { + _logger.Error(ex, "初始化编码管线失败"); + RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, + errorMessage: $"初始化编码管线失败:{ex.Message}"); + CleanupEncodingResources(); + return false; + } + } + + private void StartCaptureTimer() + { + _captureTimer = new DispatcherTimer(DispatcherPriority.Render) + { + Interval = TimeSpan.FromMilliseconds(CaptureIntervalMs) + }; + _captureTimer.Tick += OnCaptureTimerTick; + _captureTimer.Start(); + } + + private void OnCaptureTimerTick(object? sender, EventArgs e) + { + if (_currentState != RecordingState.Recording || _captureTarget == null) + return; + + // Check max recording duration + var elapsed = DateTime.Now - _recordingStartTime; + if (elapsed.TotalMinutes >= MaxRecordingMinutes) + { + _logger.Info("已达到最大录制时长 {0} 分钟,自动停止", MaxRecordingMinutes); + StopRecording(); + return; + } + + // Capture frame from the visual tree + Mat? frame = CaptureFrame(); + + if (frame == null) + { + // Generate black frame when capture fails (task 2.4 requirement) + frame = CreateBlackFrame(_lockedWidth, _lockedHeight); + } + + // Enqueue frame for encoding (producer side) + if (_frameQueue != null && !_frameQueue.IsAddingCompleted) + { + if (!_frameQueue.TryAdd(frame)) + { + // Queue is full - drop frame (backpressure) + _logger.Warn("帧队列已满,丢弃当前帧"); + frame.Dispose(); + } + } + else + { + frame.Dispose(); + } + } + + private Mat? CaptureFrame() + { + try + { + if (_captureTarget == null) + return null; + + // Render the visual to a RenderTargetBitmap + var rtb = new RenderTargetBitmap( + _lockedWidth, _lockedHeight, + 96, 96, + PixelFormats.Pbgra32); + + rtb.Render(_captureTarget); + + // Convert RenderTargetBitmap to BGR24 Mat + return BitmapSourceToMat(rtb); + } + catch (Exception ex) + { + _logger.Warn("帧捕获失败: {0}", ex.Message); + return null; + } + } + + /// + /// Convert a BitmapSource (Pbgra32) to an Emgu.CV Mat (BGR24) + /// + private Mat BitmapSourceToMat(BitmapSource source) + { + // Ensure we have Bgr24 format for OpenCV compatibility + BitmapSource bgr24Source; + if (source.Format != PixelFormats.Bgr24) + { + bgr24Source = new FormatConvertedBitmap(source, PixelFormats.Bgr24, null, 0); + } + else + { + bgr24Source = source; + } + + int width = bgr24Source.PixelWidth; + int height = bgr24Source.PixelHeight; + int stride = width * 3; // BGR24 = 3 bytes per pixel + + var mat = new Mat(height, width, DepthType.Cv8U, 3); + bgr24Source.CopyPixels(new Int32Rect(0, 0, width, height), mat.DataPointer, height * stride, stride); + + return mat; + } + + private void EncoderLoop(CancellationToken ct) + { + try + { + foreach (var frame in _frameQueue!.GetConsumingEnumerable(ct)) + { + try + { + if (_videoWriter != null && _videoWriter.IsOpened) + { + _videoWriter.Write(frame); + } + } + catch (Exception ex) + { + _logger.Error(ex, "编码帧写入失败"); + HandleEncoderError(ex); + return; // frame disposed by finally + } + finally + { + frame.Dispose(); + } + } + } + catch (OperationCanceledException) + { + // Normal cancellation + _logger.Debug("编码线程已取消"); + } + catch (Exception ex) + { + _logger.Error(ex, "编码线程异常退出"); + } + } + + private void HandleEncoderError(Exception ex) + { + _logger.Error(ex, "编码错误,中止录制"); + var filePath = _currentFilePath; + + // 在 UI 线程上执行状态转换 + Application.Current?.Dispatcher?.BeginInvoke(new Action(() => + { + if (_currentState == RecordingState.Recording) + { + _captureTimer?.Stop(); + _captureTimer = null; + var oldState = _currentState; + _currentState = RecordingState.Idle; + CleanupEncodingResources(); + + // 删除未完成文件 + if (filePath != null && File.Exists(filePath)) + { + try { File.Delete(filePath); } catch { /* best effort */ } + } + + RaiseStateChanged(oldState, RecordingState.Idle, + errorMessage: $"编码错误:{ex.Message}"); + } + })); + } + + private void CleanupEncodingResources() + { + _encoderCts?.Cancel(); + + try + { + _videoWriter?.Dispose(); + } + catch { /* best effort */ } + _videoWriter = null; + + try + { + _frameQueue?.Dispose(); + } + catch { /* best effort */ } + _frameQueue = null; + + _encoderCts?.Dispose(); + _encoderCts = null; + _encoderTask = null; + _captureTarget = null; + } + + /// + /// 录制前置条件校验:检查 captureTarget、目录可创建性、磁盘空间及目录可写性。 + /// 任何一项失败均通过 StateChanged 事件传递错误信息并返回 false。 + /// + private bool ValidatePreConditions(FrameworkElement captureTarget, string videoFolder) + { + // 1. 检查 captureTarget 不为 null 且尺寸有效 + if (captureTarget == null) + { + RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, errorMessage: "视口尚未就绪"); + return false; + } + + int width = (int)captureTarget.ActualWidth; + int height = (int)captureTarget.ActualHeight; + if (width <= 0 || height <= 0) + { + RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, errorMessage: "视口尚未就绪"); + return false; + } + + // 2. 尝试递归创建 videoFolder(如不存在) + try + { + Directory.CreateDirectory(videoFolder); + } + catch (Exception ex) + { + _logger.Error(ex, "无法创建录制目录:{0}", videoFolder); + RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, + errorMessage: $"无法创建录制目录:{ex.Message}"); + return false; + } + + // 3. 检查磁盘可用空间 >= 100MB + try + { + string root = Path.GetPathRoot(Path.GetFullPath(videoFolder)) + ?? videoFolder; + var driveInfo = new DriveInfo(root); + const long minFreeBytes = (long)MinDiskSpaceMB * 1024 * 1024; + if (driveInfo.AvailableFreeSpace < minFreeBytes) + { + string msg = $"磁盘可用空间不足(需要至少 {MinDiskSpaceMB}MB)"; + _logger.Warn(msg); + RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, errorMessage: msg); + return false; + } + } + catch (Exception ex) + { + _logger.Error(ex, "检查磁盘空间失败"); + RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, + errorMessage: $"检查磁盘空间失败:{ex.Message}"); + return false; + } + + // 4. 检查目录可写:创建临时文件后立即删除 + string tempFile = Path.Combine(videoFolder, $".write_check_{Guid.NewGuid():N}.tmp"); + try + { + using (File.Create(tempFile)) { /* 仅验证可写性 */ } + File.Delete(tempFile); + } + catch (Exception ex) + { + _logger.Error(ex, "录制目录不可写:{0}", videoFolder); + RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, + errorMessage: $"录制目录不可写:{ex.Message}"); + // 尽力清理临时文件 + try { if (File.Exists(tempFile)) File.Delete(tempFile); } catch { /* best effort */ } + return false; + } + + return true; + } + + private void RaiseStateChanged( + RecordingState oldState, + RecordingState newState, + string? errorMessage = null, + string? savedFilePath = null) + { + StateChanged?.Invoke(this, new RecordingStateChangedEventArgs( + oldState, newState, errorMessage, savedFilePath)); + } + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index 9e9deb8..a45c2e8 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -11,8 +11,10 @@ using System.Linq; using System.Threading.Tasks; using System.Windows; using System.Windows.Media.Imaging; +using System.Windows.Threading; using XplorePlane.Events; using XplorePlane.Services.MainViewport; +using XplorePlane.Services.Recording; using XplorePlane.Services.Storage; using XplorePlane.ViewModels.Cnc; using XplorePlane.Views; @@ -38,6 +40,9 @@ namespace XplorePlane.ViewModels private readonly CncPageView _cncPageView; private readonly PipelineEditorViewModel _pipelineEditorViewModel; private readonly PipelineEditorView _pipelineEditorView; + private readonly IViewportRecordingService _recordingService; + private DispatcherTimer? _statusMessageTimer; + private DispatcherTimer? _recordingTimerDispatcher; public string LicenseInfo { @@ -52,6 +57,31 @@ namespace XplorePlane.ViewModels set => SetProperty(ref _statusMessage, value); } + private bool _isRecording; + public bool IsRecording + { + get => _isRecording; + private set => SetProperty(ref _isRecording, value); + } + + private bool _isRecordingSaving; + public bool IsRecordingSaving + { + get => _isRecordingSaving; + private set => SetProperty(ref _isRecordingSaving, value); + } + + private string _recordingTimerText = "00:00"; + public string RecordingTimerText + { + get => _recordingTimerText; + private set => SetProperty(ref _recordingTimerText, value); + } + + public bool IsTimerOverlayVisible => IsRecording; + + public bool CanRecord => !IsRecording && !IsRecordingSaving; + public ObservableCollection NavigationTree { get; set; } // 导航命令 @@ -114,6 +144,9 @@ namespace XplorePlane.ViewModels // 辅助线命令 public DelegateCommand ToggleCrosshairCommand { get; } + // 录屏命令 + public DelegateCommand ToggleRecordingCommand { get; } + // 设置命令 public DelegateCommand OpenLanguageSwitcherCommand { get; } public DelegateCommand OpenRealTimeLogViewerCommand { get; } @@ -205,7 +238,8 @@ namespace XplorePlane.ViewModels IContainerProvider containerProvider, IEventAggregator eventAggregator, IMainViewportService mainViewportService, - IXpDataPathService xpDataPathService) + IXpDataPathService xpDataPathService, + IViewportRecordingService recordingService) { _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); _containerProvider = containerProvider ?? throw new ArgumentNullException(nameof(containerProvider)); @@ -320,6 +354,13 @@ namespace XplorePlane.ViewModels DataRootPath = _xpDataPathService.RootPath; LoadBuiltInInspectionModules(); + _recordingService = recordingService ?? throw new ArgumentNullException(nameof(recordingService)); + _recordingService.StateChanged += OnRecordingStateChanged; + _eventAggregator.GetEvent() + .Subscribe(OnCaptureTargetProvided, ThreadOption.UIThread); + + ToggleRecordingCommand = new DelegateCommand(ExecuteToggleRecording, CanExecuteToggleRecording); + _logger.Info("MainViewModel 已初始化"); } @@ -1000,5 +1041,111 @@ namespace XplorePlane.ViewModels public sealed record BuiltInInspectionModuleItem(string DisplayName, string FilePath); #endregion + + #region 录制命令实现 + + private bool CanExecuteToggleRecording() + { + // Idle 状态可启动,Recording 状态可停止,Saving 状态禁用 + return _recordingService.CurrentState != RecordingState.Saving; + } + + private void ExecuteToggleRecording() + { + if (_recordingService.CurrentState == RecordingState.Idle) + { + // 启动录制:captureTarget 将在 6.2 中通过事件传递 + // 此处先触发事件让 View 提供 captureTarget + _eventAggregator.GetEvent().Publish(); + } + else if (_recordingService.CurrentState == RecordingState.Recording) + { + _recordingService.StopRecording(); + } + } + + private void OnCaptureTargetProvided(System.Windows.FrameworkElement captureTarget) + { + if (_recordingService.CurrentState == RecordingState.Idle) + { + bool started = _recordingService.StartRecording(captureTarget); + if (!started) + { + _logger.Warn("录制启动失败"); + } + } + } + + private void OnRecordingStateChanged(object? sender, RecordingStateChangedEventArgs e) + { + Application.Current?.Dispatcher?.BeginInvoke(new Action(() => + { + IsRecording = e.NewState == RecordingState.Recording; + IsRecordingSaving = e.NewState == RecordingState.Saving; + RaisePropertyChanged(nameof(IsTimerOverlayVisible)); + RaisePropertyChanged(nameof(CanRecord)); + ToggleRecordingCommand.RaiseCanExecuteChanged(); + + if (e.NewState == RecordingState.Recording) + { + // 启动计时器更新 RecordingTimerText + StartRecordingTimer(); + } + else + { + StopRecordingTimer(); + RecordingTimerText = "00:00"; + } + + if (e.NewState == RecordingState.Idle && e.SavedFilePath != null) + { + // 保存成功,在状态栏显示文件路径(持续 5 秒) + ShowStatusMessage($"录制已保存:{e.SavedFilePath}", durationSeconds: 5); + } + else if (e.ErrorMessage != null) + { + ShowStatusMessage($"录制错误:{e.ErrorMessage}", durationSeconds: 5); + } + })); + } + + private void StartRecordingTimer() + { + _statusMessageTimer?.Stop(); + var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + timer.Tick += (s, e) => + { + if (_recordingService.CurrentState == RecordingState.Recording) + { + int totalSeconds = (int)_recordingService.ElapsedTime.TotalSeconds; + RecordingTimerText = ViewportRecordingService.FormatElapsedTime(totalSeconds); + } + }; + timer.Start(); + // Store in a dedicated field + _recordingTimerDispatcher = timer; + } + + private void StopRecordingTimer() + { + _recordingTimerDispatcher?.Stop(); + _recordingTimerDispatcher = null; + } + + private void ShowStatusMessage(string message, int durationSeconds = 5) + { + StatusMessage = message; + _statusMessageTimer?.Stop(); + _statusMessageTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(durationSeconds) }; + _statusMessageTimer.Tick += (s, e) => + { + StatusMessage = "就绪"; + _statusMessageTimer?.Stop(); + _statusMessageTimer = null; + }; + _statusMessageTimer.Start(); + } + + #endregion } } diff --git a/XplorePlane/ViewModels/Main/ViewportPanelViewModel.cs b/XplorePlane/ViewModels/Main/ViewportPanelViewModel.cs index 4a84b40..44ef8da 100644 --- a/XplorePlane/ViewModels/Main/ViewportPanelViewModel.cs +++ b/XplorePlane/ViewModels/Main/ViewportPanelViewModel.cs @@ -9,6 +9,7 @@ using XplorePlane.Events; using XplorePlane.Models; using XplorePlane.Services.AppState; using XplorePlane.Services.MainViewport; +using XplorePlane.Services.Recording; namespace XplorePlane.ViewModels { @@ -21,6 +22,9 @@ namespace XplorePlane.ViewModels private readonly IMainViewportService _mainViewportService; private readonly IEventAggregator _eventAggregator; private readonly IAppStateService _appStateService; + private readonly IViewportRecordingService _recordingService; + private bool _isTimerOverlayVisible; + private string _recordingTimerText = "00:00"; private ImageSource _imageSource; private string _imageInfo = "等待探测器图像..."; @@ -42,12 +46,15 @@ namespace XplorePlane.ViewModels IMainViewportService mainViewportService, IEventAggregator eventAggregator, IAppStateService appStateService, - ILoggerService logger) + ILoggerService logger, + IViewportRecordingService recordingService) { _mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService)); _eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator)); _appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService)); _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); + _recordingService = recordingService ?? throw new ArgumentNullException(nameof(recordingService)); + _recordingService.StateChanged += OnRecordingStateChanged; CancelMeasurementCommand = new DelegateCommand(ExecuteCancelMeasurement); @@ -147,6 +154,20 @@ namespace XplorePlane.ViewModels set => SetProperty(ref _measurementResult, value); } + /// 录制计时器覆盖层是否可见 + public bool IsTimerOverlayVisible + { + get => _isTimerOverlayVisible; + private set => SetProperty(ref _isTimerOverlayVisible, value); + } + + /// 录制计时器文本(格式 MM:SS) + public string RecordingTimerText + { + get => _recordingTimerText; + private set => SetProperty(ref _recordingTimerText, value); + } + public DelegateCommand CancelMeasurementCommand { get; } // Task 5.5: ToggleRealtimeCommand @@ -238,5 +259,25 @@ namespace XplorePlane.ViewModels MessageBoxImage.Warning); } } + + private void OnRecordingStateChanged(object? sender, RecordingStateChangedEventArgs e) + { + Application.Current?.Dispatcher?.BeginInvoke(new Action(() => + { + IsTimerOverlayVisible = e.NewState == RecordingState.Recording; + if (e.NewState != RecordingState.Recording) + { + RecordingTimerText = "00:00"; + } + })); + } + + /// + /// 由 MainViewModel 的录制计时器调用,更新计时器文本 + /// + public void UpdateRecordingTimerText(string timerText) + { + RecordingTimerText = timerText; + } } } diff --git a/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml b/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml index d350263..9f45274 100644 --- a/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml +++ b/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml @@ -117,6 +117,12 @@ FontSize="14" Text="" /> + + + + + + + + + diff --git a/XplorePlane/Views/Main/ViewportPanelView.xaml b/XplorePlane/Views/Main/ViewportPanelView.xaml index 195d669..75f9a38 100644 --- a/XplorePlane/Views/Main/ViewportPanelView.xaml +++ b/XplorePlane/Views/Main/ViewportPanelView.xaml @@ -10,7 +10,9 @@ d:DesignHeight="400" d:DesignWidth="600" mc:Ignorable="d"> - + + + @@ -34,6 +36,23 @@ Background="White" ImageSource="{Binding ImageSource}"> + + + + + diff --git a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs index cecf8f8..992be28 100644 --- a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs +++ b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs @@ -111,6 +111,12 @@ namespace XplorePlane.Views _ => XP.ImageProcessing.RoiControl.Models.MeasureMode.None }; }, Prism.Events.ThreadOption.UIThread); + + // 录制请求:提供 RoiCanvas 作为 captureTarget + ea?.GetEvent().Subscribe(() => + { + ea.GetEvent().Publish(RoiCanvas); + }, Prism.Events.ThreadOption.UIThread); } catch { } }