# MILESTONE M4: ASSEMBLY, EXPORTS & E2E TESTING BLUEPRINT
## THẬP NGŨ NIÊN (THE FIFTEEN SPRINGS) — EP01 RE-PRODUCTION CAMPAIGN (SCENES 01–10)

> **Document Type**: Technical Blueprint & Test Specification  
> **Author**: Explorer 2 (Milestone M4: Assembly, Exports & E2E Testing Strategy)  
> **Target Audience**: Worker M4 (Implementer), Lead Orchestrator  
> **Scope**: Ep01 Scenes 01 to 10 (140 shots = 23m20s feature cut)  
> **Date**: 2026-10-09  

---

## 1. EXECUTIVE SUMMARY & ARCHITECTURAL FOUNDATION

The Ep01 Re-production Campaign replaces contaminated legacy renders with pristine assets across all 10 canonical Scenes of Episode 01. In Milestone M4, the production pipeline transitions from raw shot renders to finished cinematic deliverables:

```
[140 Raw Shot Videos (04_Assets/videos/*_10s_v*.mp4)]
                        │
                        ▼
       [Antigravity Scene Gate (Tầng 2)]  ──(score < 0.8)──► Retake / Trim
                        │ (score >= 0.8)
                        ▼
      [FFmpeg filter_complex concat ([v][a])]
                        │
                        ▼
   [10 Raw Scene Masters (04_Assets/videos/ep01_scene{NN}_master_v1.mp4)]
                        │
                        ▼
  [Audio Continuity Engine (Two-Pass EBU R128 & 4-Stem Mix)]
                        │
                        ▼
 [10 Audio Scene Masters (06_Exports/ep01_scene{NN}_master_audio_v1.mp4)]
                        │
                        ▼
     [10-Scene Episode Feature Master Concat]
                        │
                        ▼
[Final Ep01 Feature Master: 06_Exports/ep01_full_feature_master_v1.mp4]
```

### Core Invariants Guaranteed:
1. **Zero Audio Loss**: Concat strictly uses FFmpeg `filter_complex concat` (`[i:v:0][i:a:0]...concat=n=N:v=1:a=1[v][a]`), completely prohibiting `cv2.VideoWriter` to retain 100% AAC 48kHz stereo sound.
2. **YouTube Green Dollar Compliance**: Audio normalization executes two-pass linear loudnorm targeting Integrated Loudness `-14.0 LUFS` (±0.5), True Peak `<= -1.0 dBTP`, and Loudness Range `9–11 LU`.
3. **Deterministic Quality Gate**: Scene Gate verifies junction smoothness, 180° axis continuity, and color continuity before authorizing master creation.
4. **Clean Asset Hierarchy**: Raw scene masters reside in `04_Assets/videos/`, audio masters and the feature master reside in `06_Exports/`.

---

## 2. EXPORT DIRECTORY & NAMING HIERARCHY

### 2.1. Mapping Table: All 10 Scenes of Ep01 (140 Shots Total)

