#!/usr/bin/env python3
"""Generate videos via APIYI's Seedance 2.5 / 2.0 (text / image / reference-image to video). Pure stdlib, zero dependencies."""
import argparse
import base64
import json
import os
import shutil
import sys
import time
import urllib.error
import urllib.request
# Line-buffer stdout even when redirected, so agents can tail progress from the background
sys.stdout.reconfigure(line_buffering=True)
TASKS_URL = "https://api.apiyi.com/seedance/api/v3/contents/generations/tasks"
# Short name -> full model ID
MODELS = {
"sd25": "doubao-seedance-2-5-260628",
"mini": "doubao-seedance-2-0-mini-260615",
"fast": "doubao-seedance-2-0-fast-260128",
"std": "doubao-seedance-2-0-260128",
}
# Resolution caps per model (mini/fast reject 1080p upstream with 400; fail fast client-side)
MODEL_CAPS = {
"sd25": ("480p", "720p", "1080p"),
"mini": ("480p", "720p"),
"fast": ("480p", "720p"),
"std": ("480p", "720p", "1080p"),
}
# Duration cap: 30 s on 2.5, 15 s on the 2.0 family
MAX_DURATION = {"sd25": 30, "mini": 15, "fast": 15, "std": 15}
# Reference-image cap: 30 on 2.5, 9 on the 2.0 family
MAX_REF_IMAGES_BY_MODEL = {"sd25": 30, "mini": 9, "fast": 9, "std": 9}
RATIOS = ("adaptive", "16:9", "4:3", "1:1", "3:4", "9:16", "21:9")
MAX_REF_IMAGES = 30
# Generation is async: wait a bit before the first poll; 720p/5s takes ~90-170s in practice
POLL_FIRST_DELAY = 25
POLL_INTERVAL = 15
POLL_TIMEOUT = 15 * 60
def load_api_key():
"""Prefer the environment variable; otherwise look for .env in the script dir and its parent."""
key = os.environ.get("APIYI_API_KEY")
if key:
return key
here = os.path.dirname(os.path.abspath(__file__))
for d in (here, os.path.dirname(here)):
env_path = os.path.join(d, ".env")
if os.path.exists(env_path):
with open(env_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("APIYI_API_KEY") and "=" in line:
return line.split("=", 1)[1].strip().strip('"').strip("'")
return None
def project_root():
"""Walk up from the script location to the first dir containing .git or .claude; else use cwd."""
d = os.path.dirname(os.path.abspath(__file__))
while True:
if os.path.isdir(os.path.join(d, ".git")) or os.path.isdir(os.path.join(d, ".claude")):
return d
parent = os.path.dirname(d)
if parent == d:
return os.getcwd()
d = parent
def resolve_path(out):
"""Bare file name -> save under <project root>/seedance-output/ so it's easy to find; else use the given path."""
if os.path.dirname(out):
return os.path.abspath(out)
out_dir = os.path.join(project_root(), "seedance-output")
os.makedirs(out_dir, exist_ok=True)
return os.path.join(out_dir, out)
def image_source(src):
"""Image inputs: pass URLs / asset:// / data: through as-is; encode local files as base64 data URIs."""
if src.startswith(("http://", "https://", "asset://", "data:")):
return src
if not os.path.exists(src):
sys.exit(f"Image file not found: {src}")
ext = src.lower().rsplit(".", 1)[-1]
mime = {"png": "image/png", "webp": "image/webp"}.get(ext, "image/jpeg")
with open(src, "rb") as f:
return f"data:{mime};base64,{base64.b64encode(f.read()).decode()}"
def api_request(url, api_key, body=None):
"""The gateway labels responses content-encoding: gzip without compressing them — Accept-Encoding: identity is required."""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept-Encoding": "identity",
}
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers,
method="POST" if body is not None else "GET")
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
raise RuntimeError(f"Request failed HTTP {e.code}: {e.read().decode(errors='replace')[:800]}")
def download(url, path):
"""Download the result video: it's a signed URL — do NOT send the Authorization header."""
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=300) as r, open(path, "wb") as f:
shutil.copyfileobj(r, f)
return os.path.getsize(path)
def build_content(args):
content = [{"type": "text", "text": args.prompt}]
if args.image:
content.append({"type": "image_url",
"image_url": {"url": image_source(args.image)},
"role": "first_frame"})
if args.last_frame:
content.append({"type": "image_url",
"image_url": {"url": image_source(args.last_frame)},
"role": "last_frame"})
for src in args.ref_image:
content.append({"type": "image_url",
"image_url": {"url": image_source(src)},
"role": "reference_image"})
return content
def main():
api_key = load_api_key()
if not api_key:
sys.exit("API key not found: add a line APIYI_API_KEY=sk-xxx to .env in the skill folder"
" (the token needs the SeeDance2 group)")
parser = argparse.ArgumentParser(description="Seedance 2.5 / 2.0 video generation")
parser.add_argument("prompt", help="prompt (scene + camera motion + mood)")
parser.add_argument("--model", default="mini", choices=sorted(MODELS),
help="mini=fastest & cheapest (default) / fast / std / "
"sd25=Seedance 2.5 (up to 30 s, 30 reference images, 1080p and mov)")
parser.add_argument("--resolution", default="720p", choices=("480p", "720p", "1080p"),
help="resolution, default 720p")
parser.add_argument("--ratio", default="adaptive", choices=RATIOS,
help="aspect ratio, default adaptive (same price across ratios per tier)")
parser.add_argument("--duration", type=int, default=5,
help="duration in integer seconds (4-30 on sd25, 4-15 on the 2.0 family), "
"or -1 to let the model decide; default 5")
parser.add_argument("--output-format", default=None, choices=("mp4", "mov"),
help="output container, sd25 only; mov has better colour fidelity but limited player support")
parser.add_argument("--no-audio", action="store_true",
help="disable synced audio (on by default)")
parser.add_argument("--seed", type=int, default=None, help="random seed, for reproducibility")
parser.add_argument("-i", "--image", help="first-frame image (local path / URL / asset://); enables image-to-video")
parser.add_argument("--last-frame", help="last-frame image, used together with -i")
parser.add_argument("--ref-image", action="append", default=[],
help=f"reference image (repeatable; up to {MAX_REF_IMAGES} on sd25, 9 on the 2.0 family); "
"mutually exclusive with -i")
parser.add_argument("-o", "--out", default="output.mp4", help="output file name")
args = parser.parse_args()
if args.image and args.ref_image:
sys.exit("First-frame mode (-i) and reference mode (--ref-image) are mutually exclusive.")
if args.last_frame and not args.image:
sys.exit("--last-frame must be used together with -i (first-frame image).")
max_refs = MAX_REF_IMAGES_BY_MODEL[args.model]
if len(args.ref_image) > max_refs:
sys.exit(f"{args.model} accepts at most {max_refs} reference images"
f"{' (use --model sd25 for 30)' if max_refs == 9 else ''}.")
max_dur = MAX_DURATION[args.model]
if args.duration != -1 and not 4 <= args.duration <= max_dur:
sys.exit(f"{args.model} supports durations of 4-{max_dur} whole seconds, or -1 for smart duration"
f"{' (use --model sd25 for 30 s)' if max_dur == 15 else ''}.")
if args.resolution not in MODEL_CAPS[args.model]:
sys.exit(f"{args.model} supports up to {MODEL_CAPS[args.model][-1]}; "
f"use --model sd25 or --model std for 1080p.")
# 2.5 forces ratio=adaptive on first-frame / first+last-frame tasks; fail fast client-side
if args.model == "sd25" and args.image and args.ratio != "adaptive":
sys.exit("Seedance 2.5 requires --ratio adaptive for first-frame / first+last-frame tasks (upstream constraint).")
if args.output_format and args.model != "sd25":
sys.exit("--output-format is supported on Seedance 2.5 (--model sd25) only.")
body = {
"model": MODELS[args.model],
"content": build_content(args),
"resolution": args.resolution,
"ratio": args.ratio,
"duration": args.duration,
}
if args.no_audio:
body["generate_audio"] = False
if args.seed is not None:
body["seed"] = args.seed
if args.output_format:
body["output_format"] = args.output_format
try:
task = api_request(TASKS_URL, api_key, body)
except (RuntimeError, OSError) as e:
sys.exit(f"Submission failed: {e}")
task_id = task.get("id")
if not task_id:
sys.exit(f"Submission failed, response: {json.dumps(task, ensure_ascii=False)[:500]}")
print(f"Task submitted task_id={task_id}; generation usually takes 2-5 minutes, polling...")
t0 = time.time()
time.sleep(POLL_FIRST_DELAY)
while True:
try:
task = api_request(f"{TASKS_URL}/{task_id}", api_key)
except (RuntimeError, OSError) as e: # network hiccups don't stop the poll loop
print(f" poll error (continuing): {e}")
time.sleep(POLL_INTERVAL)
continue
status = task.get("status", "unknown")
elapsed = round(time.time() - t0)
print(f" [{elapsed:>4}s] status={status}")
if status in ("succeeded", "failed", "expired"):
break
if time.time() - t0 > POLL_TIMEOUT:
sys.exit(f"Polling timed out ({POLL_TIMEOUT}s). The task is still server-side; query it later:\n"
f" GET {TASKS_URL}/{task_id}")
time.sleep(POLL_INTERVAL)
if status != "succeeded":
err = task.get("error") or task
sys.exit(f"Generation failed (status={status}): {json.dumps(err, ensure_ascii=False)[:500]}")
video_url = (task.get("content") or {}).get("video_url")
if not video_url:
sys.exit(f"Task succeeded but no video URL returned: {json.dumps(task, ensure_ascii=False)[:500]}")
path = resolve_path(args.out)
size = download(video_url, path)
tokens = (task.get("usage") or {}).get("completion_tokens", "?")
print(f"Video saved to {path} ({size / 1e6:.1f} MB, {round(time.time() - t0)}s elapsed, "
f"billed {tokens} tokens)")
if __name__ == "__main__":
main()