修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。
二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。
三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
This commit is contained in:
1 parent
e03465c398
commit
237a09a5b0
161 files changed
+19429
-2
No files matched your search
@@ -0,0 +1,478 @@
|
||||
#!/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
|
||||
Reference in new issue
Block a user