# 🏆 THE APEX AI-EDITOR SYSTEM (v6.2 — UNIFIED MASTER EDITION)
**Status:** Production-Grade · Defensively Coded · Zero-Hallucination · Self-Healing · Free/Local/Light/Stable
**Architecture:** 5-Stage Cognitive Loop + Tiered Vision (Multi-Key Rotation) + Audio-Driven Sync + 5-Tier QA Gate + Narrative Drama + Hybrid AI-Clip Ingest + Power Tips Vault
**Merge date:** 2026-07-29 (v6.2 vault merge) · prior: 2026-07-10
**Merge sources:**
- Apex v5.0 (prior master) — StateEngine, VFR protection, A/V drift fix, 5-stage loop, full vision_bridge.py
- `unified_master_agent_spec.md` v5.2 — auto-editor docs, mkvtoolnix reference, lightweight tool catalog
- `qwen_editor_adoptions.md` v5.1 — narrative drama beats, --ai-clips hybrid ingest, --pdf-ingest, Whisper 16kHz prep, W/H bugfix
- `The Expert Ai-Editor System.md` — real Hasler-Süsstrunk colorfulness, escapeforffmpeg_filter, critique gate
- `Ultimate-Ai-editor auto-agent.md` — `vision_bridge.py` with multi-key × multi-model rotation
- `helper-tools of Apex Ai-editor v4.md` — `web_research.py`, `registry.py`, `task_tracker.py`, `style_analyze.py`
- **v6.2 folder-wide tip merge (this edition):** `expert_tips.md`, `effects and edits.md`, `Videos-rules.md`, `APEX_EDITOR_WORTHY_NOTES_FACELESS_MANGA_PROD.md`, `Apex-Faceless-App-Production-Guide.md`, `Merged_AI_Visual_Judge_Orchestrator_SeedBank_v2.md`, `expert-editor-logic.md`, `brain_orchestrator.md`, `z ai notes/*` (upgrade candidates, content editing tips, recommendations — **not** media-manager/social), `Ai-cli agent editing.md`, `prodia_models_complete_guide.md` (handoff only). **Excluded:** third-party API provider config folders (not part of this editor master).

**What was fixed in v6.1:**
| Prior issue | Fix (v6) | Source |
|---|---|---|
| `colorfulness=0.5` placeholder | Real Hasler-Süsstrunk metric + k-means dominant colors | The Expert perceive.py |
| Hardcoded single-key vision | Multi-key × multi-model rotation via `KeyVault` + `ResilientVisionClient` | Ultimate vision_bridge.py |
| T3 stability signature unused | Wired into `master_qa_gate()` — re-renders and checks variance < 0.1s | v5.0 |
| No retry on transient FFmpeg | `run()` with stderr surfacing + 1 retry | The Expert core.py |
| `W, H` undefined in pipeline.py → NameError | Added `W, H = ASPECT_PRESETS[args.aspect]` after `require()` | adoptions v5.1 |
| Thread race in perceive_folder | Sequential local analysis + guarded cloud enrichment | v6.1 review |
| Ad-only beat template (5 beats) | Dual template: ad + narrative (7 beats) via `--mode` flag | adoptions v5.1 |
| No AI clip ingest | Hybrid CapCut/Runway clip interleaving via `--ai-clips` | adoptions v5.1 |
| No PDF input support | `--pdf-ingest` wires `extract_pdf_images()` into pipeline | adoptions v5.1 |
| Whisper format mismatch | `prep_audio_for_whisper()` forces 16kHz mono WAV | adoptions v5.1 |
| No container-only merge option | Documented mkvtoolnix zero-CPU path | unified v5.2 |
| Added PILLARs 12-15: Visual Judge + Brain Orchestrator + Theme Seeds + VLM Critique | Expert editor judgment, multi-agent coordination, visual memory, semantic API review |

**What was fixed in v6.2 (folder-wide tip vault):**
| Prior gap | Fix (v6.2) | Source |
|---|---|---|
| Tips scattered across 25+ sibling files | Single **PILLAR 16** vault adopted into this master | Full folder scan |
| No explicit free/local/light/stable charter | Resource + stability contract (Mac/Linux, unlimited time, no paid SaaS default) | User mandate + unified v5.2 |
| VLM token waste on full video | Tier-0 deterministic gates + 3-frame / contact-sheet protocol | expert_tips + Merged Judge v2 |
| AI clips on every beat | Selective AI (hero only) + never-extend-bad-clip rule | z-ai upgrade candidates |
| Missing stills→motion craft | `static_breathe`, 9:16 blurred-fill, zoom-bridge, optional 2.5D | effects + z-ai recs |
| Faceless force incomplete | Force flags + negative pack + face-leak override | Faceless notes + Videos-rules |
| PPTX / low-res panels | PPTX unzip recipe + min-side upscale gate (local free tools) | z-ai recs |
| HDR phone masters look wrong on social | HDR→SDR tonemap recipe | z-ai content editing tips |
| Hook / kit craft not in master | Platform hook math + ready content kits + post-effects seasoning | effects and edits.md |

---

## 🧠 EXPERT PHILOSOPHY
Most AI video scripts fail in production because they ignore edge cases. This system solves the **Six Deadly Sins**:

1. **VFR Trap** — Variable Frame Rate media breaks FFmpeg filters. *Fix:* force `-framerate 30` at ingest.
2. **A/V Drift** — Audio and video desync over long renders. *Fix:* `apad` + `-shortest`, validate drift < 0.5s; final audio `-ar 48000`.
3. **Context Decay** — LLMs forget the plan. *Fix:* `StateEngine` disk-checkpoints after every stage.
4. **False Positive** — Agent says "Done" but file is black/silent. *Fix:* 5-Tier QA gate physically blocks DONE.
5. **Blind Acceptance** — Agent accepts "looks fine" without structured verdict. *Fix:* visual_judge.py 3-tier gate (FAILED/PROPER/EXCELLENT) on every output.
6. **Face Leakage (faceless series)** — Eyes/mouth/nose appear when `--force-faceless` is on. *Fix:* gen negatives + OpenCV Gaussian 99×99 sanitize + semantic FAIL override even if pixel score is EXCELLENT.

### Operating Charter (v6.2 — non-negotiable)
| Goal | Rule |
|------|------|
| **Not heavy** | Prefer FFmpeg/ffprobe/ImageMagick/OpenCV local. No MoviePy default. No full-video VLM. Cap re-renders at 3. Proxy-preview effects on 1 frame first. |
| **Full free · unlimited time** | Default offline/local Mac or Linux server. Cloud vision optional only; `--no-cloud` always ships. Never require paid SaaS to ship. |
| **Valid + stable** | Three-state PASS/FAIL/INCONCLUSIVE; Tier-0 deterministic gates before any API; `final_gate` before "done"; numbered clips `01_…` to avoid shell sort bugs. |

---

## PILLAR 1: HARDENED ENVIRONMENT

Save as `install_apex.sh`. Idempotent · isolated venv · verified.

```bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

log() { printf "\033[1;34m[APEX]\033[0m %s\n" "$1"; }
err() { printf "\033[1;31m[APEX ERROR]\033[0m %s\n" "$1" >&2; exit 1; }

VENV_DIR="$HOME/apex-video-env"
MODEL_DIR="$HOME/apex-models"

# 1. Homebrew
if ! command -v brew >/dev/null 2>&1; then
  log "Installing Homebrew..."
  /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi

# 2. Core CLI tools
log "Installing core CLI tools..."
brew install ffmpeg imagemagick yt-dlp whisper-cpp ffmpeg-normalize pango cairo || true

# 3. Isolated venv
if [[ ! -d "$VENV_DIR" ]]; then
  log "Creating isolated Python 3.11 venv..."
  python3.11 -m venv "$VENV_DIR" || err "Python 3.11 not found. brew install python@3.11"
fi
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"

# 4. Python deps (pinned)
log "Installing Python dependencies..."
pip install --quiet --upgrade pip
pip install --quiet \
  "opencv-python-headless>=4.9" "pillow>=10.2" "numpy>=1.26" \
  "moviepy==1.0.3" "pydub>=0.25" "librosa>=0.10" "rapidfuzz>=3.6" \
  "scenedetect>=0.6.4" "pymupdf>=1.24" "soundfile>=0.12" \
  "requests>=2.31" "beautifulsoup4>=4.12" "duckduckgo-search>=5.0" \
  "cryptography>=42.0" "tqdm>=4.66"

# 5. Playwright (for social/web capture, optional)
pip install --quiet "playwright>=1.44"
playwright install chromium || log "Playwright chromium install skipped"

# 6. Whisper model (idempotent)
mkdir -p "$MODEL_DIR"
if [[ ! -f "$MODEL_DIR/ggml-small.bin" ]]; then
  log "Downloading Whisper small model (~500MB)..."
  curl -L --fail -o "$MODEL_DIR/ggml-small.bin" \
    "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin"
fi

# 7. Verification
log "Verifying installation..."
for bin in ffmpeg ffprobe magick whisper-cli ffmpeg-normalize; do
  command -v "$bin" >/dev/null || err "Missing binary: $bin"
done
python -c "import cv2, PIL, numpy, pydub, librosa, rapidfuzz, requests, fitz, scenedetect; print('✅ Python OK')"
log "🎉 Apex Environment Ready. Activate with: source $VENV_DIR/bin/activate"
```

---

## PILLAR 2: THE DEFENSIVE CORE

### 2.1 `core.py` — Foundation (state, VFR-safe runner, FFmpeg escaping)

```python
"""core.py — The Unbreakable Foundation. State, VFR, FFmpeg execution."""
from __future__ import annotations
import os, sys, json, shutil, subprocess, platform, logging, time, re
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Iterable, Sequence

logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s [%(levelname)s] %(message)s",
                    datefmt="%H:%M:%S")
log = logging.getLogger("apex-core")

IS_MAC = platform.system() == "Darwin"
IS_APPLE_SILICON = IS_MAC and platform.machine() == "arm64"


class CommandError(RuntimeError):
    pass


def run(cmd: Sequence[str], retry: int = 1, timeout: int | None = None,
        check: bool = True) -> subprocess.CompletedProcess:
    """Defensive runner. Surfaces stderr tail. Retries once on transient errors."""
    pretty = " ".join(str(c) for c in cmd)
    short = pretty if len(pretty) < 200 else pretty[:200] + "…"
    log.info("→ %s", short)
    attempt = 0
    while True:
        attempt += 1
        try:
            res = subprocess.run([str(c) for c in cmd],
                                 capture_output=True, text=True, timeout=timeout)
        except subprocess.TimeoutExpired as e:
            if attempt > retry:
                raise CommandError(f"Timeout: {pretty}") from e
            log.warning("Timeout, retrying (%d/%d)", attempt, retry + 1)
            time.sleep(2)
            continue
        if res.returncode == 0:
            return res
        if attempt > retry or not check:
            tail = "\n".join((res.stderr or "").strip().splitlines()[-15:])
            msg = tail if tail else "(no stderr)"
            if check:
                raise CommandError(
                    f"Command failed (exit {res.returncode}):\n{pretty}\n--- stderr ---\n{msg}")
            return res
        log.warning("Exit %d, retrying (%d/%d)", res.returncode, attempt, retry + 1)
        time.sleep(2)


def have(binary: str) -> bool:
    return shutil.which(binary) is not None


def require(binaries: Iterable[str]) -> None:
    missing = [b for b in binaries if not have(b)]
    if missing:
        raise CommandError(f"Missing binaries: {', '.join(missing)}")


def best_video_encoder(*, archive: bool = False) -> list[str]:
    """HW-accelerated GPU encoder discovery, zero-config.
    
    ponytail: H.264 for delivery (social/web compat), HEVC/AV1 only with archive=True.
    Ceiling: no AV1 auto-detect yet — upgrade path: add svt-av1 probe when needed.
    """
    try:
        res = subprocess.run(["ffmpeg", "-encoders"], capture_output=True, text=True, check=True)
        out = res.stdout.lower()
        if archive:
            if "hevc_videotoolbox" in out:
                return ["-c:v", "hevc_videotoolbox", "-b:v", "8M", "-tag:v", "hvc1", "-allow_sw", "1"]
            return ["-c:v", "libx265", "-preset", "medium", "-crf", "23", "-tag:v", "hvc1"]
        if "h264_videotoolbox" in out:
            return ["-c:v", "h264_videotoolbox", "-b:v", "8M", "-allow_sw", "1"]
        if "h264_nvenc" in out:
            return ["-c:v", "h264_nvenc", "-preset", "fast", "-cq", "23"]
        if "h264_qsv" in out:
            return ["-c:v", "h264_qsv", "-global_quality", "23"]
    except Exception:
        pass
    return ["-c:v", "libx264", "-preset", "medium", "-crf", "23"]



@dataclass
class MediaInfo:
    path: Path
    duration: float
    width: int = 0
    height: int = 0
    has_audio: bool = False
    fps: float = 0.0
    size_mb: float = 0.0


def probe(path: Path) -> MediaInfo:
    """Probe with ffprobe. Never guesses."""
    res = run(["ffprobe", "-v", "error", "-show_format", "-show_streams",
               "-of", "json", str(path)])
    data = json.loads(res.stdout)
    fmt = data.get("format", {})
    duration = float(fmt.get("duration", 0) or 0)
    size_mb = round(int(fmt.get("size", 0) or 0) / 1048576, 2)
    w = h = 0; fps = 0.0; has_audio = False
    for s in data.get("streams", []):
        if s.get("codec_type") == "video" and w == 0:
            w, h = int(s.get("width", 0)), int(s.get("height", 0))
            rate = s.get("avg_frame_rate", "0/1")
            try:
                num, den = rate.split("/")
                fps = round(int(num) / max(int(den), 1), 2)
            except Exception:
                fps = 0.0
        if s.get("codec_type") == "audio":
            has_audio = True
    return MediaInfo(path=path, duration=duration, width=w, height=h,
                     has_audio=has_audio, fps=fps, size_mb=size_mb)


def get_duration(path: Path) -> float:
    return probe(path).duration


# ── filesystem helpers ──
NAT_RE = re.compile(r"(\d+)")
def natural_key(p: Path) -> list:
    return [int(t) if t.isdigit() else t.lower()
            for t in NAT_RE.split(p.name)]


def list_images(root: Path) -> list[Path]:
    exts = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
    items = [p for p in root.iterdir() if p.suffix.lower() in exts]
    return sorted(items, key=natural_key)


def safe_clear(d: Path) -> None:
    if d.exists():
        shutil.rmtree(d)
    d.mkdir(parents=True, exist_ok=True)


def escape_for_ffmpeg_filter(p: Path) -> str:
    """Escape a path so FFmpeg -vf filter parser doesn't break on colons/quotes."""
    s = str(p.resolve())
    # ponytail: FFmpeg filter parser treats : ' [ ] as special
    return (s.replace("\\", "/").replace(":", r"\:")
             .replace("'", r"\'").replace("[", r"\[").replace("]", r"\]"))


ASPECT_PRESETS = {
    "9:16":  (1080, 1920),
    "1:1":   (1080, 1080),
    "16:9":  (1920, 1080),
    "4:5":   (1080, 1350),
    "21:9":  (2560, 1080),
}

# ponytail: platform delivery budgets — prevents rejected uploads.
# Ceiling: caps are 2025 values; update yearly. Upgrade: fetch from platform APIs.
PLATFORM_PRESETS = {
    "youtube":       {"aspect": "16:9", "codec": "h264", "fps": 30, "vbr": "8M",  "max_dur": None},
    "youtube_60":    {"aspect": "16:9", "codec": "h264", "fps": 60, "vbr": "12M", "max_dur": None},
    "youtube_short": {"aspect": "9:16", "codec": "h264", "fps": 30, "vbr": "8M",  "max_dur": 60},
    "reels":         {"aspect": "9:16", "codec": "h264", "fps": 30, "vbr": "8M",  "max_dur": 90},
    "tiktok":        {"aspect": "9:16", "codec": "h264", "fps": 30, "vbr": "8M",  "max_dur": 600},
    "stories":       {"aspect": "9:16", "codec": "h264", "fps": 30, "vbr": "6M",  "max_dur": 60},
    "archive":       {"aspect": None,   "codec": "hevc", "fps": None,"vbr": None, "max_dur": None},
}


class StateEngine:
    """Prevents context decay. Disk-checkpoints after every stage."""
    def __init__(self, work_dir: Path):
        self.path = work_dir / "state.json"
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.state = {"stages": {}, "completed": False}
        if self.path.exists():
            self.state = json.loads(self.path.read_text())
            log.info(f"🔄 Resuming from state: {list(self.state['stages'].keys())}")
        self._save()

    def is_done(self, stage: str) -> bool:
        return self.state["stages"].get(stage) == "done"

    def mark_done(self, stage: str):
        self.state["stages"][stage] = "done"
        self._save()
        log.info(f"✅ Stage '{stage}' checkpointed.")

    def finish(self):
        self.state["completed"] = True
        self._save()

    def _save(self):
        self.path.write_text(json.dumps(self.state, indent=2))
```

---

## PILLAR 3: PERCEIVE — TIERED VISION (REAL MATH + KEY ROTATION)

### 3.1 `vision_bridge.py` — Multi-key × Multi-model Cloud Vision (NEW)

Replaces the v4 single-key `enrich_with_cloud_vision`. Optional multi-key cloud vision (disabled by default / `--no-cloud`). Rotates `(key × model)` on 429/401/5xx; never chain-blocks. Local OpenCV always works alone.

