The goal was straightforward: start with one product image, generate three references of the same character, and create a 20-second, 720p vertical ad.
The engineering work happens between the model calls: passing character identity from one image to the next, registering a photorealistic virtual person as a usable asset, recovering asynchronous tasks, and ensuring that multiple asset IDs reach the video endpoint as separate arguments.
This article breaks down a working Python implementation built on AIHubMix. Claude Code helped organize prompts and execute the workflow; the script sent every API request through AIHubMix.
AIHubMix is useful here for more than sharing one Base URL. It covers most mainstream models through one API service, so the image stage can use Seedream, the video stage can switch to Seedance, and later versions can select another model without rebuilding authentication and request entry points for every supplier.
AIHubMix connects to multiple suppliers and automatically selects an available low-latency route, reducing the effect of a single supplier's instability. For asynchronous image and video jobs, one entry point is easier to maintain and fits a multi-stage workflow orchestrated with Claude Code. Actual model availability, routes, and latency still depend on the platform state at request time.
The code, model IDs, and schema details come from a project snapshot dated September 16, 2026. Check the current API before reusing them. No online generation API was called while preparing this publication package.
Download the Code and Run the Shortest Path
You need Python 3, your own AIHubMix API key, access and quota for the required models, and public URLs for the three reference images. The script uses only the Python standard library; no SDK installation is required.
After extracting the package, enter seedance-starter and run:
cp .env.example .env
Edit .env and add your key. Upload assets/frame-01.jpg, assets/frame-02.jpg, and assets/frame-03.jpg to your own public image hosting, then replace these three URLs:
REF_A='https://your-host.example/frame-01.jpg'
REF_B='https://your-host.example/frame-02.jpg'
REF_C='https://your-host.example/frame-03.jpg'
python3 flow.py check "$REF_A" "$REF_B" "$REF_C"
python3 flow.py assets "$REF_A" "$REF_B" "$REF_C"
After the assets become active, read their saved IDs and generate the video:
python3 - <<'PYCODE'
import json
import subprocess
import sys
from pathlib import Path
state = json.loads(Path("state.json").read_text())
refs = ["asset://" + asset_id for asset_id in state["asset_ids"]]
subprocess.run([sys.executable, "flow.py", "video", *refs], check=True)
PYCODE
The completed result is saved to out/video.mp4. This shortest path reuses the included images and covers asset registration plus video generation. To regenerate the images for another product, follow the dependency chain below.
1. Model the Workflow as Explicit State
Product image and prompts
→ three character reference images
→ publicly accessible image URLs
→ active virtual-portrait assets in one group
→ asset:// references
→ video task ID
→ completed
→ local MP4
The script exposes one command for each state transition:
| Command | Responsibility |
|---|---|
stills |
Generate and download the reference images in order |
check URL... |
Verify HTTP 200 and an image Content-Type |
assets URL... |
Create or reuse an asset group, register images, and wait for activation |
video asset://... |
Create a video task, poll it, and download the result |
status [ID] |
Resume polling or download an existing task |
tasks |
List recent tasks when a create response was lost |
Public image hosting is a manual step; the script does not push files to GitHub.
2. Image Generation Is a Dependency Chain
prompts.json defines these reference relationships:
A: prompt-a.txt → frame-01.jpg
B: product image + frame-01.jpg + prompt-b.txt → frame-02.jpg
C: product image + frame-02.jpg + prompt-c.txt → frame-03.jpg
A establishes the person, wardrobe, setting, and lighting. B introduces the product. C introduces its use action. B and C both reference the original product image to constrain packaging.
To switch products, replace assets/product.png, update the runtime prompts, back up and move the three old reference images, then run:
python3 flow.py stills
Existing image files are skipped. If you edit a prompt and rerun without moving its output, nothing may happen. After changing A, decide whether B and C also need regeneration.
character-lock.txt is a writing reference; the script never reads it. Editing that file alone does not change an API request.
The script skips existing files. If you change a prompt, back up and move the corresponding output before rerunning. Changing A may also require regenerating B and C.
The three images establish character identity, product presentation, and the use action.
3. One API Does Not Mean One Request Schema
The project uses these full model IDs:
IMAGE_MODEL = doubao-seedream-5-0-pro-260628
VIDEO_MODEL = doubao-seedance-2-5-260628
In the bundled schema snapshot, image generation uses size, while video generation uses aspect_ratio. They can coexist in the local configuration, but the script must build two different request bodies.
A unified API solves model access and switching; it does not make model-specific parameters interchangeable. This project keeps shared authentication and task handling in flow.py, then assembles Seedream and Seedance requests separately.
Here is the video request structure. Replace ACTIVE_ASSET_ID with an asset ID registered and activated in your account:
{
"model": "doubao-seedance-2-5-260628",
"prompt": "the complete contents of prompts/prompt-video.txt",
"duration": 20,
"resolution": "720p",
"aspect_ratio": "9:16",
"generate_audio": true,
"input_references": [
{"type": "image_url", "url": "asset://ACTIVE_ASSET_ID"}
]
}
The request is sent to POST /ai/v1/videos. The real script creates one input_references item per asset.
preflight() checks the cached schema for unknown top-level fields and catches accidental parameter mixing locally. It is not a complete JSON Schema validator and does not validate every type, enum, or range.
4. Register a Photorealistic Virtual Person Before Referencing It
Passing the photorealistic character references directly as image URLs produced this error in the project:
doubao_real_person_required
Because the character in this example was AI-generated, the correct classification was virtual_portrait. The script then performs these steps:
-
POST /ai/v1/asset-groupscreates a virtual-portrait group. -
POST /ai/v1/asset-groups/{group_id}/assetsregisters each public image URL. -
GET /ai/v1/assets/{asset_id}polls until the asset becomesactive. - The video request uses an
asset://reference.
All references in one video request should belong to the same group. A photograph of a real person must follow the identity-verification path and must not be classified as a virtual person.
The URL must return the actual image. A GitHub /blob/ page can return HTTP 200 while its Content-Type is HTML, so the script checks both status and media type.
The references were generated and all three assets were active; the video task was still running when this screenshot was captured.
5. Do Not Pass Three Asset IDs as One String
The source project encountered asset_invalid in zsh because multiple references stored in a plain string reached the program as one argument.
The quick-start command calls the script with a Python argument list:
subprocess.run([sys.executable, "flow.py", "video", *refs], check=True)
Each reference becomes a separate argument without relying on shell word splitting.
6. Recover an Async Task Instead of Creating Another One
Video generation is asynchronous. An HTTP 200 from the create endpoint does not mean that the video is complete.
The script saves the returned video_id to state.json, polls the task's status, and downloads only after it reaches completed. Resume after a local timeout with:
python3 flow.py status
If the create response was lost, list recent tasks first:
python3 flow.py tasks
python3 flow.py status VIDEO_TASK_ID
video() never automatically retries the create request, because the server may already have created the task. Read-only status requests can be retried; creation should be handled differently.
7. Give the Prompt Testable Constraints
Once the API works, the output still needs to satisfy the ad brief.
The final video prompt divides one continuous take into four actions: present, spray, react, and present again. The camera may push in only to a mid-close shot. The product must stay visible, and the label should remain legible during the first and last three seconds.
The full text is in prompts/prompt-video.txt. Resolution and duration belong in configuration; character action and camera limits belong in the prompt. Both must be verified in the generated result.
Reusing the Structure
The starter preserves the source implementation. Its documentation calls out a stale code comment, existing-output skips, output-file replacement, and asset-group reuse.
Offline checks can verify local configuration, dependencies, and request assembly. They cannot prove account permissions, current endpoint availability, routing behavior, or output quality. Run the shortest path with the included references before replacing the product and character so that failures are easier to isolate.
To switch image or video models later, select the target model in AIHubMix, read its current schema, and update the model ID, parameters, and result handling. A unified entry point lowers the switching cost, but it does not remove differences in model capability or request fields.
See the starter package for the complete workflow. README.md contains the runbook, while docs/PROMPTS.md and docs/TROUBLESHOOTING.md cover prompt adaptation and failure modes.


Top comments (0)