diff --git a/docs/JSON_OUTPUT_SPEC.md b/docs/JSON_OUTPUT_SPEC.md new file mode 100644 index 0000000..5a386cc --- /dev/null +++ b/docs/JSON_OUTPUT_SPEC.md @@ -0,0 +1,503 @@ +# Momentry JSON 輸出檔案規範 + +本文檔定義 Momentry Core 系統中所有 JSON 輸出檔案的結構、命名規範與儲存位置。 + +--- + +## 1. 輸出檔案總覽 + +### 1.1 檔案類型 + +| 類型 | 前綴 | 說明 | 狀態 | +|------|------|------|------| +| **Probe** | `{uuid}.probe.json` | 影片元數據 | ✅ 已實作 | +| **ASR** | `{uuid}.asr.json` | 語音識別結果 | ✅ 已實作 | +| **ASRx** | `{uuid}.asrx.json` | 說話者分離 | 🔜 規劃中 | +| **OCR** | `{uuid}.ocr.json` | 文字辨識結果 | 🔜 規劃中 | +| **YOLO** | `{uuid}.yolo.json` | 物件偵測結果 | 🔜 規劃中 | +| **Face** | `{uuid}.face.json` | 人臉偵測結果 | 🔜 規劃中 | +| **Pose** | `{uuid}.pose.json` | 姿態估計結果 | 🔜 規劃中 | +| **Thumbnail** | `{uuid}/thumb_XXX.jpg` | 縮圖檔案 | ✅ 已實作 | + +### 1.2 命名規範 + +``` +{UUID}.{類型}.json + +範例: +1636719dc31f78ac.probe.json - 影片探測結果 +1636719dc31f78ac.asr.json - 語音識別結果 +1636719dc31f78ac.ocr.json - 文字辨識結果 +``` + +- **UUID**: 16 字元,基於檔案路徑計算 +- **類型**: 小寫 snake_case +- **副檔名**: `.json` + +--- + +## 2. 輸出目錄結構 + +### 2.1 預設輸出位置 + +``` +momentry_core_0.1/ +├── {uuid}.probe.json # 影片探測 +├── {uuid}.asr.json # 語音識別 +├── {uuid}.asrx.json # 說話者分離 +├── {uuid}.ocr.json # 文字辨識 +├── {uuid}.yolo.json # 物件偵測 +├── {uuid}.face.json # 人臉偵測 +├── {uuid}.pose.json # 姿態估計 +└── thumbnails/ + └── {uuid}/ + ├── thumb_000.jpg + ├── thumb_001.jpg + └── ... +``` + +### 2.2 儲存策略 + +| 資料類型 | 儲存位置 | 說明 | +|----------|----------|------| +| JSON 檔案 | 專案根目錄 | 方便快速存取 | +| 縮圖 | thumbnails/{uuid}/ | 分離儲存 | +| 資料庫 | PostgreSQL | 長期儲存 | + +--- + +## 3. JSON 結構定義 + +### 3.1 Probe (影片探測) + +**檔案**: `{uuid}.probe.json` + +```json +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_type": "video", + "width": 1920, + "height": 1080, + "r_frame_rate": "60000/1001", + "duration": "6879.329524", + "sample_rate": null, + "channels": null + }, + { + "index": 1, + "codec_name": "aac", + "codec_type": "audio", + "width": null, + "height": null, + "r_frame_rate": "0/0", + "duration": "6879.245333", + "sample_rate": "48000", + "channels": 2 + } + ], + "format": { + "filename": "/path/to/video.mov", + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "duration": "6879.329524", + "size": "2361629896", + "bit_rate": "2748000" + } +} +``` + +**欄位說明**: + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `streams` | Array | 媒體串流陣列 | +| `streams[].index` | Integer | 串流索引 | +| `streams[].codec_name` | String | 編碼名稱 | +| `streams[].codec_type` | String | 串流類型 (video/audio) | +| `streams[].width` | Integer | 寬度 (video) | +| `streams[].height` | Integer | 高度 (video) | +| `streams[].r_frame_rate` | String | 幀率 | +| `streams[].duration` | String | 持續時間 (秒) | +| `streams[].sample_rate` | String | 採樣率 (audio) | +| `streams[].channels` | Integer | 聲道數 (audio) | +| `format` | Object | 檔案格式資訊 | +| `format.filename` | String | 原始檔案路徑 | +| `format.format_name` | String | 格式名稱 | +| `format.duration` | String | 總時長 (秒) | +| `format.size` | String | 檔案大小 (bytes) | +| `format.bit_rate` | String | 位元率 | + +--- + +### 3.2 ASR (語音識別) + +**檔案**: `{uuid}.asr.json` + +```json +{ + "language": "en", + "language_probability": 0.9945855736732483, + "segments": [ + { + "start": 0.0, + "end": 19.04, + "text": "Hello and welcome to the old-time movie show." + }, + { + "start": 19.04, + "end": 25.44, + "text": "Today we are featuring the 1963 comedy mystery film Charade." + } + ] +} +``` + +**欄位說明**: + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `language` | String | 偵測語言代碼 (ISO 639-1) | +| `language_probability` | Float | 語言偵測機率 (0-1) | +| `segments` | Array | 語音分段陣列 | +| `segments[].start` | Float | 開始時間 (秒) | +| `segments[].end` | Float | 結束時間 (秒) | +| `segments[].text` | String | 識別文字 | + +--- + +### 3.3 ASRx (說話者分離) + +**檔案**: `{uuid}.asrx.json` + +```json +{ + "language": "en", + "language_probability": 0.95, + "segments": [ + { + "start": 0.0, + "end": 19.04, + "text": "Hello and welcome to the old-time movie show.", + "speaker_id": "SPEAKER_00", + "speaker_embedding": [0.123, -0.456, ...] + }, + { + "start": 19.04, + "end": 25.44, + "text": "Today we are featuring the 1963 comedy mystery film Charade.", + "speaker_id": "SPEAKER_01", + "speaker_embedding": [0.789, -0.123, ...] + } + ] +} +``` + +**欄位說明**: + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `language` | String | 偵測語言代碼 | +| `language_probability` | Float | 語言偵測機率 | +| `segments` | Array | 語音分段陣列 | +| `segments[].start` | Float | 開始時間 (秒) | +| `segments[].end` | Float | 結束時間 (秒) | +| `segments[].text` | String | 識別文字 | +| `segments[].speaker_id` | String | 說話者 ID | +| `segments[].speaker_embedding` | Array | 說話者嵌入向量 (可選) | + +--- + +### 3.4 OCR (文字辨識) + +**檔案**: `{uuid}.ocr.json` + +```json +{ + "segments": [ + { + "start": 10.5, + "end": 12.3, + "text": "EXAMPLE TEXT", + "boxes": [ + { + "x1": 100, + "y1": 50, + "x2": 400, + "y2": 100 + } + ], + "confidence": 0.95 + } + ] +} +``` + +**欄位說明**: + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `segments` | Array | OCR 分段陣列 | +| `segments[].start` | Float | 開始時間 (秒) | +| `segments[].end` | Float | 結束時間 (秒) | +| `segments[].text` | String | 辨識文字 | +| `segments[].boxes` | Array | 文字邊界框陣列 | +| `segments[].boxes[].x1` | Integer | 左上 X 座標 | +| `segments[].boxes[].y1` | Integer | 左上 Y 座標 | +| `segments[].boxes[].x2` | Integer | 右下 X 座標 | +| `segments[].boxes[].y2` | Integer | 右下 Y 座標 | +| `segments[].confidence` | Float | 辨識信心度 | + +--- + +### 3.5 YOLO (物件偵測) + +**檔案**: `{uuid}.yolo.json` + +```json +{ + "segments": [ + { + "start": 0.0, + "end": 1.0, + "objects": [ + { + "class": "person", + "confidence": 0.92, + "box": { + "x1": 150, + "y1": 200, + "x2": 400, + "y2": 800 + } + }, + { + "class": "car", + "confidence": 0.87, + "box": { + "x1": 800, + "y1": 400, + "x2": 1200, + "y2": 700 + } + } + ] + } + ] +} +``` + +**欄位說明**: + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `segments` | Array | 時間分段陣列 | +| `segments[].start` | Float | 開始時間 (秒) | +| `segments[].end` | Float | 結束時間 (秒) | +| `segments[].objects` | Array | 偵測物件陣列 | +| `segments[].objects[].class` | String | 物件類別 | +| `segments[].objects[].confidence` | Float | 偵測信心度 | +| `segments[].objects[].box` | Object | 邊界框 | + +--- + +### 3.6 Face (人臉偵測) + +**檔案**: `{uuid}.face.json` + +```json +{ + "segments": [ + { + "start": 0.0, + "end": 1.0, + "faces": [ + { + "face_id": "face_001", + "box": { + "x1": 100, + "y1": 50, + "x2": 300, + "y2": 350 + }, + "embedding": [0.123, -0.456, ...], + "emotion": "happy", + "age": 35, + "gender": "female" + } + ] + } + ] +} +``` + +**欄位說明**: + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `segments` | Array | 時間分段陣列 | +| `segments[].start` | Float | 開始時間 (秒) | +| `segments[].end` | Float | 結束時間 (秒) | +| `segments[].faces` | Array | 人臉陣列 | +| `segments[].faces[].face_id` | String | 人臉 ID | +| `segments[].faces[].box` | Object | 邊界框 | +| `segments[].faces[].embedding` | Array | 人臉嵌入向量 | +| `segments[].faces[].emotion` | String | 情緒分類 (可選) | +| `segments[].faces[].age` | Integer | 年齡估計 (可選) | +| `segments[].faces[].gender` | String | 性別估計 (可選) | + +--- + +### 3.7 Pose (姿態估計) + +**檔案**: `{uuid}.pose.json` + +```json +{ + "segments": [ + { + "start": 0.0, + "end": 1.0, + "poses": [ + { + "person_id": "person_001", + "keypoints": { + "nose": {"x": 320, "y": 120, "confidence": 0.98}, + "left_eye": {"x": 335, "y": 110, "confidence": 0.95}, + "right_eye": {"x": 305, "y": 110, "confidence": 0.93}, + "left_shoulder": {"x": 280, "y": 180, "confidence": 0.91}, + "right_shoulder": {"x": 360, "y": 180, "confidence": 0.89} + }, + "confidence": 0.92 + } + ] + } + ] +} +``` + +**欄位說明**: + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `segments` | Array | 時間分段陣列 | +| `segments[].start` | Float | 開始時間 (秒) | +| `segments[].end` | Float | 結束時間 (秒) | +| `segments[].poses` | Array | 姿態陣列 | +| `segments[].poses[].person_id` | String | 人員 ID | +| `segments[].poses[].keypoints` | Object | 關鍵點 | +| `segments[].poses[].keypoints.{name}` | Object | 各關鍵點 | +| `segments[].poses[].keypoints.{name}.x` | Integer | X 座標 | +| `segments[].poses[].keypoints.{name}.y` | Integer | Y 座標 | +| `segments[].poses[].keypoints.{name}.confidence` | Float | 信心度 | +| `segments[].poses[].confidence` | Float | 整體信心度 | + +--- + +## 4. 處理流程 + +### 4.1 處理管線 + +``` +影片檔案 + │ + ▼ +┌─────────────────┐ +│ 1. Register │ 建立 UUID,註冊影片 +└────────┬────────┘ + │ + ┌────▼────┐ + │ 2. Probe │ ffprobe 擷取元數據 + └────┬────┘ + │ {uuid}.probe.json + ┌────▼─────┐ + │ 3. ASR │ faster-whisper 語音識別 + └────┬─────┘ + │ {uuid}.asr.json + ┌────▼──────┐ + │ 4. ASRx │ 說話者分離 (pyannote) + └────┬──────┘ + │ {uuid}.asrx.json + ┌────▼────┐ + │ 5. OCR │ Tesseract 文字辨識 + └────┬────┘ + │ {uuid}.ocr.json + ┌────▼────┐ + │ 6. YOLO │ 物件偵測 + └────┬────┘ + │ {uuid}.yolo.json + ┌────▼────┐ + │ 7. Face │ 人臉偵測 + └────┬────┘ + │ {uuid}.face.json + ┌────▼────┐ + │ 8. Pose │ 姿態估計 + └────┬────┘ + │ {uuid}.pose.json + ┌────▼──────┐ + │ 9. Chunk │ 轉換為資料庫 chunks + └───────────┘ +``` + +### 4.2 失敗處理 + +| 階段 | 失敗時 | 處理 | +|------|--------|------| +| Probe | 無法讀取影片 | 終止流程,輸出錯誤 | +| ASR | 無音軌 | 產生空 segments,繼續流程 | +| OCR/YOLO/Face/Pose | 處理失敗 | 跳過該階段,記錄日誌 | + +--- + +## 5. 資料庫儲存 + +### 5.1 Chunk 結構 + +```sql +CREATE TABLE chunks ( + id BIGSERIAL PRIMARY KEY, + uuid VARCHAR(16) NOT NULL, + chunk_id VARCHAR(64) NOT NULL, + chunk_index INTEGER NOT NULL, + chunk_type VARCHAR(32) NOT NULL, + start_time DOUBLE PRECISION NOT NULL, + end_time DOUBLE PRECISION NOT NULL, + content JSONB NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(uuid, chunk_id) +); +``` + +### 5.2 轉換範例 + +```rust +// ASR → Chunk (Sentence) +for (i, seg) in asr_result.segments.iter().enumerate() { + let chunk = Chunk::new( + uuid.clone(), + i as u32, + ChunkType::Sentence, + seg.start, + seg.end, + serde_json::json!({"text": seg.text}), + ); + db.store_chunk(&chunk).await?; +} +``` + +--- + +## 6. 版本歷史 + +| 版本 | 日期 | 變更 | +|------|------|------| +| 1.0.0 | 2026-03-16 | 初始版本 | + +--- + +## 7. 相關文件 + +- [RUST_DEVELOPMENT.md](./RUST_DEVELOPMENT.md) - Rust 開發規範 +- [AGENTS.md](../AGENTS.md) - 開發規範 +- [monitor_config.yaml](../monitor/config/monitor_config.yaml) - 監控配置