← Files PixVerseARCHIVED FILE

pvx/capabilities.py

12.5 KB · Oct 2, 2026 · 00:28 UTC

↓ Download file

from __future__ import annotations

import json
import zipfile
from copy import deepcopy
from functools import lru_cache
from pathlib import Path
from typing import Any

from .canvas_sync import (
    CANVAS_OPTIONAL_REVIEWED_COMMANDS,
    CANVAS_REVIEWED_COMMAND_POLICIES,
    canvas_command_contract_issues,
)
from .compatibility import (
    PIXVERSE_CLI_BASELINE_CAPABILITIES_SCHEMA,
    PIXVERSE_CLI_MINIMUM_VERSION,
    normalize_cli_version,
    pixverse_cli_capabilities_path,
    pixverse_cli_channel,
    pixverse_cli_installed_version,
    pixverse_cli_internal_artifact,
    pixverse_cli_version_at_least,
)
from .internal_cli import (
    CANVAS_CAPABILITY_DISCOVERY,
    CREATE_CAPABILITIES_SCHEMA,
    INTERNAL_CLI_CHANNEL,
    InternalCliError,
)


def _normalize_duration_scalar(value: Any, *, zero_is_auto: bool) -> Any:
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        if zero_is_auto and value == 0:
            return "auto"
        if float(value).is_integer():
            return str(int(value))
    if isinstance(value, str) and zero_is_auto and value.strip() == "0":
        return "auto"
    return value


def _normalize_reference_duration_contract(create: dict[str, Any]) -> dict[str, Any]:
    """Canonicalize equivalent public/internal reference duration encodings.

    PixVerse CLI 1.4.0 accepts ``--duration auto`` for models that expose the
    public schema's numeric ``0`` sentinel.  The internal artifact already
    serializes the same contract as strings with an explicit ``auto`` value.
    Keep one wrapper-facing representation so queue validation is independent
    of the selected distribution channel.
    """

    normalized = deepcopy(create)
    modes = normalized.get("modes")
    reference = modes.get("reference") if isinstance(modes, dict) else None
    if not isinstance(reference, dict):
        return normalized

    parameters = reference.get("parameters")
    duration = parameters.get("duration") if isinstance(parameters, dict) else None
    if isinstance(duration, dict):
        duration["type"] = "string"
        if "default" in duration:
            duration["default"] = _normalize_duration_scalar(
                duration.get("default"),
                zero_is_auto=True,
            )
        if isinstance(duration.get("enum"), list):
            duration["enum"] = [
                _normalize_duration_scalar(value, zero_is_auto=True)
                for value in duration["enum"]
            ]
        duration["unit"] = "seconds_or_auto"
        duration["description"] = "Output duration in seconds, or auto where supported."

    model_parameters = reference.get("model_parameters")
    by_model = model_parameters.get("duration") if isinstance(model_parameters, dict) else None
    if isinstance(by_model, dict):
        for contract in by_model.values():
            if not isinstance(contract, dict):
                continue
            if "default" in contract:
                contract["default"] = _normalize_duration_scalar(
                    contract.get("default"),
                    zero_is_auto=True,
                )
            if isinstance(contract.get("enum"), list):
                contract["enum"] = [
                    _normalize_duration_scalar(value, zero_is_auto=True)
                    for value in contract["enum"]
                ]
    return normalized


def _normalized_create_domain(payload: Any) -> dict[str, Any]:
    if not isinstance(payload, dict) or payload.get("schema_version") != "1.2.0":
        return {}
    domains = payload.get("capability_domains")
    create = domains.get("create") if isinstance(domains, dict) else None
    if (
        not isinstance(create, dict)
        or create.get("schema_version") != CREATE_CAPABILITIES_SCHEMA
        or create.get("encoding") != "normalized"
        or not isinstance(create.get("modes"), dict)
    ):
        return {}
    return _normalize_reference_duration_contract(create)


def _capabilities_payload(payload: Any) -> dict[str, Any]:
    return payload if isinstance(payload, dict) else {}


