Skip to main content
This page focuses on the code path: turn a local image into an asset:// asset ID, then reference it to generate a character-consistent video — one script, end to end. For endpoint-by-endpoint documentation and the zero-code web UI see the Asset Library; for the full parameter table of the generation endpoint see the Video Generation API.

When you need asset references

When generating character-consistent videos, Seedance 2.0 does not accept reference images containing photorealistic human faces directly (anti-deepfake filtering). You must first ingest the portrait as a trusted asset, get an asset://xxx asset ID, and reference it in the generation request — keeping the character’s face and clothing consistent across episodes and shots. Find your case first:
Material typeExamplesHow to use
Anime / stylized charactersAnime, cartoon, 3D-cartoon charactersNo photorealistic face, generally not blocked: use a public URL / Base64 reference image directly — no ingest needed; start from step 4 (generate) of this page
Virtual avatar (AI-generated photorealistic portrait)Model-generated, no such person existsUse virtual-avatar ingest (the full flow on this page) and reference the asset:// ID
Real facePhotos of celebrities, models, or the user themselvesFirst run the fully automated real-person verification API flow to get a real-person asset group, pass its groupId when ingesting — the rest of the code is identical to this page

Prerequisites

Two different keys — do not mix them up:
  • Asset Library KEY (created under Settings → Asset Library KEY on icover.ai, sk-...): for uploading / ingesting / querying assets; see Step 0 of the Asset Library page.
  • APIYI SeeDance2 token (created on api.apiyi.com, sk-..., with the SeeDance2 group enabled): for the video-generation endpoint.

The pipeline at a glance

Local image → 1) presign a direct-upload URL and PUT the file to get a public URL → 2) ingest the asset and get an asset Id → 3) poll until Active (about 13 seconds per image) → 4) reference asset://<Id> in the generation request, referring to it as “Image 1” in the prompt → 5) poll the task until succeeded → 6) download content.video_url (the signed link expires in 24 hours — save it immediately). If you already have a public image URL, skip step 1. Asset IDs are permanent — ingest once, reference forever.

Complete code

import time
from pathlib import Path

import requests

ASSET_KEY = "sk-your-asset-library-KEY"  # icover.ai Settings → Asset Library KEY
APIYI_KEY = "sk-your-APIYI-token"        # api.apiyi.com, SeeDance2 group enabled
IMAGE_PATH = "portrait.jpg"              # local reference image (virtual avatar)
PROMPT = "The character in Image 1 smiles at the camera, slow push-in, natural light"

ICOVER = "https://icover.ai/api"
SEEDANCE = "https://api.apiyi.com/seedance/api/v3/contents/generations/tasks"
ASSET_HEADERS = {"Authorization": f"Bearer {ASSET_KEY}"}
APIYI_HEADERS = {
    "Authorization": f"Bearer {APIYI_KEY}",
    "Content-Type": "application/json",
    "Accept-Encoding": "identity",  # required: works around a gateway gzip header mismatch
}


def upload_image(path: str) -> str:
    """1) presign + PUT the local image; returns a public URL (skip if you have one)"""
    ext = Path(path).suffix.lstrip(".").lower() or "jpg"
    content_type = f"image/{'jpeg' if ext in ('jpg', 'jpeg') else ext}"
    data = requests.post(
        f"{ICOVER}/storage/presign", headers=ASSET_HEADERS,
        json={"ext": ext, "contentType": content_type}, timeout=30,
    ).json()["data"]
    resp = requests.put(
        data["uploadUrl"], data=Path(path).read_bytes(),
        headers={"Content-Type": content_type}, timeout=120,  # must match the presign
    )
    resp.raise_for_status()
    return data["publicUrl"]


def ingest_asset(image_url: str, label: str = "", group_id: str = None) -> str:
    """2) ingest the asset; returns the asset Id. Real-person assets: pass the verified group_id"""
    body = {"imageUrl": image_url, "label": label}
    if group_id:
        body["groupId"] = group_id
    r = requests.post(
        f"{ICOVER}/asset-library/assets", headers=ASSET_HEADERS,
        json=body, timeout=60,
    ).json()
    return r["Result"]["Id"]