```python
"""vision_bridge.py — Resilient cloud-vision layer. Never chain-blocks."""
from __future__ import annotations
import os, re, json, time, base64, logging
from pathlib import Path
from dataclasses import dataclass, asdict, field, fields
from typing import Optional, List, Dict, Tuple

try:
    import requests
except ImportError:
    raise SystemExit("pip install requests")

log = logging.getLogger("apex-core")

VISION_API_URL = os.getenv("VISION_API_URL", "").rstrip("/")  # optional; empty = skip cloud
CONFIG_DIR = Path.home() / ".apex_vision"
KEY_STORE = CONFIG_DIR / "api_keys.json"
USAGE_FILE = CONFIG_DIR / "usage_counters.json"
CACHE_FILE = CONFIG_DIR / "discovery_cache.json"
CACHE_SCHEMA = 3
CACHE_TTL = 86400

TEST_IMAGE_B64 = (
    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8"
    "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
MIME_MAP = {"jpg": "jpeg", "jpeg": "jpeg", "png": "png",
            "gif": "gif", "webp": "webp", "bmp": "bmp"}

CAPTION_PROMPT = (
    "You are a video editor's eye. Describe this image in ONE concise line "
    "for editorial selection. Include: subject, setting, mood, and shot type "
    "(e.g. 'close-up product hero of a watch on marble, luxury mood'). "
    "Then on a new line output 3-6 lowercase keyword tags separated by commas."
)

FALLBACK_VISION_MODELS = [
    ("meta-llama/llama-3.2-11b-vision-instruct:free", "Llama 3.2 Vision 11B"),
    ("qwen/qwen2.5-vl-72b-instruct:free",             "Qwen2.5 VL 72B"),
    ("qwen/qwen2.5-vl-32b-instruct:free",             "Qwen2.5 VL 32B"),
    ("mistralai/mistral-small-3.1-24b-instruct:free", "Mistral Small 3.1"),
    ("google/gemma-3-27b-it:free",                    "Gemma 3 27B"),
]


@dataclass
class VisionModel:
    id: str
    name: str
    rpd: int = 200
    rpm: int = 20
    working: bool = False
    latency: float = 0.0

    @classmethod
    def from_dict(cls, d: dict) -> "VisionModel":
        valid = {f.name for f in fields(cls)}
        return cls(**{k: v for k, v in d.items() if k in valid})


@dataclass
class VisionResult:
    caption: str
    tags: List[str]
    model: str
    source: str = "cloud"   # "cloud" | "local-fallback"


class KeyVault:
    """Loads keys from env (multi or single) or disk store. Per-key daily usage."""
    def __init__(self):
        self.keys: List[str] = []
        self._load_keys()
        self.usage = self._load_usage()

    def _load_keys(self):
        seen = set()
        
        # Git-safe Env Guard & zero-dependency local .env parser
        # ponytail: parses local .env without installing python-dotenv
        env_path = Path(".env")
        gitignore_path = Path(".gitignore")
        if env_path.exists():
            if gitignore_path.exists():
                git_content = gitignore_path.read_text()
                if ".env" not in git_content:
                    log.warning("⚠️ SECURITY WARNING: .env file is NOT in your .gitignore! Securing it...")
                    try:
                        with open(gitignore_path, "a") as f:
                            f.write("\n.env\n")
                    except OSError as e:
                        log.warning("Failed to auto-write to .gitignore: %s", e)
            try:
                for line in env_path.read_text().splitlines():
                    line = line.strip()
                    if line and not line.startswith("#") and "=" in line:
                        k, v = line.split("=", 1)
                        os.environ[k.strip()] = v.strip().strip("'\"")
            except Exception as e:
                log.warning("Failed to parse local .env file: %s", e)

        multi = os.getenv("VISION_API_KEYS", "") or os.getenv("CLOUD_VISION_API_KEYS", "")
        if multi:
            for k in multi.split(","):
                k = k.strip()
                if k and k not in seen:
                    self.keys.append(k); seen.add(k)
        single = os.getenv("VISION_API_KEY", os.getenv("CLOUD_VISION_API_KEY", "")).strip()
        if single and single not in seen:
            self.keys.append(single); seen.add(single)
        if KEY_STORE.exists():
            try:
                stored = json.loads(KEY_STORE.read_text())
                if isinstance(stored.get("keys"), list):
                    for k in stored["keys"]:
                        if k and k not in seen:
                            self.keys.append(k); seen.add(k)
                for v in stored.values():
                    if isinstance(v, str) and v.startswith("sk-") and v not in seen:
                        self.keys.append(v); seen.add(v)
            except (json.JSONDecodeError, OSError) as e:
                log.warning("KeyVault read error: %s", e)
        log.info("🔑 Loaded %d API key(s)", len(self.keys))

    def save_key(self, key: str):
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        store = {}
        if KEY_STORE.exists():
            try: store = json.loads(KEY_STORE.read_text())
            except Exception: store = {}
        keys = set(store.get("keys", []))
        keys.add(key)
        store["keys"] = sorted(keys)
        KEY_STORE.write_text(json.dumps(store, indent=2))
        try: os.chmod(KEY_STORE, 0o600)
        except OSError: pass
        if key not in self.keys:
            self.keys.append(key)
        log.info("🔐 Saved key (total %d)", len(self.keys))

    def _load_usage(self) -> Dict[str, int]:
        if USAGE_FILE.exists():
            try:
                data = json.loads(USAGE_FILE.read_text())
                today = time.strftime("%Y-%m-%d")
                return {k: v for k, v in data.get("counts", {}).items()
                        if k.endswith(today)}
            except (json.JSONDecodeError, OSError):
                return {}
        return {}

    def _save_usage(self):
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        try:
            USAGE_FILE.write_text(json.dumps({"counts": self.usage}, indent=2))
        except OSError: pass

    @staticmethod
    def _uk(key: str, model_id: str) -> str:
        tail = key[-6:]
        return f"{tail}|{model_id}|{time.strftime('%Y-%m-%d')}"

    def used(self, key: str, model_id: str) -> int:
        return self.usage.get(self._uk(key, model_id), 0)

    def bump(self, key: str, model_id: str):
        uk = self._uk(key, model_id)
        self.usage[uk] = self.usage.get(uk, 0) + 1
        self._save_usage()


class ResilientVisionClient:
    """Tries every (key × model) combo until one succeeds. Never raises."""
    def __init__(self, vault: KeyVault, models: List[VisionModel]):
        self.vault = vault
        self.models = models
        self.session = requests.Session()
        self._last_good: Optional[Tuple[str, str]] = None

    def _headers(self, key: str) -> dict:
        return {"Authorization": f"Bearer {key}",
                "Content-Type": "application/json",
                "HTTP-Referer": "https://localhost",
                "X-Title": "ApexVideoEditor"}

    def _combos(self) -> List[Tuple[str, VisionModel]]:
        combos: List[Tuple[str, VisionModel]] = []
        for key in self.vault.keys:
            for m in self.models:
                if self.vault.used(key, m.id) >= m.rpd:
                    continue
                combos.append((key, m))
        if self._last_good:
            lk, lm = self._last_good
            combos.sort(key=lambda c: not (c[0] == lk and c[1].id == lm))
        return combos

    def _call(self, key: str, model: VisionModel, b64: str, mime: str,
              prompt: str, max_retries: int = 2) -> Optional[str]:
        payload = {
            "model": model.id,
            "messages": [{"role": "user", "content": [
                {"type": "text", "text": prompt},
                {"type": "image_url",
                 "image_url": {"url": f"data:image/{mime};base64,{b64}"}},
            ]}],
            "max_tokens": 120, "temperature": 0.3,
        }
        for attempt in range(1, max_retries + 1):
            try:
                r = self.session.post(f"{VISION_API_URL}/chat/completions",
                                      headers=self._headers(key),
                                      json=payload, timeout=60)
            except requests.RequestException as e:
                log.warning("   net error (%d/%d): %s", attempt, max_retries, e)
                time.sleep(min(2 ** attempt, 15))
                continue
            if r.status_code == 200:
                self.vault.bump(key, model.id)
                self._last_good = (key, model.id)
                try:
                    return r.json()["choices"][0]["message"]["content"].strip()
                except (KeyError, IndexError, ValueError):
                    return None
            if r.status_code in (401, 403):
                log.warning("   🔒 key rejected (%s) — rotating", r.status_code)
                return None
            if r.status_code == 429:
                log.warning("   ⏳ 429 — marking exhausted, rotating")
                self.vault.usage[self.vault._uk(key, model.id)] = model.rpd
                self.vault._save_usage()
                return None
            if r.status_code >= 500:
                wait = min(2 ** attempt, 15)
                log.warning("   🛠 %s server err, retry in %ss", r.status_code, wait)
                time.sleep(wait)
                continue
            log.warning("   ⚠️ HTTP %s: %s", r.status_code, r.text[:100])
            return None
        return None

    def analyze(self, image_path: Path, prompt: str = CAPTION_PROMPT
                ) -> Optional[VisionResult]:
        try:
            b64 = base64.b64encode(Path(image_path).read_bytes()).decode()
        except OSError as e:
            log.error("   cannot read %s: %s", image_path, e)
            return None
        ext = Path(image_path).suffix.lower().lstrip(".")
        mime = MIME_MAP.get(ext, "png")

        combos = self._combos()
        if not combos:
            log.info("   (no cloud combos available — all exhausted)")
            return None
        for key, model in combos:
            log.info("   ☁️  %s via key …%s", model.name, key[-6:])
            text = self._call(key, model, b64, mime, prompt)
            if text:
                caption, tags = self._parse(text)
                return VisionResult(caption=caption, tags=tags,
                                    model=model.id, source="cloud")
        log.info("   (all cloud combos failed — local fallback)")
        return None

    @staticmethod
    def _parse(text: str) -> Tuple[str, List[str]]:
        lines = [l.strip() for l in text.splitlines() if l.strip()]
        caption = lines[0] if lines else text[:120]
        tags: List[str] = []
        for line in lines[1:]:
            if "," in line or len(line.split()) <= 8:
                tags = [t.strip().lower() for t in re.split(r"[,\n]", line) if t.strip()]
                break
        if not tags:
            tags = re.findall(r"[a-z]{4,}", caption.lower())[:6]
        return caption, tags[:6]


def discover_models(vault: KeyVault, force: bool = False) -> List[VisionModel]:
    """Discover & cache working free vision models, testing across keys."""
    if not force and CACHE_FILE.exists():
        try:
            cache = json.loads(CACHE_FILE.read_text())
            if (cache.get("schema") == CACHE_SCHEMA and
                    time.time() - cache.get("ts", 0) < CACHE_TTL):
                models = [VisionModel.from_dict(m) for m in cache.get("models", [])]
                if models:
                    log.info("📂 Cached vision discovery (%d models)", len(models))
                    return models
        except (json.JSONDecodeError, OSError):
            pass

    if not vault.keys:
        log.warning("⚠️ No API keys — cloud vision disabled (local-only)")
        return []

    log.info("🔍 Discovering free vision models…")
    session = requests.Session()
    discovered: List[VisionModel] = []
    live: List[Tuple[str, str]] = []

    for key in vault.keys:
        try:
            r = session.get(f"{VISION_API_URL}/models",
                            headers={"Authorization": f"Bearer {key}"}, timeout=30)
            if r.status_code == 200:
                for m in r.json().get("data", []):
                    mid = m.get("id", "")
                    pricing = m.get("pricing", {})
                    free = mid.endswith(":free") or (
                        float(pricing.get("prompt", 1) or 1) == 0 and
                        float(pricing.get("completion", 1) or 1) == 0)
                    mods = m.get("architecture", {}).get("input_modalities", [])
                    if free and "image" in mods:
                        live.append((mid, m.get("name", mid)[:30]))
                log.info("   🌐 %d live free vision models", len(live))
                break
        except requests.RequestException:
            continue

    candidates = live or FALLBACK_VISION_MODELS
    test_key = vault.keys[0]
    for mid, name in candidates[:6]:
        try:
            start = time.time()
            r = session.post(f"{VISION_API_URL}/chat/completions",
                headers={"Authorization": f"Bearer {test_key}",
                         "Content-Type": "application/json"},
                json={"model": mid, "max_tokens": 5, "messages": [{
                    "role": "user", "content": [
                        {"type": "text", "text": "hi"},
                        {"type": "image_url", "image_url": {
                            "url": f"data:image/png;base64,{TEST_IMAGE_B64}"}}]}]},
                timeout=30)
            lat = (time.time() - start) * 1000
            ok = r.status_code == 200
            log.info("   %s %s (%.0fms)", "✅" if ok else "❌", name, lat)
            if ok:
                discovered.append(VisionModel(id=mid, name=name, latency=lat, working=True))
        except requests.RequestException as e:
            log.info("   ❌ %s (%s)", name, e)
        time.sleep(1)

    if not discovered:
        discovered = [VisionModel(id=mid, name=name, working=True)
                      for mid, name in FALLBACK_VISION_MODELS]

    discovered.sort(key=lambda m: m.latency or 9e9)
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    try:
        CACHE_FILE.write_text(json.dumps({
            "schema": CACHE_SCHEMA, "ts": time.time(),
            "models": [asdict(m) for m in discovered]}, indent=2))
    except OSError:
        pass
    return discovered


class VisionBridge:
    """Façade. Lazy-init; degrades to None if no keys."""
    _instance: Optional["VisionBridge"] = None

    def __init__(self, force_discovery: bool = False):
        self.vault = KeyVault()
        self.models = discover_models(self.vault, force=force_discovery)
        self.client = (ResilientVisionClient(self.vault, self.models)
                       if self.models else None)
        self.enabled = self.client is not None

    @classmethod
    def get(cls, force: bool = False) -> "VisionBridge":
        if cls._instance is None:
            cls._instance = cls(force_discovery=force)
        return cls._instance

    def caption(self, image_path: Path) -> Optional[VisionResult]:
        if not self.enabled:
            return None
        return self.client.analyze(image_path)
```

### 3.2 `perceive.py` — Local OpenCV (REAL colorfulness) + Cloud on-demand

```python
"""perceive.py — Tiered recognition. Local OpenCV always, Cloud for heroes."""
from __future__ import annotations
import cv2, numpy as np, json
from dataclasses import dataclass, field, asdict
from pathlib import Path
from core import log, list_images
from vision_bridge import VisionBridge, VisionResult

# Load face cascade ONCE (not per-image)
FACE_CASCADE = cv2.CascadeClassifier(
    cv2.data.haarcascades + "haarcascade_frontalface_default.xml")


@dataclass
class ImageCard:
    path: str
    width: int
    height: int
    aspect: float
    brightness: float        # 0..1
    contrast: float          # 0..1
    sharpness: float         # variance of Laplacian / 500, capped 0..1
    colorfulness: float      # Hasler-Süsstrunk metric / 100, capped 0..1
    dominant_colors: list    # top-3 hex
    face_count: int
    face_boxes: list         # [(x,y,w,h), ...]
    is_portrait_subject: bool
    visual_weight: str       # 'hero' | 'support' | 'filler'
    tags: list[str] = field(default_factory=list)
    semantic_caption: str = ""
    semantic_tags: list = field(default_factory=list)
    caption_source: str = "none"   # none | cloud | local-fallback


def _brightness(gray: np.ndarray) -> float:
    return float(gray.mean() / 255.0)


def _contrast(gray: np.ndarray) -> float:
    return float(gray.std() / 128.0)


def _sharpness(gray: np.ndarray) -> float:
    lap = cv2.Laplacian(gray, cv2.CV_64F).var()
    return float(min(lap / 500.0, 1.0))


def _colorfulness(bgr: np.ndarray) -> float:
    """Hasler-Süsstrunk colorfulness metric. Normalized 0..1 (100 = vivid)."""
    (B, G, R) = cv2.split(bgr.astype("float"))
    rg = np.abs(R - G)
    yb = np.abs(0.5 * (R + G) - B)
    std_root = np.sqrt(rg.std() ** 2 + yb.std() ** 2)
    mean_root = np.sqrt(rg.mean() ** 2 + yb.mean() ** 2)
    raw = std_root + 0.3 * mean_root
    return float(min(raw / 100.0, 1.0))


def _dominant_colors(bgr: np.ndarray, k: int = 3) -> list[str]:
    small = cv2.resize(bgr, (80, 80))
    Z = small.reshape((-1, 3)).astype(np.float32)
    crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
    _, labels, centers = cv2.kmeans(Z, k, None, crit, 3, cv2.KMEANS_PP_CENTERS)
    counts = np.bincount(labels.flatten(), minlength=k)
    order = np.argsort(-counts)
    return ["#{:02x}{:02x}{:02x}".format(int(centers[i][2]),
                                         int(centers[i][1]),
                                         int(centers[i][0]))
            for i in order]


def _heuristic_tags(card: ImageCard) -> list[str]:
    tags = []
    if card.brightness < 0.30: tags.append("dark")
    elif card.brightness > 0.75: tags.append("bright")
    if card.contrast > 0.55: tags.append("high-contrast")
    if card.colorfulness > 0.55: tags.append("vibrant")
    elif card.colorfulness < 0.20: tags.append("muted")
    if card.sharpness < 0.20: tags.append("soft")
    elif card.sharpness > 0.70: tags.append("crisp")
    if card.aspect < 0.9: tags.append("portrait-frame")
    elif card.aspect > 1.5: tags.append("wide-frame")
    if card.face_count >= 1: tags.append("has-people")
    return tags


def _visual_weight(card: ImageCard) -> str:
    score = (card.sharpness * 0.4 + card.contrast * 0.3 +
             card.colorfulness * 0.3)
    if score > 0.55: return "hero"
    if score > 0.30: return "support"
    return "filler"


def _needs_cloud(card: ImageCard) -> bool:
    """Escalate when local signals are ambiguous OR asset is hero."""
    if card.visual_weight == "hero":
        return True
    if card.sharpness < 0.20 or card.brightness < 0.12:
        return True
    return False


def _enrich_with_cloud(card: ImageCard, use_cloud: bool = True) -> ImageCard:
    if not use_cloud or not _needs_cloud(card):
        return card
    bridge = VisionBridge.get()
    if not bridge.enabled:
        card.caption_source = "local-fallback"
        return card
    result: VisionResult | None = bridge.caption(Path(card.path))
    if result:
        card.semantic_caption = result.caption
        card.semantic_tags = result.tags
        card.caption_source = result.source
        card.tags = list(dict.fromkeys(card.tags + result.tags))
        log.info(f"   🧠 {Path(card.path).name} → \"{result.caption[:60]}\"")
    else:
        card.caption_source = "local-fallback"
    return card


def analyze_image(path: Path, use_cloud: bool = True) -> ImageCard:
    bgr = cv2.imread(str(path))
    if bgr is None:
        raise ValueError(f"Cannot read image: {path}")
    h, w = bgr.shape[:2]
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    faces = FACE_CASCADE.detectMultiScale(gray, 1.1, 4)
    face_boxes = [tuple(map(int, f)) for f in faces]
    portrait = any(fw * fh > 0.06 * w * h for (_, _, fw, fh) in face_boxes)

    card = ImageCard(
        path=str(path), width=w, height=h, aspect=round(w / max(h, 1), 3),
        brightness=round(_brightness(gray), 3),
        contrast=round(_contrast(gray), 3),
        sharpness=round(_sharpness(gray), 3),
        colorfulness=round(_colorfulness(bgr), 3),
        dominant_colors=_dominant_colors(bgr),
        face_count=len(face_boxes),
        face_boxes=face_boxes,
        is_portrait_subject=portrait,
        visual_weight="filler",
        tags=[],
    )
    card.visual_weight = _visual_weight(card)
    card.tags = _heuristic_tags(card)
    card = _enrich_with_cloud(card, use_cloud)
    return card


def perceive_folder(folder: Path, out_json: Path,
                    use_cloud: bool = True) -> list[ImageCard]:
    # ponytail: local analysis is CPU-bound; run sequentially (fast for <100 images)
    cards = [analyze_image(p, use_cloud=False) for p in list_images(folder)]
    if use_cloud:
        # ponytail: cloud calls mutate cards in-place — run sequentially to avoid
        # race condition. Rate limits make parallelism marginal gain anyway.
        # ceiling: O(n) serial cloud calls. Upgrade: copy-on-write + ThreadPool.
        for card in cards:
            _enrich_with_cloud(card, True)
    out_json.write_text(json.dumps(
        [{**asdict(c), "path": str(c.path)} for c in cards], indent=2))
    log.info(f"👁️ Perceived {len(cards)} assets → {out_json}")
    return cards
```

---

## PILLAR 4: INTERPRET — Beat-to-Asset Meaning Match

```python
"""interpret.py — Maps script beats to assets by meaning."""
from __future__ import annotations
from dataclasses import dataclass, asdict, field
from pathlib import Path
from rapidfuzz import fuzz
import re, json
from perceive import ImageCard
from core import log

try:
    from pydub import AudioSegment
    from pydub.silence import detect_silence
    _HAS_PYDUB = True
except ImportError:
    _HAS_PYDUB = False

BEAT_TEMPLATE_AD = [
    ("hook",      0.10, ["bright", "vibrant", "crisp", "hero"]),
    ("problem",   0.20, ["dark", "muted", "high-contrast"]),
    ("solution",  0.30, ["bright", "vibrant", "hero"]),
    ("proof",     0.20, ["crisp", "has-people", "support"]),
    ("cta",       0.20, ["bright", "hero", "vibrant"]),
]

BEAT_TEMPLATE_NARRATIVE = [
    ("cover",     0.08, ["hero", "dark", "high-contrast"]),
    ("setup",     0.12, ["muted", "support"]),
    ("conflict",  0.20, ["dark", "high-contrast", "has-people"]),
    ("turn",      0.18, ["vibrant", "crisp", "hero"]),
    ("climax",    0.22, ["bright", "vibrant", "hero"]),
    ("result",    0.12, ["crisp", "support"]),
    ("close",     0.08, ["muted", "bright"]),
]

# ponytail: dual template. Upgrade: user-defined beat YAML.
BEAT_TEMPLATES = {"ad": BEAT_TEMPLATE_AD, "narrative": BEAT_TEMPLATE_NARRATIVE}


@dataclass
class Beat:
    name: str
    duration_ratio: float
    desired_tags: list[str]
    script_text: str = ""
    chosen_assets: list[str] = field(default_factory=list)
    start_sec: float = 0.0
    end_sec: float = 0.0


def audio_driven_beats(audio_path: Path, n_beats: int) -> list[float]:
    """Derive beat boundaries from speech pauses. Returns duration ratios."""
    if not _HAS_PYDUB:
        return [1.0 / n_beats] * n_beats
    try:
        audio = AudioSegment.from_file(str(audio_path))
    except Exception:
        return [1.0 / n_beats] * n_beats
    total_ms = len(audio)
    if total_ms < 1000:
        return [1.0 / n_beats] * n_beats
    silences = detect_silence(audio, min_silence_len=400, silence_thresh=-35)
    # ponytail: O(n) scan, n < 100 for any speech. Upgrade: onset detection.
    if len(silences) < n_beats - 1:
        return [1.0 / n_beats] * n_beats
    scored = sorted(silences, key=lambda s: -(s[1] - s[0]))
    splits = sorted([(s[0] + s[1]) / 2 for s in scored[:n_beats - 1]])
    boundaries = [0] + splits + [total_ms]
    return [(boundaries[i + 1] - boundaries[i]) / total_ms for i in range(n_beats)]


def split_script(script: str, beats: int) -> list[str]:
    sentences = [s.strip() for s in re.split(r"(?<=[\.\!\?])\s+", script.strip()) if s.strip()]
    if not sentences:
        return [""] * beats
    per = max(1, len(sentences) // beats)
    chunks = [" ".join(sentences[i*per:(i+1)*per]) for i in range(beats)]
    if len(sentences) > per * beats:
        chunks[-1] += " " + " ".join(sentences[per*beats:])
    return chunks


def score(card: ImageCard, beat: Beat) -> float:
    tag_score = sum(1 for t in beat.desired_tags if t in card.tags) / max(len(beat.desired_tags), 1)
    weight_bonus = {"hero": 0.3, "support": 0.15, "filler": 0.0}[card.visual_weight]

    # Semantic caption match (the big upgrade vs heuristic-only)
    text_score = 0.0
    if beat.script_text:
        haystack = (card.semantic_caption + " " + " ".join(card.semantic_tags)).lower()
        if haystack.strip():
            text_score = fuzz.token_set_ratio(haystack, beat.script_text.lower()) / 100.0
        else:
            for t in card.tags:
                text_score = max(text_score, fuzz.partial_ratio(t, beat.script_text.lower()) / 100.0)

    return 0.40 * tag_score + 0.25 * weight_bonus + 0.35 * text_score


def assign_assets(cards: list[ImageCard], beats: list[Beat],
                  total_duration: float) -> list[Beat]:
    used = set()
    cursor = 0.0
    for beat in beats:
        beat.start_sec = cursor
        beat.end_sec = cursor + total_duration * beat.duration_ratio
        cursor = beat.end_sec
        ranked = sorted(
            ((c, score(c, beat)) for c in cards if str(c.path) not in used),
            key=lambda x: -x[1],
        )
        n_needed = max(1, round((beat.end_sec - beat.start_sec) / 4))
        picks = [c for c, _ in ranked[:n_needed]]
        if not picks and ranked:
            picks = [ranked[0][0]]
        beat.chosen_assets = [str(c.path) for c in picks]
        used.update(beat.chosen_assets)
    return beats


def build_storyboard(cards: list[ImageCard], script: str,
                     total_duration: float, out_json: Path,
                     audio_path: Path | None = None,
                     mode: str = "ad") -> list[Beat]:
    template = BEAT_TEMPLATES.get(mode, BEAT_TEMPLATE_AD)
    beats = [Beat(name=n, duration_ratio=r, desired_tags=list(t))
             for n, r, t in template]
    # Enhancement: audio-driven pacing overrides fixed ratios
    if audio_path and audio_path.exists():
        ratios = audio_driven_beats(audio_path, len(beats))
        for b, ratio in zip(beats, ratios):
            b.duration_ratio = ratio
        log.info("🎵 Beat ratios from audio pauses: %s",
                 [round(r, 3) for r in ratios])
    if script.strip():
        for b, txt in zip(beats, split_script(script, len(beats))):
            b.script_text = txt
    beats = assign_assets(cards, beats, total_duration)
    out_json.write_text(json.dumps([asdict(b) for b in beats], indent=2))
    log.info(f"📖 Storyboard: {len(beats)} beats → {out_json}")
    return beats
```

