← Files PixVerseARCHIVED FILE

pvx/qa.py

13.1 KB · Oct 3, 2026 · 06:30 UTC

↓ Download file

from __future__ import annotations

import json
import mimetypes
import subprocess
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.request import Request, urlopen

from .shell import CommandResult, run, run_json, which
from .state import utc_now


def inspect_media(
    target: str,
    output_dir: Path | None = None,
    expect_audio: bool | None = None,
    expect_duration: float | None = None,
    duration_tolerance: float = 0.75,
    expect_aspect_ratio: str = "",
) -> dict[str, Any]:
    report: dict[str, Any] = {
        "target": target,
        "checked_at": utc_now(),
        "exists": False,
        "kind": "",
        "issues": [],
        "metadata": {},
    }
    if target.startswith(("http://", "https://")):
        report.update(_inspect_url(target))
    else:
        report.update(_inspect_file(Path(target).expanduser()))
    _add_expectation_findings(
        report,
        expect_audio=expect_audio,
        expect_duration=expect_duration,
        duration_tolerance=duration_tolerance,
        expect_aspect_ratio=expect_aspect_ratio,
    )

    if output_dir is not None:
        output_dir.mkdir(parents=True, exist_ok=True)
        report_path = output_dir / f"{_target_slug(target)}-qa.json"
        latest_path = output_dir / "qa-report.json"
        report["report_path"] = str(report_path)
        report["latest_report_path"] = str(latest_path)
        rendered = json.dumps(report, ensure_ascii=False, indent=2)
        report_path.write_text(rendered, encoding="utf-8")
        latest_path.write_text(rendered, encoding="utf-8")
    return report


def _inspect_url(url: str) -> dict[str, Any]:
    parsed = urlparse(url)
    kind = _kind_from_name(parsed.path)
    out: dict[str, Any] = {"exists": False, "kind": kind, "metadata": {"url": url}, "issues": []}
    try:
        req = Request(url, method="HEAD", headers={"User-Agent": "PixVerse Agent Plugin/0.1"})
        with urlopen(req, timeout=15) as response:
            out["exists"] = 200 <= response.status < 400
            out["metadata"]["status"] = response.status
            out["metadata"]["content_type"] = response.headers.get("Content-Type", "")
            out["metadata"]["content_length"] = response.headers.get("Content-Length", "")
    except Exception as exc:
        out["issues"].append(f"url_check_failed: {exc}")
    if out["exists"] and kind in {"video", "audio"} and which("ffprobe"):
        result, payload = _safe_run_json(
            [
                "ffprobe",
                "-v",
                "error",
                "-print_format",
                "json",
                "-show_format",
                "-show_streams",
                url,
            ],
            timeout=45,
        )
        if result.ok:
            out["metadata"]["ffprobe"] = payload
            _add_media_findings(out, payload)
        else:
            out["issues"].append(result.stderr or "ffprobe_url_failed")
    return out


def _inspect_file(path: Path) -> dict[str, Any]:
    out: dict[str, Any] = {
        "exists": path.exists(),
        "kind": _kind_from_name(path.name),
        "metadata": {"path": str(path)},
        "issues": [],
    }
    if not path.exists():
        out["issues"].append("file_not_found")
        return out
    out["metadata"]["bytes"] = path.stat().st_size
    out["metadata"]["mime_guess"] = mimetypes.guess_type(path.name)[0] or ""
    if out["kind"] in {"video", "audio"}:
        if not which("ffprobe"):
            out["issues"].append("ffprobe_missing")
            return out
        result, payload = _safe_run_json(
            [
                "ffprobe",
                "-v",
                "error",
                "-print_format",
                "json",
                "-show_format",
                "-show_streams",
                str(path),
            ],
            timeout=30,
        )
        if result.ok:
            out["metadata"]["ffprobe"] = payload
            _add_media_findings(out, payload)
        else:
            out["issues"].append(result.stderr or "ffprobe_failed")
    elif out["kind"] == "image":
        if which("ffprobe"):
            result, payload = _safe_run_json(
                [
                    "ffprobe",
                    "-v",
                    "error",
                    "-print_format",
                    "json",
                    "-show_streams",
                    str(path),
                ],
                timeout=20,
            )
            if result.ok:
                out["metadata"]["ffprobe"] = payload
                _add_image_findings(out, payload)
    return out


