diff --git a/helpers/modo3.py b/helpers/modo3.py new file mode 100644 index 0000000..5f7fce9 --- /dev/null +++ b/helpers/modo3.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +"""SPEC-009 — Modo 3 (XML direto → sequência Premiere). + +Modo 3 = canário de fumaça MCP. Sem decisão editorial, sem LLM. +Recebe xmeml (FCP7 XML) pronto, valida, devolve estrutura pra Claude +importar via Adobe_Premiere_Pro_MCP. + +Pipeline interno: +1. validate(xml_path) → XmlValidationResult (paths absolutos + checks) +2. (Claude executa import via MCP usando o output) +3. verify(expected, actual) → VerificationResult (cross-check pós-import, + mitigação bug Premiere 2025 "random footage from other projects") + +CLI: + uv run python helpers/modo3.py validate + uv run python helpers/modo3.py verify --expected --actual +""" + +from __future__ import annotations + +import json +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Optional +from urllib.parse import unquote, urlparse + +import typer +from pydantic import BaseModel, Field + +try: + from helpers.schemas import utc_now_iso +except ModuleNotFoundError: + sys.path.insert(0, str(Path(__file__).parent.parent)) + from helpers.schemas import utc_now_iso # type: ignore + + +ALLOWED_PATH_ROOTS: list[Path] = [ + (Path.home() / "Documents" / "AUTOEDIT" / "material_teste").resolve(), + (Path.home() / "Documents" / "AUTOEDIT" / "tests" / "fixtures").resolve(), + (Path.home() / "Movies").resolve(), + (Path.home() / "Documents").resolve(), + Path("/Volumes").resolve(), +] + +ALLOWED_MEDIA_SUFFIXES = { + ".mp4", ".mov", ".m4v", ".avi", ".mkv", + ".wav", ".mp3", ".aif", ".aiff", ".flac", +} + + +def _log(msg: str) -> None: + print(msg, file=sys.stderr, flush=True) + + +class XmlClip(BaseModel): + name: str + pathurl_absolute: str | None = None + in_frames: int = Field(..., ge=0) + out_frames: int = Field(..., ge=0) + track_video: str + file_exists: bool = False + path_allowed: bool = False + suffix_allowed: bool = False + + +class XmlValidationResult(BaseModel): + source_xml: str + valid: bool + sequence_name: str + duration_frames: int = Field(..., ge=0) + fps: float = Field(..., gt=0) + n_clips: int = Field(..., ge=0) + clips: list[XmlClip] + warnings: list[str] = Field(default_factory=list) + errors: list[str] = Field(default_factory=list) + validated_at: str + + +class ExpectedClip(BaseModel): + name: str + pathurl_absolute: str + + +class ActualClip(BaseModel): + name: str + pathurl_absolute: str | None = None + + +class ClipMismatch(BaseModel): + expected_name: str + actual_name: str | None + expected_path: str + actual_path: str | None + reason: str + + +class VerificationResult(BaseModel): + ok: bool + n_expected: int + n_actual: int + mismatches: list[ClipMismatch] + verified_at: str + + +def _is_path_allowed(p: Path) -> bool: + try: + resolved = p.resolve(strict=False) + except (OSError, RuntimeError): + return False + for root in ALLOWED_PATH_ROOTS: + try: + resolved.relative_to(root) + return True + except ValueError: + continue + return False + + +def _resolve_pathurl(pathurl: str) -> Path | None: + if not pathurl: + return None + parsed = urlparse(pathurl) + if parsed.scheme not in ("file", ""): + return None + raw = unquote(parsed.path) if parsed.scheme == "file" else pathurl + if parsed.scheme == "file" and parsed.netloc and parsed.netloc != "localhost": + return None + if not raw: + return None + return Path(raw) + + +def _build_file_id_map(root: ET.Element) -> dict[str, str]: + id_map: dict[str, str] = {} + for file_el in root.iter("file"): + fid = file_el.get("id") + if not fid: + continue + pathurl_el = file_el.find("pathurl") + if pathurl_el is not None and pathurl_el.text: + id_map[fid] = pathurl_el.text + return id_map + + +def _extract_clip( + clip_el: ET.Element, track_video: str, file_id_map: dict[str, str] +) -> XmlClip: + name = clip_el.findtext("name") or "untitled" + in_frames = int(clip_el.findtext("in") or 0) + out_frames = int(clip_el.findtext("out") or 0) + + file_el = clip_el.find("file") + pathurl: str | None = None + if file_el is not None: + pathurl_el = file_el.find("pathurl") + if pathurl_el is not None and pathurl_el.text: + pathurl = pathurl_el.text + else: + fid = file_el.get("id") + if fid and fid in file_id_map: + pathurl = file_id_map[fid] + + pathurl_absolute: str | None = None + file_exists = False + path_allowed = False + suffix_allowed = False + + if pathurl: + local = _resolve_pathurl(pathurl) + if local is not None: + resolved = local.expanduser().resolve(strict=False) + pathurl_absolute = resolved.as_uri() + file_exists = resolved.is_file() + path_allowed = _is_path_allowed(resolved) + suffix_allowed = resolved.suffix.lower() in ALLOWED_MEDIA_SUFFIXES + + return XmlClip( + name=name, + pathurl_absolute=pathurl_absolute, + in_frames=in_frames, + out_frames=out_frames, + track_video=track_video, + file_exists=file_exists, + path_allowed=path_allowed, + suffix_allowed=suffix_allowed, + ) + + +def validate_xml(xml_path: Path) -> XmlValidationResult: + xml_path = xml_path.expanduser().resolve() + errors: list[str] = [] + warnings: list[str] = [] + + if not xml_path.is_file(): + return XmlValidationResult( + source_xml=str(xml_path), + valid=False, + sequence_name="", + duration_frames=0, + fps=25.0, + n_clips=0, + clips=[], + warnings=[], + errors=[f"arquivo XML não existe: {xml_path}"], + validated_at=utc_now_iso(), + ) + + try: + tree = ET.parse(xml_path) + except ET.ParseError as e: + return XmlValidationResult( + source_xml=str(xml_path), + valid=False, + sequence_name="", + duration_frames=0, + fps=25.0, + n_clips=0, + clips=[], + warnings=[], + errors=[f"XML mal-formado: {e}"], + validated_at=utc_now_iso(), + ) + + root = tree.getroot() + if root.tag != "xmeml": + errors.append(f"root deve ser , achei <{root.tag}>") + + sequences = root.findall("sequence") + if not sequences: + errors.append("xmeml sem ") + return XmlValidationResult( + source_xml=str(xml_path), + valid=False, + sequence_name="", + duration_frames=0, + fps=25.0, + n_clips=0, + clips=[], + warnings=warnings, + errors=errors, + validated_at=utc_now_iso(), + ) + + if len(sequences) > 1: + warnings.append( + f"xmeml com {len(sequences)} sequences; Modo 3 V0 só processa a primeira" + ) + + seq = sequences[0] + seq_name = seq.findtext("name") or "untitled" + duration_frames = int(seq.findtext("duration") or 0) + rate_el = seq.find("rate") + fps = 25.0 + if rate_el is not None: + timebase_text = rate_el.findtext("timebase") + if timebase_text: + fps = float(timebase_text) + + file_id_map = _build_file_id_map(root) + + clips: list[XmlClip] = [] + video_tracks = seq.findall(".//video/track") + for v_idx, track in enumerate(video_tracks): + track_name = f"V{v_idx + 1}" + for clip_el in track.findall("clipitem"): + clips.append(_extract_clip(clip_el, track_name, file_id_map)) + + for i, c in enumerate(clips): + ctx = f"clip[{i}] '{c.name}'" + if c.pathurl_absolute is None: + errors.append(f"{ctx}: pathurl ausente ou não resolvível") + continue + if not c.file_exists: + errors.append(f"{ctx}: arquivo não existe em {c.pathurl_absolute}") + if not c.path_allowed: + errors.append( + f"{ctx}: path fora da allowlist — {c.pathurl_absolute}. " + f"Roots permitidos: {[str(r) for r in ALLOWED_PATH_ROOTS]}" + ) + if not c.suffix_allowed: + errors.append(f"{ctx}: extensão não permitida — {c.pathurl_absolute}") + if c.out_frames <= c.in_frames: + errors.append( + f"{ctx}: out_frames ({c.out_frames}) <= in_frames ({c.in_frames})" + ) + + valid = len(errors) == 0 and len(clips) > 0 + if not clips: + errors.append("nenhum clipitem encontrado em ") + + return XmlValidationResult( + source_xml=str(xml_path), + valid=valid, + sequence_name=seq_name, + duration_frames=duration_frames, + fps=fps, + n_clips=len(clips), + clips=clips, + warnings=warnings, + errors=errors, + validated_at=utc_now_iso(), + ) + + +def verify_imported_clips( + expected: list[ExpectedClip], actual: list[ActualClip] +) -> VerificationResult: + mismatches: list[ClipMismatch] = [] + + if len(expected) != len(actual): + mismatches.append( + ClipMismatch( + expected_name=f"<{len(expected)} clips esperados>", + actual_name=f"<{len(actual)} clips importados>", + expected_path="", + actual_path=None, + reason="contagem de clips diverge", + ) + ) + + for i, exp in enumerate(expected): + act = actual[i] if i < len(actual) else None + if act is None: + mismatches.append( + ClipMismatch( + expected_name=exp.name, + actual_name=None, + expected_path=exp.pathurl_absolute, + actual_path=None, + reason="clip esperado ausente no Premiere", + ) + ) + continue + if act.pathurl_absolute != exp.pathurl_absolute: + mismatches.append( + ClipMismatch( + expected_name=exp.name, + actual_name=act.name, + expected_path=exp.pathurl_absolute, + actual_path=act.pathurl_absolute, + reason=( + "pathurl divergente — possível bug Premiere 2025 " + "'random footage from other projects'" + ), + ) + ) + + return VerificationResult( + ok=len(mismatches) == 0, + n_expected=len(expected), + n_actual=len(actual), + mismatches=mismatches, + verified_at=utc_now_iso(), + ) + + +app = typer.Typer(add_completion=False, no_args_is_help=True) + + +@app.command() +def validate( + xml_path: str = typer.Argument(..., help="Path para o xmeml (.xml) a validar"), +) -> None: + """Valida xmeml e imprime XmlValidationResult JSON em stdout.""" + result = validate_xml(Path(xml_path)) + sys.stdout.write(result.model_dump_json(indent=2) + "\n") + if not result.valid: + _log(f"[modo3] Validação FALHOU: {len(result.errors)} erro(s)") + raise typer.Exit(code=1) + _log( + f"[modo3] Validação OK: sequência '{result.sequence_name}', " + f"{result.n_clips} clip(s), fps={result.fps}" + ) + + +@app.command() +def verify( + expected: str = typer.Option(..., "--expected", "-e", help="JSON list[ExpectedClip]"), + actual: str = typer.Option(..., "--actual", "-a", help="JSON list[ActualClip]"), +) -> None: + """Cross-check pós-import. Detecta bug Premiere 2025 'random footage'.""" + expected_data = json.loads(Path(expected).expanduser().read_text(encoding="utf-8")) + actual_data = json.loads(Path(actual).expanduser().read_text(encoding="utf-8")) + expected_models = [ExpectedClip.model_validate(x) for x in expected_data] + actual_models = [ActualClip.model_validate(x) for x in actual_data] + result = verify_imported_clips(expected_models, actual_models) + sys.stdout.write(result.model_dump_json(indent=2) + "\n") + if not result.ok: + _log(f"[modo3] Verificação FALHOU: {len(result.mismatches)} mismatch(es)") + raise typer.Exit(code=1) + _log(f"[modo3] Verificação OK: {result.n_actual} clip(s) batem") + + +if __name__ == "__main__": + app() diff --git a/scripts/spec-009-harness.sh b/scripts/spec-009-harness.sh index a06343d..108c6ed 100755 --- a/scripts/spec-009-harness.sh +++ b/scripts/spec-009-harness.sh @@ -37,7 +37,7 @@ else fi echo -echo "[3/4] Implementação Modo 3 (skipa até existir)" +echo "[3/6] Implementação Modo 3" if [ -f "helpers/modo3.py" ]; then ok "helpers/modo3.py existe" if command -v uv >/dev/null 2>&1; then @@ -50,16 +50,53 @@ if [ -f "helpers/modo3.py" ]; then skip "uv não disponível" fi else - skip "helpers/modo3.py ainda não implementado (esperado durante F2)" + fail "helpers/modo3.py ausente" fi echo -echo "[4/4] OTIO + otio-fcp-adapter disponíveis" +echo "[4/6] CLI validate em fixture" +if [ -f "helpers/modo3.py" ] && command -v uv >/dev/null 2>&1 && [ -f "$FIXTURE_FILE" ]; then + set +e + CLI_OUTPUT=$(uv run python helpers/modo3.py validate "$FIXTURE_FILE" 2>&1) + CLI_EXIT=$? + set -e + if [ $CLI_EXIT -eq 0 ] && echo "$CLI_OUTPUT" | grep -q '"valid": true'; then + ok "CLI validate retorna valid=true em fixture" + else + # Fixture aponta pra material que não existe ainda — esperado parcial + if echo "$CLI_OUTPUT" | grep -q '"sequence_name"'; then + ok "CLI validate parseou fixture (errors esperados — material sintético não existe)" + else + fail "CLI validate falhou inesperadamente: $CLI_OUTPUT" + fi + fi +else + skip "CLI test pulado (deps faltando)" +fi + +echo +echo "[5/6] Pytest tests/test_spec_009_modo3.py" +if command -v uv >/dev/null 2>&1; then + set +e + uv run pytest tests/test_spec_009_modo3.py -q --no-header 2>&1 | tail -3 | grep -qE "passed" + PYTEST_EXIT=$? + set -e + if [ $PYTEST_EXIT -eq 0 ]; then + ok "pytest test_spec_009_modo3 passa" + else + fail "pytest test_spec_009_modo3 falhou" + fi +else + skip "uv não disponível" +fi + +echo +echo "[6/6] OTIO + otio-fcp-adapter disponíveis" if command -v uv >/dev/null 2>&1; then if uv run python -c "import opentimelineio; import opentimelineio_fcp_adapter" 2>/dev/null; then ok "OTIO + otio-fcp-adapter importáveis" elif uv run python -c "import opentimelineio" 2>/dev/null; then - ok "OTIO importável (otio-fcp-adapter pode estar faltando — verificar pyproject)" + ok "OTIO importável (otio-fcp-adapter pode estar faltando)" else skip "OTIO não instalado (rodar uv sync)" fi @@ -75,4 +112,4 @@ if [ $FAIL -gt 0 ]; then echo "✗ SPEC-009 harness FAILED" exit 1 fi -echo "✓ SPEC-009 harness OK (SKIPs viram PASS quando F2 implementar modo3.py)" +echo "✓ SPEC-009 harness OK" diff --git a/tests/test_spec_009_modo3.py b/tests/test_spec_009_modo3.py new file mode 100644 index 0000000..9fe4e9e --- /dev/null +++ b/tests/test_spec_009_modo3.py @@ -0,0 +1,225 @@ +"""Tests pra SPEC-009 (Modo 3 XML direto). + +Cobre: validação xmeml, path hardening, detecção de erros, cross-check +pós-import (mitigação bug Premiere 2025). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from helpers.modo3 import ( + ActualClip, + ExpectedClip, + ALLOWED_MEDIA_SUFFIXES, + XmlValidationResult, + _is_path_allowed, + _resolve_pathurl, + validate_xml, + verify_imported_clips, +) + + +REPO_ROOT = Path(__file__).resolve().parent.parent +FIXTURE_XML = REPO_ROOT / "tests" / "fixtures" / "decupagens" / "exemplo-modo3.xml" + + +@pytest.fixture +def fixture_xml() -> Path: + assert FIXTURE_XML.is_file(), f"fixture ausente: {FIXTURE_XML}" + return FIXTURE_XML + + +@pytest.fixture +def real_media_file(tmp_path: Path) -> Path: + """Cria mp4 sintético dentro de tmp_path (será allowlist-ed via fixture rooting).""" + media = tmp_path / "entrevista-sintetica.mp4" + media.write_bytes(b"\x00" * 1024) + return media + + +def test_resolve_pathurl_file_localhost(): + p = _resolve_pathurl("file://localhost/tmp/foo.mp4") + assert p == Path("/tmp/foo.mp4") + + +def test_resolve_pathurl_plain_file(): + p = _resolve_pathurl("file:///tmp/foo.mp4") + assert p == Path("/tmp/foo.mp4") + + +def test_resolve_pathurl_remote_rejected(): + assert _resolve_pathurl("file://remote.host/tmp/foo.mp4") is None + + +def test_resolve_pathurl_http_rejected(): + assert _resolve_pathurl("http://example.com/foo.mp4") is None + + +def test_resolve_pathurl_url_encoded(): + p = _resolve_pathurl("file:///Users/dudu/Documents/My%20Folder/clip.mp4") + assert p == Path("/Users/dudu/Documents/My Folder/clip.mp4") + + +def test_is_path_allowed_documents(monkeypatch, tmp_path): + safe = Path.home() / "Documents" / "test.mp4" + assert _is_path_allowed(safe) + + +def test_is_path_allowed_traversal_rejected(): + bad = Path("/etc/passwd") + assert not _is_path_allowed(bad) + + +def test_validate_xml_fixture_parses(fixture_xml): + result = validate_xml(fixture_xml) + assert isinstance(result, XmlValidationResult) + assert result.sequence_name == "AUTOEDIT_modo3_fixture" + assert result.n_clips == 2 + assert result.fps == 30.0 + + +def test_validate_xml_fixture_paths_resolved(fixture_xml): + result = validate_xml(fixture_xml) + for clip in result.clips: + assert clip.pathurl_absolute is not None + assert clip.pathurl_absolute.startswith("file://") + + +def test_validate_xml_nonexistent_file_flags_error(tmp_path): + missing = tmp_path / "nope.xml" + result = validate_xml(missing) + assert not result.valid + assert any("não existe" in e for e in result.errors) + + +def test_validate_xml_malformed(tmp_path): + bad = tmp_path / "bad.xml" + bad.write_text("", encoding="utf-8" + ) + result = validate_xml(f) + assert any("root deve ser " in e for e in result.errors) + + +def test_validate_xml_no_sequence(tmp_path): + f = tmp_path / "empty.xml" + f.write_text("", encoding="utf-8") + result = validate_xml(f) + assert not result.valid + assert any("sem " in e for e in result.errors) + + +def test_validate_xml_path_traversal_rejected(tmp_path): + f = tmp_path / "evil.xml" + f.write_text( + """ + + + evil + 30 + 30 + + + """, + encoding="utf-8", + ) + result = validate_xml(f) + assert not result.valid + assert any("fora da allowlist" in e for e in result.errors) + + +def test_validate_xml_disallowed_suffix(tmp_path): + media = tmp_path / "evil.exe" + media.write_bytes(b"\x00") + f = tmp_path / "exe.xml" + f.write_text( + f""" + + + exe + 30 + 30 + + + """, + encoding="utf-8", + ) + result = validate_xml(f) + assert not result.valid + assert any("extensão não permitida" in e for e in result.errors) + + +def test_verify_imported_clips_match(): + expected = [ + ExpectedClip(name="clip1", pathurl_absolute="file:///tmp/a.mp4"), + ExpectedClip(name="clip2", pathurl_absolute="file:///tmp/b.mp4"), + ] + actual = [ + ActualClip(name="clip1", pathurl_absolute="file:///tmp/a.mp4"), + ActualClip(name="clip2", pathurl_absolute="file:///tmp/b.mp4"), + ] + result = verify_imported_clips(expected, actual) + assert result.ok + assert result.mismatches == [] + + +def test_verify_imported_clips_path_mismatch_detects_bug(): + """Cobre bug Premiere 2025 'random footage from other projects'.""" + expected = [ExpectedClip(name="clip1", pathurl_absolute="file:///tmp/correct.mp4")] + actual = [ActualClip(name="clip1", pathurl_absolute="file:///other-project/wrong.mp4")] + result = verify_imported_clips(expected, actual) + assert not result.ok + assert len(result.mismatches) == 1 + assert "random footage" in result.mismatches[0].reason + + +def test_verify_imported_clips_count_mismatch(): + expected = [ + ExpectedClip(name="a", pathurl_absolute="file:///tmp/a.mp4"), + ExpectedClip(name="b", pathurl_absolute="file:///tmp/b.mp4"), + ] + actual = [ActualClip(name="a", pathurl_absolute="file:///tmp/a.mp4")] + result = verify_imported_clips(expected, actual) + assert not result.ok + assert any("contagem" in m.reason for m in result.mismatches) + + +def test_allowed_media_suffixes_includes_common(): + assert ".mp4" in ALLOWED_MEDIA_SUFFIXES + assert ".mov" in ALLOWED_MEDIA_SUFFIXES + assert ".wav" in ALLOWED_MEDIA_SUFFIXES + assert ".exe" not in ALLOWED_MEDIA_SUFFIXES + assert ".sh" not in ALLOWED_MEDIA_SUFFIXES