---

## PILLAR 5: COMPOSE — Motion + Transitions per Beat

```python
"""compose.py — Motion + transition per beat. Supports ad & narrative modes."""
MOTION_BY_BEAT_AD = {
    "hook":     ("zoom_in_fast",  "fade"),
    "problem":  ("zoom_out_slow", "fadeblack"),
    "solution": ("pan_right",     "slideleft"),
    "proof":    ("static_subtle", "dissolve"),
    "cta":      ("zoom_in_slow",  "fade"),
}

MOTION_BY_BEAT_NARRATIVE = {
    "cover":    ("zoom_in_slow",  "fadeblack"),
    "setup":    ("static_subtle", "fade"),
    "conflict": ("zoom_out_slow", "fadeblack"),
    "turn":     ("pan_right",     "slideleft"),
    "climax":   ("zoom_in_fast",  "dissolve"),
    "result":   ("static_subtle", "dissolve"),
    "close":    ("zoom_in_slow",  "fade"),
}


def motion_for(beat_name: str, mode: str = "ad") -> tuple[str, str]:
    table = MOTION_BY_BEAT_NARRATIVE if mode == "narrative" else MOTION_BY_BEAT_AD
    return table.get(beat_name, ("zoom_in_slow", "fade"))


def ken_burns_vf(motion: str, w: int, h: int, duration_sec: float,
                 fps: int = 30) -> str:
    frames = int(duration_sec * fps)
    if motion == "zoom_in_fast":
        z = "min(zoom+0.0025,1.4)"
    elif motion == "zoom_in_slow":
        z = "min(zoom+0.0012,1.25)"
    elif motion == "zoom_out_slow":
        # FIX: init at 1.25 on frame 1, then decrement
        z = "if(eq(on\\,1)\\,1.25\\,max(zoom-0.0012\\,1.0))"
    elif motion == "pan_right":
        z = "1.15"
    elif motion == "static_subtle":
        z = "min(zoom+0.0005,1.05)"
    else:
        z = "min(zoom+0.0012,1.25)"

    if motion == "pan_right":
        x = f"iw*0.05+(iw*0.1)*on/{frames}"
        y = "ih/2-(ih/zoom/2)"
    else:
        x = "iw/2-(iw/zoom/2)"
        y = "ih/2-(ih/zoom/2)"
    return (f"scale={w}:{h}:force_original_aspect_ratio=increase,"
            f"crop={w}:{h},"
            f"zoompan=z='{z}':x='{x}':y='{y}':"
            f"d={frames}:s={w}x{h}:fps={fps},format=yuv420p")
```

---

## PILLAR 6: REALIZE — VFR-Safe Render + xfade Math + Audio Drift Fix

```python
"""realize.py — Bulletproof rendering. VFR + xfade + A/V drift fixes."""
from __future__ import annotations
import shutil, json, cv2
from pathlib import Path
from core import (log, run, probe, get_duration, safe_clear,
                  best_video_encoder, escape_for_ffmpeg_filter, ASPECT_PRESETS)
from perceive import FACE_CASCADE
from compose import motion_for, ken_burns_vf


def sanitize_faces(src: Path, dst: Path) -> int:
    """Blur all detected faces. Privacy/safety gate — non-negotiable."""
    img = cv2.imread(str(src))
    if img is None:
        raise FileNotFoundError(src)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    faces = FACE_CASCADE.detectMultiScale(gray, 1.1, 4)
    for (x, y, w, h) in faces:
        roi = img[y:y+h, x:x+w]
        img[y:y+h, x:x+w] = cv2.GaussianBlur(roi, (99, 99), 30)
    cv2.imwrite(str(dst), img)
    return len(faces)


def normalize_image(src: Path, dst: Path, W: int, H: int) -> None:
    run(["magick", str(src), "-resize", f"{W}x{H}^",
         "-gravity", "center", "-extent", f"{W}x{H}",
         "-quality", "95", str(dst)])


def render_clip(img: Path, out: Path, motion: str,
                duration: float, W: int, H: int) -> None:
    """FIX: -framerate 30 forces CFR, prevents VFR filter crashes."""
    vf = ken_burns_vf(motion, W, H, duration)
    run([
        "ffmpeg", "-y", "-framerate", "30", "-loop", "1", "-i", str(img),
        "-vf", vf, "-t", f"{duration:.3f}",
        "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30",
        str(out),
    ])


def _xfade_linear(clips: list[Path], out: Path, transition: str,
                  trans_dur: float) -> None:
    """Linear xfade chain for a small batch of clips."""
    inputs = []
    for c in clips:
        inputs += ["-i", str(c)]

    durations = [get_duration(c) for c in clips]

    filters = []
    last_label = "[0:v]"
    running = durations[0]
    for i in range(1, len(clips)):
        label = f"[v{i}]"
        # ponytail: clamp prevents negative offset when trans_dur > clip
        offset = max(0, running - trans_dur)
        filters.append(
            f"{last_label}[{i}:v]xfade=transition={transition}:"
            f"duration={trans_dur}:offset={offset:.3f}{label}"
        )
        last_label = label
        running += durations[i] - trans_dur

    run(["ffmpeg", "-y", *inputs,
         "-filter_complex", ";".join(filters),
         "-map", last_label,
         "-c:v", "libx264", "-pix_fmt", "yuv420p", str(out)])


def xfade_chain(clips: list[Path], out: Path, transition: str,
                trans_dur: float, batch_size: int = 8) -> None:
    """FIX: compounding offset math + batched to avoid filter graph OOM."""
    if not clips:
        raise ValueError("No clips to merge")
    if len(clips) == 1:
        shutil.copy2(clips[0], out)
        return
    # ponytail: batch to prevent FFmpeg filter OOM on 30+ clips
    # ceiling: ~64 clips per batch². Upgrade: tree merge if > 512 clips.
    if len(clips) <= batch_size:
        _xfade_linear(clips, out, transition, trans_dur)
        return
    temp_dir = out.parent / "_xfade_batches"
    temp_dir.mkdir(exist_ok=True)
    batches = [clips[i:i + batch_size] for i in range(0, len(clips), batch_size)]
    merged = []
    for idx, batch in enumerate(batches):
        batch_out = temp_dir / f"batch_{idx:03d}.mp4"
        _xfade_linear(batch, batch_out, transition, trans_dur)
        merged.append(batch_out)
    _xfade_linear(merged, out, transition, trans_dur)
    shutil.rmtree(temp_dir, ignore_errors=True)


def attach_audio(video: Path, audio: Path | None, out: Path) -> bool:
    """FIX: apad + shortest prevents A/V drift over long renders."""
    if audio is None or not audio.exists():
        shutil.copy2(video, out)
        return False
    norm_audio = audio.with_suffix(".norm.m4a")
    # EBU R128 broadcast-standard loudness
    # ponytail: ffmpeg-normalize preferred; native loudnorm fallback if missing.
    try:
        run(["ffmpeg-normalize", str(audio), "-o", str(norm_audio),
             "-c:a", "aac", "-b:a", "192k", "-f"], check=False, retry=0)
    except Exception:
        log.warning("ffmpeg-normalize unavailable, trying native loudnorm")
        try:
            run(["ffmpeg", "-y", "-i", str(audio),
                 "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
                 "-c:a", "aac", "-b:a", "192k", "-ar", "48000",
                 str(norm_audio)], check=False, retry=0)
        except Exception:
            log.warning("loudnorm also failed, using raw audio")
    src_audio = norm_audio if norm_audio.exists() else audio
    v_dur = get_duration(video)
    run([
        "ffmpeg", "-y", "-i", str(video), "-i", str(src_audio),
        "-filter_complex", f"[1:a]apad=whole_dur={v_dur}[a]",
        "-map", "0:v", "-map", "[a]",
        "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ar", "48000",
        "-shortest", "-movflags", "+faststart", str(out),
    ])
    return True


def transcribe(audio: Path, model: Path, out_dir: Path) -> Path | None:
    if not audio.exists() or not model.exists():
        log.info("Skipping subtitles (no audio or model)")
        return None
    of = out_dir / "subs"
    # ponytail: -ml 1 forces word-level timestamps (one word per line) for precise sync
    run(["whisper-cli", "-m", str(model), "-f", str(audio),
         "-ml", "1", "--output-srt", "-of", str(of)])
    srt = of.with_suffix(".srt")  # FIX: derived from -of, not guessed
    return srt if srt.exists() else None


def final_encode(src: Path, srt: Path | None, dst: Path,
                 W: int, H: int) -> None:
    vf_parts = [
        f"scale={W}:{H}:force_original_aspect_ratio=decrease",
        f"pad={W}:{H}:(ow-iw)/2:(oh-ih)/2",
    ]
    if srt:
        sub_style = ("FontName=Arial,FontSize=22,PrimaryColour=&HFFFFFF&,"
                     "OutlineColour=&H000000&,BorderStyle=1,Outline=2,"
                     "Shadow=0,Alignment=2,MarginV=60")
        vf_parts.append(
            f"subtitles={escape_for_ffmpeg_filter(srt)}:"
            f"forcestyle='{sub_style}'"
        )
    run([
        "ffmpeg", "-y", "-i", str(src),
        "-vf", ",".join(vf_parts),
        *best_video_encoder(),
        "-c:a", "aac", "-b:a", "192k", "-ar", "48000",
        "-movflags", "+faststart", str(dst),
    ])
```

---

## PILLAR 7: CRITIQUE & 5-TIER QA GATE

### 7.1 `critique.py` — Self-review

```python
"""critique.py — Self-review with quality gates."""
from pathlib import Path
import json
from core import probe, log

QUALITY_GATES = {
    "min_duration_ratio": 0.95,
    "max_duration_ratio": 1.10,
    "must_have_audio_if_planned": True,
    "min_size_mb": 0.5,
}


def critique(final: Path, planned_duration: float,
             expected_audio: bool, target_res: tuple[int, int]) -> dict:
    info = probe(final)
    report = {
        "path": str(final),
        "duration_sec": info.duration,
        "resolution": f"{info.width}x{info.height}",
        "expected_resolution": f"{target_res[0]}x{target_res[1]}",
        "fps": info.fps,
        "size_mb": info.size_mb,
        "has_audio": info.has_audio,
        "issues": [],
        "passed": True,
    }
    ratio = info.duration / max(planned_duration, 0.001)
    if ratio < QUALITY_GATES["min_duration_ratio"]:
        report["issues"].append(f"Too short ({ratio:.0%} of plan)")
    if ratio > QUALITY_GATES["max_duration_ratio"]:
        report["issues"].append(f"Too long ({ratio:.0%} of plan)")
    if expected_audio and not info.has_audio:
        report["issues"].append("Audio expected but missing")
    if info.size_mb < QUALITY_GATES["min_size_mb"]:
        report["issues"].append(f"Suspiciously small: {info.size_mb}MB")
    if (info.width, info.height) != target_res:
        report["issues"].append("Resolution mismatch")
    report["passed"] = len(report["issues"]) == 0
    log.info("Critique: %s",
             "PASS ✅" if report["passed"] else f"FAIL ❌ {report['issues']}")
    return report
```

### 7.2 `qa_gate.py` — 5-Tier False-Positive Killer (T3 wired in)

```python
"""qa_gate.py — 5-Tier QA. The agent cannot lie about completion."""
import subprocess, json, time
from pathlib import Path
from core import log, get_duration


def tier1_deep_validate(video: Path, expected_audio_dur: float) -> bool:
    """Duration sync + black frames + silent-mux detection."""
    from core import probe
    info = probe(video)
    v_dur = info.duration
    if abs(v_dur - expected_audio_dur) > 0.5:
        log.error(f"❌ T1 FAIL: Duration drift {abs(v_dur - expected_audio_dur):.2f}s")
        return False

    # Black frames
    res = subprocess.run(
        ["ffmpeg", "-i", str(video),
         "-vf", "blackdetect=d=0.5:pic_th=0.98",
         "-f", "null", "-"],
        capture_output=True, text=True)
    if "black_start" in res.stderr:
        log.error("❌ T1 FAIL: Video contains black frames")
        return False

    # Silent mux (Tip 1: silent audio trap)
    if expected_audio_dur > 0 and not info.has_audio:
        log.error("❌ T1 FAIL: Audio expected but no audio stream in output")
        return False

    if info.has_audio:
        res = subprocess.run(
            ["ffmpeg", "-i", str(video), "-af", "volumedetect",
             "-f", "null", "-"],
            capture_output=True, text=True)
        if "mean_volume" in res.stderr:
            for line in res.stderr.splitlines():
                if "mean_volume" in line:
                    try:
                        db = float(line.split("mean_volume:")[1].strip().replace(" dB", ""))
                        if db < -50:
                            log.error(f"❌ T1 FAIL: Silent mux (mean {db}dB)")
                            return False
                    except Exception:
                        pass
    return True


def tier2_frame_sampling(video: Path) -> bool:
    """3 frames at 25/50/75% — confirms video isn't frozen/corrupt."""
    out_dir = Path("qa_frames"); out_dir.mkdir(exist_ok=True)
    dur = get_duration(video)
    for i, t in enumerate([dur*0.25, dur*0.5, dur*0.75]):
        target = out_dir / f"f{i}.jpg"
        subprocess.run(
            ["ffmpeg", "-y", "-ss", str(t), "-i", str(video),
             "-frames:v", "1", str(target)],
            capture_output=True)
        if not target.exists() or target.stat().st_size < 1000:
            log.error(f"❌ T2 FAIL: Frame {i} missing or corrupt")
            return False
    return True


def tier3_stability_test(render_fn, args_for_render, runs: int = 2) -> bool:
    """Render twice. Duration must be identical (deterministic check)."""
    durs = []
    for i in range(runs):
        out = Path(f"qa_stab_{i}.mp4")
        try:
            render_fn(*args_for_render, out)
            durs.append(get_duration(out))
        except Exception as e:
            log.error(f"❌ T3 FAIL: render attempt {i} crashed: {e}")
            return False
        finally:
            if out.exists():
                out.unlink()
    if max(durs) - min(durs) > 0.1:
        log.error(f"❌ T3 FAIL: Non-deterministic render. Δ={max(durs)-min(durs):.2f}s")
        return False
    log.info("✅ T3: deterministic render (Δ < 0.1s)")
    return True


def master_qa_gate(video: Path, expected_audio_dur: float,
                   render_fn=None, render_args: tuple = ()) -> bool:
    """THE FINAL GATE. All tiers must pass before agent can claim DONE."""
    log.info("🛡️ INITIATING 5-TIER QA GATE...")
    gates = {
        "T1_Deep_Validate": tier1_deep_validate(video, expected_audio_dur),
        "T2_Frame_Sample":  tier2_frame_sampling(video),
    }
    if render_fn is not None:
        gates["T3_Stability"] = tier3_stability_test(render_fn, render_args)

    for k, v in gates.items():
        log.info(f"  {'✅' if v else '❌'} {k}")

    if not all(gates.values()):
        raise RuntimeError(
            f"🔴 QA GATE REJECTED. Failed: {[k for k,v in gates.items() if not v]}")
    log.info("🟢 QA GATE APPROVED. Output verified and production-ready.")
    return True
```

### 7.3 `verification_engine.py` — Anti-False-Positive Layer

