Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
396 changes: 396 additions & 0 deletions helpers/modo3.py
Original file line number Diff line number Diff line change
@@ -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 <xml-path>
uv run python helpers/modo3.py verify --expected <path> --actual <path>
"""

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 <xmeml>, achei <{root.tag}>")

sequences = root.findall("sequence")
if not sequences:
errors.append("xmeml sem <sequence>")
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 <sequence>")

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()
Loading