# 智能录制特性说明书

## 1. 功能概述

智能录制是一个按 SOP 状态触发的视频录制机制，用于在关键操作发生时自动记录生产过程。

### 1.1 两种模式

| 模式 | 行为 |
|------|------|
| **智能录制关闭** | 相机打开后立即开始连续录制，直到相机关闭 |
| **智能录制开启** | 默认不录制；当 SOP 步骤号发生跳转时触发录制，5 分钟内无新跳转则自动停止 |

### 1.2 关键参数

- **触发条件**：SOP 步骤号从 N 变为 N+1（真正的状态前进）
- **录制时长**：触发后连续录制 5 分钟
- **超时重置**：5 分钟内再次触发，重新计时
- **Modbus 寄存器**：使用寄存器 50 记录 `videoRecorder` 值（0=停止，1=录制中）

---

## 2. 架构设计

### 2.1 分层结构

```
┌─────────────────────────────────────────────────────────────┐
│                    CameraSessionViewModel                    │
│  - 检测 SOP 步骤跳转（_lastSmartRecordingStep）             │
│  - 调用 _smartRecordingStateMachine.OnSopStepTransition()  │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│              SmartRecordingStateMachine                      │
│  - 管理录制激活状态                                           │
│  - 写寄存器 50 控制 PLC                                      │
│  - 5 分钟超时计时器                                           │
│  - 10 秒轮询状态                                             │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│          PipelineSmartRecordingCoordinator                   │
│  - 录制器启停协调                                            │
│  - 连续录制 vs 智能录制模式路由                               │
│  - 录制选项延迟获取（Func<T>）                                │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│                    VideoPipeline                             │
│  - 第一帧到达时通知录制协调器                                  │
│  - 连接/断开状态机                                            │
└─────────────────────────────────────────────────────────────┘
```

### 2.2 核心类

#### SmartRecordingStateMachine

负责与 PLC 交互，管理录制激活状态。

```csharp
public sealed class SmartRecordingStateMachine : IDisposable
{
    private const ushort RegisterAddress = 50;
    private const int EnableTimeoutMs = 5 * 60 * 1000;  // 5分钟
    private const int PollIntervalMs = 10 * 1000;       // 10秒轮询

    // 事件
    public event Action? RecordingActivated;
    public event Action? RecordingDeactivated;

    // 状态
    public bool IsRecordingActive { get; private set; }

    // 触发录制
    public void OnSopStepTransition();

    // 轮询状态
    public void PollStatus();
    public void StartPolling();
}
```

**超时机制**：
- 触发时调用 `EnableRecording()`，写入寄存器 50 为 1
- 启动 `Task.Delay(EnableTimeoutMs, cancellationToken)`
- 超时后自动写入寄存器 50 为 0
- 再次触发时取消旧计时器，重启新计时器

**轮询机制**：
- `StartPolling()` 启动后台任务，每 10 秒 `PollStatus()`
- 轮询读取寄存器 50，同步外部可能的状态变化

#### PipelineSmartRecordingCoordinator

负责录制器的实际启停。

```csharp
internal sealed class PipelineSmartRecordingCoordinator : IDisposable
{
    // 关键设计：使用 Func<T> 延迟获取录制选项
    private readonly Func<CameraRecordingOptions> _getRecordingOptions;

    public void OnFirstFrame(int width, int height, double fps);
    public void OnRecordingActivated();
    public void OnRecordingDeactivated();

    private void StartRecorderCore()
    {
        // 每次获取最新的 options，避免使用旧值
        var options = _getRecordingOptions();
        _recorder.Start(options, _width, _height, _fps);
    }
}
```

**两种录制模式路由**：

| 模式 | 触发时机 |
|------|----------|
| 连续录制 | `OnFirstFrame()` 被调用时，`_isRecordingEnabled()=true` 且 `_isSmartRecordingEnabled()=false` |
| 智能录制 | `OnRecordingActivated()` 被调用时，`_isSmartRecordingEnabled()=true` |

### 2.3 延迟求值设计

录制选项使用 `Func<CameraRecordingOptions>` 而非直接值传递：

```csharp
// CameraSessionViewModel 构造函数中
_pipeline = new VideoPipeline(
    camera: this,
    getRecordingOptions: () => _cameraRecordingOptions,  // 延迟引用
    getRoi: () => _currentRoi,
    getMinScore: () => _minScore);
```