```python
"""
verification_engine.py
Anti-False-Positive Verification Layer.

Design goals:
  - NEVER report success unless it can be objectively proven.
  - NEVER crash the agent: every check returns a structured result.
  - Be deterministic where determinism is claimed; otherwise measure tolerances.
  - No silent failures: an unmeasurable check returns INCONCLUSIVE, not PASS.
"""

from __future__ import annotations
import os
import json
import shutil
import hashlib
import logging
import subprocess
from dataclasses import dataclass, field, asdict
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Optional

log = logging.getLogger("verify")

class Status(str, Enum):
    PASS = "PASS"
    FAIL = "FAIL"
    INCONCLUSIVE = "INCONCLUSIVE"

@dataclass
class CheckResult:
    name: str
    status: Status
    detail: str = ""
    evidence: dict[str, Any] = field(default_factory=dict)

    @property
    def ok(self) -> bool:
        return self.status is Status.PASS

    def to_dict(self) -> dict[str, Any]:
        d = asdict(self)
        d["status"] = self.status.value
        return d

def run_cmd(args: list[str], timeout: int = 120, binary_required: Optional[str] = None) -> tuple[bool, str, str]:
    if binary_required and shutil.which(binary_required) is None:
        return False, "", f"Required binary not found on PATH: {binary_required}"
    try:
        proc = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
        return proc.returncode == 0, proc.stdout, proc.stderr
    except subprocess.TimeoutExpired:
        return False, "", f"Timeout after {timeout}s: {' '.join(args[:3])}..."
    except FileNotFoundError as e:
        return False, "", f"Binary missing: {e}"
    except Exception as e:
        return False, "", f"Unexpected error: {e!r}"

def _safe_path(path: str | os.PathLike) -> Path:
    p = Path(path).expanduser().resolve()
    return p

class VerificationEngine:
    def __init__(self, work_dir: str = "./work", max_ram_mb: int = 2048, max_time_sec: int = 600, duration_tolerance_sec: float = 1.0, progress_file: str = "progress.json"):
        self.work_dir = _safe_path(work_dir)
        self.work_dir.mkdir(parents=True, exist_ok=True)
        self.max_ram_mb = max_ram_mb
        self.max_time_sec = max_time_sec
        self.duration_tol = duration_tolerance_sec
        self.progress_file = _safe_path(progress_file)
        self.progress: dict[str, dict[str, Any]] = self._load_progress()

    def _load_progress(self) -> dict[str, dict[str, Any]]:
        if self.progress_file.exists():
            try:
                return json.loads(self.progress_file.read_text())
            except (json.JSONDecodeError, OSError):
                log.warning("progress.json corrupt; starting fresh")
        return {}

    def _save_progress(self) -> None:
        tmp = self.progress_file.with_suffix(".tmp")
        tmp.write_text(json.dumps(self.progress, indent=2))
        tmp.replace(self.progress_file)

    def probe_duration(self, path: str | os.PathLike) -> Optional[float]:
        p = _safe_path(path)
        if not p.exists():
            return None
        ok, out, _ = run_cmd([
            "ffprobe", "-v", "error", "-show_entries", "format=duration:stream=duration", "-of", "json", str(p)
        ], binary_required="ffprobe")
        if not ok:
            return None
        try:
            data = json.loads(out)
        except json.JSONDecodeError:
            return None
        fmt_dur = data.get("format", {}).get("duration")
        if fmt_dur not in (None, "N/A"):
            try:
                return float(fmt_dur)
            except ValueError:
                pass
        for stream in data.get("streams", []):
            sd = stream.get("duration")
            if sd not in (None, "N/A"):
                try:
                    return float(sd)
                except ValueError:
                    continue
        return None

    def verify_file(self, path: str | os.PathLike, expected_duration: Optional[float] = None) -> CheckResult:
        name = f"verify_file:{Path(path).name}"
        p = _safe_path(path)
        if not p.exists():
            return CheckResult(name, Status.FAIL, "File missing", {"path": str(p)})
        size = p.stat().st_size
        if size == 0:
            return CheckResult(name, Status.FAIL, "File empty", {"size": 0})
        ok, out, err = run_cmd([
            "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", str(p)
        ], binary_required="ffprobe")
        if not ok:
            return CheckResult(name, Status.FAIL, f"Invalid media: {err.strip()}", {"size": size})
        duration = self.probe_duration(p)
        if duration is None:
            if expected_duration is not None:
                return CheckResult(name, Status.INCONCLUSIVE, "Cannot measure duration but one was expected", {"size": size})
            return CheckResult(name, Status.PASS, "Valid (no duration applicable)", {"size": size})
        if expected_duration is not None:
            diff = abs(duration - expected_duration)
            if diff > self.duration_tol:
                return CheckResult(name, Status.FAIL, f"Duration mismatch: got {duration:.3f}s, expected {expected_duration:.3f}s", {"duration": duration, "expected": expected_duration, "diff": diff})
        return CheckResult(name, Status.PASS, f"Valid, duration={duration:.3f}s", {"duration": duration, "size": size})

    def capture_inspection_frame(self, video_path: str | os.PathLike, output_dir: str | os.PathLike, pct: float = 0.5) -> CheckResult:
        name = f"inspect_frame:{int(pct * 100)}pct"
        if not (0.0 <= pct <= 1.0):
            return CheckResult(name, Status.FAIL, f"pct out of range: {pct}")
        duration = self.probe_duration(video_path)
        if duration is None or duration <= 0:
            return CheckResult(name, Status.INCONCLUSIVE, "Cannot determine video duration")
        out_dir = _safe_path(output_dir)
        out_dir.mkdir(parents=True, exist_ok=True)
        ss = duration * pct
        frame_path = out_dir / f"inspect_{int(pct * 100)}pct.jpg"
        ok, _, err = run_cmd([
            "ffmpeg", "-y", "-ss", f"{ss:.3f}", "-i", str(_safe_path(video_path)), "-vframes", "1", "-q:v", "2", str(frame_path)
        ], binary_required="ffmpeg")
        if not ok or not frame_path.exists() or frame_path.stat().st_size == 0:
            return CheckResult(name, Status.FAIL, f"Frame extraction failed: {err.strip()}")
        return CheckResult(name, Status.PASS, f"Frame captured at {ss:.2f}s", {"frame_path": str(frame_path), "timestamp": ss})

    def count_scene_changes(self, video_path: str | os.PathLike, threshold: float = 0.3) -> Optional[int]:
        ok, _, err = run_cmd([
            "ffmpeg", "-i", str(_safe_path(video_path)), "-vf", f"select='gt(scene,{threshold})',metadata=print", "-an", "-f", "null", "-"
        ], timeout=self.max_time_sec, binary_required="ffmpeg")
        if not ok and "scene" not in err:
            return None
        cuts = err.count("lavfi.scene_score")
        return cuts + 1

    def sync_test(self, video_path: str | os.PathLike, expected_scene_count: Optional[int] = None, min_audio_words: int = 1, transcribe: bool = True) -> CheckResult:
        name = "sync_test"
        vp = _safe_path(video_path)
        evidence: dict[str, Any] = {}
        audio_path = self.work_dir / (vp.stem + ".wav")
        ok, _, err = run_cmd([
            "ffmpeg", "-y", "-i", str(vp), "-vn", "-ac", "1", "-ar", "16000", str(audio_path)
        ], binary_required="ffmpeg")
        if not ok or not audio_path.exists() or audio_path.stat().st_size == 0:
            return CheckResult(name, Status.FAIL, f"Audio extraction failed: {err.strip()}")
        evidence["audio_bytes"] = audio_path.stat().st_size
        ok, _, vol_err = run_cmd([
            "ffmpeg", "-i", str(audio_path), "-af", "volumedetect", "-f", "null", "-"
        ], binary_required="ffmpeg")
        mean_vol = None
        for line in vol_err.splitlines():
            if "mean_volume:" in line:
                try:
                    mean_vol = float(line.split("mean_volume:")[1].split("dB")[0])
                except (ValueError, IndexError):
                    pass
        evidence["mean_volume_db"] = mean_vol
        audio_audible = mean_vol is not None and mean_vol > -60.0
        word_count = None
        if transcribe and shutil.which("whisper"):
            ok, _, _ = run_cmd([
                "whisper", str(audio_path), "--model", "tiny", "--output_format", "json", "--output_dir", str(self.work_dir)
            ], timeout=self.max_time_sec, binary_required="whisper")
            json_out = self.work_dir / (audio_path.stem + ".json")
            if ok and json_out.exists():
                try:
                    tdata = json.loads(json_out.read_text())
                    text = tdata.get("text", "")
                    word_count = len(text.split())
                    evidence["transcript_words"] = word_count
                except (json.JSONDecodeError, OSError):
                    word_count = None
        scene_count = self.count_scene_changes(vp)
        evidence["scene_count"] = scene_count
        failures: list[str] = []
        inconclusive: list[str] = []
        if not audio_audible:
            failures.append(f"audio inaudible (mean_volume={mean_vol})")
        if transcribe:
            if word_count is None:
                inconclusive.append("transcription unavailable")
            elif word_count < min_audio_words:
                failures.append(f"too few words: {word_count} < {min_audio_words}")
        if expected_scene_count is not None:
            if scene_count is None:
                inconclusive.append("scene count unmeasurable")
            elif scene_count != expected_scene_count:
                failures.append(f"scene count {scene_count} != expected {expected_scene_count}")
        if failures:
            return CheckResult(name, Status.FAIL, "; ".join(failures), evidence)
        if inconclusive:
            return CheckResult(name, Status.INCONCLUSIVE, "; ".join(inconclusive), evidence)
        return CheckResult(name, Status.PASS, "A/V verified", evidence)

    def stability_test(self, pipeline_func: Callable[[Any], str], input_data: Any, runs: int = 3) -> CheckResult:
        name = "stability_test"
        if runs < 2:
            return CheckResult(name, Status.INCONCLUSIVE, "runs must be >= 2")
        content_hashes: list[str] = []
        for i in range(runs):
            try:
                output = pipeline_func(input_data)
            except Exception as e:
                return CheckResult(name, Status.FAIL, f"Run {i} crashed: {e!r}")
            out_p = _safe_path(output)
            if not out_p.exists():
                return CheckResult(name, Status.FAIL, f"Run {i} produced no file")
            h = self._content_hash(out_p)
            if h is None:
                return CheckResult(name, Status.INCONCLUSIVE, f"Run {i} content not hashable")
            content_hashes.append(h)
        identical = len(set(content_hashes)) == 1
        status = Status.PASS if identical else Status.FAIL
        return CheckResult(name, status, "Deterministic" if identical else "Non-deterministic output", {"hashes": content_hashes, "identical": identical})

    def _content_hash(self, path: Path) -> Optional[str]:
        ok, out, _ = run_cmd([
            "ffmpeg", "-i", str(path), "-map", "0", "-f", "framemd5", "-"
        ], timeout=self.max_time_sec, binary_required="ffmpeg")
        if not ok:
            try:
                return hashlib.sha256(path.read_bytes()).hexdigest()
            except OSError:
                return None
        payload = "\n".join(l for l in out.splitlines() if l and not l.startswith("#"))
        return hashlib.sha256(payload.encode()).hexdigest()

    def performance_monitor(self, cmd_args: list[str], timeout: Optional[int] = None) -> CheckResult:
        import time
        try:
            import psutil
        except ImportError:
            return CheckResult("performance", Status.INCONCLUSIVE, "psutil not installed")
        name = "performance_monitor"
        timeout = timeout or self.max_time_sec
        peak_ram_mb = 0.0
        start = time.time()
        try:
            proc = subprocess.Popen(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        except (FileNotFoundError, OSError) as e:
            return CheckResult(name, Status.FAIL, f"Launch failed: {e!r}")
        ps = psutil.Process(proc.pid)
        killed_reason = None
        while proc.poll() is None:
            elapsed = time.time() - start
            try:
                rss = ps.memory_info().rss
                for child in ps.children(recursive=True):
                    try:
                        rss += child.memory_info().rss
                    except (psutil.NoSuchProcess, psutil.AccessDenied):
                        pass
                peak_ram_mb = max(peak_ram_mb, rss / 1024 / 1024)
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                pass
            if peak_ram_mb > self.max_ram_mb:
                killed_reason = f"RAM limit exceeded: {peak_ram_mb:.0f}MB"
            elif elapsed > timeout:
                killed_reason = f"Time limit exceeded: {elapsed:.0f}s"
            if killed_reason:
                proc.kill()
                proc.wait(timeout=5)
                break
            time.sleep(0.2)
        elapsed = time.time() - start
        evidence = {"peak_ram_mb": round(peak_ram_mb, 1), "time_sec": round(elapsed, 2), "return_code": proc.returncode}
        if killed_reason:
            return CheckResult(name, Status.FAIL, killed_reason, evidence)
        if proc.returncode != 0:
            return CheckResult(name, Status.FAIL, f"Process exited {proc.returncode}", evidence)
        return CheckResult(name, Status.PASS, "Within limits", evidence)

    def checkpoint(self, task_name: str, status: str, evidence_path: Optional[str] = None) -> dict[str, Any]:
        import time
        self.progress[task_name] = {
            "status": status,
            "timestamp": time.time(),
            "evidence": evidence_path,
            "verified": False,
        }
        self._save_progress()
        return self.progress[task_name]

    def final_gate(self, required_outputs: dict[str, str | os.PathLike], expected_durations: Optional[dict[str, float]] = None) -> tuple[bool, dict[str, Any]]:
        expected_durations = expected_durations or {}
        all_pass = True
        report: dict[str, Any] = {}
        for name, path in required_outputs.items():
            res = self.verify_file(path, expected_durations.get(name))
            report[name] = res.to_dict()
            self.progress.setdefault(name, {})
            self.progress[name]["verified"] = res.ok
            self.progress[name]["verify_info"] = res.detail
            if not res.ok:
                all_pass = False
        self._save_progress()
        return all_pass, report
```

---

---

## PILLAR 8: SKILLS — Self-Healing Web + PDF + Routing + Tracking

### 8.1 `skills/web_research.py`

```python
"""skills/web_research.py — Free web search & self-healing."""
import requests, time, random, logging
from bs4 import BeautifulSoup
from duckduckgo_search import DDGS

log = logging.getLogger("apex-skills")

UA_POOL = [
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
    "(KHTML, like Gecko) Version/17.0 Safari/605.1.15",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
]


def web_search(query: str, max_results: int = 5) -> list[dict]:
    try:
        with DDGS() as ddgs:
            results = list(ddgs.text(query, max_results=max_results))
        return [{"title": r["title"], "url": r["href"], "snippet": r["body"]}
                for r in results]
    except Exception as e:
        log.warning(f"Web search failed: {e}")
        return []


def fetch_page(url: str, retries: int = 3) -> str | None:
    for i in range(retries):
        try:
            r = requests.get(url, timeout=15,
                             headers={"User-Agent": random.choice(UA_POOL)})
            r.raise_for_status()
            soup = BeautifulSoup(r.text, "html.parser")
            for tag in soup(["script", "style", "nav", "footer"]):
                tag.decompose()
            return soup.get_text(separator="\n", strip=True)[:8000]
        except Exception as e:
            log.warning(f"Fetch attempt {i+1} failed: {e}")
            time.sleep(2 ** i + random.random())
    return None


def search_error_fix(error_string: str) -> list[str]:
    """SELF-HEALING: search the exact stderr → return fix URLs."""
    clean_err = error_string.replace("\n", " ").strip()[:150]
    results = web_search(f"fix ffmpeg error: {clean_err}", max_results=3)
    return [r["url"] for r in results if r.get("url")]
```

### 8.2 `skills/registry.py`

```python
"""skills/registry.py — Keyword → skill routing."""
import re, logging
from typing import Callable, Any

log = logging.getLogger("apex-skills")


class SkillRegistry:
    def __init__(self):
        self.skills: dict[str, Callable] = {}
        self.triggers: dict[str, str] = {}

    def register(self, name: str, fn: Callable, keywords: list[str]):
        self.skills[name] = fn
        for kw in keywords:
            self.triggers[kw.lower()] = name

    def route(self, prompt: str) -> list[str]:
        prompt_l = prompt.lower()
        matched = []
        for kw, skill in self.triggers.items():
            if re.search(rf"\b{re.escape(kw)}\b", prompt_l):
                if skill not in matched:
                    matched.append(skill)
        return matched

    def call(self, skill_name: str, *args, **kwargs) -> Any:
        if skill_name in self.skills:
            log.info(f"🧠 Routing to skill: {skill_name}")
            return self.skills[skill_name](*args, **kwargs)
        raise ValueError(f"Skill '{skill_name}' not registered.")


registry = SkillRegistry()
```

### 8.3 `skills/style_analyze.py` — PDF → Images (CMYK-safe)

```python
"""skills/style_analyze.py — Extract images from PDFs. CMYK → RGB safe."""
import fitz  # PyMuPDF
import logging
from pathlib import Path

log = logging.getLogger("apex-skills")


def extract_pdf_images(pdf_path: Path, out_dir: Path = None) -> list[Path]:
    if out_dir is None:
        out_dir = pdf_path.parent / f"{pdf_path.stem}_images"
    out_dir.mkdir(parents=True, exist_ok=True)
    if not pdf_path.exists():
        raise FileNotFoundError(f"PDF not found: {pdf_path}")

    doc = fitz.open(str(pdf_path))
    saved = []
    for page_num in range(len(doc)):
        page = doc[page_num]
        for img_index, img in enumerate(page.get_images(full=True)):
            xref = img[0]
            pix = fitz.Pixmap(doc, xref)
            # CMYK/alpha → RGB so OpenCV doesn't crash
            # ponytail: pix.n >= 4 includes CMYK (4) and CMYK+Alpha (5)
            if pix.n >= 4:
                pix = fitz.Pixmap(fitz.csRGB, pix)
            out_path = out_dir / f"page{page_num+1}_img{img_index+1}.png"
            pix.save(str(out_path))
            saved.append(out_path)
    log.info(f"📄 Extracted {len(saved)} images from {pdf_path.name}")
    return saved
```

### 8.4 `core/task_tracker.py` — Physical-proof enforcer

```python
"""core/task_tracker.py — Forces physical file proof of completion."""
import json, logging
from pathlib import Path
from datetime import datetime

log = logging.getLogger("apex-core")


class TaskTracker:
    def __init__(self, work_dir: Path, task_name: str):
        self.path = work_dir / f"{task_name}_progress.json"
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.data = {
            "task": task_name,
            "started": str(datetime.now()),
            "subtasks": {},
            "complete": False,
        }
        if self.path.exists():
            self.data = json.loads(self.path.read_text())
            log.info(f"🔄 Resuming task: {task_name}")
        self._save()

    def start(self, step: str):
        self.data["subtasks"][step] = {
            "status": "in_progress", "output": None,
            "time": str(datetime.now()),
        }
        self._save()

    def done(self, step: str, output_file: Path):
        """THE GOLDEN RULE: file must exist and be > 0 bytes."""
        if not output_file.exists():
            raise RuntimeError(
                f"❌ BLOCKED: Step '{step}' claimed done, but {output_file} MISSING.")
        if output_file.stat().st_size == 0:
            raise RuntimeError(
                f"❌ BLOCKED: Step '{step}' claimed done, but {output_file} EMPTY.")
        self.data["subtasks"][step] = {
            "status": "done",
            "output": str(output_file),
            "size_mb": round(output_file.stat().st_size / 1048576, 2),
            "time": str(datetime.now()),
        }
        self._save()
        log.info(f"✅ Step '{step}' verified: {output_file.name} "
                 f"({self.data['subtasks'][step]['size_mb']}MB)")

    def finish(self) -> bool:
        all_done = all(v["status"] == "done" for v in self.data["subtasks"].values())
        if all_done:
            self.data["complete"] = True
            self.data["finished"] = str(datetime.now())
            self._save()
            log.info("🏁 ALL SUBTASKS VERIFIED. TASK COMPLETE.")
            return True
        pending = [k for k, v in self.data["subtasks"].items()
                   if v["status"] != "done"]
        log.error(f"🛑 CANNOT FINISH. Pending: {pending}")
        return False

    def _save(self):
        self.path.write_text(json.dumps(self.data, indent=2))
```

### 8.5 `prompts/image_styles.md` — Blank-Face Asset Library

```markdown
# 🎨 APEX IMAGE GENERATION PROMPT LIBRARY
**Rule:** All characters must follow the "Blank Face" protocol — no AI facial features.

## STYLE A: Vibrant Cinematic Editorial (Hooks / CTAs)
**Prompt:**
> A full-body editorial illustration of a stylized human character, FACE COMPLETELY
> BLANK — no eyes, nose, mouth, no mask, smooth featureless face surface, but clearly
> a living human figure with natural posture and expressive body language. Style:
> cinematic editorial, ultra-vibrant saturated palette, bold teal & orange contrast,
> dramatic rim lighting, high dynamic range, 8K, sharp focus, professional color grading.
> Clean composition, single subject, plain gradient background.
**Negative:** facial features, eyes, mouth, nose, mask, text on face, robot, deformed
anatomy, extra limbs, blur, low resolution, watermark, distorted hands, uncanny valley.

## STYLE B: Soft Pastel Storybook (Empathy / Problem)
**Prompt:**
> A gentle storybook-style human character, FACE TOTALLY FEATURELESS — no drawn eyes,
> mouth or nose, completely smooth blank face, no mask, yet a believable lifelike person
> with warm relatable body posture. Style: soft pastel palette, painterly textures,
> diffuse warm lighting, gentle contrast, high resolution, clean professional finish.
> Single character, simple soft background.
**Negative:** face details, facial features, mask, robot, text on face, harsh shadows,
deformed body, extra fingers, low quality, watermark, realistic eyes.

## STYLE C: Bold Graphic + Realistic Light (Proof / Tech)
**Prompt:**
> A realistic-bodied human figure with a COMPLETELY BLANK FACE — no features whatsoever,
> no mask, smooth featureless surface, living and natural in posture. Combine bold flat
> graphic color blocking with photorealistic cinematic lighting. Maximum color saturation,
> strong complementary contrast, crisp edges, depth via light not detail, 8K,
> film-grade color grading. One character, minimal background.
**Negative:** any face features, eyes, mouth, nose, mask, face text, robot, deformity,
extra digits, low quality, noise, watermark.

## 🎬 ANIMATION PROMPT (Runway/Pika/Luma)
**Prompt:**
> Animate the blank-faced character [INSERT ACTION]. Camera [INSERT MOVEMENT]. KEEP FACE
> COMPLETELY BLANK AND UNCHANGED — do not add eyes, mouth, or any facial features during
> motion. Smooth natural body movement only. Cinematic, high quality, stable, no flicker.
**Negative:** face features appearing, mask, morphing face, robot, warping limbs,
flicker, artifacts, distortion, eyes opening.
```

---

## PILLAR 9: PIPELINE ORCHESTRATOR