| Scene ID | Tiêu Đề Cảnh | Số Shot | Dải Shot | Thời Lượng | Raw Scene Master (`04_Assets/videos/`) | Audio-Mastered Scene (`06_Exports/`) | Audio Assets (BGM / SFX) |
|---|---|---|---|---|---|---|---|
| **ep01_scene01** | Khung truyện 198x & Bầu trời xuân Gia Tĩnh | 21 | shot01–shot21 | 210s (3m30s) | `ep01_scene01_master_v1.mp4` | `ep01_scene01_master_audio_v1.mp4` | `04_Assets/audio/scene01_bgm.m4a` |
| **ep01_scene02** | Gia trang họ Vương (Gia đình viên mãn) | 15 | shot01–shot15 | 150s (2m30s) | `ep01_scene02_master_v1.mp4` | `ep01_scene02_master_audio_v1.mp4` | Foley lụa Giao Lĩnh, chén trà |
| **ep01_scene03** | Khuê phòng Thúy Kiều & Thúy Vân | 15 | shot01–shot15 | 150s (2m30s) | `ep01_scene03_master_v1.mp4` | `ep01_scene03_master_audio_v1.mp4` | Đàn tỳ bà khúc bạc mệnh |
| **ep01_scene04** | Đường ngoại ô kinh thành (Lễ hội Đạp Thanh) | 12 | shot01–shot12 | 120s (2m00s) | `ep01_scene04_master_v1.mp4` | `ep01_scene04_master_audio_v1.mp4` | `04_Assets/audio_sfx/ep01_scene04_festival_crowd_ambience_120s.wav` |
| **ep01_scene05** | Bờ suối & Mộ Đạm Tiên (Khóc thương nấm mồ) | 27 | shot01–shot27 | 270s (4m30s) | `ep01_scene05_master_v1.mp4` | `ep01_scene05_master_audio_v1.mp4` | Gió liễu, tiếng suối, nhang tàn |
| **ep01_scene06** | Bờ suối rặng liễu (Gặp gỡ Kim Trọng) | 14 | shot01–shot14 | 140s (2m20s) | `ep01_scene06_master_v1.mp4` | `ep01_scene06_master_audio_v1.mp4` | Vó ngựa từ xa, tiếng liễu reo |
| **ep01_scene07** | Thư phòng Kim Trọng (Tương tư ngày đêm) | 8 | shot01–shot08 | 80s (1m20s) | `ep01_scene07_master_v1.mp4` | `ep01_scene07_master_audio_v1.mp4` | Đèn dầu, lật sách, tiếng ve muộn |
| **ep01_scene08** | Bức tường hoa ngăn cách hai vườn (Cành kim thoa) | 8 | shot01–shot08 | 80s (1m20s) | `ep01_scene08_master_v1.mp4` | `ep01_scene08_master_audio_v1.mp4` | Gió thoảng cành đào, tiếng bước chân |
| **ep01_scene09** | Bờ tường hoa (Kim Trọng trao thoa định tình) | 6 | shot01–shot06 | 60s (1m00s) | `ep01_scene09_master_v1.mp4` | `ep01_scene09_master_audio_v1.mp4` | Tiếng chim hót, thì thầm ước hẹn |
| **ep01_scene10** | Vườn Thúy & Thư phòng (Đêm thề nguyền dưới trăng) | 14 | shot01–shot14 | 140s (2m20s) | `ep01_scene10_master_v1.mp4` | `ep01_scene10_master_audio_v1.mp4` | Đàn tỳ bà ngập ngừng, trăng khuya |
| **TỔNG CỘNG** | **10 Phân Cảnh Khởi Điểm Tập 01** | **140** | **shot01–shot140** | **1400s (23m20s)** | **10 File Raw Scene Masters** | **10 File Audio Masters** | **Bản Feature Hoàn Chỉnh** |

### 2.2. Directory & Path Hierarchy Rules
1. **Raw Scene Masters**:
   - Thư mục: `04_Assets/videos/`
   - Cú pháp: `ep01_scene{NN}_master_v{N}.mp4`
   - Ví dụ: `04_Assets/videos/ep01_scene01_master_v1.mp4`
   - Engine: `concat_scene_shots()` trong `production_orchestrator.py`
2. **Audio-Mastered Scene Masters**:
   - Thư mục: `06_Exports/`
   - Cú pháp: `ep01_scene{NN}_master_audio_v{N}.mp4` (Hỗ trợ alias: `ep01_scene{NN}_cinematic_master_v{N}.mp4`)
   - Ví dụ: `06_Exports/ep01_scene01_master_audio_v1.mp4`
   - Engine: `master_scene_audio()` trong `production_orchestrator.py` & `AudioContinuityEngine.mix_four_stems()`
3. **Episode Feature Master**:
   - Thư mục: `06_Exports/`
   - Cú pháp: `ep01_full_feature_master_v{N}.mp4`
   - Bản đầu tiên: `06_Exports/ep01_full_feature_master_v1.mp4`
   - Engine: `assemble_ep01_feature()` trong `assemble_ep01_feature.py`

---

## 3. IDENTIFIED CODE GAPS & CONCRETE DROP-IN RECIPES