def _kind_from_name(name: str) -> str:
    lower = name.lower()
    if lower.endswith((".mp4", ".mov", ".webm", ".m4v")):
        return "video"
    if lower.endswith((".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac")):
        return "audio"
    if lower.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
        return "image"
    return "unknown"


def _target_slug(value: str) -> str:
    parsed = urlparse(value)
    if parsed.scheme in {"http", "https"}:
        value = parsed.path or parsed.netloc
    stem = Path(value).stem or "target"
    slug = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in stem)
    return slug.strip("-_")[:80] or "target"


def _add_media_findings(out: dict[str, Any], payload: dict[str, Any]) -> None:
    streams = payload.get("streams") if isinstance(payload, dict) else []
    if not isinstance(streams, list):
        streams = []
    video_streams = [s for s in streams if s.get("codec_type") == "video"]
    audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
    if out["kind"] == "video" and not video_streams:
        out["issues"].append("no_video_stream")
    if out["kind"] == "audio" and not audio_streams:
        out["issues"].append("no_audio_stream")
    out["metadata"]["video_stream_count"] = len(video_streams)
    out["metadata"]["audio_stream_count"] = len(audio_streams)
    out["metadata"]["has_audio"] = bool(audio_streams)
    if audio_streams:
        out["metadata"]["audio_codecs"] = sorted({str(stream.get("codec_name") or "") for stream in audio_streams})
        audio_duration = _max_stream_duration(audio_streams)
        if audio_duration > 0:
            out["metadata"]["audio_duration_seconds"] = audio_duration
    fmt = payload.get("format") if isinstance(payload, dict) else {}
    try:
        duration = float(fmt.get("duration") or 0)
    except (TypeError, ValueError):
        duration = 0.0
    if duration <= 0 and out["kind"] in {"video", "audio"}:
        out["issues"].append("missing_duration")
    out["metadata"]["duration_seconds"] = duration
    if video_streams:
        stream = video_streams[0]
        out["metadata"]["width"] = stream.get("width")
        out["metadata"]["height"] = stream.get("height")
        if not stream.get("width") or not stream.get("height"):
            out["issues"].append("missing_dimensions")


def _add_image_findings(out: dict[str, Any], payload: dict[str, Any]) -> None:
    streams = payload.get("streams") if isinstance(payload, dict) else []
    if not isinstance(streams, list):
        streams = []
    image_streams = [s for s in streams if s.get("codec_type") in {"video", "image"}]
    if not image_streams:
        return
    stream = image_streams[0]
    out["metadata"]["width"] = stream.get("width")
    out["metadata"]["height"] = stream.get("height")
    if not stream.get("width") or not stream.get("height"):
        out["issues"].append("missing_dimensions")


def _add_expectation_findings(
    report: dict[str, Any],
    *,
    expect_audio: bool | None,
    expect_duration: float | None,
    duration_tolerance: float,
    expect_aspect_ratio: str,
) -> None:
    if report.get("kind") not in {"video", "audio", "image"}:
        return
    metadata = report.get("metadata")
    if not isinstance(metadata, dict):
        return
    if expect_audio is not None and report.get("kind") in {"video", "audio"}:
        has_audio = metadata.get("has_audio")
        if has_audio is not None:
            if expect_audio and not has_audio:
                report["issues"].append("expected_audio_missing")
            if expect_audio is False and has_audio:
                report["issues"].append("unexpected_audio_stream")
    if expect_duration is not None and report.get("kind") in {"video", "audio"}:
        duration = metadata.get("duration_seconds")
        if isinstance(duration, (int, float)):
            delta = abs(float(duration) - expect_duration)
            metadata["expected_duration_seconds"] = expect_duration
            metadata["duration_tolerance_seconds"] = duration_tolerance
            if delta > max(0.0, duration_tolerance):
                report["issues"].append("duration_mismatch")
    if expect_aspect_ratio and report.get("kind") in {"video", "image"}:
        expected = _ratio_value(expect_aspect_ratio)
        width = metadata.get("width")
        height = metadata.get("height")
        if expected and isinstance(width, int) and isinstance(height, int) and height > 0:
            actual = width / height
            metadata["expected_aspect_ratio"] = expect_aspect_ratio
            metadata["actual_aspect_ratio"] = round(actual, 4)
            if abs(actual - expected) > 0.03:
                report["issues"].append("aspect_ratio_mismatch")