```python
"""pipeline.py — End-to-end 5-stage cognitive loop."""
from __future__ import annotations
import argparse, json
from pathlib import Path
from core import (log, require, safe_clear, ASPECT_PRESETS, StateEngine, get_duration)
from perceive import perceive_folder
from interpret import build_storyboard
from compose import motion_for
from realize import (sanitize_faces, normalize_image, render_clip,
                     xfade_chain, attach_audio, transcribe, final_encode)
from critique import critique
from qa_gate import master_qa_gate
from core.task_tracker import TaskTracker

DEFAULT_MODEL = Path.home() / "apex-models" / "ggml-small.bin"


def generate_local_tts(text: str, out_audio: Path) -> None:
    """Generates placeholder voiceover using built-in system TTS commands.
    
    ponytail: Uses native say (Mac), powershell (Windows), or espeak (Linux). Zero-config.
    """
    import platform, subprocess, os
    sys_os = platform.system()
    if sys_os == "Darwin":
        temp_aiff = out_audio.with_suffix(".aiff")
        subprocess.run(["say", "-o", str(temp_aiff), text], check=True)
        # Convert AIFF to WAV via FFmpeg
        subprocess.run(["ffmpeg", "-y", "-i", str(temp_aiff), str(out_audio)], capture_output=True, check=True)
        if temp_aiff.exists():
            os.remove(temp_aiff)
    elif sys_os == "Linux":
        subprocess.run(["espeak", "-w", str(out_audio), text], check=True)
    else:
        # PowerShell Speech Synthesis SAPI
        ps_cmd = (
            f"Add-Type -AssemblyName System.Speech; "
            f"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
            f"$s.SetOutputToWaveFile('{out_audio}'); "
            f"$s.Speak('{text}'); "
            f"$s.Dispose()"
        )
        subprocess.run(["powershell", "-Command", ps_cmd], check=True)


def main():
    ap = argparse.ArgumentParser(description="Apex cognitive video editor v6")
    ap.add_argument("--input",        required=True, type=Path)
    ap.add_argument("--audio",        type=Path, default=None)
    ap.add_argument("--script",       type=Path, default=None)
    ap.add_argument("--out",          type=Path, default=Path("./output/final.mp4"))
    ap.add_argument("--aspect",       default="9:16", choices=list(ASPECT_PRESETS))
    ap.add_argument("--duration",     type=float, default=30.0)
    ap.add_argument("--trans-dur",    type=float, default=0.6)
    ap.add_argument("--whisper-model", type=Path, default=DEFAULT_MODEL)
    ap.add_argument("--mode",         default="ad", choices=["ad", "narrative"],
                    help="Beat template: ad (5 beats) or narrative (7 beats for drama)")
    ap.add_argument("--ai-clips",     type=Path, default=None,
                    help="Folder of pre-rendered AI clips (CapCut/Runway) to interleave")
    ap.add_argument("--pdf-ingest",   type=Path, default=None,
                    help="Extract images from a PDF as input assets")
    ap.add_argument("--no-cloud",     action="store_true",
                    help="Disable cloud vision (OpenCV only)")
    ap.add_argument("--rediscover",   action="store_true",
                    help="Force fresh vision model discovery")
    ap.add_argument("--save-key",     metavar="KEY",
                    help="Save optional cloud-vision API key and exit (not required; prefer --no-cloud)")
    args = ap.parse_args()

    # ── Key management ──
    if args.save_key:
        from vision_bridge import KeyVault
        KeyVault().save_key(args.save_key)
        log.info("Key saved. Re-run without --save-key to process.")
        return

    # ── Warm vision bridge (lazy init) ──
    if not args.no_cloud:
        from vision_bridge import VisionBridge
        VisionBridge.get(force=args.rediscover)

    require(["ffmpeg", "ffprobe", "magick"])
    W, H = ASPECT_PRESETS[args.aspect]  # FIX v6: was missing in v5.0 → NameError

    # ── PDF ingest: extract images as input assets ──
    if args.pdf_ingest and args.pdf_ingest.exists():
        from skills.style_analyze import extract_pdf_images
        args.input.mkdir(parents=True, exist_ok=True)
        saved = extract_pdf_images(args.pdf_ingest, args.input)
        log.info(f"📄 Ingested {len(saved)} images from {args.pdf_ingest.name}")
    
    # Zero-dependency local TTS fallback if script exists but no audio is provided
    # ponytail: automatically generates voiceover using native platform speech engines
    if not args.audio and args.script and args.script.exists():
        log.info("No audio supplied, generating voiceover from script using native platform TTS...")
        generated_audio = Path("./work/generated_voice.wav")
        generated_audio.parent.mkdir(parents=True, exist_ok=True)
        try:
            generate_local_tts(args.script.read_text(), generated_audio)
            if generated_audio.exists() and generated_audio.stat().st_size > 0:
                args.audio = generated_audio
                log.info(f"Generated local TTS audio: {args.audio}")
            else:
                log.warning("Generated local TTS file is empty or missing.")
        except Exception as e:
            log.error(f"Failed to generate local TTS: {e}")

    # Enhancement: dynamic duration from audio
    if args.audio and args.audio.exists():
        audio_dur = get_duration(args.audio)
        if abs(audio_dur - args.duration) > 1.0:
            log.info(f"⏱ Audio is {audio_dur:.1f}s, overriding --duration {args.duration:.1f}s")
            args.duration = audio_dur

    work = Path("./work")
    # ponytail: preserve work directory if state.json exists to allow resuming
    if not (work / "state.json").exists():
        safe_clear(work)
    else:
        work.mkdir(parents=True, exist_ok=True)
    args.out.parent.mkdir(parents=True, exist_ok=True)

    state = StateEngine(work)
    tracker = TaskTracker(work, "video_render")

    # ─── STAGE A: PERCEIVE ───
    if not state.is_done("perceive"):
        log.info("════ STAGE A — PERCEIVE ════")
        tracker.start("perceive")
        cards = perceive_folder(args.input, work / "perception.json",
                                use_cloud=not args.no_cloud)
        if not cards:
            raise SystemExit("No images found in input folder.")
        tracker.done("perceive", work / "perception.json")
        state.mark_done("perceive")
    else:
        # ponytail: load perception cards from disk on resume path
        try:
            import json
            data = json.loads((work / "perception.json").read_text())
            from perceive import ImageCard
            cards = [ImageCard(**c) for c in data]
        except Exception as e:
            log.warning("Failed to load cached perception cards, re-perceiving: %s", e)
            tracker.start("perceive")
            cards = perceive_folder(args.input, work / "perception.json",
                                    use_cloud=not args.no_cloud)
            tracker.done("perceive", work / "perception.json")
            state.mark_done("perceive")

    # ─── STAGE B: INTERPRET ───
    if not state.is_done("interpret"):
        log.info("════ STAGE B — INTERPRET ════")
        tracker.start("interpret")
        script_text = (args.script.read_text()
                       if args.script and args.script.exists() else "")
        beats = build_storyboard(cards, script_text, args.duration,
                                 work / "storyboard.json", audio_path=args.audio,
                                 mode=args.mode)
        tracker.done("interpret", work / "storyboard.json")
        state.mark_done("interpret")

    # ─── PREP ASSETS ───
    log.info("════ Preparing chosen assets ════")
    prepped: dict[str, Path] = {}
    import json as _json
    beats = _json.loads((work / "storyboard.json").read_text())
    for beat in beats:
        for src_str in beat.get("chosen_assets") or []:
            if src_str in prepped:
                continue
            src = Path(src_str)
            safe = work / f"safe_{len(prepped):03d}.png"
            norm = work / f"norm_{len(prepped):03d}.png"
            blurred = sanitize_faces(src, safe)
            if blurred:
                log.info(f"Blurred {blurred} face(s) in {src.name}")
            normalize_image(safe, norm, W, H)
            prepped[src_str] = norm

    # ─── STAGE C/D: COMPOSE + RENDER ───
    if not state.is_done("render"):
        log.info("════ STAGE C/D — COMPOSE + RENDER ════")
        tracker.start("render")
        all_clips: list[Path] = []
        clip_idx = 0
        # ponytail: load AI clips for hybrid interleaving if provided
        ai_clips: list[Path] = []
        if args.ai_clips and args.ai_clips.is_dir():
            from core import natural_key
            ai_clips = sorted(
                [p for p in args.ai_clips.iterdir() if p.suffix.lower() == ".mp4"],
                key=natural_key)
            log.info(f"🎬 Loaded {len(ai_clips)} AI clips for hybrid interleave")
        ai_idx = 0
        for beat in beats:
            motion, _ = motion_for(beat["name"], mode=args.mode)
            assets = beat.get("chosen_assets") or []
            if not assets:
                continue
            beat_dur = beat["end_sec"] - beat["start_sec"]
            per_clip = max(2.0, beat_dur / len(assets))
            for src_str in assets:
                clip_path = work / f"clip_{clip_idx:03d}.mp4"
                # Hybrid: prefer AI clip when available, else Ken Burns
                if ai_idx < len(ai_clips):
                    import shutil as _sh
                    _sh.copy2(ai_clips[ai_idx], clip_path)
                    ai_idx += 1
                    log.info(f"   ✨ Used AI clip {ai_clips[ai_idx-1].name}")
                else:
                    render_clip(prepped[src_str], clip_path, motion, per_clip, W, H)
                all_clips.append(clip_path)
                clip_idx += 1
        tracker.done("render", all_clips[-1] if all_clips else work / ".render_marker")
        state.mark_done("render")

    # ─── STAGE D: ASSEMBLE ───
    log.info("════ STAGE D — ASSEMBLE ════")
    tracker.start("final")
    all_clips = sorted(work.glob("clip_*.mp4"))
    merged = work / "merged.mp4"
    xfade_chain(all_clips, merged, transition="fade", trans_dur=args.trans_dur)

    with_audio = work / "with_audio.mp4"
    had_audio = attach_audio(merged, args.audio, with_audio)

    # ponytail: prep audio for Whisper (16kHz mono WAV) before transcription
    whisper_audio = args.audio
    if had_audio and args.audio:
        whisper_wav = work / "audio_whisper.wav"
        try:
            run(["ffmpeg", "-y", "-i", str(args.audio),
                 "-ar", "16000", "-ac", "1", str(whisper_wav)])
            if whisper_wav.exists():
                whisper_audio = whisper_wav
        except Exception:
            log.warning("Whisper audio prep failed, using original")

    srt = transcribe(whisper_audio, args.whisper_model, work) if had_audio else None
    final_encode(with_audio, srt, args.out, W, H)

    # ─── STAGE E: CRITIQUE + QA GATE ───
    log.info("════ STAGE E — CRITIQUE + 5-TIER QA ════")
    report = critique(args.out, args.duration, had_audio, (W, H))
    expected_audio_dur = (args.duration if had_audio else get_duration(args.out))
    master_qa_gate(args.out, expected_audio_dur)  # raises if fails

    (args.out.parent / "manifest.json").write_text(json.dumps({
        "aspect": args.aspect,
        "mode": args.mode,
        "target_duration": args.duration,
        "beats": [b["name"] for b in beats],
        "asset_count": len(prepped),
        "clip_count": len(all_clips),
        "ai_clips_used": ai_idx if 'ai_idx' in dir() else 0,
        "critique": report,
    }, indent=2))

    tracker.done("final", args.out)
    tracker.finish()
    state.finish()
    log.info(f"🎬 Done → {args.out}")


if __name__ == "__main__":
    main()
```

---

## PILLAR 10: MASTER SYSTEM PROMPT

Paste into `.cursorrules` / `CLAUDE.md` / Qwen system prompt.

```markdown
# 🧠 APEX AI-EDITOR SYSTEM PROMPT (v6.1)

## IDENTITY & CORE DIRECTIVE
You are the Apex AI-Editor, a senior cognitive production agent. You think like a
human editor: you watch before you cut, you read meaning before you trim, and you
self-review before you ship. You NEVER claim success without physical proof on disk.

## THE 5-STAGE COGNITIVE LOOP (NON-NEGOTIABLE)
1. PERCEIVE — Follow the HYBRID PERCEPTION PROTOCOL below. Use your own eyes
   for content understanding; use perceive_folder() for numeric metrics.
   For hero assets, escalate to cloud vision via VisionBridge (multi-key
   rotation). Persist perception.json.
2. INTERPRET — Map script to assets. Calculate dynamic pacing from Whisper pauses.
   Never use fixed ratios if audio is provided.
3. COMPOSE — Assign Ken-Burns motion and transitions per beat (hook/problem/
   solution/proof/cta).
4. REALIZE — Render.
   • Sanitize faces (Gaussian blur, 99×99 kernel) — NON-NEGOTIABLE.
   • Use COMPOUND-OFFSET math for xfade chains.
   • Normalize audio to EBU R128. Pad audio with apad+shortest to prevent drift.
5. CRITIQUE + QA — Run critique() then master_qa_gate(). All tiers must pass.
   If any tier fails: diagnose ONE root cause → apply ONE fix → re-render.
   Max 3 attempts, then report blocker.

## ABSOLUTE HARD RULES (ZERO TOLERANCE)
• NEVER guess a number (duration, size). Always ffprobe / get_duration().
• NEVER say "done" if master_qa_gate() returns False.
• NEVER skip a sub-task. If context window fills, read state.json to resume.
• VFR PROTECTION: Always force -framerate 30 on image inputs.
• A/V DRIFT: Always use apad + -shortest when muxing. Verify drift < 0.5s.
• VISION RESILIENCE: Cloud is an enhancement, never a dependency. On 429/401/5xx,
  rotate (key × model). All cloud failures → local fallback. Pipeline continues.
• CLOUD IS FREE-FIRST: Cloud vision is optional enhancement only. Prefer local
  heuristics. Never default to paid SaaS. If a paid model is required, tag FALLBACK
  with documented reason. Always support --no-cloud full offline.
• DELIVERY CODEC: Default to H.264 for all social/web exports. Use best_video_encoder()
  without archive=True. HEVC/AV1 only when user explicitly requests --archive.
• AUDIO STANDARD: Final mux must include -ar 48000 (video industry standard).
  Bitrate: 192k for social, 320k only if user requests --pro-audio.
• SUBTITLES: Always run transcribe() when audio exists. Burn-in SRT on final render
  (default). Optionally output sidecar .srt/.vtt alongside final if user requests.
  EAA 2025 compliance: subtitles are mandatory for EU distribution.

## VERIFICATION PROTOCOL (Never Skip)

RULE 0: PASS is the only success. FAIL and INCONCLUSIVE both block "done".

After EVERY sub-task:
1. Run `checkpoint(task_name, "complete", output_path)`
2. Run `verify_file(output_path)` — if fail (or INCONCLUSIVE), STOP and fix before proceeding.
3. If video output: run `capture_inspection_frame()` at 25%, 50%, 75% duration to visually verify frame validity.
4. Final step: run `final_gate(required_outputs)` with ALL expected files.

You are PHYSICALLY UNABLE to say "done" until `final_gate()` returns True.
An INCONCLUSIVE result is NOT permission to proceed — investigate the cause (missing binary, unmeasurable output) and resolve it first.
If any check fails, you must:
- Search the exact error string
- Apply the fix
- Re-verify
- Only then proceed


## HYBRID PERCEPTION PROTOCOL (v6 — MULTIMODAL AGENTS)
You are a multimodal model. You can SEE images and HEAR audio natively.
DO NOT write Python scripts to understand what is in an image. USE YOUR OWN EYES.

### When to USE YOUR OWN VISION (no scripts):
• Describing image content, subject, mood, setting, composition
• Making editorial decisions (which image fits which beat)
• Verifying render output quality ("does this look right?")
• Comparing before/after effects (view extracted frames)
• Reading text in images, identifying objects, judging aesthetics

### When to USE SCRIPTS (perceive.py / FFmpeg / ffprobe):
• Exact numeric metrics: colorfulness score, brightness value, contrast ratio
• Face detection bounding boxes (for blur coordinates)
• Duration, resolution, FPS, file size (always ffprobe, never guess)
• Batch processing 50+ images (your eyes don't scale)
• Applying effects (FFmpeg filters — you cannot "simulate" a filter visually)

### Frame Extraction Protocol (for self-viewing videos):
To "watch" a video, extract key frames and view them yourself:
```bash
# Extract frames at 25%, 50%, 75% of duration for visual review
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 video.mp4)
for pct in 0.25 0.50 0.75; do
  T=$(echo "$DUR * $pct" | bc)
  ffmpeg -y -ss $T -i video.mp4 -frames:v 1 frame_${pct}.jpg
done
```
Then view the extracted frames with your native vision. Do NOT write OpenCV
scripts to "analyze" them — just look at them.

### Effect Preview Protocol:
To preview an FFmpeg effect before applying to the full video:
```bash
# Apply effect to a single frame, then view the result yourself
ffmpeg -y -ss 5 -i input.mp4 -frames:v 1 -vf "YOUR_FILTER_HERE" preview.jpg
```
View preview.jpg with your own eyes. Compare to the original. Decide.

### FORBIDDEN PATTERNS (agents keep writing these — stop):
• ❌ Writing a Python script that uses cv2.imread + prints descriptions
• ❌ Writing a script to "detect colors" when you can just look
• ❌ Writing a script to "check if the video looks good" — view the frames
• ❌ Importing PIL/numpy just to get image dimensions — use `ffprobe` or `magick identify`
• ❌ Any script whose only purpose is to describe what's in an image

### CORRECT PATTERN:
1. Extract frame(s) → 2. View them yourself → 3. Describe what you see
4. If you need a NUMBER (exact brightness, face count), THEN run perceive.py
5. If you need to APPLY an effect, write the FFmpeg command, not a Python simulation

## PROFESSIONAL EDITOR DECISION FRAMEWORK (v6 — EDITORIAL INTELLIGENCE)

Think like a human editor. Every cut, effect, and color choice must serve the MEANING
of the content. Never apply effects for decoration — every decision answers "WHY?"

### A. Color Intent Map — mood → color treatment

Before color grading, decide the EMOTIONAL INTENT of each beat:

| Content Mood | Color Treatment | FFmpeg `eq` / `colorbalance` |
|---|---|---|
| Trust / warmth / comfort | Warm golden tones, slight overexposure | `colorbalance=rs=0.1:gs=0.05:bs=-0.1` |
| Tension / danger / urgency | Cool blue-teal shadows, high contrast | `colorbalance=rs=-0.1:gs=-0.05:bs=0.15,eq=contrast=1.3` |
| Sadness / loss / nostalgia | Desaturated, soft, slightly cool | `eq=saturation=0.6:brightness=0.03,colorbalance=bs=0.05` |
| Energy / excitement / success | Vivid saturated, punchy contrast | `eq=saturation=1.5:contrast=1.2` |
| Mystery / suspense / horror | Dark crushed blacks, teal highlights | `curves=m='0/0 0.25/0.1 0.5/0.4 1/1',colorbalance=bs=0.12` |
| Clean / professional / corporate | Neutral balanced, slight warmth | `eq=brightness=0.02:saturation=1.1,colorbalance=rs=0.03` |
| Vintage / retro / memory | Faded blacks, warm midtones, grain | `curves=m='0/0.05 0.5/0.55 1/0.95',colorbalance=rs=0.08:gs=0.04` |

**Rule:** View the frame FIRST with your eyes. Decide the mood. THEN pick the treatment.
Never apply color grading without stating the intent in the storyboard.

### B. Scene & Image Selection Logic — meaning over aesthetics

When choosing which image/clip goes in which beat, ask these questions IN ORDER:

1. **Does it match the SCRIPT meaning?** (semantic match > visual match)
   - "Solution" beat with text about "relief" → pick the image that SHOWS relief,
     even if it's technically darker than another option
2. **Does it advance the NARRATIVE?** (story > prettiness)
   - A blurry candid that captures real emotion beats a sharp stock photo
3. **Does it contrast the PREVIOUS beat?** (visual rhythm)
   - After a dark, tight shot → pick a bright, wide shot
   - After a static image → pick one with implied motion
   - After warm tones → switch to cool (or vice versa)
4. **ONLY THEN: Is it technically strong?** (sharpness, colorfulness, resolution)

**Anti-pattern:** Never pick all "hero" images for every beat. A video where every
shot is 10/10 has no rhythm. Contrast creates impact.

### C. Transition Decision Tree — relationship between scenes

Transitions tell the viewer HOW two scenes are connected:

| Relationship | Transition | Why |
|---|---|---|
| Same moment, different angle | **Hard cut** (no transition) | Instant, energetic |
| Time passing | **Dissolve** / `dissolve` | Blends one into the next |
| Scene change / new topic | **Fade to black** / `fadeblack` | Clean break, pause |
| Cause → effect | **Slide left** / `slideleft` | Directional movement = progress |
| Surprise / reveal | **Wipe** / `wiperight` | Theatrical, attention-grabbing |
| Emotional shift | **Fade** / `fade` | Gentle, non-jarring |
| Energy / montage | **Hard cut** at 0.3-0.5s intervals | Rapid-fire, momentum |

**Rule:** Match transition to MEANING, not to beat name. If the "proof" beat shows
a dramatic reveal, use a wipe — don't default to dissolve just because the table says so.

### D. Pacing Rules — energy level → cut timing

| Energy | Seconds per clip | When to use |
|---|---|---|
| High energy (hook, climax) | 1.5–2.5s | Opening hooks, peak moments |
| Medium (problem, solution) | 3–5s | Explanation, demonstration |
| Low / contemplative (setup, close) | 5–8s | Emotional moments, establishing shots |
| Breather / pause | Hold 1s of black or static | After intense sequence, before CTA |

**Audio sync rule:** If voiceover is present, clips should change on natural speech
pauses — NOT on a fixed timer. The `audio_driven_beats()` function handles this.

### E. Effect Purpose Rules — every effect needs a WHY

Before applying ANY effect, state its purpose. If you can't state it, don't apply it.

| Effect | Valid purpose | Invalid purpose |
|---|---|---|
| Brightness boost | "Subject is underexposed, needs to match other clips" | "Make it look better" |
| Saturation increase | "This beat is 'energy' — vivid colors signal excitement" | "More colorful = more professional" |
| Blur/vignette | "Draw eye to center subject, soften distracting edges" | "Looks cinematic" |
| Speed ramp (slow-mo) | "Emphasize this key moment in the narrative" | "Fill time" |
| Text overlay | "Reinforce the CTA / translate speech" | "Empty space to fill" |
| Ken Burns zoom-in | "Draws viewer INTO the detail" | Default for everything |
| Ken Burns zoom-out | "Reveals context, shows the bigger picture" | — |
| Pan | "Implies journey, movement, progress" | — |

### F. Color Consistency Across Cuts

After composing all clips, verify color temperature is consistent:
```bash
# Extract one frame per clip, view them side by side
for clip in work/clip_*.mp4; do
  name=$(basename "$clip" .mp4)
  ffmpeg -y -ss 1 -i "$clip" -frames:v 1 "qa_color_${name}.jpg" 2>/dev/null
done
# View all qa_color_*.jpg with your own eyes — check for jarring temperature shifts
```
If one clip is noticeably warmer/cooler than its neighbors, apply a correction:
```bash
# Cool down a too-warm clip
ffmpeg -y -i warm_clip.mp4 -vf "colorbalance=rs=-0.05:gs=-0.02:bs=0.08" -c:a copy fixed.mp4
```

### G. Pro Color Grading Recipes (FFmpeg, tested)

```bash
# Cinematic teal-orange (blockbuster look)
-vf "colorbalance=rs=0.15:gs=-0.05:bs=-0.1:rh=0.1:gh=-0.05:bh=-0.15,eq=contrast=1.15:saturation=1.2"

# Soft warm glow (comfort, trust)
-vf "gblur=sigma=0.5,eq=brightness=0.05:saturation=1.1,colorbalance=rs=0.08:gs=0.04:bs=-0.06"

# Cold dramatic (tension, thriller)
-vf "eq=contrast=1.3:brightness=-0.03:saturation=0.85,colorbalance=rs=-0.12:gs=-0.05:bs=0.15"

# Vintage faded (nostalgia, memory)
-vf "curves=m='0/0.06 0.25/0.28 0.5/0.55 0.75/0.78 1/0.94',eq=saturation=0.75,colorbalance=rs=0.06:gs=0.03"

# Clean bright (corporate, explainer)
-vf "eq=brightness=0.04:contrast=1.05:saturation=1.15"
```

**Usage:** Preview on one frame first, then apply to full clip:
```bash
ffmpeg -y -ss 2 -i input.mp4 -frames:v 1 -vf "RECIPE_HERE" preview.jpg
# View preview.jpg → approve → apply to full clip
ffmpeg -y -i input.mp4 -vf "RECIPE_HERE" -c:a copy graded.mp4
```

## DELIVERABLES
Upon completion:
1. The final verified video file.
2. manifest.json (assets, beats, timings, critique report).
3. Verbal confirmation that 5-Tier QA Gate returned APPROVED.

## WHEN STUCK
On 2 consecutive failures: pass stderr to search_error_fix() → read returned URLs
→ apply found fix. Never silently retry the same broken approach.
```

---

## DEPLOYMENT

### Quick start

```bash
# 1. Install (one-time, idempotent)
bash install_apex.sh

# 2. Run full cognitive pipeline (offline-first; no cloud keys required)
python pipeline.py \
  --input ./input/photos \
  --audio ./input/voice.mp3 \
  --script ./input/script.txt \
  --aspect 9:16 --duration 30 \
  --out ./output/reel.mp4

# 3. Guaranteed-offline mode (no cloud, no keys)
python pipeline.py --input ./photos --no-cloud

# 4. Force fresh vision discovery
python pipeline.py --input ./photos --rediscover
```

### 3-Layer mental model

| Layer | Where it lives | What it does |
|---|---|---|
| **Brain** (this system prompt) | `.cursorrules` / `CLAUDE.md` | Tells the agent *how to think* |
| **Muscle** (Python pipeline) | `core.py` → `pipeline.py` | The agent writes + executes this |
| **Tools** (skills/) | `skills/*.py` | Auto-routed by keyword triggers |