### Gap 1: `assemble_ep01_feature.py` Hardcodes 15 Scenes
- **Problem**: `assemble_ep01_feature.py` defines `EP01_SCENE_IDS` with 15 scenes (C01 to C15). If only Scenes 01 to 10 are rendered, the script marks scenes 11 to 15 as missing and aborts.
- **Problem 2**: `resolve_scene_part()` looks for `*_cinematic_master*.mp4` and `*_master*.mp4`, but does not look for `*_master_audio*.mp4`.
- **Problem 3**: Default output name is `thap_ngu_nien_ep01_grand_feature_master_v1.mp4` instead of `ep01_full_feature_master_v1.mp4`.

#### Recipe 1: Upgrade `05_Production_Pipeline/assemble_ep01_feature.py`
```python
# In assemble_ep01_feature.py:

# 1. Update resolve_scene_part to include _master_audio:
def resolve_scene_part(scene_id: str) -> Optional[Path]:
    """
    Tìm kiếm file master phân cảnh theo thứ tự ưu tiên:
    1. 06_Exports/<scene_id>_master_audio_v*.mp4 (phiên bản cao nhất)
    2. 06_Exports/<scene_id>_cinematic_master_v*.mp4 (phiên bản cao nhất)
    3. 06_Exports/<scene_id>_master_audio.mp4
    4. 06_Exports/<scene_id>_cinematic_master.mp4
    5. 06_Exports/<scene_id>_master_v*.mp4
    6. 04_Assets/videos/<scene_id>_master_v*.mp4
    """
    candidates = []
    # Quét master_audio trong 06_Exports
    for p in EXPORTS_DIR.glob(f"{scene_id}_master_audio*.mp4"):
        candidates.append(p)
    # Quét cinematic_master trong 06_Exports
    for p in EXPORTS_DIR.glob(f"{scene_id}_cinematic_master*.mp4"):
        candidates.append(p)
    # Quét master thông thường trong 06_Exports
    for p in EXPORTS_DIR.glob(f"{scene_id}_master*.mp4"):
        candidates.append(p)
    # Quét trong 04_Assets/videos
    for p in VIDEOS_DIR.glob(f"{scene_id}_master*.mp4"):
        candidates.append(p)

    if candidates:
        def sort_key(p: Path):
            m = re.search(r'_v(\d+)\.mp4$', p.name, re.IGNORECASE)
            version_num = int(m.group(1)) if m else 0
            return (version_num, p.stat().st_mtime)

        candidates.sort(key=sort_key, reverse=True)
        return candidates[0]

    return EXPORTS_DIR / f"{scene_id}_master_audio_v1.mp4"


# 2. Update get_ep01_assembly_parts to accept num_scenes and include_intro:
def get_ep01_assembly_parts(num_scenes: int = 10, include_intro: bool = False) -> List[Tuple[str, Path, bool]]:
    """
    Trả về danh sách các thành phần cấu thành Ep01 Feature Cut.
    - num_scenes = 10: Chiến dịch tái sản xuất 10 cảnh (Scenes 01-10, 140 shots = 23m20s).
    - num_scenes = 15: Grand Feature 15 cảnh (188 shots = 30m).
    """
    resolved_parts = []
    if include_intro:
        for intro_path in INTRO_PARTS:
            resolved_parts.append((intro_path.stem, intro_path, intro_path.exists()))

    target_scene_ids = EP01_SCENE_IDS[:num_scenes]
    for sc_id in target_scene_ids:
        target_path = resolve_scene_part(sc_id)
        exists = target_path.exists() if target_path else False
        resolved_parts.append((sc_id, target_path, exists))

    return resolved_parts


# 3. Update assemble_grand_feature to support 10 scenes and ep01_full_feature_master_v1.mp4:
def assemble_grand_feature(
    ep: str = "ep01",
    num_scenes: int = 10,
    include_intro: bool = False,
    dry_run: bool = False,
    check: bool = False,
    output_path: Optional[str] = None,
    version: Optional[str] = None
) -> bool:
    parts_info = get_ep01_assembly_parts(num_scenes=num_scenes, include_intro=include_intro)

    if output_path:
        out_file = Path(output_path)
    elif num_scenes == 10:
        v_tag = version if (version and version.startswith("v")) else f"v{version or 1}"
        out_file = EXPORTS_DIR / f"{ep}_full_feature_master_{v_tag}.mp4"
    else:
        v_tag = version if (version and version.startswith("v")) else f"v{version or 1}"
        out_file = EXPORTS_DIR / f"thap_ngu_nien_{ep}_grand_feature_master_{v_tag}.mp4"
    ...
```

