On this page

Chapter 3 of 9. Prerequisite: Chapter 2. You can submit one task, poll it and download the MP4. This chapter is what happens when the content array stops being a single text item: mixed reference media with typed roles, @Image1 citation, and the batch loop that submits a whole beat list at once.

The create-task reference documents the fields. What it undersells is the role model: which items in a mixed payload need a "role" key, which values the model accepts, which combinations the API rejects, and what any of that costs you when you get it wrong. I learned most of it by 400 error, one rejection at a time. Some rejections became guards in a small Python wrapper; most became rules in the workflow around it, and two of them left fossils in code written before the API taught me better. This chapter builds that wrapper, honestly: the payload shapes exactly as the API accepts them, the five errors verbatim, where each lesson actually landed, and the parallel submission pattern that turns an async API into a whole film rendering at once.

The reference_image and reference_video payload: one content array, four roles

Everything you send Seedance goes through one field. The create-task endpoint takes POST /contents/generations/tasks against the ap-southeast-1 base URL (https://ark.ap-southeast.bytepluses.com/api/v3) with a model id and a content array. Text is one item type. Every piece of reference media is another item in the same array, and on Seedance 2.0 each one carries a role.

An image item is this, exactly:

{ "type": "image_url", "image_url": { "url": "https://... or data:image/png;base64,..." }, "role": "reference_image" }

A video item is this, exactly:

{ "type": "video_url", "video_url": { "url": "https://..." }, "role": "reference_video" }

Four roles cover the whole surface: first_frame, last_frame, reference_image, reference_video (audio items take reference_audio). The first two are the image-to-video mode you met in Chapter 2: pin the opening or closing frame. The reference roles are the Seedance 2.0 multimodal mode, documented in the Dreamina Seedance 2.0 series tutorial: the model states support for "multimodal input such as images, videos, audios and texts", and the launch announcement confirms the four input modalities are architectural, built on a unified joint-generation model rather than bolted on. A reference_image says "this person, this place, this prop exists in the output". A reference_video says "move like this".

Here is a full mixed payload, the shape this course uses for every fight beat from Chapter 5 onwards. Two character references, one scene plate, one motion clip:

{
  "model": "dreamina-seedance-2-0-260128",
  "content": [
    { "type": "text",
      "text": "@Image1 and @Image2 spar in the arena of @Image3. Motion follows @Video1. Wide two-shot, low golden light." },
    { "type": "image_url", "image_url": { "url": "asset://<your-asset-id>" }, "role": "reference_image" },
    { "type": "image_url", "image_url": { "url": "asset://<your-asset-id>" }, "role": "reference_image" },
    { "type": "image_url", "image_url": { "url": "data:image/png;base64,..." }, "role": "reference_image" },
    { "type": "video_url", "video_url": { "url": "https://<your-host>/beat-03.mp4" }, "role": "reference_video" }
  ],
  "resolution": "720p",
  "ratio": "16:9",
  "duration": 8,
  "watermark": false,
  "generate_audio": true
}

The budget for that array on Seedance 2.0 is text plus up to 9 images, up to 3 videos and up to 3 audios, per the 2.0 tutorial. Text-only-audio and audio-only payloads are rejected.

The role requirement is version-dependent, and this is the first thing the API taught me the hard way. My first Seedance 2.0 call sent an image item without a "role" key, because the 1.0-pro payloads from Chapter 2 never needed one. HTTP 400:

role must be specified for image contents

Seedance 2.0 requires a role on every image. Seedance 1.0 Pro takes a first-frame image with no role at all. The wrapper encodes that in two lines:

is_v2 = "seedance-2" in args.model
ref_role = "reference_image" if is_v2 else None

Every image the user passes as a plain reference gets ref_role; explicit --first-frame and --last-frame flags get their named roles regardless of model. That one conditional has absorbed every role-related 400 since.

From flags to content array: assembly order is citation order

The wrapper is a single-file Python CLI called byteplus_seedance.py. Its flags, exactly:

FlagDefaultMeaning
--modelseedance-1-0-pro-250528model id from the model list
--imagerepeatablereference image (role reference_image on 2.0)
--videorepeatable, max 3reference video (role reference_video, 2.0 only)
--first-frameimage pinned as the opening frame (role first_frame)
--last-frameimage pinned as the closing frame (role last_frame)
--ratio16:9aspect ratio
--resolution720p480p 720p 1080p 4k
--duration5seconds, integer
--seedreproducibility seed
--audiooffgenerate_audio: true (2.0 and 1.5-pro only)
--no-watermarkwatermark defaults to true; production renders need this flag
--camera-fixedofflock the camera (see the 400 below)
--outdownload directory

Two of those defaults are traps. watermark defaults to true, so every render you forget the flag on carries a burned-in "AI generated" tag. And --duration 5 against a shorter motion reference makes the model improvise a tail, which is a Chapter 7 problem with a Chapter 3 flag.

URL handling: sources beginning http://, https://, data: or asset:// pass through untouched. Local file paths get inlined as base64 data URIs with a guessed MIME type, videos included. A local video over 4 MB triggers this warning, verbatim from the wrapper:

warn: inlining a N.N MB video as base64 — may exceed the request limit; host it or upload as an asset if this 400s

That warning turned out to be optimistic. The API rejects base64 video outright, whatever the size; the next section has the error. And here is the honest part: the fix never got ported back into the code. The wrapper still inlines a local --video as base64 today, on every model, and will still eat that 400 if you hand it a local path. The hosting rule lives in the workflow instead, and the warning's wording, which imagines a size limit rather than a flat rejection, is a relic of the hour before I knew better.

Assembly order matters more than it looks. The wrapper builds the content array as: text first, then --first-frame, then each --image in flag order, then --last-frame, then each --video in flag order. That ordering is load-bearing because of how citation works. The prompt text can reference specific inputs by index, @Image1, @Video1, and the 2.0 tutorial's convention resolves Image n as the nth item of that type in the content array, counted from 1 in array order. Not by asset id; referencing by id is unsupported. So in the wrapper, citation order is flag order. First --image is @Image1. Swap two flags and your attacker becomes your defender.

Five 400s, verbatim, and where each lesson landed

This is the section the docs will not give you. Each error below is quoted exactly as the API returned it, with the constraint it encodes and where the lesson actually landed: sometimes a guard in the wrapper, more often a rule in the workflow around it. I am being precise about which is which, because the difference is the most instructive thing in this chapter.

One: the role requirement. Already covered above.

role must be specified for image contents

Where it landed: in the code. The is_v2 conditional gives every image item a role on any model whose id contains seedance-2. This is the only one of the five that became a real in-wrapper guard.

Two: reference video must be hosted.

InvalidParameter: reference_video must be provided as a web url

Images accept base64 data URIs. Videos do not, in any size. A reference_video must be a URL that BytePlus's servers can fetch over the public internet, which means every local motion clip needs hosting before submission. Where it landed: in the workflow, not the wrapper. This constraint forced a hosting step that the parallel section below leans on: a hardened local static server plus a quick tunnel giving an unguessable public HTTPS URL, stood up per render batch and torn down after. The wrapper itself never learned the rule; it still inlines local video with only the size warning quoted above, so the discipline of hosting first is enforced by the operator, not the code.

Three: the pixel floor.

video pixel count must be >= 409600

A reference video's frame must contain at least 409,600 pixels. My 640x360 clip is 230,400 pixels: rejected. The same clip re-encoded to 960x540 is 518,400 pixels: accepted. Where it landed: in the workflow again. The wrapper is stdlib-only Python with no ffprobe and no dimension check anywhere, so an undersized clip sails straight into the request and 400s server-side. The rule I actually enforce, by hand, before hosting anything: encode 16:9 references at 854x480 or better.

Four: camera lock is incompatible with reference mode.

HTTP 400 "camera_fixed is not supported for dreamina-seedance-2-0 in r2v, must be empty"

camera_fixed is a documented field on the create-task body, and it works in plain text-to-video. The moment any reference media is present (a character asset, a scene plate, a motion clip), the API is in r2v mode and rejects the field. For a reference-driven pipeline that is always. Where it landed: in my head and in the playbook. The wrapper sets camera_fixed: true whenever the flag is passed, reference media or not, so passing --camera-fixed on a reference render still produces this exact 400 today. The operating rule is simply never to pass it on a reference render; camera consistency becomes a post-production problem, which Chapter 8 solves with crops and reframes.

Five: frames and references are mutually exclusive.

first/last frame content cannot be mixed with reference media content

This one killed an architecture. The plan was frame-chaining: render shot one, take its last frame, feed it as first_frame of shot two alongside the character assets and motion reference, and walk the film forward with perfect continuity. The API treats first/last-frame image-to-video and reference media as mutually exclusive modes, so that plan is structurally impossible. The pivot: either one continuous render (up to 15 seconds on 2.0) or independent shots joined by hard cuts, with identity held by the character assets rather than by pixel continuity. Where it landed: nowhere in the code, and this is the second fossil. The wrapper's own --first-frame help text still reads, verbatim:

explicit first frame (role first_frame) — for continuity chaining: the previous shot's last frame. Composes with --image refs + --video.

The assembly branch behind that flag still dutifully appends a first frame, the reference images and the videos into one content array. The code does not just lack a guard; it actively advertises the combination the API proved impossible, because it was written for the architecture the 400 later killed.

What the wrapper does guard, it guards before the network. Three pre-flight checks of its own, verbatim:

--video (reference_video) is a Seedance 2.0 feature; use --model dreamina-seedance-2-0-260128
Seedance 2.0 accepts at most 3 reference videos
warn: --audio is ignored by Seedance 1.0-pro (use 1.5-pro or 2.0)

Parallel submission: eight beats, three slots, one tunnel

Here is the payoff for the API being async. Chapter 2's submit call returns a task id in under a second; the render happens server-side over the following minutes. Nothing forces you to wait for one task before submitting the next. So a multi-shot film does not render shot by shot. It renders all at once:

  1. Chop the source into beats

    Cut the motion reference into per-beat files with ffmpeg -ss/-to, one clip per shot, each at or above the 409,600-pixel floor. A reference_video is a whole-file reference with no timestamp parameter, so the cutting has to happen before hosting, not in the prompt.

  2. Host every beat behind one tunnel

    Start one local static server over the beats directory and one quick tunnel in front of it. Every beat file is now https://<your-host>/beat-N.mp4. One tunnel serves the whole batch.

  3. Submit every render, then poll

    Loop over the beats, submitting each with its own prompt and its own reference URL. Collect task ids. Only then start polling, all ids in one loop, every 10 seconds, per the retrieve-task endpoint.

  4. Download as they land

    Each task that reaches succeeded exposes content.video_url, a signed MP4 URL. Download to <out>/<task_id>.mp4 and keep polling the rest.

The submission loop, in the shape I actually run it:

for i in {1..8}; do
  python3 byteplus_seedance.py "$(cat prompts/beat-$i.txt)" \
    --model dreamina-seedance-2-0-260128 \
    --image "asset://<your-asset-id>" \
    --image "asset://<your-asset-id>" \
    --image plates/arena.png \
    --video "https://<your-host>/beats/beat-$i.mp4" \
    --ratio 16:9 --resolution 480p --duration 5 \
    --no-watermark --out renders/ &
done
wait

Two constraints bound the loop. First, concurrency: an individual account gets 3 concurrent non-4K generations (1 for 4K) and 180 requests per minute. Submitting eight tasks against three slots does not fail; the excess tasks sit in the queued state that the task-state list documents, and start as slots free up. Push past the request-rate limits and you get a 429 instead, with retry behaviour covered in BytePlus's burst-traffic best practices; at eight submissions per batch you will never see it. Second, the tunnel: BytePlus fetches each reference_video by URL at its own pace, so the tunnel must stay up until the last task leaves queued and completes. Tear it down when the final MP4 is on disk, never when the last submission returns.

The receipts, from the practice film this course is built on. The first full movie was 5 beats of 7 seconds each, all submitted in parallel over one tunnel, assembled with FFmpeg afterwards: roughly $2.50 total for 35 seconds of footage. A later 11-beat batch cost about $5.50, and was not clean: one beat failed twice and got skipped rather than retried a third time, which is the correct call when a beat resists. The best batch in the whole arc was 8 directed-prompt beats, all submitted in parallel, all succeeded first try, between $3 and $4 total. That last number is the shape of the economics: a whole multi-shot film, rendered simultaneously, for less than a takeaway.

What the wrapper and its playbook know that the docs do not

Stand back and the wrapper plus the playbook around it form a small database of constraints, each row paid for: a role rule that cost one 400 and became two lines of code, a hosting rule that cost one 400 and became a tunnel workflow, a pixel floor that cost a re-encode and became an encoding rule, a camera flag the pipeline can never use, and a mode exclusivity that killed an architecture and left its optimistic help text behind. The official payload reference and the video generation tutorial define the space of valid requests; the five errors in this chapter trace the boundary where invalid ones actually fail, in the server's own words. Where each lesson got written down, in code, in a playbook, or in a help string that still promises too much, is its own small lesson in how fast an API's constraints outrun the tools that first hit them.

The next chapter moves from payload mechanics to the thing the payloads reference: registering characters as portrait assets, so asset://<your-asset-id> stops being a placeholder and starts being a face that survives every render.

// EXERCISE

Port the role model and the parallel loop into your own wrapper

Build or extend a CLI wrapper in your language of choice that assembles a mixed Seedance content array with typed roles, adds the local guards this chapter's wrapper never got (refusing the known 400s before any network call), and submits a multi-beat batch in parallel against one hosted source directory.

Expected behaviour
  • A single command that assembles text, image and video items into one content array, applying role keys per model version so 2.0 images always carry one and 1.0-pro first-frame images never do
  • A local guard that rejects a reference video below 409,600 pixels before any HTTP request, printing the API's own error text
  • A local guard that refuses base64 for video sources on 2.0 and points at the hosting step, mirroring the web-url rejection
  • A batch mode that submits every beat before polling any of them, and prints each accepted task id as it returns
  • A per-task cost estimate printed at submission and summed for the batch

PROVE IT Run a two-beat batch at 480p: show both task ids accepted before the first poll, one task in the queued state while the other runs, and the pixel-floor guard firing offline on a 640x360 clip without spending anything.

// CHECKPOINT — ROLES AND PARALLELISM
multiple choice · auto-checked

Your wrapper inlines every local file as a base64 data URI before submission. Which item gets rejected by the API?

exact answer · auto-checked

What is the minimum pixel count Seedance accepts in a reference video frame?

open · self-checked

You submit eight renders in a loop on an account limited to three concurrent tasks. Why does the batch work, and what must stay true until the last task finishes?

Show answer

Submission is async: each call returns a task id immediately, and the API holds tasks beyond the concurrency limit in the queued state, starting them as slots free up rather than rejecting them. The hosted tunnel serving the beat files must stay reachable until every task completes, because BytePlus fetches each reference_video by URL at its own pace during the render, so tearing the tunnel down after the last submission rather than the last download starves the queued tasks.

↺ re-read: “Parallel submission: eight beats, three slots, one tunnel

Sources

  • Create a video generation task
    BytePlus (ModelArk API reference)
    The create-task endpoint and the content-array request body every payload in this chapter targets
    docs.byteplus.com
  • Dreamina Seedance 2.0 series tutorial
    BytePlus (ModelArk documentation)
    The official statement of Seedance 2.0 multimodal inputs, which is where the reference_image and reference_video roles live
    docs.byteplus.com
  • Video generation tutorial
    BytePlus (ModelArk documentation)
    Request schemas and code samples for the video generation surface the wrapper drives
    docs.byteplus.com
  • Retrieve a video generation task
    BytePlus (ModelArk API reference)
    The task-state list, including the queued state the parallel pattern depends on
    docs.byteplus.com
  • Best practices for handling burst traffic
    BytePlus (ModelArk documentation)
    The rate-limit and 429 behaviour that bounds how hard the submission loop can push
    docs.byteplus.com
Back to guide overview