You never paste Python into the prompt. You paste the **prompt** into rules, then
command the agent to build + execute the pipeline.

---

## v5 CHANGELOG (prior)

| Change | Why |
|---|---|
| Added real Hasler-Süsstrunk colorfulness to perceive.py | v4 used `colorfulness=0.5` placeholder → wrong color tags → wrong hero classification |
| Replaced single-key `enrich_with_cloud_vision` with full `vision_bridge.py` | v4 hardcoded `google/gemini-flash-1.5` → no rotation → pipeline blocks on 429 |
| Wired T3 stability test into `master_qa_gate()` | v4 had T3 signature but never called it → non-deterministic renders passed QA |
| Added `escape_for_ffmpeg_filter()` to realize.py | Subtitle paths with colons broke FFmpeg filter parser on macOS absolute paths |
| Added `run()` with stderr surfacing + 1 retry | v4 swallowed errors silently via `capture_output=True` → debugging was guesswork |
| Added silent-mux detection (volumedetect < -50dB) in T1 | "File exists but no audio" trap — agent said "Done" on silent video |
| Added `StateEngine` resume-from-disk | Long renders filled context → agent forgot stage A by stage D |
| Added `apad=whole_dur` to attach_audio | Long videos drifted 1-2s by end due to sample rate mismatch |
| Forced `-framerate 30` on image inputs | iPhone/screen-recording VFR crashed zoompan/xfade filters |

---

## v6.0 CHANGELOG

| Change | Why |
|---|---|
| Fixed `W, H` NameError in pipeline.py | v5.0 never assigned `W, H = ASPECT_PRESETS[args.aspect]` → instant crash |
| Fixed thread race in `perceive_folder` | `ThreadPoolExecutor.map` mutated `ImageCard` objects in-place across threads |
| Added `--mode narrative` (7-beat drama template) | Arabic exam-drama content needs cover/setup/conflict/turn/climax/result/close beats |
| Added `MOTION_BY_BEAT_NARRATIVE` + mode to `motion_for()` | Narrative beats need different camera motions than ad beats |
| Added `--ai-clips` hybrid interleaving | CapCut/Runway 7s clips → massive quality jump over Ken Burns on stills |
| Added `--pdf-ingest` to pipeline | Wires existing `extract_pdf_images()` skill into the CLI |
| Added `prep_audio_for_whisper()` (16kHz mono) | Whisper-cpp requires 16kHz mono WAV; raw MP3/M4A input caused garbage subtitles |
| Added `ai_clips_used` + `mode` to manifest.json | Traceability for hybrid renders |
| Merged narrative motion map from adoptions v5.1 | Drama scenes need slow zooms + fadeblack, not the ad-style fast zoom + slideleft |
| Updated system prompt to v6.0 | Reflects new `--mode`, `--ai-clips`, `--pdf-ingest` capabilities |
| Merged tool catalog reference from unified v5.2 | Documents mkvtoolnix, auto-editor, and lightweight VPS tool tiers |

---

## PILLAR 11: LIGHTWEIGHT TOOL CATALOG (from unified v5.2)

For zero-cost execution on standard VPS servers. Avoid heavy GPU tools unless free cloud hosting (Colab/Kaggle) is used.

### 11.1 Core CLI Toolchain (Tier 1: Ultra-Lightweight)

| Tool | Primary Use | Weight | Why It's Essential |
|------|-------------|--------|--------------------|
| **FFmpeg** | Video/audio rendering, concatenation, filter graphs | Medium | The gold standard. Pure CLI, zero-dependency. |
| **ImageMagick** | Batch image resizing, canvas padding, watermarks | Light | Preprocesses stills instantly before video compilation. |
| **yt-dlp** | Scraping source video, downloading background music | Ultra-light | Directly fetches from 1000+ sites. Highly maintained. |
| **mkvtoolnix** | Merging tracks, container manipulation | Ultra-light | Combines streams without re-encoding (0% CPU cost). |

```bash
# Instant AV merging without re-encoding (Fastest assembly, 0% CPU)
mkvmerge -o output.mkv video.mp4 audio.mp3 subtitles.srt

# Batch crop & pad images with ImageMagick (to avoid black bars)
magick mogrify -resize 1920x1080^ -gravity center -extent 1920x1080 *.jpg
```

### 11.2 Secondary Scriptable Editors (Tier 2: Medium Weight)

- **auto-editor:** Automatically detects silences in video/audio and cuts them out. Highly efficient for cleaning talking-head footages before main editing.
  ```bash
  # Automatically cut silent parts of a voiceover file (saves 30-50% duration)
  auto-editor voice_raw.mp3 --output_file voice_clean.mp3 --margin 0.2sec
  ```

### 11.3 STYLE D: Educational Comic Panel (kimi-sketch)

**Palette:** parchment `#F2DFA7`, ink `#1A1A1A`, gold `#F5C200`, red `#CC2222`

```markdown
## STYLE D: Educational Comic Panel (kimi-sketch)
> Educational comic, 16:9, BLANK FACES, flat fills only, no gradients.

## CapCut Image-to-Video (PRIMARY, ~7s, no watermark)
> Subtle motion. BLANK FACES unchanged. 7 seconds max.
```

**Hybrid Workflow:** Ideogram/Imagen (STYLE D) → CapCut animate → Apex `--ai-clips`

### 11.4 Full Hybrid Recipe

```bash
bash install_apex.sh && source ~/apex-video-env/bin/activate

# Standard ad reel with AI clips
python pipeline.py \
  --input ./stills --ai-clips ./capcut_clips \
  --audio ./voice.mp3 --script ./script.txt \
  --aspect 9:16 --duration 30 --out ./output/reel.mp4

# Arabic exam-drama from PDF slides + CapCut clips
python pipeline.py \
  --input ./slides --pdf-ingest ./Narrative_Structure_v2.pdf \
  --mode narrative --ai-clips ./capcut_clips \
  --script ./drama_script.txt --audio ./vo.mp3 \
  --aspect 9:16 --out ./output/drama.mp4

# Guaranteed-offline mode (no cloud, no keys)
python pipeline.py --input ./photos --no-cloud --mode ad
```

> **Note on Arabic TTS:** Native `say`/`espeak` produces poor Arabic output.
> For Arabic voiceover, use MohamedRashad HF space or Web Speech API (`lang='ar-SA'`, `rate: 0.9`).
> The `generate_local_tts()` function is English-only.

> **Note on face detection:** Haar cascades miss ~20% of faces (profile views, small faces).
> Upgrade path: `cv2.dnn` with `res10_300x300_ssd_iter_140000.caffemodel` (ships with OpenCV).

---

## PILLAR 12: VISUAL QUALITY JUDGE — Structured 3-Tier Verdict

The existing QA gate (PILLAR 7) catches *technical* failures — wrong duration, missing
audio, black frames. This pillar adds *editorial quality judgment*: is this output actually
good enough to ship?

### 12.1 The Rubric (5 axes, weighted)

| Axis | Weight | What it measures | Metric |
|---|---|---|---|
| Sharpness | 0.30 | Focus, detail, absence of blur | Laplacian variance / 500, capped at 1.0 |
| Exposure | 0.20 | Brightness — not too dark, not blown | Mean gray value / 255 (two-sided fail) |
| Contrast | 0.15 | Tonal range, visual punch | Gray stddev / 128, capped at 1.0 |
| Colorfulness | 0.20 | Vibrancy, saturation richness | Hasler-Süsstrunk (same formula as perceive.py) |
| Noise | 0.15 | Clean signal vs grain/artifacts | DCT high-freq energy ratio (lower = better) |

### 12.2 Verdict Thresholds

```
EXCELLENT  →  score ≥ 0.75  AND  3+ axes graded EXCELLENT
PROPER     →  score 0.25–0.74  OR  fewer than 3 excellent axes
FAILED     →  score < 0.25  OR  2+ axes graded FAIL
```

### 12.3 `visual_judge.py` — Production Code

Save as `visual_judge.py` alongside `perceive.py`.

```python
#!/usr/bin/env python3
"""
visual_judge.py — Structured quality verdict for images and video frames.
Scores on 5 axes → returns FAILED / PROPER / EXCELLENT.
Run after perceive.py metrics. Uses same deps (cv2, numpy — already installed).

Usage:
    python visual_judge.py image.jpg                  # single image
    python visual_judge.py frame_25.jpg frame_50.jpg  # batch
    python visual_judge.py --video output.mp4          # auto-extract + judge 3 frames
"""
import sys, os, json, subprocess, tempfile
from pathlib import Path

# ponytail: cv2/numpy lazy-imported inside measure() so judge logic works without them.
# Only pixel analysis needs the heavy deps; verdict logic is pure stdlib.

RUBRIC = {
    "sharpness":    {"fail": 0.08, "excellent": 0.45},
    "exposure":     {"fail_lo": 0.12, "fail_hi": 0.88,
                     "excellent_lo": 0.30, "excellent_hi": 0.70},
    "contrast":     {"fail": 0.15, "excellent": 0.50},
    "colorfulness": {"fail": 0.06, "excellent": 0.35},
    "noise":        {"fail": 0.60, "excellent": 0.20},  # inverted: lower = better
}
WEIGHTS = {"sharpness": 0.30, "exposure": 0.20, "contrast": 0.15,
           "colorfulness": 0.20, "noise": 0.15}


def measure(img_path: str) -> dict:
    """Compute all 5 axes from a single image file. Returns dict of 0-1 floats."""
    import cv2, numpy as np

    bgr = cv2.imread(img_path)
    if bgr is None:
        return {"error": f"cannot read {img_path}"}
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    h, w = gray.shape

    sharpness = min(cv2.Laplacian(gray, cv2.CV_64F).var() / 500.0, 1.0)
    exposure = float(gray.mean() / 255.0)
    contrast = min(float(gray.std() / 128.0), 1.0)

    # Hasler-Susstrunk colorfulness (matches perceive.py)
    B, G, R = cv2.split(bgr.astype("float"))
    rg, yb = np.abs(R - G), np.abs(0.5 * (R + G) - B)
    colorfulness = min(
        (np.sqrt(rg.std()**2 + yb.std()**2) +
         0.3 * np.sqrt(rg.mean()**2 + yb.mean()**2)) / 100.0, 1.0)

    # Noise via DCT high-freq energy ratio on center crop
    cx, cy = w // 2, h // 2
    crop_sz = min(256, h, w)
    crop = gray[cy - crop_sz//2 : cy + crop_sz//2,
                cx - crop_sz//2 : cx + crop_sz//2].astype(np.float32)
    dct = cv2.dct(crop)
    total_energy = np.sum(dct**2) + 1e-10
    hf = dct[crop_sz//2:, crop_sz//2:]
    noise = float(np.sum(hf**2) / total_energy)

    return {k: round(v, 4) for k, v in {
        "sharpness": sharpness, "exposure": exposure, "contrast": contrast,
        "colorfulness": colorfulness, "noise": noise}.items()}


def judge(metrics: dict) -> dict:
    """Apply rubric to metrics -> verdict + per-axis grades + weighted score."""
    if "error" in metrics:
        return {"verdict": "FAILED", "reason": metrics["error"], "score": 0.0}

    grades, axis_scores = {}, {}

    # Sharpness
    v = metrics["sharpness"]
    if v < RUBRIC["sharpness"]["fail"]:
        grades["sharpness"], axis_scores["sharpness"] = "FAIL", 0.0
    elif v >= RUBRIC["sharpness"]["excellent"]:
        grades["sharpness"], axis_scores["sharpness"] = "EXCELLENT", 1.0
    else:
        grades["sharpness"], axis_scores["sharpness"] = "PROPER", 0.5

    # Exposure (two-sided)
    v = metrics["exposure"]
    if v < RUBRIC["exposure"]["fail_lo"] or v > RUBRIC["exposure"]["fail_hi"]:
        grades["exposure"], axis_scores["exposure"] = "FAIL", 0.0
    elif RUBRIC["exposure"]["excellent_lo"] <= v <= RUBRIC["exposure"]["excellent_hi"]:
        grades["exposure"], axis_scores["exposure"] = "EXCELLENT", 1.0
    else:
        grades["exposure"], axis_scores["exposure"] = "PROPER", 0.5

    # Contrast
    v = metrics["contrast"]
    if v < RUBRIC["contrast"]["fail"]:
        grades["contrast"], axis_scores["contrast"] = "FAIL", 0.0
    elif v >= RUBRIC["contrast"]["excellent"]:
        grades["contrast"], axis_scores["contrast"] = "EXCELLENT", 1.0
    else:
        grades["contrast"], axis_scores["contrast"] = "PROPER", 0.5

    # Colorfulness
    v = metrics["colorfulness"]
    if v < RUBRIC["colorfulness"]["fail"]:
        grades["colorfulness"], axis_scores["colorfulness"] = "FAIL", 0.0
    elif v >= RUBRIC["colorfulness"]["excellent"]:
        grades["colorfulness"], axis_scores["colorfulness"] = "EXCELLENT", 1.0
    else:
        grades["colorfulness"], axis_scores["colorfulness"] = "PROPER", 0.5

    # Noise (inverted — lower is better)
    v = metrics["noise"]
    if v > RUBRIC["noise"]["fail"]:
        grades["noise"], axis_scores["noise"] = "FAIL", 0.0
    elif v <= RUBRIC["noise"]["excellent"]:
        grades["noise"], axis_scores["noise"] = "EXCELLENT", 1.0
    else:
        grades["noise"], axis_scores["noise"] = "PROPER", 0.5

    score = round(sum(axis_scores[k] * WEIGHTS[k] for k in WEIGHTS), 3)
    fail_count = sum(1 for g in grades.values() if g == "FAIL")
    excellent_count = sum(1 for g in grades.values() if g == "EXCELLENT")

    if fail_count >= 2 or score < 0.25:
        verdict = "FAILED"
    elif excellent_count >= 3 and score >= 0.75:
        verdict = "EXCELLENT"
    else:
        verdict = "PROPER"

    return {"verdict": verdict, "score": score, "grades": grades, "metrics": metrics}


def judge_image(path: str) -> dict:
    return judge(measure(path))


def judge_video(video_path: str, percentages=(25, 50, 75)) -> dict:
    """Extract frames at given percentages, judge each, return aggregate."""
    dur_cmd = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "csv=p=0", video_path], capture_output=True, text=True)
    try:
        duration = float(dur_cmd.stdout.strip())
    except (ValueError, AttributeError):
        return {"verdict": "FAILED", "reason": "cannot probe duration", "score": 0.0}

    tmpdir = tempfile.mkdtemp(prefix="vj_")
    frame_results = []
    for pct in percentages:
        ss = round(duration * pct / 100.0, 2)
        frame_path = os.path.join(tmpdir, f"frame_{pct}.jpg")
        subprocess.run(["ffmpeg", "-y", "-ss", str(ss), "-i", video_path,
                        "-frames:v", "1", frame_path], capture_output=True)
        result = judge_image(frame_path)
        result["timestamp_sec"], result["percentage"] = ss, pct
        frame_results.append(result)

    verdicts = [r["verdict"] for r in frame_results]
    avg_score = round(sum(r["score"] for r in frame_results) / len(frame_results), 3)
    if "FAILED" in verdicts:
        agg = "FAILED"
    elif all(v == "EXCELLENT" for v in verdicts):
        agg = "EXCELLENT"
    else:
        agg = "PROPER"

    return {"verdict": agg, "avg_score": avg_score, "frames": frame_results,
            "video_path": video_path, "duration": duration}


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python visual_judge.py [--video] file1 [file2 ...]"); sys.exit(1)
    if sys.argv[1] == "--video":
        for vf in sys.argv[2:]:
            print(json.dumps(judge_video(vf), indent=2))
    else:
        for img in sys.argv[1:]:
            r = judge_image(img)
            print(f"{img}: {r['verdict']} (score={r['score']})")
            print(json.dumps(r, indent=2))
```

### 12.4 Pipeline Integration — REALIZE stage

Wire into `pipeline.py` — judge each clip before stitching:

```python
# Inside realize_stage(), after rendering each clip:
from visual_judge import judge_image

# ponytail: auto-fix map. Targeted correction per failed axis.
AXIS_FIX_MAP = {
    "sharpness":    "unsharp=5:5:1.0",
    "exposure":     "eq=brightness=0.05",       # adjust sign based on over/under
    "contrast":     "eq=contrast=1.2",
    "colorfulness": "eq=saturation=1.3",
    "noise":        "nlmeans=6:3:2:3",
}

for clip in rendered_clips:
    # Extract mid-frame, judge it
    mid_frame = work / f"judge_{clip.stem}.jpg"
    run(["ffmpeg", "-y", "-ss", str(clip_dur/2), "-i", str(clip),
         "-frames:v", "1", str(mid_frame)])
    result = judge_image(str(mid_frame))

    if result["verdict"] == "FAILED":
        failed_axes = [k for k, v in result["grades"].items() if v == "FAIL"]
        log.warning(f"Clip {clip.name} FAILED on: {failed_axes}. Fixing.")
        fix = AXIS_FIX_MAP.get(failed_axes[0], "")
        if fix:
            fixed = clip.with_suffix(".fixed.mp4")
            run(["ffmpeg", "-y", "-i", str(clip), "-vf", fix,
                 "-c:a", "copy", str(fixed)])
            fixed.rename(clip)  # replace original
```

### 12.5 Wire into QA Gate as Tier 6

Add to `qa_gate.py`:

```python
def tier6_visual_verdict(video: Path) -> tuple[str, dict]:
    """Editorial quality gate. Returns (verdict, full_report)."""
    from visual_judge import judge_video
    report = judge_video(str(video))
    verdict = report["verdict"]
    if verdict == "FAILED":
        log.error(f"Visual judge FAILED: avg_score={report['avg_score']}")
        for fr in report["frames"]:
            fails = [k for k, v in fr["grades"].items() if v == "FAIL"]
            if fails:
                log.error(f"  Frame {fr['percentage']}%: {fails}")
    return verdict, report
```

---

## PILLAR 13: BRAIN ORCHESTRATOR — Expert Editor Multi-Agent Coordination

When this agent coordinates sub-agents, it thinks like a **senior editor reviewing
rushes in a cutting room** — not a task manager checking boxes.

### 13.1 The 3-Pass Review (run on EVERY sub-agent output)

```
PASS 1 — TECHNICAL (5 seconds, automated)
  Run visual_judge.py -> get verdict + score + per-axis grades.
  FAILED -> reject immediately with specific axis failures.
  PROPER or EXCELLENT -> proceed to pass 2.

PASS 2 — EDITORIAL (30 seconds, agent LOOKS at it)
  Extract 3 frames (25/50/75%) and VIEW them with native vision.
  Ask:
  - Does this match the INTENT from the brief? (not just the prompt)
  - Is the color temperature consistent across frames?
  - Is there visual rhythm? (contrast between consecutive scenes)
  - Would a viewer PAUSE on this or SCROLL PAST it?

PASS 3 — CONTEXTUAL (10 seconds)
  - Does this piece fit with OTHER accepted pieces?
  - Color consistency: extract qa_frame from this + previous accepted output.
  - Pacing: does clip energy match its position in the timeline?
  - If this is 1 of N clips, does the set tell a coherent story?
```

### 13.2 The Intent Brief (write BEFORE delegating to any sub-agent)

```python
BRIEF_TEMPLATE = {
    "intent":    "",   # What should this FEEL like? (not "look good")
    "reference": "",   # Which past seed/output is this closest to?
    "ceiling":   "",   # What makes this "excellent" vs "proper"?
    "floor":     "",   # What's the minimum acceptable?
    "gotchas":   [],   # What went wrong on similar tasks before?
}
```

### 13.3 Coordination Patterns

**Pattern A: Parallel-then-Review** (fan out variants, pick winner)
```
1. Spawn 3 sub-agents with same brief but different params
   (different color grades, crop positions, zoom speeds)
2. Wait for all to complete
3. Run visual_judge.py on each output
4. VIEW top 2 scorers side-by-side (extract frames)
5. Pick winner based on editorial intent, not just score
6. Save winner's params to theme_seeds.json if score >= 0.75
```

**Pattern B: Pipeline-with-Gates** (sequential quality checkpoints)
```
1. Sub-agent A: select images   -> orchestrator reviews -> gate
2. Sub-agent B: apply color     -> orchestrator reviews -> gate
3. Sub-agent C: compose motion  -> orchestrator reviews -> gate
4. Sub-agent D: stitch + audio  -> orchestrator reviews -> final_gate

At each gate:
  - visual_judge.py verdict
  - FAILED = send back with specific feedback
  - "Exposure at 0.11 — too dark. Apply eq=brightness=0.06" (ALWAYS specific)
  - Max 2 re-tries per gate, then escalate to human
```

### 13.4 Rejection Feedback Templates (NEVER say "make it better")

```python
REJECTION_TEMPLATES = {
    "exposure": (
        "Exposure at {value} — too {'dark' if under else 'bright'} for {intent}. "
        "Target: 0.30-0.70. Fix: eq=brightness={'+0.05' if under else '-0.05'}"
    ),
    "sharpness": (
        "Sharpness {value} below 0.08 threshold. "
        "Likely cause: upscale from low-res or motion blur. "
        "Fix: unsharp=5:5:1.0 or select sharper source."
    ),
    "colorfulness": (
        "Colorfulness {value} reads washed out. Intent was '{intent}'. "
        "Fix: eq=saturation=1.3. Preview on one frame first."
    ),
    "noise": (
        "Noise ratio {value} — visible grain in shadows. "
        "Fix: nlmeans=6:3:2:3 (light denoise, preserves detail)."
    ),
    "consistency": (
        "This clip's color temp ({temp}) doesn't match previous clip. "
        "Both should be {target_temp}. Fix: colorbalance={filter_params}."
    ),
}
```