@lru_cache(maxsize=8)
def _read_capabilities_payload_file(path_text: str, _mtime_ns: int, _size: int) -> dict[str, Any]:
    try:
        return _capabilities_payload(json.loads(Path(path_text).read_text(encoding="utf-8")))
    except (OSError, json.JSONDecodeError):
        return {}


@lru_cache(maxsize=8)
def _read_capabilities_payload_archive(
    path_text: str,
    _mtime_ns: int,
    _size: int,
    archive_root: str,
) -> dict[str, Any]:
    try:
        with zipfile.ZipFile(path_text) as archive:
            payload = json.loads(archive.read(f"{archive_root}/dist/capabilities.json"))
    except (OSError, KeyError, json.JSONDecodeError, zipfile.BadZipFile):
        return {}
    return _capabilities_payload(payload)


@lru_cache(maxsize=8)
def _read_capabilities_file(path_text: str, _mtime_ns: int, _size: int) -> dict[str, Any]:
    return _normalized_create_domain(
        _read_capabilities_payload_file(path_text, _mtime_ns, _size)
    )


@lru_cache(maxsize=8)
def _read_capabilities_archive(
    path_text: str,
    _mtime_ns: int,
    _size: int,
    archive_root: str,
) -> dict[str, Any]:
    return _normalized_create_domain(
        _read_capabilities_payload_archive(path_text, _mtime_ns, _size, archive_root)
    )


def load_cli_capabilities() -> dict[str, Any]:
    """Load the complete offline CLI capability manifest for either channel."""

    if pixverse_cli_channel() == INTERNAL_CLI_CHANNEL:
        try:
            artifact = pixverse_cli_internal_artifact()
        except InternalCliError:
            artifact = None
        if artifact is not None:
            try:
                archive_stat = artifact.path.stat()
            except OSError:
                pass
            else:
                payload = _read_capabilities_payload_archive(
                    str(artifact.path),
                    archive_stat.st_mtime_ns,
                    archive_stat.st_size,
                    artifact.archive_root,
                )
                if payload:
                    return payload

    installed = pixverse_cli_capabilities_path()
    try:
        installed_stat = installed.stat()
    except OSError:
        installed_stat = None
    if installed_stat is not None:
        payload = _read_capabilities_payload_file(
            str(installed.resolve()),
            installed_stat.st_mtime_ns,
            installed_stat.st_size,
        )
        if payload:
            return payload

    try:
        artifact = pixverse_cli_internal_artifact()
    except InternalCliError:
        artifact = None
    if artifact is None:
        return {}
    try:
        archive_stat = artifact.path.stat()
    except OSError:
        return {}
    return _read_capabilities_payload_archive(
        str(artifact.path),
        archive_stat.st_mtime_ns,
        archive_stat.st_size,
        artifact.archive_root,
    )