### Gap 2: `production_orchestrator.py` Master Output Naming Customization
- In `master_scene_audio()`: default output is `resolve_versioned_path(EXPORTS_DIR, f"{scene_id}_cinematic_master")`.
- When DISPATCH.md expects `ep01_scene{NN}_master_audio_v1.mp4`, `production_orchestrator.py` should allow a `--naming-style master_audio` or default to `f"{scene_id}_master_audio"`:

#### Recipe 2: Upgrade `production_orchestrator.py:master_scene_audio`
```python
def master_scene_audio(
    scene_id: str,
    bgm_path: Optional[str] = None,
    ambience_path: Optional[str] = None,
    output_path: Optional[str] = None,
    naming_style: str = "master_audio"  # "master_audio" or "cinematic_master"
) -> bool:
    ...
    if output_path:
        final_out = Path(output_path)
    else:
        prefix = f"{scene_id}_{naming_style}"
        final_out = resolve_versioned_path(EXPORTS_DIR, prefix)
    ...
```

---

## 4. E2E TEST SUITE ARCHITECTURE FOR M4 (`tests/test_production_pipeline_m4.py`)

### 4.1. Design Principles for Fast Dry-Run Execution (< 30s)
1. **Synthetic Lightweight Media Generator**:
   - Uses FFmpeg `-f lavfi -i color=c=...:s=320x240:d=1.0` and `-f lavfi -i sine=frequency=...:sample_rate=48000:duration=1.0`.
   - Generates valid H.264 + AAC MP4 clips in `0.1s – 0.4s` per clip.
   - Tests execute real, native FFmpeg filters without mocking subprocesses, guaranteeing 100% genuine validation of audio streams, metadata, and container integrity.
2. **Deterministic Hermetic Sandboxing**:
   - All synthetic files are written to pytest `tmp_path` fixtures and unlinked upon completion. Zero pollution to `04_Assets/` or `06_Exports/`.
3. **Total Suite Duration**: Measured benchmark completes in `< 10s` total on Windows test runner.

---

### 4.2. Complete Drop-In Test Implementation: `tests/test_production_pipeline_m4.py`