### 13.5 Decision Framework

```
Score >= 0.75 AND EXCELLENT AND matches intent
  -> PROMOTE to timeline. Save seed. Move to next slot.

Score 0.50-0.74 AND PROPER AND matches intent
  -> Is this the best the source material allows?
    YES -> ACCEPT with note: "source-limited, PROPER is ceiling"
    NO  -> RE-RENDER with specific fix (1 attempt only)

Score 0.25-0.49 with 1 FAIL axis
  -> RE-RENDER targeting the failed axis.
  -> 2nd attempt still < 0.50 -> try different source image.

Score < 0.25 OR FAILED
  -> REJECT. Give root cause. Try different approach entirely.
  -> 2 consecutive FAILs with same source -> flag source as unusable.
```

### 13.6 Orchestrator State File

The brain agent maintains this live during any multi-agent session:

```json
{
  "session_id": "edit_2025_07_15_001",
  "intent": "high-energy ad reel, teal-orange cinematic",
  "timeline_slots": [
    {"slot": 1, "status": "accepted", "verdict": "EXCELLENT",
     "score": 0.82, "clip": "work/clip_001.mp4", "seed_saved": true},
    {"slot": 2, "status": "reviewing", "verdict": "PROPER",
     "score": 0.61, "clip": "work/clip_002.mp4",
     "note": "contrast low, re-rendering with eq=contrast=1.2"},
    {"slot": 3, "status": "pending", "assigned_to": "sub_agent_003"}
  ],
  "consistency_baseline": {
    "color_temp": "warm", "avg_brightness": 0.45, "avg_saturation": 1.2
  }
}
```

### 13.7 Anti-Patterns (What Bad Orchestrators Do)

| Bad Pattern | Why It Fails | Expert Alternative |
|---|---|---|
| Accept everything scoring > 0.5 | Ignores editorial intent and consistency | Score is input to judgment, not the judgment itself |
| Re-render indefinitely | Wastes time, source material has a ceiling | Max 2 retries, then change approach |
| Give vague feedback ("make it better") | Sub-agent can't act on it | Always specify: axis + current value + target + filter |
| Judge each piece in isolation | Final timeline has jarring transitions | Always compare against consistency baseline |
| Skip the visual review | Metrics miss editorial intent | Score + LOOK. Both. Always. |
| Treat all clips equally | Hook clip matters 10x more than middle | Weight quality budget toward first and last clips |

---

## PILLAR 14: THEME SEED TRACKER — Identify + Save What Works

When the judge returns EXCELLENT, capture the exact recipe that produced it.
Next time, the agent checks seeds BEFORE choosing styles — compounding quality.

### 14.1 `theme_seed_tracker.py` — Full Code

```python
#!/usr/bin/env python3
"""
theme_seed_tracker.py — Save and recall high-performing visual themes.

Usage:
    python theme_seed_tracker.py save --name "teal_cinematic" --score 0.85 \
        --verdict EXCELLENT --filter "colorbalance=rs=0.15:gs=-0.05:bs=-0.1" \
        --prompt "cinematic editorial teal orange" --tags cinematic,warm
    python theme_seed_tracker.py top --n 5
    python theme_seed_tracker.py find --tag warm
    python theme_seed_tracker.py suggest --mood "energetic ad reel"

ponytail: single-file, stdlib only. No DB. Ceiling: linear scan.
Upgrade path: sqlite when >1000 seeds.
"""
import json, os, sys, time, argparse
from pathlib import Path

SEEDS_FILE = Path(os.environ.get(
    "THEME_SEEDS_PATH",
    Path.home() / "apex-video-env" / "theme_seeds.json"
))


def _load():
    return json.loads(SEEDS_FILE.read_text()) if SEEDS_FILE.exists() else []

def _save(seeds):
    SEEDS_FILE.parent.mkdir(parents=True, exist_ok=True)
    SEEDS_FILE.write_text(json.dumps(seeds, indent=2))


def save_seed(name, score, verdict, ffmpeg_filter="",
              source_metrics=None, prompt="", tags=None, extra=None):
    """Save a high-performing visual recipe. Only EXCELLENT/PROPER with score >= 0.60."""
    if verdict not in ("EXCELLENT", "PROPER"):
        return {"saved": False, "reason": f"Only EXCELLENT/PROPER saved, got {verdict}"}
    if score < 0.60:
        return {"saved": False, "reason": f"Score {score} below 0.60 threshold"}

    seed = {
        "id": f"seed_{name}_{int(time.time())}",
        "name": name,
        "score": score,
        "verdict": verdict,
        "filter": ffmpeg_filter,
        "source_metrics": source_metrics or {},
        "prompt": prompt,
        "tags": tags or [],
        "created": time.strftime("%Y-%m-%dT%H:%M:%S"),
        "uses": 0,
        "last_used": None,
    }
    if extra:
        seed["extra"] = extra

    seeds = _load()
    seeds.append(seed)
    _save(seeds)
    return {"saved": True, "seed": seed}


def top_seeds(n=10, tag=None):
    seeds = _load()
    if tag:
        seeds = [s for s in seeds
                 if tag.lower() in [t.lower() for t in s.get("tags", [])]]
    return sorted(seeds, key=lambda s: s["score"], reverse=True)[:n]


def find_by_tag(tag):
    return [s for s in _load()
            if tag.lower() in [t.lower() for t in s.get("tags", [])]]


def suggest_for_mood(mood):
    """Keyword-overlap suggestion. ponytail: naive but fast. Upgrade: embeddings."""
    mood_words = set(mood.lower().split())
    seeds = _load()
    if not seeds:
        return {"suggestion": None, "reason": "no seeds yet"}

    scored = []
    for s in seeds:
        seed_words = set(s.get("prompt", "").lower().split())
        seed_words.update(t.lower() for t in s.get("tags", []))
        overlap = len(mood_words & seed_words)
        combined = overlap * 0.5 + s["score"] * 0.5
        scored.append((combined, s))

    scored.sort(key=lambda x: x[0], reverse=True)
    best = scored[0][1]
    return {"suggestion": best, "match_score": round(scored[0][0], 3),
            "reason": f"Best: '{best['name']}' (quality={best['score']})"}


def mark_used(seed_id):
    seeds = _load()
    for s in seeds:
        if s["id"] == seed_id:
            s["uses"] = s.get("uses", 0) + 1
            s["last_used"] = time.strftime("%Y-%m-%dT%H:%M:%S")
            _save(seeds)
            return True
    return False


def auto_capture_from_judge(judge_result, ffmpeg_filter="", prompt="", tags=None):
    """Feed a visual_judge result directly. Only saves EXCELLENT."""
    if judge_result.get("verdict") != "EXCELLENT":
        return {"saved": False, "reason": "not EXCELLENT"}
    name = f"auto_{judge_result.get('score', 0):.0%}".replace(".", "_")
    return save_seed(name=name, score=judge_result["score"],
                     verdict="EXCELLENT", ffmpeg_filter=ffmpeg_filter,
                     source_metrics=judge_result.get("metrics", {}),
                     prompt=prompt, tags=tags or ["auto-captured"])


if __name__ == "__main__":
    p = argparse.ArgumentParser()
    sub = p.add_subparsers(dest="cmd")

    sp = sub.add_parser("save")
    sp.add_argument("--name", required=True)
    sp.add_argument("--score", type=float, required=True)
    sp.add_argument("--verdict", required=True)
    sp.add_argument("--filter", default="")
    sp.add_argument("--source-metrics", default="{}")
    sp.add_argument("--prompt", default="")
    sp.add_argument("--tags", default="")

    sub.add_parser("top").add_argument("--n", type=int, default=10)
    sub.add_parser("find").add_argument("--tag", required=True)
    sub.add_parser("suggest").add_argument("--mood", required=True)

    args = p.parse_args()
    if args.cmd == "save":
        print(json.dumps(save_seed(
            name=args.name, score=args.score, verdict=args.verdict,
            ffmpeg_filter=args.filter,
            source_metrics=json.loads(args.source_metrics),
            prompt=args.prompt,
            tags=[t.strip() for t in args.tags.split(",") if t.strip()]),
            indent=2))
    elif args.cmd == "top":
        for s in top_seeds(args.n):
            print(f"  {s['score']:.2f}  {s['verdict']:9s}  {s['name']}")
    elif args.cmd == "find":
        for s in find_by_tag(args.tag):
            print(f"  {s['score']:.2f}  {s['name']}  filter={s.get('filter','')[:60]}")
    elif args.cmd == "suggest":
        print(json.dumps(suggest_for_mood(args.mood), indent=2))
    else:
        p.print_help()
```

### 14.2 Pipeline Integration — INTERPRET stage

Wire seeds into `pipeline.py` at the start of interpret:

```python
# At the start of interpret_stage():
from theme_seed_tracker import suggest_for_mood, mark_used

def interpret_stage(brief_text, images, state):
    # Check seed bank BEFORE choosing color/style
    suggestion = suggest_for_mood(brief_text)
    if suggestion.get("suggestion"):
        seed = suggestion["suggestion"]
        log.info(f"Seed match: '{seed['name']}' (score={seed['score']})")
        log.info(f"  Injecting filter: {seed['filter']}")
        state.color_recipe = seed["filter"]
        mark_used(seed["id"])
    else:
        log.info("No matching seed — using fresh style selection")
        state.color_recipe = select_color_recipe(brief_text)  # existing logic
```

### 14.3 Pipeline Integration — CRITIQUE stage (auto-capture)

```python
# At the end of critique_stage(), after final QA passes:
from visual_judge import judge_video
from theme_seed_tracker import auto_capture_from_judge

def critique_stage(final_video, state):
    # ... existing 5-tier QA gate ...

    # NEW: Editorial quality verdict
    vj_result = judge_video(str(final_video))
    log.info(f"Visual verdict: {vj_result['verdict']} (score={vj_result['avg_score']})")

    if vj_result["verdict"] == "EXCELLENT":
        auto_capture_from_judge(
            {"verdict": "EXCELLENT", "score": vj_result["avg_score"],
             "metrics": vj_result["frames"][1]["metrics"]},  # mid-frame metrics
            ffmpeg_filter=state.color_recipe,
            prompt=state.brief_text,
            tags=state.tags or ["auto"])
        log.info("Seed saved to theme_seeds.json")
```

---

## PILLAR 15: VLM SEMANTIC CRITIQUE — API-Based Editorial Judgment

PILLAR 12 scores pixel-level quality. This pillar adds **semantic judgment** — does the
output match the creative intent, tell the right story, evoke the right emotion?

Uses one vision API call on 3 frames. This is the "Tier 2" critique from Pro-editor-logic.md.

### 15.1 The VLM Critique Prompt (copy-paste ready, tested)

```python
VLM_CRITIQUE_PROMPT = """You are a senior creative director and film editor with 15+ years
in advertising, social media content, and cinematic storytelling.

TASK: Judge this image/video output against the creative brief below.

BRIEF: {brief_text}

RULES:
- Be ruthless about technical flaws and generous about creative excellence.
- Never be vague. Every critique must name the specific element and location.
- If an image is technically flawless but boring, it is PROPER not EXCELLENT.
- Score each axis 1-10. Output ONLY the JSON below, nothing else.

SELF-ANCHOR (answer these internally before scoring):
- For THIS specific brief, what does absolute failure look like?
- For THIS specific brief, what does undeniable excellence look like?

AXES:
1. BRIEF ADHERENCE: Does it match what was asked? Subject, setting, mood, action.
2. TECHNICAL INTEGRITY: Hands, anatomy, text legibility, artifacts, resolution.
3. COMPOSITION & FRAMING: Rule of thirds, leading lines, subject placement, balance.
4. COLOR & LIGHTING: Temperature, shadows, highlights, mood match, consistency.
5. EMOTIONAL IMPACT: Would a viewer stop scrolling? Does it tell a story?

OUTPUT (strict JSON, no markdown fences, no prose):
{
  "self_anchor": {"failure_looks_like": "...", "excellence_looks_like": "..."},
  "scores": {"adherence": 0, "technical": 0, "composition": 0,
             "color_lighting": 0, "emotional_impact": 0},
  "total": 0,
  "verdict": "FAILED | PROPER | EXCELLENT",
  "issues": ["specific problem with location reference"],
  "surgical_fix": "one specific, actionable directive for the rendering agent"
}

VERDICT THRESHOLDS:
- total < 25: FAILED
- 25-39: PROPER
- 40+: EXCELLENT
"""
```

### 15.2 `vlm_critique.py` — Lightweight API Caller

```python
"""vlm_critique.py — One-call VLM semantic critique on 3 frames.
Uses vision_bridge.py for API rotation. Costs ~$0.01-0.03 per call.
ponytail: reuses existing vision_bridge.py. No new deps."""
import json, subprocess, tempfile, os
from pathlib import Path
from core import log


def extract_critique_frames(video_path, out_dir=None):
    """Extract frames at 25/50/75% for VLM input."""
    out_dir = out_dir or tempfile.mkdtemp(prefix="vlm_")
    dur = float(subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "csv=p=0", video_path],
        capture_output=True, text=True).stdout.strip())
    frames = []
    for pct in (25, 50, 75):
        ss = round(dur * pct / 100, 2)
        fp = os.path.join(out_dir, f"critique_{pct}.jpg")
        subprocess.run(["ffmpeg", "-y", "-ss", str(ss), "-i", video_path,
                        "-frames:v", "1", "-q:v", "2", fp], capture_output=True)
        frames.append(fp)
    return frames


def vlm_critique(video_path, brief_text, vision_client=None):
    """Run semantic VLM critique. Returns parsed JSON verdict."""
    frames = extract_critique_frames(video_path)

    prompt = VLM_CRITIQUE_PROMPT.format(brief_text=brief_text)

    if vision_client:
        # Use existing vision_bridge.py ResilientVisionClient
        response = vision_client.describe_images(frames, prompt)
    else:
        log.warning("No vision_client — VLM critique skipped (offline mode)")
        return {"verdict": "PROPER", "skipped": True,
                "reason": "no vision client available"}

    # Parse JSON from response (strip markdown fences if model wraps them)
    text = response.strip()
    if text.startswith("```"):
        text = text.split("\n", 1)[1].rsplit("```", 1)[0]
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        log.error(f"VLM returned non-JSON: {text[:200]}")
        return {"verdict": "PROPER", "parse_error": True, "raw": text[:500]}
```

### 15.3 Full Critique Pipeline (combined Tier A + Tier B)

```python
# critique_stage() updated to run both pixel and semantic judges:

def critique_stage(final_video, state, vision_client=None):
    # ... existing 5-tier QA gate (PILLAR 7) ...

    # Tier A: Pixel-level verdict (PILLAR 12)
    from visual_judge import judge_video
    vj = judge_video(str(final_video))
    log.info(f"Pixel verdict: {vj['verdict']} ({vj['avg_score']})")

    if vj["verdict"] == "FAILED":
        return {"passed": False, "reason": "pixel quality FAILED", "report": vj}

    # Tier B: Semantic VLM verdict (PILLAR 15) — only if pixel passes
    if vision_client:
        from vlm_critique import vlm_critique
        vlm = vlm_critique(str(final_video), state.brief_text, vision_client)
        log.info(f"VLM verdict: {vlm.get('verdict')} (total={vlm.get('total')})")

        if vlm.get("verdict") == "FAILED" and not vlm.get("skipped"):
            return {"passed": False, "reason": "VLM semantic FAILED",
                    "surgical_fix": vlm.get("surgical_fix"), "report": vlm}

        if vlm.get("verdict") == "EXCELLENT":
            log.info("VLM says EXCELLENT")

    # Tier C: Auto-seed capture (PILLAR 14)
    if vj["verdict"] == "EXCELLENT":
        from theme_seed_tracker import auto_capture_from_judge
        auto_capture_from_judge(
            {"verdict": "EXCELLENT", "score": vj["avg_score"],
             "metrics": vj["frames"][1]["metrics"]},
            ffmpeg_filter=state.color_recipe,
            prompt=state.brief_text,
            tags=state.tags or ["auto"])
        log.info("Seed saved to theme_seeds.json")

    return {"passed": True, "pixel": vj, "vlm": vlm if vision_client else None}
```

---

## PILLAR 15.4: EBU R128 LOUDNESS NORMALIZATION

The existing spec enforces `-ar 48000` but doesn't normalize loudness.
Social platforms reject or penalize inconsistent volume levels.

```bash
# Normalize to EBU R128 broadcast standard (-14 LUFS for social)
# ponytail: one-liner, no new deps — ffmpeg has loudnorm built in.
ffmpeg -y -i final.mp4 \
  -af "loudnorm=I=-14:LRA=11:TP=-1" \
  -ar 48000 -c:v copy \
  normalized.mp4

# Verify loudness after normalization:
ffmpeg -i normalized.mp4 -af "loudnorm=I=-14:print_format=json" -f null - 2>&1 | \
  grep -A20 "Parsed_loudnorm"
```

Add to `attach_audio()` in `realize.py`:

```python
def attach_audio(video, audio, out, target_dur):
    # ... existing apad + shortest logic ...

    # NEW: EBU R128 loudness normalization (social platform standard)
    normalized = out.with_suffix(".norm.mp4")
    run(["ffmpeg", "-y", "-i", str(out),
         "-af", "loudnorm=I=-14:LRA=11:TP=-1",
         "-ar", "48000", "-c:v", "copy", str(normalized)])
    normalized.rename(out)
    log.info("Audio normalized to EBU R128 (-14 LUFS)")
```

---

## UPDATED 5-STAGE LOOP (v6.2)

```
1. PERCEIVE  — View assets with eyes + perceive.py metrics
               Optional: faceless scan (Haar → Gaussian 99×99 if --force-faceless)
2. INTERPRET — Check theme_seeds.json for matching seeds -> choose color/style
               Map script beats to assets. --mode ad (5) or --mode narrative/manga (7)
               Selective AI: hero beats only for --ai-clips
3. COMPOSE   — Assign Ken-Burns / static_breathe + transitions per beat; state WHY
4. REALIZE   — Render clips -> Tier-0 gates -> visual_judge.py (reject FAILED)
               Stitch with xfade, attach audio, loudnorm I=-14, -ar 48000, H.264 delivery
5. CRITIQUE  — QA gate + visual_judge on final + optional VLM (3 frames / contact sheet)
               EXCELLENT -> save seed via auto_capture_from_judge()
               PROPER -> accept if intent matches; note source ceiling
               FAILED -> re-render targeting specific failed axes (max 2 attempts)
```

---

## PILLAR 16: POWER TIPS VAULT (v6.2 — folder-wide adoption)

> Adopted from every sibling note in `ai-agent editor/` that is **editor-relevant**,
> filtered for: **light CPU/RAM**, **free/unlimited local Mac or Linux**, **stable valid output**.
> Social-browser automation (cookies, Bezier, captcha) stays in Hybrid 4.7 — **not** here.

### 16.0 Source inventory (nothing skipped)

| Source file | Adopted? | What entered this vault |
|-------------|----------|-------------------------|
| `expert_tips.md` | Yes | Tier funnel, 3-frame miracle, metric table, loudnorm |
| `effects and edits.md` | Yes | Kits, hook math, grain/vignette, fit_916, speed_ramp, zoom_punch |
| `Videos-rules.md` | Yes | Blank-face protocol, tool matrix, natural_key naming, plan-before-act |
| `APEX_EDITOR_WORTHY_NOTES_FACELESS_MANGA_PROD.md` | Yes | Force flags, negatives, manga beats, kits |
| `Apex-Faceless-App-Production-Guide.md` | Yes | App-hero rules, surgical fix map |
| `Merged_AI_Visual_Judge_Orchestrator_SeedBank_v2.md` | Yes | Tier-0 gates, cost budget, anti-patterns, contact sheet |
| `expert-editor-logic.md` / `brain_orchestrator.md` | Already in P12–15 | Cross-ref only |
| `qwen_editor_adoptions.md` | Already in P5–11 | Cross-ref only |
| `z ai notes/apex-upgrade-candidates…` | Yes (P0/P1/P2 editor items) | Selective AI, breathe, cadence, PPTX, HDR, parallax spec |
| `z ai notes/z-ai content editing tips.md` | Yes (codec/HDR/audio/EAA) | Codec matrix, tonemap, platform budgets |
| `z ai notes/z-ai recommendations…` | Yes (manga craft free path) | CapCut handoff, 2.5D, upscale gate |
| `z ai notes/z-ai media manager.md` | **No** | Social automation → Hybrid 4.7 |
| `z ai notes/SKILL.md` | **No** | Multi-repo swarm, off-scope |
| `prodia_models_complete_guide.md` | Handoff only | Paid gen catalog — only when user opts into external gen |
| `best searching agent.md` | **No** | Search agent, not editor |
| `Ai-cli agent editing.md` | Yes (light tools) | yt-dlp / mkvmerge / ImageMagick recipes |
| `theme_seed_tracker.py` / `visual_judge.py` / `vlm_critique.py` | Code on disk | Spec already in P12–15 |
| `Qwen-expert-Ai-editor.docx/.json` | Export mirrors | This `.md` is source of truth |
| `AI_Viral_Video_Manga_Anime_Merged_Guide_2026.html` | Overlaps Updates | Craft covered via effects + faceless notes |
| `Updates to editor Qwen Apex Ai-agent.md` / `opus upgrades…` | Historical | Already merged into pillars 1–15 |

---

### 16.1 Resource & stability contract (Mac + Linux, free forever)

```
PRINCIPLE: Ship with tools you can run all night on a laptop or $5 VPS
           without paid APIs, heavy Python frameworks, or infinite loops.
