deer-flow/tests/skills/test_video_generation.py
VicFei a3d21e5e4d
feat(skills): expose MiniMax resolution/duration in video-generation (#7912)
* feat(skills): expose MiniMax resolution/duration in video-generation

Hailuo 2.3 accepts explicit resolution (768P/1080P) and duration (6/10s)
in the /v1/video_generation body, but the skill sent neither — and its
SKILL.md told the caller --aspect-ratio is ignored for MiniMax without
offering any alternative, leaving cost/quality/length uncontrollable.
Read MINIMAX_VIDEO_RESOLUTION and MINIMAX_VIDEO_DURATION from the
environment and pass them through when set (unset keeps the API defaults
byte-for-byte); values are passed through unvalidated beyond the integer
check so newer tiers (H3 per-second 4-15s) need no script change. Document
both knobs in SKILL.md with a draft/final example. Fixes #7911.

* fix(skills): address MiniMax video override review

---------

Co-authored-by: vicfei <4058491+vicfei@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-07 15:52:35 +08:00

352 lines
13 KiB
Python

import sys
from pathlib import Path
import pytest
import requests
sys.path.insert(0, str(Path(__file__).resolve().parent))
from skill_loader import FakeResp, load # noqa: E402
vid = load("video-generation")
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in (
"GEMINI_API_KEY",
"MINIMAX_API_KEY",
"VIDEO_GENERATION_PROVIDER",
"MINIMAX_API_HOST",
"MINIMAX_VIDEO_MODEL",
"MINIMAX_VIDEO_RESOLUTION",
"MINIMAX_VIDEO_DURATION",
):
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(vid.time, "sleep", lambda *_: None)
def test_resolve_prefers_gemini():
assert vid._resolve_provider("VIDEO_GENERATION_PROVIDER", "gemini", True) == "gemini"
def test_resolve_falls_back_to_minimax(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "m")
assert vid._resolve_provider("VIDEO_GENERATION_PROVIDER", "gemini", False) == "minimax"
def test_resolve_override(monkeypatch):
monkeypatch.setenv("VIDEO_GENERATION_PROVIDER", "minimax")
assert vid._resolve_provider("VIDEO_GENERATION_PROVIDER", "gemini", True) == "minimax"
def test_unknown_provider_raises(monkeypatch, tmp_path):
monkeypatch.setenv("VIDEO_GENERATION_PROVIDER", "openai")
monkeypatch.setenv("GEMINI_API_KEY", "g")
pf = tmp_path / "p.json"
pf.write_text("x", encoding="utf-8")
with pytest.raises(ValueError):
vid.generate_video(str(pf), [], str(tmp_path / "v.mp4"), "16:9")
@pytest.mark.parametrize(
"video_settings",
[{}, {"resolution": "1080P", "duration": 6}],
ids=["defaults", "overrides"],
)
def test_minimax_full_flow(monkeypatch, tmp_path, video_settings):
monkeypatch.setenv("MINIMAX_API_KEY", "m")
for field, value in video_settings.items():
monkeypatch.setenv(f"MINIMAX_VIDEO_{field.upper()}", str(value))
posts = {}
def fake_post(url, headers=None, json=None, **kw):
posts["url"] = url
posts["json"] = json
assert headers["Authorization"] == "Bearer m"
return FakeResp({"task_id": "T1", "base_resp": {"status_code": 0}})
def fake_get(url, headers=None, params=None, **kw):
if url.endswith("/v1/query/video_generation"):
assert headers["Authorization"] == "Bearer m"
assert params["task_id"] == "T1"
return FakeResp({"status": "Success", "file_id": "F1",
"base_resp": {"status_code": 0}})
if url.endswith("/v1/files/retrieve"):
assert headers["Authorization"] == "Bearer m"
assert params["file_id"] == "F1"
return FakeResp({"file": {"download_url": "https://dl/v.mp4"},
"base_resp": {"status_code": 0}})
return FakeResp(content=b"MP4DATA") # the actual download
monkeypatch.setattr(vid.requests, "post", fake_post)
monkeypatch.setattr(vid.requests, "get", fake_get)
out = tmp_path / "v.mp4"
pf = tmp_path / "p.json"
pf.write_text("a cat runs", encoding="utf-8")
msg = vid.generate_video(str(pf), [], str(out), "16:9")
assert out.read_bytes() == b"MP4DATA"
assert posts["url"].endswith("/v1/video_generation")
assert posts["json"] == {"model": "MiniMax-Hailuo-2.3", "prompt": "a cat runs", **video_settings}
if "duration" in video_settings:
assert type(posts["json"]["duration"]) is int
assert "successfully" in msg.lower()
@pytest.mark.parametrize("duration", ["abc", "2.5", "²"])
def test_minimax_rejects_invalid_duration(monkeypatch, tmp_path, duration):
monkeypatch.setenv("MINIMAX_API_KEY", "m")
monkeypatch.setenv("MINIMAX_VIDEO_DURATION", duration)
def fail_if_called(*args, **kwargs):
pytest.fail("Invalid duration should be rejected before submission")
monkeypatch.setattr(vid.requests, "post", fail_if_called)
prompt = tmp_path / "prompt.json"
prompt.write_text("a cat runs", encoding="utf-8")
with pytest.raises(
ValueError, match="MINIMAX_VIDEO_DURATION must be an integer number of seconds"
):
vid.generate_video(str(prompt), [], str(tmp_path / "v.mp4"))
def test_minimax_reference_first_frame(monkeypatch, tmp_path):
monkeypatch.setenv("MINIMAX_API_KEY", "m")
posts = {}
def fake_post(url, headers=None, json=None, **kw):
posts["json"] = json
return FakeResp({"task_id": "T1", "base_resp": {"status_code": 0}})
def fake_get(url, headers=None, params=None, **kw):
if url.endswith("/v1/query/video_generation"):
return FakeResp({"status": "Success", "file_id": "F1", "base_resp": {"status_code": 0}})
if url.endswith("/v1/files/retrieve"):
return FakeResp({"file": {"download_url": "https://dl/v.mp4"}, "base_resp": {"status_code": 0}})
return FakeResp(content=b"X")
monkeypatch.setattr(vid.requests, "post", fake_post)
monkeypatch.setattr(vid.requests, "get", fake_get)
ref = tmp_path / "f.jpg"
ref.write_bytes(b"\xff\xd8img")
pf = tmp_path / "p.json"
pf.write_text("x", encoding="utf-8")
vid.generate_video(str(pf), [str(ref)], str(tmp_path / "v.mp4"), "16:9")
assert posts["json"]["first_frame_image"].startswith("data:image/jpeg;base64,")
def test_minimax_task_fail(monkeypatch, tmp_path):
monkeypatch.setenv("MINIMAX_API_KEY", "m")
def fake_post(url, headers=None, json=None, **kw):
return FakeResp({"task_id": "T1", "base_resp": {"status_code": 0}})
def fake_get(url, headers=None, params=None, **kw):
return FakeResp({"status": "Fail", "base_resp": {"status_code": 1027, "status_msg": "blocked"}})
monkeypatch.setattr(vid.requests, "post", fake_post)
monkeypatch.setattr(vid.requests, "get", fake_get)
pf = tmp_path / "p.json"
pf.write_text("x", encoding="utf-8")
with pytest.raises(Exception):
vid.generate_video(str(pf), [], str(tmp_path / "v.mp4"), "16:9")
def test_minimax_poll_timeout(monkeypatch):
def fake_get(url, headers=None, params=None, **kw):
return FakeResp({"status": "Processing", "base_resp": {"status_code": 0}})
monkeypatch.setattr(vid.requests, "get", fake_get)
with pytest.raises(Exception) as e:
vid._poll_video_task("https://h", "Bearer m", "T1", max_attempts=3, interval=0)
assert "timed out" in str(e.value)
def test_minimax_task_fail_keeps_task_context(monkeypatch, tmp_path):
# A Fail status takes priority over the generic base_resp check, so the
# error keeps the task_id and the task-level failure message.
monkeypatch.setenv("MINIMAX_API_KEY", "m")
def fake_post(url, headers=None, json=None, **kw):
return FakeResp({"task_id": "T1", "base_resp": {"status_code": 0}})
def fake_get(url, headers=None, params=None, **kw):
return FakeResp({"status": "Fail", "base_resp": {"status_code": 1027, "status_msg": "blocked"}})
monkeypatch.setattr(vid.requests, "post", fake_post)
monkeypatch.setattr(vid.requests, "get", fake_get)
pf = tmp_path / "p.json"
pf.write_text("x", encoding="utf-8")
with pytest.raises(Exception, match="task T1 failed"):
vid.generate_video(str(pf), [], str(tmp_path / "v.mp4"), "16:9")
def test_gemini_download_raises_on_http_error(monkeypatch, tmp_path):
monkeypatch.setenv("GEMINI_API_KEY", "g")
calls = {}
def fake_get(url, headers=None, **kw):
calls["timeout"] = kw.get("timeout")
return FakeResp(content=b"error page", status_code=500)
monkeypatch.setattr(vid.requests, "get", fake_get)
out = tmp_path / "sub" / "v.mp4"
with pytest.raises(requests.HTTPError):
vid.download("https://dl/v.mp4", str(out))
assert calls["timeout"] # a timeout is now passed
assert not out.exists()
def test_gemini_download_writes_nested_dir(monkeypatch, tmp_path):
monkeypatch.setenv("GEMINI_API_KEY", "g")
def fake_get(url, headers=None, **kw):
assert headers["x-goog-api-key"] == "g"
return FakeResp(content=b"VIDEO")
monkeypatch.setattr(vid.requests, "get", fake_get)
out = tmp_path / "nested" / "dir" / "v.mp4"
vid.download("https://dl/v.mp4", str(out))
assert out.read_bytes() == b"VIDEO"
def test_gemini_post_raises_on_http_error(monkeypatch, tmp_path):
monkeypatch.setenv("GEMINI_API_KEY", "g")
def fake_post(url, headers=None, json=None, **kw):
return FakeResp(status_code=503)
monkeypatch.setattr(vid.requests, "post", fake_post)
pf = tmp_path / "p.json"
pf.write_text("a cat", encoding="utf-8")
with pytest.raises(requests.HTTPError):
vid.generate_video(str(pf), [], str(tmp_path / "v.mp4"), "16:9")
def test_gemini_poll_is_bounded(monkeypatch):
# The MiniMax path bounds its polling; the Gemini operation path must too.
polls = {"count": 0}
def fake_get(url, headers=None, **kw):
polls["count"] += 1
return FakeResp(json_data={"done": False})
monkeypatch.setattr(vid.requests, "get", fake_get)
with pytest.raises(Exception, match="timed out"):
vid._poll_gemini_operation("g", "operations/op", max_attempts=3, interval=0)
assert polls["count"] == 3
def test_gemini_operation_error_is_reported(monkeypatch, tmp_path):
# A finished operation carrying an error must not surface as a KeyError on
# the missing "response" field.
monkeypatch.setenv("GEMINI_API_KEY", "g")
def fake_post(url, headers=None, json=None, **kw):
return FakeResp(json_data={"name": "operations/op"})
def fake_get(url, headers=None, **kw):
return FakeResp(json_data={"done": True, "error": {"code": 3, "message": "prompt rejected"}})
monkeypatch.setattr(vid.requests, "post", fake_post)
monkeypatch.setattr(vid.requests, "get", fake_get)
pf = tmp_path / "p.json"
pf.write_text("x", encoding="utf-8")
out = tmp_path / "v.mp4"
with pytest.raises(Exception, match="prompt rejected"):
vid.generate_video(str(pf), [], str(out), "16:9")
assert not out.exists()
def test_gemini_filtered_sample_reports_reason(monkeypatch, tmp_path):
monkeypatch.setenv("GEMINI_API_KEY", "g")
reason = "The generated video was filtered by the safety system."
def fake_post(url, headers=None, json=None, **kw):
return FakeResp(json_data={"name": "operations/op"})
def fake_get(url, headers=None, **kw):
return FakeResp(
json_data={
"done": True,
"response": {
"generateVideoResponse": {
"raiMediaFilteredCount": 1,
"raiMediaFilteredReasons": [reason],
}
},
}
)
monkeypatch.setattr(vid.requests, "post", fake_post)
monkeypatch.setattr(vid.requests, "get", fake_get)
pf = tmp_path / "p.json"
pf.write_text("x", encoding="utf-8")
with pytest.raises(Exception, match="filtered by the safety system"):
vid.generate_video(str(pf), [], str(tmp_path / "v.mp4"), "16:9")
def test_gemini_sample_without_uri_is_reported(monkeypatch, tmp_path):
monkeypatch.setenv("GEMINI_API_KEY", "g")
def fake_post(url, headers=None, json=None, **kw):
return FakeResp(json_data={"name": "operations/op"})
def fake_get(url, headers=None, **kw):
return FakeResp(
json_data={
"done": True,
"response": {"generateVideoResponse": {"generatedSamples": [{"video": {}}]}},
}
)
monkeypatch.setattr(vid.requests, "post", fake_post)
monkeypatch.setattr(vid.requests, "get", fake_get)
pf = tmp_path / "p.json"
pf.write_text("x", encoding="utf-8")
with pytest.raises(Exception, match="without a video URI"):
vid.generate_video(str(pf), [], str(tmp_path / "v.mp4"), "16:9")
def test_gemini_forwards_aspect_ratio_to_predict_request(monkeypatch, tmp_path):
monkeypatch.setenv("GEMINI_API_KEY", "g")
captured = {}
def fake_post(url, headers=None, json=None, **kw):
captured["url"] = url
captured["json"] = json
assert headers["x-goog-api-key"] == "g"
return FakeResp(json_data={"name": "operations/op"})
def fake_get(url, headers=None, **kw):
assert headers["x-goog-api-key"] == "g"
return FakeResp(
json_data={
"done": True,
"response": {
"generateVideoResponse": {
"generatedSamples": [
{"video": {"uri": "https://example.test/v.mp4"}}
]
}
},
},
)
def fake_download(uri, output_file):
with open(output_file, "wb") as f:
f.write(b"video")
monkeypatch.setattr(vid.requests, "post", fake_post)
monkeypatch.setattr(vid.requests, "get", fake_get)
monkeypatch.setattr(vid, "download", fake_download)
pf = tmp_path / "p.json"
pf.write_text("a tall cat", encoding="utf-8")
vid.generate_video(str(pf), [], str(tmp_path / "v.mp4"), "9:16")
assert "predictLongRunning" in captured["url"]
assert captured["json"]["parameters"]["aspectRatio"] == "9:16"