|
#!/usr/bin/env python3 |
|
"""Genera un vídeo con MiniMax H3 sin instalar librerías adicionales.""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import base64 |
|
import getpass |
|
import json |
|
import mimetypes |
|
import os |
|
import sys |
|
import time |
|
import urllib.error |
|
import urllib.request |
|
from pathlib import Path |
|
from typing import Any |
|
|
|
|
|
CREATE_URL = "https://api.minimax.io/v2/video_generation" |
|
QUERY_URL = "https://api.minimax.io/v2/query/video_generation/{task_id}" |
|
MODEL = "MiniMax-H3" |
|
POLL_SECONDS = 10 |
|
TERMINAL_FAILURES = {"failed", "cancelled", "FAILED", "CANCELLED"} |
|
|
|
# Estimación observada el 4 de agosto de 2026. No es una tarifa contractual. |
|
OBSERVED_768P_USD_PER_SECOND = 0.104 |
|
|
|
|
|
class FriendlyError(RuntimeError): |
|
"""Error que se puede mostrar a una persona sin un traceback.""" |
|
|
|
|
|
def api_json( |
|
url: str, |
|
*, |
|
api_key: str, |
|
method: str = "GET", |
|
payload: dict[str, Any] | None = None, |
|
timeout: int = 60, |
|
) -> dict[str, Any]: |
|
data = None if payload is None else json.dumps(payload).encode("utf-8") |
|
request = urllib.request.Request( |
|
url, |
|
data=data, |
|
method=method, |
|
headers={ |
|
"Authorization": f"Bearer {api_key}", |
|
"Content-Type": "application/json", |
|
"User-Agent": "minimax-h3-facil/1.0", |
|
}, |
|
) |
|
try: |
|
with urllib.request.urlopen(request, timeout=timeout) as response: |
|
return json.load(response) |
|
except urllib.error.HTTPError as exc: |
|
try: |
|
detail = json.load(exc) |
|
message = detail.get("message") or detail.get("base_resp", {}).get("status_msg") |
|
except Exception: |
|
message = exc.reason |
|
if exc.code in {401, 403}: |
|
raise FriendlyError("La API key no es válida o no tiene permiso para usar H3.") from exc |
|
if exc.code == 429: |
|
raise FriendlyError("MiniMax está limitando las peticiones. Espera un minuto y vuelve a probar.") from exc |
|
raise FriendlyError(f"MiniMax devolvió el error HTTP {exc.code}: {message}") from exc |
|
except urllib.error.URLError as exc: |
|
raise FriendlyError(f"No se pudo conectar con MiniMax: {exc.reason}") from exc |
|
|
|
|
|
def image_data_url(path: Path) -> str: |
|
mime, _ = mimetypes.guess_type(path.name) |
|
if mime not in {"image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"}: |
|
raise FriendlyError("La referencia debe ser JPG, PNG, WEBP, HEIC o HEIF.") |
|
if path.stat().st_size > 20 * 1024 * 1024: |
|
raise FriendlyError("La imagen de referencia supera 20 MB. Redúcela antes de continuar.") |
|
encoded = base64.b64encode(path.read_bytes()).decode("ascii") |
|
return f"data:{mime};base64,{encoded}" |
|
|
|
|
|
def download(url: str, output: Path) -> None: |
|
output.parent.mkdir(parents=True, exist_ok=True) |
|
partial = output.with_suffix(output.suffix + ".part") |
|
request = urllib.request.Request(url, headers={"User-Agent": "minimax-h3-facil/1.0"}) |
|
try: |
|
with urllib.request.urlopen(request, timeout=300) as response, partial.open("wb") as target: |
|
while chunk := response.read(1024 * 1024): |
|
target.write(chunk) |
|
if not partial.is_file() or partial.stat().st_size == 0: |
|
raise FriendlyError("MiniMax devolvió un vídeo vacío.") |
|
partial.replace(output) |
|
except Exception: |
|
partial.unlink(missing_ok=True) |
|
raise |
|
|
|
|
|
def ask(question: str, default: str | None = None) -> str: |
|
suffix = f" [{default}]" if default is not None else "" |
|
value = input(f"{question}{suffix}: ").strip() |
|
return value or (default or "") |
|
|
|
|
|
def ask_choice(question: str, choices: dict[str, str], default: str) -> str: |
|
print(f"\n{question}") |
|
for key, label in choices.items(): |
|
marker = " (recomendado)" if key == default else "" |
|
print(f" {key}. {label}{marker}") |
|
while True: |
|
value = ask("Elige una opción", default) |
|
if value in choices: |
|
return choices[value] |
|
print("Escribe uno de los números de la lista.") |
|
|
|
|
|
def ask_duration(default: int = 10) -> int: |
|
while True: |
|
raw = ask("Duración en segundos, de 4 a 15", str(default)) |
|
try: |
|
value = int(raw) |
|
except ValueError: |
|
print("La duración debe ser un número entero.") |
|
continue |
|
if 4 <= value <= 15: |
|
return value |
|
print("La duración debe estar entre 4 y 15 segundos.") |
|
|
|
|
|
def clean_dropped_path(value: str) -> Path: |
|
# Terminal suele añadir comillas al arrastrar un archivo. |
|
return Path(value.strip().strip("'\"")).expanduser().resolve() |
|
|
|
|
|
def interactive_values(args: argparse.Namespace) -> dict[str, Any]: |
|
print("\nMiniMax H3 fácil") |
|
print("Genera un MP4 a partir de una descripción. Pulsa Ctrl+C para cancelar.\n") |
|
|
|
prompt = args.prompt or ask( |
|
"Describe el vídeo", |
|
"Un astronauta camina por Madrid al amanecer, estilo cinematográfico, música épica", |
|
) |
|
duration = args.duration or ask_duration() |
|
ratio = args.ratio or ask_choice( |
|
"¿Qué formato quieres?", |
|
{"1": "16:9 horizontal", "2": "9:16 vertical", "3": "1:1 cuadrado"}, |
|
"1", |
|
).split()[0] |
|
resolution = args.resolution or ask_choice( |
|
"¿Qué calidad quieres?", |
|
{"1": "768P normal", "2": "2K alta (más cara)"}, |
|
"1", |
|
).split()[0] |
|
|
|
reference: Path | None = args.reference.resolve() if args.reference else None |
|
if reference is None and sys.stdin.isatty(): |
|
raw_reference = ask("Imagen de referencia (opcional; puedes arrastrarla aquí)") |
|
reference = clean_dropped_path(raw_reference) if raw_reference else None |
|
if reference is not None and not reference.is_file(): |
|
raise FriendlyError(f"No encuentro la imagen de referencia: {reference}") |
|
|
|
output = args.output |
|
if output is None: |
|
output = clean_dropped_path(ask("Nombre del vídeo de salida", "video-h3.mp4")) |
|
if output.suffix.lower() != ".mp4": |
|
output = output.with_suffix(".mp4") |
|
|
|
return { |
|
"prompt": prompt.strip(), |
|
"duration": duration, |
|
"ratio": ratio, |
|
"resolution": resolution, |
|
"reference": reference, |
|
"output": output.resolve(), |
|
} |
|
|
|
|
|
def validate(values: dict[str, Any]) -> None: |
|
if not values["prompt"]: |
|
raise FriendlyError("La descripción no puede estar vacía.") |
|
if len(values["prompt"]) > 7000: |
|
raise FriendlyError("La descripción supera el máximo de 7000 caracteres.") |
|
if not 4 <= values["duration"] <= 15: |
|
raise FriendlyError("La duración debe estar entre 4 y 15 segundos.") |
|
if values["ratio"] not in {"16:9", "9:16", "1:1"}: |
|
raise FriendlyError("El formato debe ser 16:9, 9:16 o 1:1.") |
|
if values["resolution"] not in {"768P", "2K"}: |
|
raise FriendlyError("La calidad debe ser 768P o 2K.") |
|
|
|
|
|
def build_payload(values: dict[str, Any]) -> dict[str, Any]: |
|
content: list[dict[str, Any]] = [{"type": "text", "text": values["prompt"]}] |
|
if values["reference"] is not None: |
|
content.append( |
|
{ |
|
"type": "image_url", |
|
"image_url": {"url": image_data_url(values["reference"])}, |
|
"role": "reference_image", |
|
} |
|
) |
|
return { |
|
"model": MODEL, |
|
"content": content, |
|
"duration": values["duration"], |
|
"resolution": values["resolution"], |
|
"ratio": values["ratio"], |
|
} |
|
|
|
|
|
def confirm_cost(values: dict[str, Any], assume_yes: bool) -> None: |
|
print("\nResumen") |
|
print(f" Duración: {values['duration']} s") |
|
print(f" Formato: {values['ratio']}") |
|
print(f" Calidad: {values['resolution']}") |
|
print(f" Salida: {values['output']}") |
|
if values["resolution"] == "768P": |
|
estimate = values["duration"] * OBSERVED_768P_USD_PER_SECOND |
|
print(f" Coste orientativo observado: ${estimate:.2f} USD") |
|
else: |
|
print(" Coste: consulta el precio actual de 2K en tu cuenta de MiniMax") |
|
print(" IMPORTANTE: MiniMax puede cambiar las tarifas; tu panel de facturación manda.") |
|
if assume_yes: |
|
return |
|
answer = ask("¿Quieres enviar la generación y consumir créditos? (s/N)", "N").lower() |
|
if answer not in {"s", "si", "sí", "y", "yes"}: |
|
raise KeyboardInterrupt |
|
|
|
|
|
def generate(values: dict[str, Any], api_key: str) -> str: |
|
created = api_json(CREATE_URL, api_key=api_key, method="POST", payload=build_payload(values)) |
|
task_id = str(created.get("task_id", "")) |
|
if not task_id: |
|
raise FriendlyError(f"MiniMax no devolvió un identificador de tarea: {created}") |
|
print(f"\nPetición enviada. Tarea {task_id}.") |
|
print("La generación puede tardar varios minutos. Puedes dejar esta ventana abierta.") |
|
|
|
while True: |
|
task_response = api_json(QUERY_URL.format(task_id=task_id), api_key=api_key) |
|
task = task_response.get("task", {}) |
|
status = str(task.get("status", "unknown")) |
|
print(f" Estado: {status}", flush=True) |
|
if status.lower() == "succeeded": |
|
url = task.get("content", {}).get("url") |
|
if not url: |
|
raise FriendlyError("La tarea terminó, pero MiniMax no devolvió la URL del vídeo.") |
|
print("Descargando el vídeo...") |
|
download(url, values["output"]) |
|
return task_id |
|
if status in TERMINAL_FAILURES or status.lower() in {"failed", "cancelled"}: |
|
raise FriendlyError(f"MiniMax no pudo generar el vídeo: {task.get('error') or status}") |
|
time.sleep(POLL_SECONDS) |
|
|
|
|
|
def parse_args() -> argparse.Namespace: |
|
parser = argparse.ArgumentParser( |
|
description="Genera un vídeo con MiniMax H3 de forma interactiva.", |
|
epilog="Ejemplo: python minimax_h3_facil.py --dry-run --prompt \"Un bosque mágico\"", |
|
) |
|
parser.add_argument("--prompt", help="Descripción del vídeo") |
|
parser.add_argument("--duration", type=int, help="Duración de 4 a 15 segundos") |
|
parser.add_argument("--ratio", choices=("16:9", "9:16", "1:1"), help="Formato del vídeo") |
|
parser.add_argument("--resolution", choices=("768P", "2K"), help="Calidad") |
|
parser.add_argument("--reference", type=Path, help="Imagen de referencia opcional") |
|
parser.add_argument("-o", "--output", type=Path, help="Archivo MP4 de salida") |
|
parser.add_argument("-n", "--dry-run", action="store_true", help="Validar sin gastar créditos") |
|
parser.add_argument("--yes", action="store_true", help="No pedir confirmación antes de gastar") |
|
return parser.parse_args() |
|
|
|
|
|
def main() -> int: |
|
try: |
|
args = parse_args() |
|
if not sys.stdin.isatty() and not args.prompt: |
|
raise FriendlyError("Falta --prompt. Usa --help para ver un ejemplo.") |
|
values = interactive_values(args) |
|
validate(values) |
|
confirm_cost(values, assume_yes=args.yes or args.dry_run) |
|
if args.dry_run: |
|
print("\nTodo correcto. No se ha enviado nada ni se han consumido créditos.") |
|
return 0 |
|
|
|
api_key = os.environ.get("MINIMAX_API_KEY", "").strip() |
|
if not api_key: |
|
api_key = getpass.getpass("\nPega tu API key de MiniMax (no se mostrará ni se guardará): ").strip() |
|
if not api_key: |
|
raise FriendlyError("No se ha introducido ninguna API key.") |
|
|
|
task_id = generate(values, api_key) |
|
metadata = { |
|
"model": MODEL, |
|
"task_id": task_id, |
|
"prompt": values["prompt"], |
|
"duration": values["duration"], |
|
"resolution": values["resolution"], |
|
"ratio": values["ratio"], |
|
"reference": str(values["reference"] or ""), |
|
"output": str(values["output"]), |
|
} |
|
values["output"].with_suffix(".mp4.json").write_text( |
|
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", |
|
encoding="utf-8", |
|
) |
|
print(f"\n✅ Vídeo guardado en:\n{values['output']}") |
|
return 0 |
|
except KeyboardInterrupt: |
|
print("\nCancelado. No se ha enviado ninguna petición nueva.") |
|
return 130 |
|
except (FriendlyError, OSError, json.JSONDecodeError) as exc: |
|
print(f"\nError: {exc}", file=sys.stderr) |
|
return 1 |
|
|
|
|
|
if __name__ == "__main__": |
|
raise SystemExit(main()) |