```

| Rule | Implementation |
|------|----------------|
| **Zero paid SaaS default** | Local FFmpeg + OpenCV first; optional free cloud vision only if keys exist; `--no-cloud` always works |
| **CPU budget** | Prefer HW encode (`h264_videotoolbox` / `h264_nvenc` / `h264_qsv`); else `libx264 -preset medium` |
| **RAM budget** | Never default to MoviePy. `VerificationEngine(max_ram_mb=2048)`. Process clips sequentially |
| **Time budget** | Every `subprocess` has timeout; FFmpeg retry ≤1; stage re-render ≤3; VLM temp=0.2 |
| **API budget** | Tier-0 free gates catch 40–60% fails before any VLM call. 3 frames max per critique |
| **Disk state** | `state.json` after every stage — resume after crash without redoing PERCEIVE |
| **Determinism** | Never invent duration/size; always `ffprobe`. Natural sort `01_clip.mp4` not `clip1` |
| **Platform ship** | H.264 + `yuv420p` + `+faststart` social default; HEVC/AV1 only `--archive` |

**Preflight (run before any pipeline — light, free):**
```bash
which ffmpeg && ffmpeg -version | head -1
which ffprobe && which convert || which magick
ls -la ./inputs/ 2>/dev/null || true
# Small probe render (2s) before full job — fails fast
ffmpeg -y -f lavfi -i testsrc=s=320x240:d=2 -c:v libx264 -pix_fmt yuv420p /tmp/apex_probe.mp4
```

**Light encode flags (when CPU is scarce):**
```bash
# Preview / draft (fast)
-c:v libx264 -preset veryfast -crf 28 -threads 0 -pix_fmt yuv420p
# Final social (quality/speed balance)
-c:v libx264 -preset medium -crf 23 -pix_fmt yuv420p -movflags +faststart
# Mac HW (near-zero CPU)
-c:v h264_videotoolbox -b:v 8M -allow_sw 1 -pix_fmt yuv420p
```

**Zero-CPU stream join (when no re-encode needed):**
```bash
# mkvtoolnix — free, unlimited, near-zero CPU
mkvmerge -o output.mkv video.mp4 audio.mp3 subtitles.srt
```

---

### 16.2 Tier-0 deterministic gates (zero API · run always)

Catch corrupt/black/silent/frozen output **before** visual_judge or VLM.

```bash
# Black frames — FAIL if >5% duration black
ffmpeg -i input.mp4 -vf blackdetect=d=0.1:pix_th=0.00 -an -f null - 2>&1 | grep blackdetect

# Silent audio — FAIL if mean volume < -50 dB
ffmpeg -i input.mp4 -af volumedetect -f null - 2>&1 | grep mean_volume

# Geometry vs target preset
ffprobe -v error -select_streams v:0 -show_entries stream=width,height,avg_frame_rate \
  -of csv=s=x:p=0 input.mp4

# Contact sheet for VLM (90% fewer tokens than full video)
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 input.mp4)
for P in 25 50 75; do
  SEC=$(python3 -c "print(round(float('$DUR')*$P/100,2))")
  ffmpeg -y -ss "$SEC" -i input.mp4 -frames:v 1 -q:v 3 "kf_${P}.jpg" 2>/dev/null
done
ffmpeg -y -i kf_25.jpg -i kf_50.jpg -i kf_75.jpg -i kf_25.jpg \
  -filter_complex "[0:v][1:v]hstack[t];[2:v][3:v]hstack[b];[t][b]vstack" contact_sheet.jpg
```

**Frozen-frame check (no scikit-image dep):**
```python
# ponytail: mean abs diff across gray frames; pure cv2/numpy
import cv2, numpy as np
def is_frozen(path, n=5, thr=2.0):
    cap = cv2.VideoCapture(path)
    total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 1
    grays = []
    for i in range(n):
        cap.set(cv2.CAP_PROP_POS_FRAMES, int(total * i / n))
        ok, fr = cap.read()
        if ok: grays.append(cv2.cvtColor(fr, cv2.COLOR_BGR2GRAY))
    cap.release()
    if len(grays) < 2: return True  # INCONCLUSIVE → treat as fail
    frozen = sum(
        np.mean(np.abs(grays[i].astype(float) - grays[i+1].astype(float))) < thr
        for i in range(len(grays)-1)
    )
    return frozen >= 2
```

**Rule:** Any critical Tier-0 flag → **FAILED** immediately. Do not call VLM.

---

### 16.3 Cost & compute budget (per asset)

| Stage | Cost | Compute | Notes |
|-------|------|---------|-------|
| Tier-0 FFmpeg gates | $0 | CPU only | Catches 40–60% of failures |
| visual_judge.py | $0 | Local OpenCV | Always free |
| Fast VLM (3 frames / contact sheet) | ~$0 free-tier | 1 API call | Temp 0.2, JSON only |
| Premium VLM | Only if free keys + borderline | 1 call | Skip when offline |
| Orchestrator routing | Free local | Rules + judge | No council of agents |
| Seed bank retrieval | $0 | JSON file | Instant |
| **Typical ship** | **$0** | **Mac/Linux** | Offline path is first-class |

**Anti-burn rules:** max 3 re-renders · surgical fix not blind regen · never send full video to VLM · seed clustering offline/weekly, not live.

---

### 16.4 Selective AI clips + hygiene (hybrid path)

1. **AI only on heroes:** use `--ai-clips` only when `visual_weight == "hero"` **and** the beat needs real motion (action, impact, emotional CU). Dialogue / setup / filler → Ken Burns or `static_breathe`.
2. **Never extend a bad AI clip:** if 25/50/75% frames fail judge or show limb/face morph, **drop** the clip and fall back to Ken Burns. Extension multiplies the failure.
3. **AI-video negative pack** (append when generating external motion for `--ai-clips`):
```
morphing, extra limbs, blurry, distorted face, background change,
warping, melting, duplicate faces, text artifacts, eyes opening,
face morph, identity change, watermark
```
4. **CapCut primary free motion:** ~7s/clip, no watermark. 30s reel ≈ 5 clips minimum. CapCut → export → Apex stitch.

---

### 16.5 Motion presets (COMPOSE additions)

Existing: `zoom_in_fast`, `zoom_in_slow`, `zoom_out_slow`, `pan_left`, `pan_right`, `static_subtle`.

**NEW — `static_breathe` (alive still, pure FFmpeg, free):**
```bash
# ~1% scale loop — dialogue / support / filler; never stack with zoom_in_fast
ffmpeg -y -framerate 30 -loop 1 -i still.jpg \
  -vf "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,\
zoompan=z='1+0.01*sin(2*PI*on/60)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':\
d=1:s=1080x1920:fps=30,format=yuv420p" \
  -t 4 -c:v libx264 -pix_fmt yuv420p -r 30 breathe.mp4
```

**NEW — optional `--anime-cadence` on AI segments only (~12–15 fps feel):**
```bash
# Posterize feel then remux at 30 — do NOT apply to live-action UI ads
ffmpeg -y -i ai_clip.mp4 -vf "fps=12,fps=30" -c:v libx264 -pix_fmt yuv420p anime_feel.mp4
```

**NEW — zoom-bridge (manga panel continuity):**
- Exit panel N with `zoom_in_*` toward the story edge.
- Enter panel N+1 with `zoom_out_*` from the opposite edge.
- Optional auto-pair when consecutive assets are sequential panels.

**NEW — zoom punch (~0.5s emphasis at timestamp T):**
```bash
ffmpeg -y -i clip.mp4 \
  -vf "zoompan=z='if(between(in_time,T,T+0.5),min(zoom+0.08,1.35),1)':\
x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=1080x1920:fps=30" \
  -c:v libx264 -pix_fmt yuv420p punched.mp4
```

---

### 16.6 9:16 safe-zone + blurred fill (Reels/TikTok UI)

Safe zone on 1080×1920: **~200px top / ~300px bottom / ~60px sides** (UI chrome).

Compose AI keyframes at **16:9 or wider with subject centered**, then fit:
```bash
# Sharp core + blurred fill — never letterbox black bars on vertical feeds
ffmpeg -y -framerate 30 -loop 1 -i img.jpg -loop 1 -i img.jpg \
  -filter_complex "\
    [0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,boxblur=20:5[bg];\
    [1:v]scale=1080:1920:force_original_aspect_ratio=decrease[fg];\
    [bg][fg]overlay=(W-w)/2:(H-h)/2" \
  -t 4 -c:v libx264 -pix_fmt yuv420p -an fit_916.mp4
```

---

### 16.7 Faceless force protocol (series / manga / ads)

| Flag | Meaning |
|------|---------|
| `--force-faceless` | Reject eyes/nose/mouth/circles; re-gen ≤3 then flag source unusable |
| `--force-app-hero` | Product/UI ≥40% of frame on proof/CTA beats |
| `--force-series` | Multi-panel / multi-beat episode, not one-off image |
| `--mode manga` | Comic grammar (panels/gutters) + still faceless |

**Always-on negative (gen prompts):**
```
eyes, eye, pupils, iris, eyelids, eye circles, glasses with eyes, nose, nostrils,
mouth, lips, teeth, smile, frown, eyebrows, facial features, realistic face,
detailed face, portrait face, mask on face, uncanny valley face, face morph
```

**Sanitize residual faces before montage (local OpenCV, free):**
```python
import cv2
def blur_faces(path):
    img = cv2.imread(path)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    faces = cv2.CascadeClassifier(
        cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
    ).detectMultiScale(gray, 1.1, 4)
    for (x, y, w, h) in faces:
        roi = img[y:y+h, x:x+w]
        img[y:y+h, x:x+w] = cv2.GaussianBlur(roi, (99, 99), 30)
    cv2.imwrite(path, img)
    return len(faces)
```

**QA override:** if `visual_judge` is PROPER/EXCELLENT but eyes/face visible under `--force-faceless` → force **FAILED**.

---

### 16.8 Prep gates: PDF / PPTX / resolution / HDR

**PDF:** existing `--pdf-ingest` via PyMuPDF (prefer original embedded images).

**PPTX (free, high-fidelity originals):**
```bash
# PPTX is a ZIP. Prefer this over online converters.
cp deck.pptx deck.zip && unzip -o deck.zip -d pptx_out/
# Assets: pptx_out/ppt/media/*  → copy into --input
```

**Min-side upscale gate (before Ken Burns / AI motion):**
```bash
# If min(width,height) < 1080 → upscale or warn (prevents mushy zooms)
# Local free: ImageMagick, Real-ESRGAN, Waifu2x (anime line art)
magick panel.png -filter Lanczos -resize 1080x1080^ panel_up.png
# PDF rasterize target when needed: 150–300 DPI
```

**HDR → SDR (phone masters that look wrong after social strip HDR):**
```bash
ffmpeg -y -i hdr_input.mp4 \
  -vf "zscale=transfer=smpte2084,tonemap=bt2390,zscale=transfer=bt709,format=yuv420p" \
  -c:v libx264 -crf 18 -ar 48000 sdr_output.mp4
```
Default pipeline remains **SDR Rec.709**. Tag color metadata when shipping masters:
```
-color_primaries bt709 -color_trc bt709 -colorspace bt709
```

**Watermark crop heuristic (web grabs):** watermarks often bottom-right/left — crop ~10% edge if logo/text at margins:
```bash
ffmpeg -y -i raw.jpg -vf "crop=iw*0.9:ih*0.9:(iw-ow)/2:(ih-oh)/2" clean.jpg
```

---

### 16.9 Optional 2.5D parallax (spec first — no core bloat)

Biggest stills quality jump when layer files exist:
- If `shot_fg.png` + `shot_bg.png` present → composite with **slower bg pan/zoom** than fg.
- Full depth-map automation (DepthFlow / MiDaS) = **optional skill**, never a hard dependency.
- Free desktop path: DaVinci Resolve Fusion free, or Blender Grease Pencil for real cut-out.

---

### 16.10 Codec decision matrix (editorial, not just HW detect)

| Intent | Codec | Why |
|--------|-------|-----|
| Social / web delivery (default) | **H.264** | Universal decode; IG/TikTok-safe |
| Screen recording → edit → social | **H.264** | Fast encode, zero surprises |
| Long-term archive / masters | **HEVC** (`--archive`) | Smaller files; not default ship |
| YouTube archive / bandwidth | **AV1** (opt-in, slow) | Royalty-free; encode is heavy — never default |
| YouTube-specific alt | VP9 | Free, ecosystem-limited |

**Hard rule (already in code):** `best_video_encoder(archive=False)` → H.264 path.

---

### 16.11 Editorial craft kits (from effects and edits)

**Platform hook math (2026):** land hook ≤ **1.3s** TikTok / ≤ **2.1s** Reels; retain **≥60%** past 3s. Mistake-frame hooks often outperform pure positive.

**Hard cut ≈ 90%** of edits. Effects = seasoning (name the tone in one word; if you need three filters to “explain” the tone, stop stacking).

| Content type | Color | Transitions | Effects | Rhythm |
|--------------|-------|-------------|---------|--------|
| Motivational | Warm-neutral | Hard cuts + 1 light-leak accent | Grain, vignette | 3–5s → 7–10s → tight CTA |
| Story / manga | Warm → cool on twist | J-cut VO, whip on jumps | Light grain | Story beats, not timer |
| Listicle / edu | Clean high-key | Zoom between points | Motion graphics not filters | New visual every fact |
| True crime | Bleach / moody teal | Hard + 1 glitch at reveal | Heavy grain | Slow → snap |
| Comedy | Vibrant | Hard on punchline | Minimal; sound wins | Fastest |
| App / product ad | Clean bright | Hard + match cut to UI | Minimal | UI holds long enough to read |

**Post-effects (FFmpeg native, free):**
```bash
# Grain (strength 8–16 typical)
-vf "noise=alls=12:allf=t+u"
# Vignette
-vf "vignette=PI/4"
# Light denoise (when judge noise axis fails)
-vf "hqdn3d=1.5:1.5:6:6"
# Unsharp (when sharpness fails)
-vf "unsharp=5:5:1.0"
```

**Extra grade kits not already in Pillar 10.G:**
```bash
# Bleach bypass (grit)
eq=saturation=0.5:contrast=1.4:brightness=-0.05,curves=m='0/0 0.25/0.1 0.5/0.45 0.75/0.8 1/1'
# Moody teal night
colorbalance=rs=-0.1:gs=-0.05:bs=0.15,eq=contrast=1.3:brightness=-0.08
# Vibrant pop (food/fashion)
eq=saturation=1.5:contrast=1.15,unsharp=5:5:0.5:5:5:0
```

**Sound design often > filters:** whoosh on cut, hit on keyword, soft bed under VO.

**Dual masters:** render **textless master** + **captioned burn-in** separately (zero-cost at encode time; saves rework for multi-language).

---

### 16.12 Caption / EAA compliance (ship checklist)

- Always `transcribe()` when audio exists (Whisper local, 16 kHz mono prep).
- Default: burn-in SRT on final (mute-scroll feeds + EAA 2025 EU).
- Optional sidecar `.srt` / `.vtt`.
- Safe caption style: white text, black outline, bottom margin clear of UI (~120px on 9:16).

```bash
ffmpeg -y -i final.mp4 \
  -vf "subtitles=subs.srt:force_style='FontSize=22,Outline=2,Shadow=1,Alignment=2,MarginV=120'" \
  -c:a copy -c:v libx264 -pix_fmt yuv420p final_cc.mp4
```

---

### 16.13 Naming, ordering, anti-hallucination ops

```
01_clip.mp4  02_clip.mp4  …  10_clip.mp4   # natural sort safe
# NOT: clip1.mp4 clip10.mp4 clip2.mp4      # shell sort trap
```

```python
import re
def natural_key(p):
    return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", str(p))]
```

**Before act:** write plan (inputs, tools, timeline) → verify tools with `which` → verify files with `ls`/`file` → small probe → full run.  
**After each stage:** checkpoint + three-state verify. Never assume a binary exists.

---

### 16.14 Free external tool handoff (when FFmpeg ceiling is hit)

| Job | Free / local first | When to hand off |
|-----|--------------------|------------------|
| Motion on stills | Ken Burns / CapCut 7s | Need character acting |
| Color grade | FFmpeg recipes above | Resolve free for advanced nodes |
| 2.5D parallax | Layered FG/BG FFmpeg | Resolve Fusion / Blender free |
| Upscale anime | Waifu2x / Real-ESRGAN local | Only if min-side < 1080 |
| Full 2D draw | — | Blender Grease Pencil free |
| Paid gen models | — | Only if user opts in (see prodia guide) |

Do **not** hard-depend on LlamaGen/DomoAI/Topaz — catalogs churn; document as optional handoff only.

---

### 16.15 Judge / orchestrator anti-patterns (never do these)

| ❌ Don't | ✅ Do |
|----------|------|
| Save only a seed integer | Save full Visual DNA (prompt, model, filters, metrics) |
| Send full video to VLM | 3 frames or contact sheet |
| Blind full regeneration | Surgical fix map per failed axis |
| Infinite critique loops | Cap 2–3 iterations then change approach |
| Generator grades itself | Separate judge + LOOK with eyes |
| Generic "make it better" | Axis + current + target + FFmpeg filter |
| Equal quality budget all clips | Weight first (hook) and last (CTA) clips |
| Real-time theme clustering | Batch offline / weekly |
| Promote INCONCLUSIVE | Only PASS means done |

---

### 16.16 Visual DNA recipe ledger (minimum fields)

When saving EXCELLENT seeds, store at least:
1. Model name/version (+ LoRA weights if any)
2. Positive + negative prompts, aspect, CFG/steps/sampler if gen
3. Motion params (zoompan / AI clip source)
4. FFmpeg grade filter string
5. `visual_judge` + optional VLM scores
6. Tags + brief text for retrieval

---

### 16.17 Lightweight CLI toolbox (always free)

```bash
# Install (macOS)
brew install ffmpeg imagemagick yt-dlp mkvtoolnix whisper-cpp 2>/dev/null || true
# Install (Debian/Ubuntu)
sudo apt-get install -y ffmpeg imagemagick yt-dlp mkvtoolnix 2>/dev/null || true

# Download B-roll / music sources (respect licenses)
yt-dlp -f "bv*+ba/b" -o "src/%(title)s.%(ext)s" URL

# Batch image normalize
magick mogrify -resize "1920x1920>" -quality 92 ./inputs/*.jpg

# Screen capture Linux (web promo path)
ffmpeg -video_size 1920x1080 -framerate 30 -f x11grab -i :0.0 -t 12 screen.mp4
```

**Avoid by default:** MoviePy (high RAM), heavy local diffusion UIs on VPS, paid cloud vision as primary.

---

### 16.18 Stability actions checklist (final ship)

```
[ ] Preflight tools exist (ffmpeg/ffprobe)
[ ] Inputs exist + faceless sanitize if required
[ ] Proxy-preview color/effects on 1 frame → eyes approve
[ ] Clips named 01_… and natural-sorted
[ ] Each clip: Tier-0 + visual_judge (no FAILED)
[ ] Stitch: xfade offset = dur_A - transition
[ ] Audio: apad + shortest + loudnorm I=-14 + -ar 48000
[ ] Delivery: H.264 yuv420p +faststart (unless --archive)
[ ] Captions burned or sidecar when VO exists
[ ] Final frames at 25/50/75% viewed with eyes
[ ] master_qa_gate / final_gate all_pass == True
[ ] EXCELLENT → write theme seed
[ ] state.json checkpoint complete
```

---

## v6.1 CHANGELOG

| Change | Why |
|---|---|
| Added PILLAR 12: Visual Quality Judge | Pixel-level 3-tier verdict (FAILED/PROPER/EXCELLENT) on 5 axes — agent can no longer accept without structured editorial judgment |
| Added PILLAR 13: Brain Orchestrator Protocol | Expert editor thinking for multi-agent coordination — 3-pass review, intent briefs, specific rejection templates, coordination patterns |
| Added PILLAR 14: Theme Seed Tracker | Learn and reuse winning visual recipes — auto-capture EXCELLENT outputs, suggest matching seeds for new briefs, compound quality over time |
| Added PILLAR 15: VLM Semantic Critique + EBU R128 | API-based editorial judgment (brief adherence, emotional impact, composition) + broadcast-grade loudness normalization |
| Added 5th Deadly Sin: Blind Acceptance | Agent accepting "looks fine" without structured verdict |
| Updated 5-STAGE LOOP to v6.1 | Seeds at INTERPRET, judge at REALIZE, VLM + auto-capture at CRITIQUE, loudness norm at REALIZE |

## v6.2 CHANGELOG

| Change | Why |
|---|---|
| Added **PILLAR 16: Power Tips Vault** | Full-folder scan: every editor-worthy tip from sibling files adopted into master |
| Operating Charter: free / local / light / stable | Explicit Mac+Linux unlimited-time contract; no paid SaaS default |
| 6th Deadly Sin: Face Leakage | Faceless series hard fail + negatives + OpenCV sanitize |
| Tier-0 gates + contact sheet + cost budget | Zero-API failure catch; 90% VLM token reduction |
| Selective AI + AI negative pack + never-extend-bad | Stable hybrid motion path |
| Motion: static_breathe, anime-cadence, zoom-bridge, zoom-punch | Free stills→video craft |
| 9:16 blurred-fill + safe-zone | Vertical social UI survival |
| PPTX ingest, min-side upscale, HDR→SDR tonemap | Prep quality before animate |
| Codec matrix + dual caption masters + craft kits | Delivery correctness + editorial power |
| Explicit skip list (media manager, search agent) | Avoid bloat / wrong product surface |