def wait_asset_active(asset_id: str, timeout: int = 90) -> None:
    """3) poll the ingest status until Active (about 13 seconds per image, no SLA)"""
    deadline = time.time() + timeout
    while time.time() < deadline:
        status = requests.get(
            f"{ICOVER}/asset-library/assets/{asset_id}",
            headers=ASSET_HEADERS, timeout=30,
        ).json()["Result"]["Status"]
        print("asset status:", status)
        if status == "Active":
            return
        if status == "Failed":
            raise RuntimeError("Ingest failed: check format / aspect ratio 0.4-2.5 / side 300-6000px, then re-upload")
        time.sleep(3)
    raise TimeoutError("Not Active after 90 seconds — investigate and retry")


def create_video_task(asset_id: str) -> str:
    """4) create the generation task referencing asset://. Say "Image 1" in the prompt, never the raw asset ID"""
    body = {
        "model": "doubao-seedance-2-0-260128",
        "content": [
            {"type": "text", "text": PROMPT},
            {"type": "image_url",
             "image_url": {"url": f"asset://{asset_id}"},
             "role": "reference_image"},
        ],
        "resolution": "720p", "ratio": "16:9", "duration": 5,
    }
    return requests.post(SEEDANCE, headers=APIYI_HEADERS, json=body, timeout=60).json()["id"]


def wait_video(task_id: str) -> dict:
    """5) poll the task until a terminal state (succeeded / failed / expired)"""
    while True:
        time.sleep(20)
        task = requests.get(f"{SEEDANCE}/{task_id}", headers=APIYI_HEADERS, timeout=30).json()
        print("task status:", task.get("status"))
        if task.get("status") in ("succeeded", "failed", "expired"):
            return task


def download(task: dict, out: str) -> None:
    """6) download the video. The signed link expires in 24 hours; no Authorization header here"""
    with requests.get(task["content"]["video_url"], stream=True, timeout=300) as r:
        r.raise_for_status()
        with open(out, "wb") as f:
            for chunk in r.iter_content(chunk_size=1 << 20):
                f.write(chunk)
    print("saved", out)


if __name__ == "__main__":
    public_url = upload_image(IMAGE_PATH)
    asset_id = ingest_asset(public_url, label="character-A-front")
    print("asset ID:", f"asset://{asset_id}")
    wait_asset_active(asset_id)

    task_id = create_video_task(asset_id)
    print("task_id:", task_id)
    task = wait_video(task_id)
    if task["status"] == "succeeded":
        print("billed tokens:", task["usage"]["completion_tokens"])
        download(task, f"{task_id}.mp4")
    else:
        print("task did not succeed:", task.get("error"))
Refer to assets as “Image 1”, “Image 2” in the prompt (matching their order in the content array) — never write the raw asset ID in the prompt.

Advanced usage with multiple assets

  • Multiple reference images: put several image_url items in content (0–9 images, each with role: "reference_image") and refer to them as “Image 1”, “Image 2” in order. For one character, referencing a full-body frontal shot plus a neutral frontal face close-up together gives the best consistency.
  • Mixing sources: asset:// IDs, public URLs, and Base64 (data:image/png;base64,...) can be mixed in the same content array — only images containing photorealistic faces must go through asset://.
  • Reference video / audio: you can also add 0–3 video_url items (role: "reference_video") and 0–3 audio_url items (role: "reference_audio"); at least one image or one video is required, and audio must be sent together with an image or video.
  • Real-person assets: first run the fully automated real-person verification flow — the actor completes liveness verification and you get a dedicated real-person asset group GroupId; pass it as groupId when ingesting. The generation-side code is identical to this page.

Common errors

Error / symptomCauseFix
400 The specified asset ... is not foundThe asset:// ID does not belong to the APIYI channel’s account (e.g. ingested with another provider), or the ID is wrongRe-ingest on icover.ai following this page; double-check the asset ID
”No available channel for this model”The APIYI token does not have the SeeDance2 group enabledEnable it in the token settings on api.apiyi.com and retry
Python gzip decoding errorThe gateway’s gzip response header does not match the actual encodingAdd the "Accept-Encoding": "identity" request header (already in the script)
Asset status FailedImage format / dimensions out of specAspect ratio 0.4–2.5, side length 300–6000px, under 30MB — fix and re-upload
Direct face image rejectedPhotorealistic faces cannot be used as direct reference images (anti-deepfake filtering)Ingest first and reference the asset:// ID; real-person photos require real-person verification first
Errors prefixed with PUBLIC_Blocked by upstream content moderation (that attempt is not billed)Adjust the material / prompt and simply retry

Seedance 2.0 Overview

Model selection, pricing, resolution tables, and FAQ

Video Generation API

Full parameter table, the four generation modes, and response format

Asset Library

All asset-library endpoints, the zero-code web UI, and real-person verification