24 KiB
ArtCraft Omni API
📚 Full API reference: https://storyteller-docs.netlify.app/
Every ArtCraft API endpoint is documented there. This guide covers only the Omni API video endpoints — for everything else, start at the link above.
The Omni API is the API-key–authenticated surface for ArtCraft generation. It mirrors the
in-app "Omni Gen" endpoints but is designed for programmatic use: you authenticate with an API key
in the Authorization header instead of a browser session cookie.
This guide covers video generation from URL inputs — passing image, video, and audio reference URLs directly. The server downloads each URL, stores it as a media file on your account, and then runs generation against it. You never have to upload files yourself.
All examples send no cookies. The Omni API is authenticated purely by your API key. If you send session cookies they are ignored.
Base URLs
| Environment | Base URL |
|---|---|
| Development | http://localhost:12345 |
| Production | https://api.storyteller.ai |
All paths below are relative to the base URL (e.g. POST /v1/omni_api/generate/video).
The examples in this guide use the development base URL. To run against production, swap
http://localhost:12345 for https://api.storyteller.ai.
Note: Our APIs are hosted on "api.storyteller.ai", not "api.getartcraft.com". This is not a
typo. Our Cloudflare CDN URLs are cdn-2.fakeyou.com.
Getting an API key
Create and manage API keys from the ArtCraft web app:
- Go to https://app.getartcraft.com/
- Click Account → Settings → API Keys (your account must have API keys enabled from our staff)
- Create a key and copy it. The secret is shown once — store it somewhere safe.
Don't see the API Keys section? API access is gated per account. Contact the Artcraft team to enable it.
What an API key looks like
- Prefix:
artcraft_api_ - Followed by 40 random lowercase Crockford-base32 characters (
0-9 a-z, excludingi l o u) - Total length: 53 characters, e.g.
artcraft_api_3k7q9w0xv2hs5n8m4d6r1t8y0p2s4f6h8j0k2m4n6
Treat the key like a password: never commit it to source control or paste it into logs.
Authentication
Send the secret key in the Authorization header. Three forms are accepted:
Authorization: Bearer artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: Key artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Bearer is recommended. Do not send cookies.
Video generation — POST /v1/omni_api/generate/video
Generates a video and returns an inference job token you can poll for results.
Request body
| Field | Type | Notes |
|---|---|---|
model |
string | Generation model, e.g. seedance_2p0. Required. |
prompt |
string | Text prompt. |
duration_seconds |
integer | Length of the output video, e.g. 6. |
aspect_ratio |
string | e.g. wide_sixteen_by_nine, tall_nine_by_sixteen, square. |
idempotency_token |
string (UUID) | Required. A fresh UUID per request; prevents accidental duplicates. |
negative_prompt |
string | Optional. |
resolution |
string | Optional, e.g. seven_twenty_p, ten_eighty_p. |
generate_audio |
boolean | Optional. |
| URL inputs (this guide) | ||
start_frame_image_url |
string (URL) | Starting keyframe image. |
end_frame_image_url |
string (URL) | Ending keyframe image. |
reference_image_urls |
array of URL | Reference images. |
reference_video_urls |
array of URL | Reference videos (mp4). |
reference_audio_urls |
array of URL | Reference audio (wav, mp3, ogg, …). |
| Media-token inputs | ||
start_frame_image_media_token, end_frame_image_media_token, reference_image_media_tokens, reference_video_media_tokens, reference_audio_media_tokens |
token(s) | Use these instead if you've already uploaded media. |
Rules for URL inputs
- Each URL field and its media-token counterpart are mutually exclusive. Sending both
reference_image_urlsandreference_image_media_tokens, for example, returns:Either reference_image_media_tokens or reference_image_urls must be set, not both. - Every URL must start with
http://orhttps://, otherwise:URL must start with http:// or https://, bad URL: <url>. - Redirects are followed (up to 10), so CDN/redirecting URLs work.
- The file type is detected from the downloaded bytes. Images must be a real image
(
jpeg/png/gif/webp), reference videos must bemp4, reference audio must be a supported audio type (wav,mp3,aac,ogg,flac, …).
Response
{
"success": true,
"inference_job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
"all_job_tokens": ["jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"]
}
Use inference_job_token to poll for the finished video. all_job_tokens lists every job created
(batch requests can create more than one).
Examples
The examples reuse these public sample URLs:
- Images (Wikimedia Commons): an Atlantic puffin, the Matterhorn, a toco toucan
- Video: a short sample
mp4 - Audio: a short sample
wav
Replace YOUR_API_KEY with your artcraft_api_… secret, and generate a fresh idempotency_token
(UUID) for every request.
Example 1 — Starting-frame Seedance (start_frame_image_url)
Animate outward from a single starting image.
bash
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "'"$(uuidgen)"'",
"start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900"
}'
python
import uuid
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
resp = requests.post(
f"{BASE_URL}/v1/omni_api/generate/video",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": str(uuid.uuid4()),
"start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
},
timeout=240,
)
resp.raise_for_status()
print(resp.json())
javascript
import { randomUUID } from "node:crypto";
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance_2p0",
prompt:
"An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
duration_seconds: 6,
aspect_ratio: "wide_sixteen_by_nine",
idempotency_token: randomUUID(),
start_frame_image_url:
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
}),
});
console.log(resp.status, await resp.json());
Example 2 — Image-reference Seedance (reference_image_urls)
Guide the generation with one or more reference images.
bash
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "'"$(uuidgen)"'",
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900"
]
}'
python
import uuid
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
resp = requests.post(
f"{BASE_URL}/v1/omni_api/generate/video",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "seedance_2p0",
"prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": str(uuid.uuid4()),
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
},
timeout=240,
)
resp.raise_for_status()
print(resp.json())
javascript
import { randomUUID } from "node:crypto";
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance_2p0",
prompt:
"A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
duration_seconds: 6,
aspect_ratio: "wide_sixteen_by_nine",
idempotency_token: randomUUID(),
reference_image_urls: [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
}),
});
console.log(resp.status, await resp.json());
Example 3 — Image + video reference Seedance (reference_image_urls + reference_video_urls)
Combine reference images with a reference video (and, optionally, reference audio). This is the fullest URL-driven request.
bash
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "'"$(uuidgen)"'",
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900"
],
"reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"],
"reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"]
}'
python
import uuid
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
resp = requests.post(
f"{BASE_URL}/v1/omni_api/generate/video",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": str(uuid.uuid4()),
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
"reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"],
"reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"],
},
timeout=240,
)
resp.raise_for_status()
print(resp.json())
javascript
import { randomUUID } from "node:crypto";
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance_2p0",
prompt:
"An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
duration_seconds: 6,
aspect_ratio: "wide_sixteen_by_nine",
idempotency_token: randomUUID(),
reference_image_urls: [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
reference_video_urls: ["https://download.samplelib.com/mp4/sample-5s.mp4"],
reference_audio_urls: ["https://download.samplelib.com/wav/sample-6s.wav"],
}),
});
console.log(resp.status, await resp.json());
Checking job status
Generation is asynchronous. The generate call returns an inference_job_token; poll the job-status
endpoint until the job reaches a terminal state, then read the result URL.
Endpoints
- Single job:
GET /v1/omni_api/job_status/job/{token} - Batch (many jobs):
GET /v1/omni_api/job_status/batch?tokens=jinf_aaa&tokens=jinf_bbb
Both require the same Authorization: Bearer … header (no cookies).
Response shape
{
"success": true,
"state": {
"job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_xxxxxxxxxxxxxxxxxxxxxxxxx",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "pending",
"maybe_first_started_at": null,
"maybe_failure_category": null,
"progress_percentage": 0
},
"maybe_result": null,
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:06:14Z"
}
}
state.status.status— the job state. Keep polling while it'spending,started, orattempt_failed. Terminal states arecomplete_success,complete_failure,dead,cancelled_by_user, andcancelled_by_system.state.status.progress_percentage— an integer 0–100.state.maybe_result—nulluntil the job succeeds. Oncomplete_successit contains the finished media; the playable/downloadable URL isstate.maybe_result.media_links.cdn_url. For video,media_links.maybe_video_previewsalso carries still/animated preview URLs.
The batch endpoint returns { "success": true, "job_states": [ … ] } where each entry is the same
state payload as above.
Where the result video / image lives
When state.status.status is complete_success, the finished media is under state.maybe_result:
state.maybe_result.media_links.cdn_url— the primary, playable/downloadable URL of the finished asset. This is the field you want. For a video job it's the.mp4; for an image job it's the image file (.jpg/.png/ etc.).state.maybe_result.entity_token— them_…media-file token for the result.state.maybe_result.media_links.maybe_video_previews— video jobs only. Contains astill(poster.jpg) and ananimated(.gif) preview URL, plus{WIDTH}thumbnail templates. This isnullfor image jobs.state.maybe_result.media_links.maybe_thumbnail_template— image jobs only. A URL template; replace{WIDTH}with a pixel width to fetch a resized thumbnail.nullfor video jobs.
Example — completed video job:
{
"success": true,
"state": {
"job_token": "jinf_9fcn2qv4dv3vvbrrxbnmfgen06v",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_q53tp4rh97qh8q159p21tazzt",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "complete_success",
"maybe_first_started_at": "2026-06-24T04:07:11Z",
"maybe_failure_category": null,
"progress_percentage": 100
},
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_ss682134tx19yhqs5y0nbnd0bd59a6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4",
"maybe_thumbnail_template": null,
"maybe_video_previews": {
"still": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.jpg",
"animated": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.gif",
"still_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.jpg",
"animated_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.gif"
}
},
"maybe_successfully_completed_at": "2026-06-24T04:17:30Z"
},
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:17:30Z"
}
}
→ The video is at
https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4
(example, not a live URL).
Example — completed image job (from POST /v1/omni_api/generate/image):
{
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg",
"maybe_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg-thumb-{WIDTH}.jpg",
"maybe_video_previews": null
},
"maybe_successfully_completed_at": "2026-06-24T04:09:02Z"
}
}
→ The image is at
https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg
(example, not a live URL).
Poll until complete
bash
JOB_TOKEN="jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
while :; do
resp=$(curl -s "http://localhost:12345/v1/omni_api/job_status/job/$JOB_TOKEN" \
-H "Authorization: Bearer YOUR_API_KEY")
status=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['status']['status'])")
echo "status=$status"
case "$status" in
complete_success)
echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['maybe_result']['media_links']['cdn_url'])"
break ;;
complete_failure|dead|cancelled_by_user|cancelled_by_system)
echo "job ended without a result: $status"; break ;;
esac
sleep 5
done
python
import time
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
TERMINAL_FAILURES = {"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system"}
while True:
resp = requests.get(
f"{BASE_URL}/v1/omni_api/job_status/job/{JOB_TOKEN}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30,
)
resp.raise_for_status()
state = resp.json()["state"]
status = state["status"]["status"]
print("status:", status, state["status"]["progress_percentage"], "%")
if status == "complete_success":
print("result URL:", state["maybe_result"]["media_links"]["cdn_url"])
break
if status in TERMINAL_FAILURES:
print("job ended without a result:", status)
break
time.sleep(5)
javascript
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx";
const TERMINAL_FAILURES = new Set([
"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system",
]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
while (true) {
const resp = await fetch(`${BASE_URL}/v1/omni_api/job_status/job/${JOB_TOKEN}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const { state } = await resp.json();
const status = state.status.status;
console.log("status:", status, state.status.progress_percentage, "%");
if (status === "complete_success") {
console.log("result URL:", state.maybe_result.media_links.cdn_url);
break;
}
if (TERMINAL_FAILURES.has(status)) {
console.log("job ended without a result:", status);
break;
}
await sleep(5000);
}
Notes & troubleshooting
- Always send a fresh
idempotency_token(UUID). Reusing one is rejected as a duplicate. - No cookies. The endpoint authenticates only via the
Authorizationheader. - URL must be
http(s)and reachable by the server; redirects are followed. - Type mismatches (e.g. a
.webmpassed as a reference video, which requiresmp4) are rejected with a400describing the unpermitted type. 401 Unauthorized— missing/invalid API key, or the key's owner is banned.402 Payment required— insufficient credits/balance.- There is a companion image endpoint,
POST /v1/omni_api/generate/image, which acceptsimage_urls(mutually exclusive withimage_media_tokens) following the same rules.