**为什么需要延迟？** 在 `VideoPipeline` 构造函数中需要传入录制选项，但如果直接传值，`_cameraRecordingOptions` 此时可能还没有被正确设置（依赖于后续 `BuildRecordingOptions()` 调用）。使用 `Func<T>` 可以在调用时动态获取最新值。

---

## 3. 配置保存链路

### 3.1 完整链路

```
设置 UI (CheckBox IsChecked="{Binding EnableSmartRecording}")
    ↓
CameraProfileViewModel (enableSmartRecording 字段)
    ↓
CameraProfile.ToCameraProfile()
    ↓
CameraProfile.Normalize() [注意：会覆盖！]
    ↓
CameraSettingsRepository.SaveCameras()
    ↓
CameraProfileEntity (SQL Sugar ORM)
    ↓
SQLite 数据库 (enable_smart_recording 列)
```

### 3.2 配置模型对应关系

| 层 | 类/字段 | 说明 |
|----|---------|------|
| UI | `CameraProfileViewModel.enableSmartRecording` | `[ObservableProperty]` 绑定 |
| Domain | `CameraProfile.EnableSmartRecording` | bool 属性 |
| Entity | `CameraProfileEntity.EnableSmartRecording` | int 类型 (0/1) |

---

## 4. 遇到的问题与修复

### 4.1 IsTransition 误判问题

**现象**：SOP 停在步骤 1 等待时，智能录制无限触发，5 分钟永不停止。

**根因**：`AnalysisEngine.cs` 中 `isTransition` 判定逻辑有缺陷：

```csharp
// 原始逻辑
var isTransition = newStep.HasValue && (!previousStep.HasValue || previousStep.Value != newStep.Value);
```

当 SOP 停在步骤 1 等待时：
- `previousStep`（`_state.ActiveStep`）一直是 `null`（步骤 1 还没完成）
- `newStep`（`raw.Step`）一直是 `1`（等待中返回的 nextStep）
- `null != 1` 每帧都为 true

**修复**：在 `CameraSessionViewModel` 中自己维护 `_lastSmartRecordingStep`，只在该值真正变化时才触发：

```csharp
private int? _lastSmartRecordingStep;

_uiDispatcher.Post(() =>
{
    if (result.Step.HasValue && _smartRecordingStateMachine != null)
    {
        var step = result.Step.Value;
        if (!_lastSmartRecordingStep.HasValue || _lastSmartRecordingStep.Value != step)
        {
            _lastSmartRecordingStep = step;
            _smartRecordingStateMachine.OnSopStepTransition();
        }
    }
});
```

### 4.2 录制选项被覆盖问题

**现象**：录制器启动后立即停止，或录制设置不正确。

**根因**：`CameraProfile.Normalize()` 会将 `EnableCameraRecording` 和 `EnableSmartRecording` 强制设为 false：

```csharp
// CameraProfile.Normalize() 中的问题代码
public void Normalize(double fps)
{
    // ...
    EnableCameraRecording = false;
    EnableSmartRecording = false;  // 覆盖了！
}
```

**修复**：在 `TryBuild` 中，`Normalize()` 调用之后重新设置这两个值：

```csharp
public bool TryBuild(...)
{
    // ... 设置其他字段 ...

    profile.EnableCameraRecording = enableCameraRecording;
    profile.EnableSmartRecording = enableSmartRecording;

    // 校验后调用 Normalize
    profile.Normalize(frameRate);

    // Normalize 之后重新设置！
    profile.EnableCameraRecording = enableCameraRecording;
    profile.EnableSmartRecording = enableSmartRecording;
}
```

### 4.3 录制器启动时选项不完整

**现象**：智能录制触发后录制器没有重新启动。

**根因**：`PipelineSmartRecordingCoordinator.StartRecorderCore()` 传入了不完整的选项：

```csharp
// 错误的做法
_recorder.Start(new CameraRecordingOptions { Enabled = true }, ...);
// 只有 Enabled=true，其他字段为默认值，导致 Normalize() 失败
```

**修复**：使用 `Func<CameraRecordingOptions>` 在调用时获取完整选项：

```csharp
private void StartRecorderCore()
{
    var options = _getRecordingOptions();  // 每次获取最新
    _recorder.Start(options, _width, _height, _fps);
}
```

### 4.4 数据库迁移缺失

**现象**：程序崩溃，无法打开。

