mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 11:40:35 +00:00
* 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>
352 lines
13 KiB
Python
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"
|