def _max_stream_duration(streams: list[dict[str, Any]]) -> float:
    durations: list[float] = []
    for stream in streams:
        try:
            duration = float(stream.get("duration") or 0)
        except (TypeError, ValueError):
            duration = 0
        if duration > 0:
            durations.append(duration)
    return max(durations) if durations else 0


def _ratio_value(value: str) -> float | None:
    if ":" not in value:
        return None
    left, right = value.split(":", 1)
    try:
        width = float(left)
        height = float(right)
    except ValueError:
        return None
    if width <= 0 or height <= 0:
        return None
    return width / height


def sample_frames(
    video: Path,
    output_dir: Path,
    every_seconds: float = 3,
    count: int = 4,
    duration_seconds: float | None = None,
) -> list[str]:
    if count < 1 or not which("ffmpeg"):
        return []
    output_dir.mkdir(parents=True, exist_ok=True)
    pattern = output_dir / "frame-%03d.jpg"
    for stale_frame in output_dir.glob("frame-*.jpg"):
        stale_frame.unlink(missing_ok=True)
    # ffmpeg's fps filter does not promise that its first output timestamp is
    # zero, so a calculated cadence can still miss the ending. When duration is
    # known, seek each frame explicitly across the full timeline. These remain
    # internal subprocesses inside one host-level QA transaction.
    if duration_seconds is not None and duration_seconds > 0 and count > 1:
        tail_margin = min(0.25, duration_seconds * 0.05)
        interval = (duration_seconds - tail_margin) / (count - 1)
        frames: list[str] = []
        for index in range(count):
            frame = output_dir / f"frame-{index + 1:03d}.jpg"
            timestamp = interval * index
            result = _safe_run(
                [
                    "ffmpeg",
                    "-y",
                    "-ss",
                    f"{timestamp:.3f}",
                    "-i",
                    str(video),
                    "-frames:v",
                    "1",
                    "-update",
                    "1",
                    str(frame),
                ],
                timeout=60,
            )
            if result.ok and frame.exists():
                frames.append(str(frame))
        if frames:
            return frames

    result = _safe_run(
        [
            "ffmpeg",
            "-y",
            "-i",
            str(video),
            "-vf",
            f"fps=1/{every_seconds:g}",
            "-frames:v",
            str(count),
            str(pattern),
        ],
        timeout=60,
    )
    if result.ok:
        frames = [str(path) for path in sorted(output_dir.glob("frame-*.jpg"))]
        if frames:
            return frames
    first_frame = output_dir / "frame-001.jpg"
    fallback = _safe_run(
        [
            "ffmpeg",
            "-y",
            "-ss",
            "0",
            "-i",
            str(video),
            "-frames:v",
            "1",
            "-update",
            "1",
            str(first_frame),
        ],
        timeout=60,
    )
    if fallback.ok and first_frame.exists():
        return [str(first_frame)]
    return []


def _safe_run(argv: list[str], *, timeout: float) -> CommandResult:
    try:
        return run(argv, timeout=timeout)
    except subprocess.TimeoutExpired:
        return CommandResult(
            argv=argv,
            returncode=124,
            stdout="",
            stderr=f"command_timeout_after_{timeout:g}s",
        )


def _safe_run_json(argv: list[str], *, timeout: float) -> tuple[CommandResult, dict[str, Any]]:
    try:
        return run_json(argv, timeout=timeout)
    except subprocess.TimeoutExpired:
        return (
            CommandResult(
                argv=argv,
                returncode=124,
                stdout="",
                stderr=f"command_timeout_after_{timeout:g}s",
            ),
            {},
        )

SHA-256: 493f3968d2f8d586d2672479e25f98edc74831dd5931471d07077bd7cb594cc0