**根因**：新增的 `enable_smart_recording` 列在现有数据库中不存在，SQL Sugar ORM 报错。

**修复**：在 `CameraSettingsRepository` 初始化时执行迁移：

```csharp
private static void MigrateDatabase()
{
    // 迁移 V2：为 camera_profiles 表添加 enable_smart_recording 列
    MigrateCameraProfileV2(_db);

    // CREATE TABLE 语句也已更新，包含 enable_smart_recording 列
}

private static void MigrateCameraProfileV2(SugarDatabase db)
{
    try
    {
        db.Ado.ExecuteCommand(
            "ALTER TABLE camera_profiles ADD COLUMN enable_smart_recording INTEGER DEFAULT 0");
    }
    catch (Exception ex) when (ex.Message.Contains("duplicate column"))
    {
        // 列已存在，忽略
    }
}
```

### 4.5 保存链路多处遗漏

**现象**：智能录制 checkbox 勾选后保存，重新打开仍然未勾选。

**根因**：保存链路中有 3 处遗漏：

| 位置 | 问题 |
|------|------|
| `CameraProfileEntity` | 缺少 `EnableSmartRecording` 属性 |
| `SaveCameras()` | 缺少 `EnableSmartRecording` 写入 |
| `LoadCameras()` | 缺少 `EnableSmartRecording` 读取 |

**修复**：补全所有三处：

```csharp
// CameraProfileEntity.cs
[SugarColumn(ColumnName = "enable_smart_recording")]
public int EnableSmartRecording { get; set; }

// SaveCameras()
e.EnableSmartRecording = camera.EnableSmartRecording ? 1 : 0;

// LoadCameras()
camera.EnableSmartRecording = e.EnableSmartRecording != 0;
```

### 4.6 SmartRecordingStateMachine 未被连接

**现象**：状态机创建了但事件永远不触发。

**根因**：`VideoPipeline` 中没有连接状态机的回调方法。

**修复**：在 `VideoPipeline` 中添加连接方法：

```csharp
public void ConnectSmartRecordingStateMachine(SmartRecordingStateMachine stateMachine)
{
    stateMachine.RecordingActivated += _smartRecording.OnRecordingActivated;
    stateMachine.RecordingDeactivated += _smartRecording.OnRecordingDeactivated;
}

public void DisconnectSmartRecordingStateMachine(SmartRecordingStateMachine stateMachine)
{
    stateMachine.RecordingActivated -= _smartRecording.OnRecordingActivated;
    stateMachine.RecordingDeactivated -= _smartRecording.OnRecordingDeactivated;
}
```

并在 `CameraSessionViewModel` 中调用：

```csharp
if (_smartRecordingEnabled)
{
    _smartRecordingStateMachine = new SmartRecordingStateMachine(_modbusRegisters);
    _pipeline.ConnectSmartRecordingStateMachine(_smartRecordingStateMachine);
    _smartRecordingStateMachine.StartPolling();
}
```

---

## 5. 日志埋点

### 5.1 日志输出路径

```
C:\Users\Administrator\VideoInferenceDemo\src\VideoInference.Desktop\bin\Debug\net8.0-windows\Log\YYYY_MM_DD.log
```

日志文件按日期分割，使用 `CameraDiagnostics.Info("smart-recording", message)` 输出。

### 5.2 关键埋点位置

| 文件 | 位置 | 内容 |
|------|------|------|
| `CameraSessionViewModel.cs` | `_smartRecordingEnabled` 初始化 | `EnableSmartRecording`, `EnableCameraRecording`, `enabled` 值 |
| `CameraSessionViewModel.cs` | SOP 步骤变化检测 | `SOP step changed: step=X` |
| `PipelineSmartRecordingCoordinator.cs` | `OnFirstFrame()` | `recordingEnabled`, `smartEnabled`, `started` |
| `PipelineSmartRecordingCoordinator.cs` | `OnRecordingActivated()` | 智能录制激活日志 |
| `PipelineSmartRecordingCoordinator.cs` | `OnRecordingDeactivated()` | 智能录制停止日志 |
| `CameraProfileViewModel.cs` | `TryBuild()` | 保存前后的 `EnableCameraRecording`, `EnableSmartRecording` |
| `CameraSettingsRepository.cs` | `SaveCameras()` | 写入数据库的实体值 |
| `CameraSettingsRepository.cs` | `LoadCameras()` | 加载自数据库的值和 Normalize 后的值 |

### 5.3 日志示例

