Files
workbuddy_skills/oil-motion/scripts/video_job.py
T

478 lines
17 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""提交、轮询并下载 MiniMax H3 视频任务。
密钥优先从 ZENMUX_API_KEY 读取,否则读取 Oil Motion 的本地配置。
MiniMax H3 有两种互斥的图片约束模式:
- 闭环:同一张图同时作为 first_frame 与 last_frame。
- 转场:不同图片分别作为 first_frame 与 last_frame。
- 参考模式:只传 reference_image,不得与首尾帧混用。
"""
from __future__ import annotations
import argparse
import base64
import json
import mimetypes
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
from media_edges import extract_last_frame
from production_gate import validate_pilot_approval, verify_frame_chain
from PIL import Image
from oil_motion_config import require_api_key
API_ROOT = "https://zenmux.ai/api/v1"
DEFAULT_MODEL = "minimax/minimax-h3-max"
H3_MAX_RESOLUTIONS = ("480P", "768P")
TERMINAL_STATES = {"succeeded", "failed", "cancelled", "canceled"}
COMMON_RATIOS = {
"21:9": 21 / 9,
"16:9": 16 / 9,
"4:3": 4 / 3,
"1:1": 1.0,
"3:4": 3 / 4,
"9:16": 9 / 16,
}
def local_image_data_uri(path: Path) -> str:
if not path.is_file():
raise FileNotFoundError(f"找不到图片:{path}")
mime = mimetypes.guess_type(path.name)[0] or "image/png"
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{encoded}"
def image_content(path: Path, role: str) -> dict[str, Any]:
return {
"type": "image_url",
"role": role,
"image_url": {
"url": local_image_data_uri(path),
},
}
def infer_ratio(path: Path | None) -> str:
if path is None:
return "1:1"
with Image.open(path) as image:
actual = image.width / image.height
return min(COMMON_RATIOS, key=lambda name: abs(COMMON_RATIOS[name] - actual))
def request_json(
method: str,
url: str,
api_key: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
body = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url,
data=body,
method=method,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "oil-motion/1.0",
},
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
details = exc.read().decode("utf-8", errors="replace").replace(api_key, "[redacted]")
raise RuntimeError(f"ZenMux API {exc.code}: {details}") from exc
def download(url: str, output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
request = urllib.request.Request(url, headers={"User-Agent": "oil-motion/1.0"})
with urllib.request.urlopen(request, timeout=300) as response:
output.write_bytes(response.read())
def find_job_id(response: dict[str, Any]) -> str:
for key in ("id", "job_id", "jobId", "task_id", "taskId"):
if response.get(key):
return str(response[key])
data = response.get("data")
if isinstance(data, dict):
return find_job_id(data)
raise RuntimeError(f"提交成功但没有找到任务 ID:{response}")
def find_status(response: dict[str, Any]) -> str:
for source in (response, response.get("data"), response.get("result")):
if isinstance(source, dict):
for key in ("status", "state"):
if source.get(key):
return str(source[key]).lower()
return "unknown"
def walk_for_url(value: Any, preferred_keys: tuple[str, ...]) -> str | None:
if isinstance(value, dict):
for key in preferred_keys:
candidate = value.get(key)
if isinstance(candidate, str) and candidate.startswith(("http://", "https://")):
return candidate
for child in value.values():
found = walk_for_url(child, preferred_keys)
if found:
return found
elif isinstance(value, list):
for child in value:
found = walk_for_url(child, preferred_keys)
if found:
return found
return None
def redacted_metadata(
payload: dict[str, Any],
submit_response: dict[str, Any],
final_response: dict[str, Any],
production_gate: dict[str, Any] | None = None,
) -> dict[str, Any]:
def redact_remote_urls(value: Any) -> Any:
if isinstance(value, dict):
return {
key: (
"<remote-url-redacted>"
if isinstance(child, str)
and child.startswith(("http://", "https://"))
else redact_remote_urls(child)
)
for key, child in value.items()
}
if isinstance(value, list):
return [redact_remote_urls(child) for child in value]
return value
safe_payload = dict(payload)
safe_content = []
for item in payload.get("content", []):
if item.get("type") == "image_url":
safe_content.append(
{
"type": "image_url",
"role": item.get("role"),
"image_url": {
"url": "<local-image-data-uri>",
},
}
)
else:
safe_content.append(item)
safe_payload["content"] = safe_content
result = {
"payload": safe_payload,
"submit": redact_remote_urls(submit_response),
"final": redact_remote_urls(final_response),
}
if production_gate is not None:
result["productionGate"] = production_gate
return result
def validate_production_gate(args: argparse.Namespace) -> dict[str, Any]:
if args.segment_index < 1:
raise ValueError("--segment-index 必须大于等于 1")
stage = getattr(args, "stage", None) or "pilot"
if stage == "pilot":
return {"stage": "pilot", "segmentIndex": args.segment_index}
if args.segment_index < 2:
raise ValueError("production 阶段从第 2 段开始,--segment-index 必须大于等于 2")
if not args.pilot_approval:
raise ValueError("production 阶段必须提供 --pilot-approval")
approval = validate_pilot_approval(
args.pilot_approval,
force=getattr(args, "force", False),
)
gate: dict[str, Any] = {
"stage": "production",
"segmentIndex": args.segment_index,
"pilotApproval": str(
Path(args.pilot_approval).expanduser().resolve()
),
"continuityMode": args.continuity_mode,
}
if args.continuity_mode is None:
raise ValueError(
"production 阶段必须显式提供 --continuity-mode chain|independent"
)
approved_mode = approval["continuityMode"]
if args.continuity_mode != approved_mode:
raise ValueError(
"production 连续模式与 Pilot 批准的 Concept Contract 不一致:"
f"合同要求 {approved_mode},收到 {args.continuity_mode}"
)
if args.continuity_mode == "chain":
if not args.previous_tail or not args.first_frame or not args.frame_chain_manifest:
raise ValueError(
"chain 模式必须同时提供 --previous-tail、--first-frame "
"和 --frame-chain-manifest"
)
link = verify_frame_chain(
args.previous_tail,
args.first_frame,
args.segment_index,
args.frame_chain_manifest,
force=getattr(args, "force", False),
)
gate["frameChain"] = link
gate["frameChainManifest"] = str(
Path(args.frame_chain_manifest).expanduser().resolve()
)
return gate
def build_payload(args: argparse.Namespace) -> dict[str, Any]:
resolution = args.resolution
if args.model == DEFAULT_MODEL:
resolution = resolution.upper()
if resolution not in H3_MAX_RESOLUTIONS:
raise ValueError(
f"{args.model} 不支持分辨率 {args.resolution!r};"
f"仅支持 {', '.join(H3_MAX_RESOLUTIONS)}。"
"请改用 --resolution 768P(母版)或 --resolution 480P(动作草案);"
"母版像素不足时,下调 motion_budget.py 的 --display 或 --dpr,"
"并用 --source 传入实际尺寸重新预算,不要放大母版。"
)
prompt = args.prompt
if args.prompt_file:
prompt = Path(args.prompt_file).read_text(encoding="utf-8").strip()
if not prompt:
raise ValueError("必须提供 --prompt 或 --prompt-file")
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
first = Path(args.first_frame).expanduser().resolve() if args.first_frame else None
last = Path(args.last_frame).expanduser().resolve() if args.last_frame else None
if args.loop_frame and args.last_frame:
raise ValueError("--loop-frame 与 --last-frame 不能同时使用")
if args.loop_frame:
if not first:
raise ValueError("--loop-frame 需要同时提供 --first-frame")
last = first
if last and not first:
raise ValueError("--last-frame 需要同时提供 --first-frame")
frame_mode = first is not None or last is not None
reference_mode = bool(args.reference_image)
if frame_mode and reference_mode:
raise ValueError(
"MiniMax H3 的 reference_image 与 first_frame/last_frame 互斥"
"(接口错误 2013)。需要锁定身份时,请先把身份信息生成进首尾关键帧。"
)
if args.frames is not None and args.duration is not None:
raise ValueError("--frames 与 --duration 不能同时传入")
if args.frames is not None and args.frames < 2:
raise ValueError("--frames 必须至少为 2")
if args.duration is not None and args.duration <= 0:
raise ValueError("--duration 必须大于 0")
if first:
content.append(image_content(first, "first_frame"))
if last:
content.append(image_content(last, "last_frame"))
reference_paths = [
Path(raw_path).expanduser().resolve() for raw_path in args.reference_image
]
for reference_path in reference_paths:
content.append(image_content(reference_path, "reference_image"))
payload: dict[str, Any] = {
"model": args.model,
"content": content,
"resolution": resolution,
"generate_audio": False,
"watermark": False,
"return_last_frame": True,
}
if args.frames is None:
payload["duration"] = args.duration if args.duration is not None else 5
payload["ratio"] = args.ratio or infer_ratio(
first or (reference_paths[0] if reference_paths else None)
)
if args.seed is not None:
payload["seed"] = args.seed
if args.frames is not None:
payload["frames"] = args.frames
return payload
def generate(args: argparse.Namespace) -> int:
# 先做纯本地参数校验,避免因为缺少密钥而掩盖组合错误。
payload = build_payload(args)
gate_report = validate_production_gate(args)
api_key = require_api_key()
output = Path(args.output).expanduser().resolve()
metadata = (
Path(args.metadata).expanduser().resolve()
if args.metadata
else output.with_suffix(".job.json")
)
if output.exists() and not args.force:
raise FileExistsError(f"输出已存在:{output};确认后使用 --force")
submit_response = request_json("POST", f"{API_ROOT}/videos", api_key, payload)
job_id = find_job_id(submit_response)
print(f"任务已提交:{job_id}", flush=True)
deadline = time.monotonic() + args.timeout
final_response = submit_response
last_status = ""
while time.monotonic() < deadline:
final_response = request_json(
"GET", f"{API_ROOT}/videos/{job_id}", api_key
)
status = find_status(final_response)
if status != last_status:
print(f"状态:{status}", flush=True)
last_status = status
if status in TERMINAL_STATES:
break
time.sleep(args.poll_interval)
else:
raise TimeoutError(f"等待视频超时:{args.timeout} 秒,任务 {job_id}")
metadata.parent.mkdir(parents=True, exist_ok=True)
metadata.write_text(
json.dumps(
redacted_metadata(
payload,
submit_response,
final_response,
gate_report,
),
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
status = find_status(final_response)
if status != "succeeded":
raise RuntimeError(f"视频生成未成功,状态:{status};详情见 {metadata}")
video_url = walk_for_url(
final_response, ("video_url", "videoUrl", "url", "download_url")
)
if not video_url:
raise RuntimeError(f"任务成功但没有找到视频地址;详情见 {metadata}")
download(video_url, output)
print(f"视频:{output}", flush=True)
last_frame_output = (
Path(args.last_frame_output).expanduser().resolve()
if args.last_frame_output
else output.with_name(f"{output.stem}-last-frame.jpg")
)
last_frame_url = walk_for_url(
final_response, ("last_frame_url", "lastFrameUrl", "last_frame")
)
if last_frame_url:
download(last_frame_url, last_frame_output)
last_frame_source = "api"
else:
extract_last_frame(output, last_frame_output)
last_frame_source = "video-fallback"
print(f"尾帧:{last_frame_output}(来源:{last_frame_source})", flush=True)
metadata_payload = json.loads(metadata.read_text(encoding="utf-8"))
metadata_payload["lastFrame"] = {
"path": str(last_frame_output),
"source": last_frame_source,
}
metadata.write_text(
json.dumps(metadata_payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"元数据:{metadata}", flush=True)
return 0
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(
description="使用 ZenMux 的 MiniMax H3 生成并下载视频动作母版"
)
result.add_argument("--prompt")
result.add_argument("--prompt-file")
result.add_argument("--first-frame")
result.add_argument("--last-frame")
result.add_argument(
"--loop-frame",
action="store_true",
help="把首帧同时作为尾帧,约束闭环",
)
result.add_argument(
"--reference-image",
action="append",
default=[],
help="参考模式,可重复传入;不得与首帧、尾帧或闭环模式混用",
)
result.add_argument("--model", default=DEFAULT_MODEL)
result.add_argument(
"--resolution",
default="768P",
help=(
"minimax/minimax-h3-max 仅支持 480P 或 768P(默认,大小写均可);"
"其他模型按其原生参数传入"
),
)
result.add_argument(
"--ratio",
help="输出画幅;有首帧时默认推断最接近的常用画幅,否则默认 1:1",
)
result.add_argument(
"--duration",
type=int,
help="视频秒数;未传 --frames 时默认 5,不能与 --frames 同时使用",
)
result.add_argument("--seed", type=int)
result.add_argument("--frames", type=int)
result.add_argument(
"--stage",
choices=("pilot", "production"),
default="pilot",
help="阶段类型:默认 pilot(直接生成与验证);production 阶段校验 Pilot 批准与连续性",
)
result.add_argument("--segment-index", type=int, default=1)
result.add_argument("--pilot-approval")
result.add_argument(
"--continuity-mode",
choices=("chain", "independent"),
help="production 阶段必填;chain 会校验上一段尾帧与本段首帧 SHA-256",
)
result.add_argument("--previous-tail")
result.add_argument("--frame-chain-manifest")
result.add_argument("--output", required=True)
result.add_argument("--last-frame-output")
result.add_argument("--metadata")
result.add_argument("--poll-interval", type=float, default=12.0)
result.add_argument("--timeout", type=float, default=1200.0)
result.add_argument("--force", action="store_true")
return result
if __name__ == "__main__":
try:
raise SystemExit(generate(parser().parse_args()))
except (FileNotFoundError, FileExistsError, RuntimeError, TimeoutError, ValueError) as error:
print(f"错误:{error}", file=sys.stderr)
raise SystemExit(1) from error