def canvas_wrapper_contract_issues(payload: dict[str, Any] | None = None) -> list[str]:
    """Return reasons the active CLI cannot safely use the guarded Canvas wrapper."""

    capabilities = payload if payload is not None else load_cli_capabilities()
    if not capabilities:
        return ["offline CLI capabilities are unavailable"]

    issues: list[str] = []
    schema_version = str(capabilities.get("schema_version") or "")
    if schema_version != PIXVERSE_CLI_BASELINE_CAPABILITIES_SCHEMA:
        issues.append(
            "CLI capabilities schema "
            f"{schema_version or '<missing>'!r} != {PIXVERSE_CLI_BASELINE_CAPABILITIES_SCHEMA!r}"
        )

    cli = capabilities.get("cli")
    manifest_version = str(cli.get("version") or "") if isinstance(cli, dict) else ""
    active_version = normalize_cli_version(manifest_version or pixverse_cli_installed_version())
    if not pixverse_cli_version_at_least(active_version, PIXVERSE_CLI_MINIMUM_VERSION):
        issues.append(
            f"CLI version {active_version or '<missing>'!r} is below {PIXVERSE_CLI_MINIMUM_VERSION!r}"
        )

    commands = capabilities.get("commands")
    command_payloads: dict[str, list[dict[str, Any]]] = {}
    if isinstance(commands, list):
        for command in commands:
            if (
                isinstance(command, dict)
                and command.get("run") is True
                and isinstance(command.get("cmd"), str)
                and (
                    str(command["cmd"]).startswith("pixverse canvas ")
                    or str(command["cmd"]) == "pixverse capabilities canvas"
                )
            ):
                command_payloads.setdefault(str(command["cmd"]), []).append(command)
    else:
        issues.append("CLI capabilities commands must be an array")

    reviewed = set(CANVAS_REVIEWED_COMMAND_POLICIES)
    observed = set(command_payloads)
    missing = sorted(reviewed - observed - CANVAS_OPTIONAL_REVIEWED_COMMANDS)
    extra = sorted(observed - reviewed)
    duplicates = sorted(path for path, values in command_payloads.items() if len(values) != 1)
    if missing:
        issues.append("missing reviewed Canvas commands: " + ", ".join(missing))
    if extra:
        issues.append("unreviewed runnable Canvas commands: " + ", ".join(extra))
    if duplicates:
        issues.append("duplicate runnable Canvas commands: " + ", ".join(duplicates))
    for path in sorted(reviewed & observed):
        if len(command_payloads[path]) == 1:
            issues.extend(canvas_command_contract_issues(command_payloads[path][0]))

    domains = capabilities.get("capability_domains")
    canvas = domains.get("canvas") if isinstance(domains, dict) else None
    if not isinstance(canvas, dict) or canvas.get("source") != "runtime":
        issues.append("Canvas capability domain must use runtime discovery")
    elif canvas.get("discovery") != CANVAS_CAPABILITY_DISCOVERY:
        issues.append("Canvas capability discovery contract changed")
    return issues


def load_create_capabilities() -> dict[str, Any]:
    """Load reviewed normalized create capabilities without invoking the CLI or network."""
    # The bundled archive is the reviewed source of truth for the internal
    # channel. An activated runtime can temporarily be one build behind after
    # a plugin/package refresh; reading it first would reject newly reviewed
    # models until bootstrap happens to replace the cache.
    if pixverse_cli_channel() == INTERNAL_CLI_CHANNEL:
        try:
            artifact = pixverse_cli_internal_artifact()
        except InternalCliError:
            artifact = None
        if artifact is not None:
            try:
                archive_stat = artifact.path.stat()
            except OSError:
                pass
            else:
                create = _read_capabilities_archive(
                    str(artifact.path),
                    archive_stat.st_mtime_ns,
                    archive_stat.st_size,
                    artifact.archive_root,
                )
                if create:
                    return create

    installed = pixverse_cli_capabilities_path()
    try:
        installed_stat = installed.stat()
    except OSError:
        installed_stat = None
    if installed_stat is not None:
        create = _read_capabilities_file(
            str(installed.resolve()),
            installed_stat.st_mtime_ns,
            installed_stat.st_size,
        )
        if create:
            return create

    try:
        artifact = pixverse_cli_internal_artifact()
    except InternalCliError:
        artifact = None
    if artifact is None:
        return {}
    try:
        archive_stat = artifact.path.stat()
    except OSError:
        return {}
    return _read_capabilities_archive(
        str(artifact.path),
        archive_stat.st_mtime_ns,
        archive_stat.st_size,
        artifact.archive_root,
    )


def create_mode_capability(mode: str) -> dict[str, Any]:
    modes = load_create_capabilities().get("modes")
    payload = modes.get(mode) if isinstance(modes, dict) else None
    return payload if isinstance(payload, dict) else {}


def create_model_supported(mode: str, model: str) -> bool:
    model_ids = create_mode_capability(mode).get("model_ids")
    return isinstance(model_ids, list) and model in model_ids


def create_model_parameter(mode: str, model: str, parameter: str) -> dict[str, Any]:
    mode_payload = create_mode_capability(mode)
    model_parameters = mode_payload.get("model_parameters")
    parameter_payload = model_parameters.get(parameter) if isinstance(model_parameters, dict) else None
    model_payload = parameter_payload.get(model) if isinstance(parameter_payload, dict) else None
    return model_payload if isinstance(model_payload, dict) else {}

SHA-256: 1936c9fb44a496e799f530388df5852b6a33775623199a924bb9b1b42bd888b3