**智能录制正常触发**：
```
13:29:59.339 [SmartRecording] OnFirstFrame: recordingEnabled=True, smartEnabled=True, started=False
13:29:59.727 [SmartRecording] OnRecordingActivated: smartEnabled=True, started=False
13:29:59.727 [SmartRecording] Starting smart recording
13:35:00.031 [SmartRecording] OnRecordingDeactivated: started=True
```

**步骤变化**：
```
13:53:37.242 [CameraSession] SOP step changed: step=3
13:53:37.446 [CameraSession] SOP step changed: step=4
13:53:37.545 [CameraSession] SOP step changed: step=5
```

**配置保存**：
```
[TryBuild] EnableCameraRecording=True, EnableSmartRecording=True
[After Normalize] EnableCameraRecording=True, EnableSmartRecording=True
[SaveCameras] id=1, EnableSmartRecording=1
[LoadCameras] id=1, EnableSmartRecording=1
```

### 5.4 调试技巧

1. **搜索关键字**：在日志文件中搜索 `[SmartRecording]` 或 `[CameraSession] SOP`
2. **时间分析**：相邻步骤的时间差，如果约 100ms 说明是单帧跳变
3. **状态追踪**：查看 `started=False/True` 判断录制器状态
4. **配置验证**：检查 `EnableSmartRecording` 和 `EnableCameraRecording` 是否同时为 true

---

## 6. 测试清单

| # | 测试项 | 预期结果 |
|---|--------|----------|
| 1 | 智能录制关闭，相机打开 | 立即开始连续录制 |
| 2 | 智能录制开启，相机打开 | 不录制，等待触发 |
| 3 | SOP 步骤跳转 | 触发录制，写入寄存器 50 为 1 |
| 4 | 5 分钟无操作 | 自动停止录制，寄存器 50 归零 |
| 5 | 5 分钟内再次跳转 | 重置计时器，继续录制 |
| 6 | 保存配置后重启 | 智能录制设置保持 |
| 7 | step3/5/7/9 要求无 QR | 有 QR 时不推进，无 QR 时推进 |

---

---

## 7. NG 录制标识功能

当 SOP 检测到 NG（步骤跳转失败）时，自动在视频中添加标识，便于后续回溯。

### 7.1 功能特性

| 特性 | 说明 |
|------|------|
| **左上角 NG 标识** | 120×120px 红色实心矩形 + 白色粗体 "NG" 文字 |
| **文件名后缀** | 出现 NG 的录制文件加 `_ng` 后缀 |
| **状态分离** | 文件名后缀一旦有 NG 就永久保留；NG 标识仅在 NG 期间显示 |
| **自动恢复** | SOP 重置后 NG 标识自动消失，文件名后缀保持不变 |

### 7.2 状态机

```
NG 发生（result.NgReason != null）
    → MarkNg()
        → _hasNgInRecording = true    // 文件名永久带 _ng
        → _currentSegmentIsNg = true  // 绘制 NG 标识
        → RequestRotate()             // 新建分段

SOP 重置（result.IsReset）
    → MarkNgCleared()
        → _currentSegmentIsNg = false // NG 标识消失
```

### 7.3 两个独立状态

| 状态 | 字段 | 生命周期 | 作用 |
|------|------|----------|------|
| 文件名后缀 | `_hasNgInRecording` | 整个录制会话 | 一旦有 NG，文件名前缀加 `_ng` |
| NG 标识显示 | `_currentSegmentIsNg` | 当前 NG 期间 | SOP 重置后自动消失 |
| NG 旋转标记 | `_ngRotationDone` | 每次 NG 事件 | 防止同一 NG 期间多次切分段 |

### 7.4 Bug：复位后 NG 标识不消失、后续视频带 `_ng` 后缀

**现象**：
1. 手动复位后，左上角的红色 NG 标识一直挂着不消失
2. 后续录制的视频文件也带上了 `_ng` 后缀

**根因**：
1. `MarkNgCleared()` 只在 `result.IsReset && IsSopCycleReset(result)` 时调用，但手动复位不走这个分支
2. `_hasNgInRecording` 只在 `MarkNg()` 时设为 true，从未被重置

**修复**：
1. 在 `ResetSopFault()` 中调用 `MarkNgCleared()` 清除 NG 标识
2. 在 `ResetSopFault()` 中调用 `ClearNgSuffix()` 清除文件名后缀

