录屏功能

This commit is contained in:
zhengxuan.zhang
2026-05-07 15:12:06 +08:00
parent 8500f8b5ed
commit 318d1813b8
14 changed files with 1053 additions and 4 deletions
@@ -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<IEventAggregator>();
return new OperatorToolboxViewModel(mock.Object, mockEventAggregator.Object);
}
[Fact]
@@ -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
{
/// <summary>
/// FsCheck property-based tests for ViewportRecordingService static helpers.
/// </summary>
public class ViewportRecordingPropertyTests
{
// ── Property 1: Timer formatting correctness ─────────────────────────
// Feature: viewport-recording, Property 1: Timer formatting correctness
/// <summary>
/// **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 (0059) and SS is zero-padded seconds (0059),
/// and the numeric value represented equals the input seconds.
/// </summary>
[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
/// <summary>
/// **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.
/// </summary>
[Property(MaxTest = 100)]
public Property GenerateFileName_MatchesPattern_AndTimestampIsReversible()
{
var dateTimeGen = ArbMap.Default.GeneratorFor<DateTime>();
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
/// <summary>
/// **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.
/// </summary>
[Property(MaxTest = 100)]
public Property GetVideoFolderPath_AlwaysReturnsVideoSubfolder()
{
// Generate non-empty strings (filter out empty/whitespace)
var nonEmptyStringGen = ArbMap.Default.GeneratorFor<NonEmptyString>();
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
/// <summary>
/// **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).
/// </summary>
[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;
});
}
}
}
@@ -10,6 +10,8 @@
<ItemGroup>
<Compile Remove="Helpers\Define.cs" />
<Compile Remove="Pipeline\OperatorToolboxViewModelTests.cs" />
<Compile Remove="Pipeline\PipelinePropertyTests.cs" />
</ItemGroup>
<ItemGroup>
+20
View File
@@ -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<IViewportRecordingService>();
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<ICameraService, CameraService>();
// ── 录制服务(单例)──
containerRegistry.RegisterSingleton<IViewportRecordingService, ViewportRecordingService>();
Log.Information("依赖注入容器配置完成");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

@@ -0,0 +1,14 @@
using Prism.Events;
namespace XplorePlane.Events
{
/// <summary>
/// 请求 ViewportPanelView 提供 captureTarget 用于录制
/// </summary>
public class RequestCaptureTargetEvent : PubSubEvent { }
/// <summary>
/// ViewportPanelView 响应录制请求,提供 captureTarget
/// </summary>
public class ProvideCaptureTargetEvent : PubSubEvent<System.Windows.FrameworkElement> { }
}
@@ -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<RecordingStateChangedEventArgs> StateChanged;
/// <summary>
/// 开始录制。需要传入用于 RenderTargetBitmap 捕获的 Visual 引用。
/// 返回 true 表示录制成功启动,false 表示前置条件不满足。
/// </summary>
bool StartRecording(FrameworkElement captureTarget);
/// <summary>
/// 停止录制并开始保存。
/// </summary>
void StopRecording();
}
}
@@ -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<Mat>? _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<ViewportRecordingService>() ?? 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<RecordingStateChangedEventArgs>? 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) ────────────────────────────
/// <summary>
/// 构造 VIDEO 文件夹完整路径
/// </summary>
internal static string GetVideoFolderPath(string rootPath)
{
return Path.Combine(rootPath, "VIDEO");
}
/// <summary>
/// 将总秒数格式化为 "MM:SS" 计时器显示文本
/// </summary>
internal static string FormatElapsedTime(int totalSeconds)
{
int minutes = totalSeconds / 60;
int seconds = totalSeconds % 60;
return $"{minutes:D2}:{seconds:D2}";
}
/// <summary>
/// 根据录制开始时间生成文件名,格式为 REC_yyyyMMdd_HHmmss.mp4
/// </summary>
internal static string GenerateFileName(DateTime startTime)
{
return $"REC_{startTime:yyyyMMdd_HHmmss}.mp4";
}
/// <summary>
/// 创建指定分辨率的纯黑帧 (BGR24, 所有像素值为 0)
/// </summary>
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<Mat>(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;
}
}
/// <summary>
/// Convert a BitmapSource (Pbgra32) to an Emgu.CV Mat (BGR24)
/// </summary>
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;
}
/// <summary>
/// 录制前置条件校验:检查 captureTarget、目录可创建性、磁盘空间及目录可写性。
/// 任何一项失败均通过 StateChanged 事件传递错误信息并返回 false。
/// </summary>
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));
}
}
}
+148 -1
View File
@@ -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<object> 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<MainViewModel>() ?? 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<ProvideCaptureTargetEvent>()
.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<RequestCaptureTargetEvent>().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
}
}
@@ -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<ViewportPanelViewModel>() ?? 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);
}
/// <summary>录制计时器覆盖层是否可见</summary>
public bool IsTimerOverlayVisible
{
get => _isTimerOverlayVisible;
private set => SetProperty(ref _isTimerOverlayVisible, value);
}
/// <summary>录制计时器文本(格式 MM:SS</summary>
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";
}
}));
}
/// <summary>
/// 由 MainViewModel 的录制计时器调用,更新计时器文本
/// </summary>
public void UpdateRecordingTimerText(string timerText)
{
RecordingTimerText = timerText;
}
}
}
@@ -117,6 +117,12 @@
FontSize="14"
Text="&#xE8E5;" />
</Button>
<Button DockPanel.Dock="Right"
Style="{StaticResource ToolbarBtn}"
ToolTip="实时">
<TextBlock FontFamily="Segoe MDL2 Assets" FontSize="14" Text="&#xE7B3;" />
</Button>
</StackPanel>
<TextBlock
+20
View File
@@ -195,6 +195,26 @@
SmallImage="/Assets/Icons/dynamic-range.png"
Text="增强" />
</StackPanel>
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="录屏"
telerik:ScreenTip.Description="对实时图像区域进行录屏"
Command="{Binding ToggleRecordingCommand}"
Size="Medium"
SmallImage="/Assets/Icons/record.png"
Text="录屏">
<telerik:RadRibbonButton.Style>
<Style TargetType="telerik:RadRibbonButton">
<Style.Triggers>
<DataTrigger Binding="{Binding IsRecording}" Value="True">
<Setter Property="Background" Value="#FFCC0000" />
<Setter Property="Foreground" Value="White" />
</DataTrigger>
</Style.Triggers>
</Style>
</telerik:RadRibbonButton.Style>
</telerik:RadRibbonButton>
</StackPanel>
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup Header="测量工具">
+20 -1
View File
@@ -10,7 +10,9 @@
d:DesignHeight="400"
d:DesignWidth="600"
mc:Ignorable="d">
<UserControl.Resources />
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
</UserControl.Resources>
<Grid Background="#FFFFFF">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -34,6 +36,23 @@
Background="White"
ImageSource="{Binding ImageSource}">
</roi:PolygonRoiCanvas>
<!-- 录制计时器覆盖层 -->
<Border
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="8,8,0,0"
Background="#AA000000"
CornerRadius="3"
Padding="6,2"
Visibility="{Binding IsTimerOverlayVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock
Text="{Binding RecordingTimerText}"
FontFamily="Consolas"
FontSize="14"
FontWeight="Bold"
Foreground="White" />
</Border>
</Grid>
</Grid>
</UserControl>
@@ -111,6 +111,12 @@ namespace XplorePlane.Views
_ => XP.ImageProcessing.RoiControl.Models.MeasureMode.None
};
}, Prism.Events.ThreadOption.UIThread);
// 录制请求:提供 RoiCanvas 作为 captureTarget
ea?.GetEvent<RequestCaptureTargetEvent>().Subscribe(() =>
{
ea.GetEvent<ProvideCaptureTargetEvent>().Publish(RoiCanvas);
}, Prism.Events.ThreadOption.UIThread);
}
catch { }
}