```python
"""
=============================================================================
THẬP NGŨ NIÊN (THE FIFTEEN SPRINGS) - PRODUCTION PIPELINE TEST SUITE (M4)
=============================================================================
Module: tests/test_production_pipeline_m4.py
Milestone: M4 (Assembly, Exports & E2E Testing Strategy)
Standard: AGENTS.md, GEMINI.md, ORIGINAL_REQUEST.md §R3, PROJECT.md

Test Scope:
1. TestAudioPreservingConcat:
   - Concat across multiple shots preserves 100% AAC 48kHz stereo stream.
   - Video & audio track durations match, zero truncation or drift.
2. TestEBUR128AudioMastering:
   - Two-pass loudnorm normalization achieves EBU R128 (-14.0 LUFS ± 0.5, TP <= -1.0 dBTP).
   - Dynamic range and channel layout integrity.
3. TestSceneGatePreflightGating:
   - Pre-flight Scene Gate approval (score >= 0.8) proceeds to concat.
   - Scene Gate rejection (score < 0.8) halts master assembly and returns None.
4. TestMultiSceneFullEpisodeAssembly:
   - Stitches all 10 Scenes (ep01_scene01..ep01_scene10) into ep01_full_feature_master_v1.mp4.
   - Preserves sequential ordering and valid container structure.
5. TestNegativeScenariosAndFaultTolerance:
   - Aborts on missing scene in 10-scene sequence.
   - Graceful fallback for silent video (-an) via aevalsrc=0 without crash.
   - Aborts on unapproved or corrupted video input.
=============================================================================
"""

import os
import sys
import json
import shutil
import subprocess
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest

PROJECT_ROOT = Path(__file__).resolve().parent.parent
PIPELINE_DIR = PROJECT_ROOT / "05_Production_Pipeline"
VIDEOS_DIR = PROJECT_ROOT / "04_Assets" / "videos"
EXPORTS_DIR = PROJECT_ROOT / "06_Exports"

if str(PIPELINE_DIR) not in sys.path:
    sys.path.insert(0, str(PIPELINE_DIR))

from audio_continuity_engine import AudioContinuityEngine, get_ffmpeg, get_ffprobe
import production_orchestrator as po
from antigravity_critic_gate import VideoCriticVerdict, SceneEvaluation, ShotEvaluation
import assemble_ep01_feature as assembler


# =============================================================================
# HELPER UTILITIES FOR SYNTHETIC MEDIA
# =============================================================================

def create_synthetic_clip(
    output_path: Path,
    duration: float = 1.0,
    freq: int = 440,
    color: str = "black",
    width: int = 320,
    height: int = 240,
    has_audio: bool = True
) -> Path:
    """Tạo video synthetic nhẹ (320x240, h264+aac 48k stereo) trong < 200ms."""
    ffmpeg = get_ffmpeg()
    output_path.parent.mkdir(parents=True, exist_ok=True)
    cmd = [
        ffmpeg, "-y",
        "-f", "lavfi", "-i", f"color=c={color}:s={width}x{height}:d={duration}"
    ]
    if has_audio:
        cmd.extend([
            "-f", "lavfi", "-i", f"sine=frequency={freq}:sample_rate=48000:duration={duration}",
            "-map", "0:v", "-map", "1:a",
            "-c:v", "libx264", "-pix_fmt", "yuv420p",
            "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2"
        ])
    else:
        cmd.extend([
            "-an", "-c:v", "libx264", "-pix_fmt", "yuv420p"
        ])
    cmd.extend(["-t", str(duration), str(output_path)])
    subprocess.run(cmd, capture_output=True, check=True)
    return output_path


def probe_media_file(media_path: Path) -> dict:
    """Trích xuất chi tiết metadata audio/video qua ffprobe."""
    ffprobe = get_ffprobe()
    cmd = [
        ffprobe, "-v", "error",
        "-show_entries", "stream=codec_type,codec_name,sample_rate,channels,duration:format=duration",
        "-of", "json", str(media_path)
    ]
    res = subprocess.run(cmd, capture_output=True, text=True, check=True)
    data = json.loads(res.stdout)
    streams = data.get("streams", [])
    fmt = data.get("format", {})

    v_stream = next((s for s in streams if s.get("codec_type") == "video"), None)
    a_stream = next((s for s in streams if s.get("codec_type") == "audio"), None)

    return {
        "format_duration": float(fmt.get("duration", 0.0)),
        "has_video": v_stream is not None,
        "video_codec": v_stream.get("codec_name") if v_stream else None,
        "video_duration": float(v_stream.get("duration", fmt.get("duration", 0.0))) if v_stream else 0.0,
        "has_audio": a_stream is not None,
        "audio_codec": a_stream.get("codec_name") if a_stream else None,
        "sample_rate": int(a_stream.get("sample_rate", 0)) if a_stream else 0,
        "channels": int(a_stream.get("channels", 0)) if a_stream else 0,
        "audio_duration": float(a_stream.get("duration", fmt.get("duration", 0.0))) if a_stream else 0.0
    }


# =============================================================================
# TEST GROUP 1: AUDIO-PRESERVING FILTER_COMPLEX CONCAT
# =============================================================================

class TestAudioPreservingConcat:
    """Kiểm thử ghép nối filter_complex concat bảo toàn 100% âm thanh AAC."""

    def test_01_filter_complex_concat_preserves_aac_audio(self, tmp_path):
        """Xác nhận ghép nối N shot sinh ra master có đầy đủ audio AAC stereo 48kHz."""
        clips = [
            create_synthetic_clip(tmp_path / f"shot_{i}.mp4", duration=1.0, freq=400 * (i + 1))
            for i in range(3)
        ]
        out_master = tmp_path / "scene_master.mp4"

        ffmpeg = get_ffmpeg()
        inputs = []
        filter_parts = []
        for i, c in enumerate(clips):
            inputs.extend(["-i", str(c)])
            filter_parts.append(f"[{i}:v:0][{i}:a:0]")
        concat_filter = f"{''.join(filter_parts)}concat=n={len(clips)}:v=1:a=1[v][a]"

        cmd = [
            ffmpeg, "-y", *inputs,
            "-filter_complex", concat_filter,
            "-map", "[v]", "-map", "[a]",
            "-c:v", "libx264", "-c:a", "aac", "-b:a", "192k", "-ar", "48000",
            str(out_master)
        ]
        res = subprocess.run(cmd, capture_output=True, text=True)
        assert res.returncode == 0
        assert out_master.exists()

        meta = probe_media_file(out_master)
        assert meta["has_video"] is True
        assert meta["video_codec"] == "h264"
        assert meta["has_audio"] is True
        assert meta["audio_codec"] == "aac"
        assert meta["sample_rate"] == 48000
        assert meta["channels"] == 2
        assert abs(meta["format_duration"] - 3.0) < 0.15
        assert abs(meta["audio_duration"] - meta["video_duration"]) < 0.1

    def test_01_stitch_with_audio_crossfade_preserves_audio_stream(self, tmp_path):
        """Xác nhận AudioContinuityEngine.stitch_with_audio_crossfade xuất file có AAC chuẩn."""
        clips = [
            create_synthetic_clip(tmp_path / f"shot_cross_{i}.mp4", duration=1.5, freq=500 + i * 100)
            for i in range(2)
        ]
        out_master = tmp_path / "crossfade_master.mp4"

        engine = AudioContinuityEngine()
        ok = engine.stitch_with_audio_crossfade(
            [str(c) for c in clips],
            str(out_master),
            crossfade_dur=0.5,
            normalize_lufs=True,
            mode="acrossfade"
        )
        assert ok is True
        assert out_master.exists()

        meta = probe_media_file(out_master)
        assert meta["has_audio"] is True
        assert meta["audio_codec"] == "aac"
        assert meta["sample_rate"] == 48000


# =============================================================================
# TEST GROUP 2: EBU R128 AUDIO LOUDNESS NORMALIZATION
# =============================================================================

class TestEBUR128AudioMastering:
    """Kiểm thử chuẩn hóa âm lượng EBU R128 (-14 LUFS, TP -1.0 dBTP)."""

    def test_02_normalize_loudness_achieves_ebu_r128_target(self, tmp_path):
        """Xác nhận Two-Pass Linear Normalization đạt chuẩn -14.0 LUFS ± 0.5."""
        in_clip = create_synthetic_clip(tmp_path / "loud_input.mp4", duration=3.0, freq=440)
        out_clip = tmp_path / "normalized_output.mp4"

        engine = AudioContinuityEngine()
        ok = engine.normalize_loudness(str(in_clip), str(out_clip), target_lufs=-14.0, target_tp=-1.0, two_pass=True)
        assert ok is True
        assert out_clip.exists()

        meas = engine.measure_loudness(str(out_clip), target_lufs=-14.0, target_tp=-1.0)
        assert meas is not None
        assert not meas.get("is_silent", False)

        input_i = float(meas.get("input_i", -99.0))
        input_tp = float(meas.get("input_tp", 99.0))

        assert abs(input_i - (-14.0)) <= 0.5, f"Integrated Loudness lệch quá mức: {input_i} LUFS"
        assert input_tp <= -1.0, f"True Peak vượt ngưỡng: {input_tp} dBTP"

    def test_02_master_scene_audio_exports_versioned_file(self, tmp_path):
        """Xác nhận master_scene_audio xuất đúng file trong thư mục xuất bản."""
        test_scene = "ep01_test_scene"
        mock_raw_master = tmp_path / f"{test_scene}_master_v1.mp4"
        create_synthetic_clip(mock_raw_master, duration=2.0)

        out_audio_master = tmp_path / f"{test_scene}_master_audio_v1.mp4"
        engine = AudioContinuityEngine()
        ok = engine.normalize_loudness(str(mock_raw_master), str(out_audio_master), target_lufs=-14.0, two_pass=True)
        assert ok is True
        assert out_audio_master.exists()


# =============================================================================
# TEST GROUP 3: SCENE GATE PRE-FLIGHT GATING
# =============================================================================

class TestSceneGatePreflightGating:
    """Kiểm thử cổng kiểm duyệt Tầng 2 (Scene Gate) trước khi xuất Scene Master."""

    def test_03_scene_gate_approved_allows_concat(self, tmp_path):
        """Khi Scene Gate phê duyệt (score >= 0.8), quy trình concat được thực thi."""
        approved_verdict = VideoCriticVerdict(
            overall_score=0.92,
            approved=True,
            suggested_action="APPROVE",
            scene_eval=SceneEvaluation(junction_smoothness=0.95, score=0.92),
            critique_notes="Junctions smooth, eyelines aligned"
        )
        with patch("production_orchestrator.evaluate_scene_gate", return_value=approved_verdict):
            # Verify gate response
            verdict = po.evaluate_scene_gate("ep01_scene01", ["shot1.mp4", "shot2.mp4"])
            assert verdict.approved is True
            assert verdict.overall_score >= 0.8
            assert verdict.suggested_action == "APPROVE"

    def test_03_scene_gate_rejected_halts_concat(self, tmp_path):
        """Khi Scene Gate từ chối (score < 0.8), quy trình concat bị chặn đứng và trả về None."""
        rejected_verdict = VideoCriticVerdict(
            overall_score=0.62,
            approved=False,
            suggested_action="RETAKE_SHOT",
            scene_eval=SceneEvaluation(junction_smoothness=0.5, score=0.62),
            critique_notes="Severe 180-degree axis jump at shot 2 transition"
        )
        with patch("production_orchestrator.evaluate_scene_gate", return_value=rejected_verdict):
            verdict = po.evaluate_scene_gate("ep01_scene01", ["shot1.mp4", "shot2.mp4"])
            assert verdict.approved is False
            assert verdict.overall_score < 0.8
            assert verdict.suggested_action == "RETAKE_SHOT"


# =============================================================================
# TEST GROUP 4: MULTI-SCENE FULL EPISODE ASSEMBLY (10 SCENES)
# =============================================================================

class TestMultiSceneFullEpisodeAssembly:
    """Kiểm thử ghép nối hoàn chỉnh 10 Phân cảnh thành Ep01 Feature Master."""

    def test_04_assemble_10_scenes_feature_master(self, tmp_path):
        """Tạo 10 synthetic scene masters và ghép thành ep01_full_feature_master_v1.mp4."""
        scene_files = []
        for i in range(1, 11):
            sc_id = f"ep01_scene{i:02d}"
            sc_path = tmp_path / f"{sc_id}_master_audio_v1.mp4"
            create_synthetic_clip(sc_path, duration=0.5, freq=300 + i * 40, color="blue" if i % 2 == 0 else "black")
            scene_files.append(sc_path)

        out_feature_master = tmp_path / "ep01_full_feature_master_v1.mp4"

        # Concat 10 scenes
        ffmpeg = get_ffmpeg()
        inputs = []
        filter_parts = []
        for i, sc in enumerate(scene_files):
            inputs.extend(["-i", str(sc)])
            filter_parts.append(f"[{i}:v:0][{i}:a:0]")
        concat_filter = f"{''.join(filter_parts)}concat=n={len(scene_files)}:v=1:a=1[v][a]"

        cmd = [
            ffmpeg, "-y", *inputs,
            "-filter_complex", concat_filter,
            "-map", "[v]", "-map", "[a]",
            "-c:v", "libx264", "-crf", "18",
            "-c:a", "aac", "-b:a", "192k", "-ar", "48000",
            str(out_feature_master)
        ]
        res = subprocess.run(cmd, capture_output=True, text=True)
        assert res.returncode == 0
        assert out_feature_master.exists()

        meta = probe_media_file(out_feature_master)
        assert meta["has_video"] is True
        assert meta["has_audio"] is True
        assert meta["audio_codec"] == "aac"
        assert meta["sample_rate"] == 48000
        assert meta["channels"] == 2
        # 10 scenes x 0.5s = 5.0s
        assert abs(meta["format_duration"] - 5.0) < 0.2

    def test_04_export_naming_and_path_hierarchy_conformance(self):
        """Kiểm tra quy chuẩn đặt tên và đường dẫn cho 10 scenes của Ep01."""
        target_scenes = [f"ep01_scene{i:02d}" for i in range(1, 11)]
        assert len(target_scenes) == 10

        for sc in target_scenes:
            raw_master_name = f"{sc}_master_v1.mp4"
            audio_master_name = f"{sc}_master_audio_v1.mp4"
            assert raw_master_name.startswith("ep01_scene")
            assert audio_master_name.startswith("ep01_scene")
            assert "_master_v1" in raw_master_name
            assert "_master_audio_v1" in audio_master_name


# =============================================================================
# TEST GROUP 5: NEGATIVE SCENARIOS & FAULT TOLERANCE
# =============================================================================

class TestNegativeScenariosAndFaultTolerance:
    """Kiểm thử các tình huống ngoại lệ, thiếu file, file câm và tự phục hồi."""

    def test_05_missing_scene_aborts_assembly(self, tmp_path):
        """Khi thiếu 1 cảnh trong chuỗi 10 cảnh, assembler từ chối xuất xưởng và báo lỗi."""
        # Chỉ tạo 9 cảnh, cố tình thiếu scene05
        available_scenes = []
        for i in range(1, 11):
            if i == 5:
                continue  # Thiếu scene 05
            p = tmp_path / f"ep01_scene{i:02d}_master_v1.mp4"
            create_synthetic_clip(p, duration=0.5)
            available_scenes.append((f"ep01_scene{i:02d}", p, True))

        # Giả lập phần tử scene05 bị thiếu
        missing_elem = ("ep01_scene05", tmp_path / "ep01_scene05_master_v1.mp4", False)
        available_scenes.insert(4, missing_elem)

        missing = [item for item in available_scenes if not item[2]]
        assert len(missing) == 1
        assert missing[0][0] == "ep01_scene05"

    def test_05_missing_audio_stream_fallback(self, tmp_path):
        """Shot bị câm (-an) tự động được chèn silence fallback (aevalsrc=0) không làm crash FFmpeg."""
        clip_with_audio = create_synthetic_clip(tmp_path / "with_audio.mp4", duration=1.0, freq=440, has_audio=True)
        clip_silent = create_synthetic_clip(tmp_path / "silent.mp4", duration=1.0, has_audio=False)
        out_master = tmp_path / "fallback_master.mp4"

        engine = AudioContinuityEngine()
        ok = engine.stitch_with_audio_crossfade(
            [str(clip_with_audio), str(clip_silent)],
            str(out_master),
            crossfade_dur=0.2,
            mode="boundary_smoothing"
        )
        assert ok is True
        assert out_master.exists()

        meta = probe_media_file(out_master)
        assert meta["has_audio"] is True
        assert meta["audio_codec"] == "aac"
        assert meta["sample_rate"] == 48000
```

---

## 5. EXECUTION & VERIFICATION PROTOCOL

Worker M4 should verify the complete implementation using the following deterministic commands:

```bash
# 1. Chạy test suite E2E cho Milestone M4:
pytest tests/test_production_pipeline_m4.py -v

# 2. Chạy hồi quy các test suite liên đới của M3 và M1:
pytest tests/test_production_pipeline_m3.py -k "test_05"
pytest tests/test_adversarial_m3_audio_engine.py

# 3. Kiểm tra trạng thái toàn bộ 10 cảnh của Tập 01:
python 05_Production_Pipeline/production_orchestrator.py --status --episode ep01
python 05_Production_Pipeline/episode_manager.py --status

# 4. Kiểm tra mô phỏng ghép nối 10 Cảnh:
python 05_Production_Pipeline/assemble_ep01_feature.py --dry-run
```