```csharp
public void ResetSopFault(SopFaultResetContext? context)
{
    // ... 原有逻辑 ...
    
    // 清除 NG 录制状态（标识 + 文件名后缀）
    _pipeline?.MarkNgCleared();
    _pipeline?.ClearNgSuffix();
}
```

### 7.5 Bug：同一 NG 生成多个分段

**现象**：报一个 NG，出现多个 `_ng` 后缀的视频文件。

**根因**：`OnAnalysisResult` 每帧调用一次，`MarkNg()` 被多次触发。虽然 `_hasNgInRecording` 保护了文件名后缀逻辑，但 `RequestRotate()` 仍被多次调用，导致频繁切分段。

**修复**：使用 `Interlocked.Exchange` 原子操作，保证同一 NG 事件只触发一次旋转：

```csharp
private int _ngRotationDone;

public void MarkNg()
{
    _hasNgInRecording = true;
    _currentSegmentIsNg = true;
    // 使用 Interlocked 保证只触发一次旋转
    if (Interlocked.Exchange(ref _ngRotationDone, 1) == 0)
    {
        RequestRotate("ng");
    }
}

public void MarkNgCleared()
{
    _currentSegmentIsNg = false;
    _ngRotationDone = 0; // 重置，为下一次 NG 事件准备
}

public void ClearNgSuffix()
{
    _hasNgInRecording = false;
}
```

### 7.6 调用链路

```
CameraSessionViewModel.OnAnalysisResult()
    ├── NG 发生：_pipeline.MarkNg()
    │       ↓
    │   SegmentedVideoRecorder.MarkNg()
    │       ↓
    │   _hasNgInRecording = true    // 文件名永久 _ng
    │   _currentSegmentIsNg = true  // 绘制 NG 标识
    │   _ngRotationDone = 1         // 原子标记，防止重复旋转
    │   RequestRotate()             // 首次调用才切分段
    │
    └── SOP 重置：_pipeline.MarkNgCleared()
            ↓
        SegmentedVideoRecorder.MarkNgCleared()
            ↓
        _currentSegmentIsNg = false  // NG 标识消失
        _ngRotationDone = 0         // 重置，为下次 NG 准备
```

### 7.6 动态绘制机制

`SegmentWriter` 使用 `Func<bool>` 引用 `_currentSegmentIsNg`，每帧写入时实时读取：

```csharp
// 传入闭包，每次 Write 时动态读取
new SegmentWriter(..., () => _currentSegmentIsNg);

public void Write(Mat frame, long ptsMs)
{
    if (_isNg())  // 每帧实时判断
    {
        DrawNgOverlay(frame);
    }
}
```

### 7.8 实现文件

| 文件 | 修改内容 |
|------|----------|
| `FrameRecorder.cs` | 添加 `MarkNg()`、`MarkNgCleared()`、`ClearNgSuffix()` 接口 |
| `SegmentedVideoRecorder.cs` | 添加状态字段、绘制逻辑、分段后缀、`ClearNgSuffix()` |
| `PipelineRecorderCoordinator.cs` | 暴露 `MarkNg()`、`MarkNgCleared()`、`ClearNgSuffix()` |
| `PipelineSmartRecordingCoordinator.cs` | 暴露 `MarkNg()`、`MarkNgCleared()`、`ClearNgSuffix()` |
| `VideoPipeline.cs` | 暴露 `MarkNg()`、`MarkNgCleared()`、`ClearNgSuffix()` |
| `CameraSessionViewModel.cs` | NG 发生时调用 `MarkNg()`，SOP 重置时调用 `MarkNgCleared()`，手动复位时调用 `MarkNgCleared()` + `ClearNgSuffix()` |

### 7.9 效果示例

```
录制中无 NG：
  20260826_135300.mp4        ← 无 _ng 后缀

录制中出现 NG → SOP 重置：
  20260826_135400_ng.mp4     ← 有 _ng 后缀（全程）
       ↑ NG 期间显示红色 NG 标识
                                    ↑ SOP 重置后 NG 标识消失
```

---

## 8. 待优化项

1. **日志文件轮转**：当前按日期分割，长时间运行可能导致日志文件过大
2. **录制时长可配置**：目前硬编码 5 分钟，可考虑开放配置
3. **录制状态监控**：添加录制中/录制停止的 UI 状态显示
4. **NG 标识样式可配置**：目前硬编码 120×120px，可考虑开放配置
