Python client for the Nureta ("tokenstore") Generation API — a Seedance-compatible platform for generating video, images, and scripts from text and reference images, with first-class support for explicit / NSFW content.
- Website & dashboard: https://developer.nureta.ai/
- Base URL:
https://developer.nureta.ai - Auth: API key as a bearer token (
Authorization: Bearer sk-...) - Billing: pay-per-generation against your balance (USDC top-ups on Base); failed jobs are auto-refunded
- Async by design: you create a task, then poll it (or receive a webhook) until it's
succeeded/failed
Generated from
openapi.ymlwith openapi-generator (-g python). Regenerate with the command at the bottom.
pip install -e . # from this repo
# runtime deps: urllib3, pydantic v2, python-dateutil, typing-extensionsimport nureta
client = nureta.Nureta(api_key="sk-...") # base_url defaults to https://developer.nureta.ai
# ... or pass base_url="https://staging..." to point elsewhereCreate an API key in the API keys section of the dashboard. New keys default to
10 req/s (adjustable 1–100). Every call — task creation, polling, uploads — counts toward
that key's rate limit; a 429 RateLimitExceeded means back off and retry.
| Capability | Resource | Endpoint | Output |
|---|---|---|---|
| Video generation | VideosApi |
POST /api/v3/contents/generations/tasks |
content.video_url |
| Image generation | ImagesApi |
POST /api/v3/images/generations/tasks |
content.image_url |
| Script (storyboard) | ScriptsApi |
POST /api/v3/contents/scripts |
content.script |
| Reference-image upload | UploadsApi |
POST /api/v3/contents/uploads |
signed uploadUrl + assetUrl |
| Models & pricing | ModelsApi / ImagesApi / ScriptsApi |
GET .../models |
model list |
The video path also supports multi-shot "scenes" (16–180s, stitched), an editable storyboard workflow (draft → tweak → render), and per-shot explicit control (see NSFW below).
import time
import nureta
with nureta.Nureta(api_key="sk-...") as client: # base_url defaults to production
created = client.videos.create(nureta.CreateVideoTaskParams.from_dict({
"model": "seahorse-1080p",
"content": [
{"type": "text", "text": "a seahorse dancing through neon waves"},
# optional reference / start / end frames:
# {"type": "image_url", "image_url": {"url": "https://.../ref.jpg"}},
],
"ratio": "16:9",
"duration": 8, # single clip: 5/8/10/12/15s
}))
task_id = created.id # "cgt-..."
while True:
task = client.videos.retrieve(task_id)
if task.status in ("succeeded", "failed"):
break
# task.step.code = queued | generating_script | rendering | stitching | ...
time.sleep(3)
if task.status == "succeeded":
print(task.content.video_url)
else:
print("failed:", task.error.message) # auto-refunded
client.videos.create(...)is the openai-style short alias; the generatedclient.videos.create_video_task(...)name works too.
Every create / edit_* method accepts a plain dict / TypedDict or the
pydantic model — pick your style:
from nureta.types import CreateVideoTaskParamsDict # typed dict, for IDE autocomplete
params: CreateVideoTaskParamsDict = {"model": "seahorse-1080p",
"content": [{"type": "text", "text": "..."}]}
client.videos.create(params) # dict
client.videos.create(nureta.CreateVideoTaskParams.from_dict(params)) # model — same resultBoth clients retry with exponential backoff and take a default timeout, configured once on the client (openai-style):
client = nureta.Nureta(api_key="sk-...", max_retries=2, timeout=60)Retries fire on 429 and 5xx — but only for idempotent methods (GET polling,
PUT edits), so a billed create/render POST is never silently re-submitted. A
numeric Retry-After header is honored. Set max_retries=0 to disable.
nureta.AsyncNureta mirrors Nureta exactly — same methods and types, just
await each call (httpx under the hood):
import asyncio, nureta
async def main():
async with nureta.AsyncNureta(api_key="sk-...") as client:
created = await client.videos.create({"model": "seahorse-1080p",
"content": [{"type": "text", "text": "..."}]})
while True:
task = await client.videos.retrieve(created.id)
if task.status in ("succeeded", "failed"):
break
await asyncio.sleep(3)
print(task.content.video_url)
asyncio.run(main())content is a list mixing exactly one text prompt with up to 9 image_url references
and at most one audio_url:
content = [
{"type": "text", "text": "cinematic, moody lighting"},
{"type": "image_url", "image_url": {"url": "https://.../ref.jpg"}},
{"type": "image_url", "image_url": {"url": "https://.../start.jpg"}, "role": "first_frame"},
{"type": "image_url", "image_url": {"url": "https://.../end.jpg"}, "role": "last_frame"},
]A pinned first_frame/last_frame is mutually exclusive with reference images — send a
frame or references, not both.
Don't host your reference yet? Presign an upload:
signed = client.uploads.create(nureta.CreateUploadParams(file_name="ref.jpg", content_type="image/jpeg"))
# PUT the bytes to signed.upload_url with the same Content-Type, then use signed.asset_url as image_url.urlcreated = client.images.create(nureta.CreateImageTaskParams.from_dict({
"model": "seahorse-image",
"content": [{"type": "text", "text": "a clean studio product shot on marble"}],
"size": "4K", # 1K / 2K / 4K
}))
task = client.images.retrieve(created.id) # poll → task.content.image_urlFlat-priced: one image, one charge. Same content shape as video (text prompt + up to 9 refs; a text-only prompt is allowed).
Generate just a storyboard (title, characters, per-scene beats) — the same v3 director as video, stopped before rendering. Flat $0.01 regardless of length.
created = client.scripts.create(nureta.CreateScriptTaskParams.from_dict({
"model": "seahorse-script",
"content": [{"type": "text", "text": "a lonely lighthouse keeper finds a message in a bottle"}],
"duration": 30, # 5–180s; scales scene count, not price
}))
script = client.scripts.retrieve(created.id).content.script
# script.title, script.characters, script.scenes[].{setting, beat, duration_seconds}Nureta is built to render explicit adult video, not just soft/suggestive motion. The
underlying model (Seedance) has no built-in knowledge of sex acts or anatomy, so explicit
shots are routed through a separate anatomy-reference pipeline that attaches real anatomy
references at render time. You opt into that pipeline per shot with the explicit flag.
seahorse-1080p — the flagship. All three video models (seahorse-480p / 720p /
1080p) support the explicit pipeline, but 1080p gives the highest-fidelity anatomy and
detail, which is exactly where explicit content lives or dies. Use 720p to cut cost during
iteration, then final-render at 1080p. For explicit stills, use seahorse-image at
4K.
Explicit is a per-segment control, so it applies to multi-shot scenes — i.e. either
advanced mode (segments) or edit mode, both video-only with a total duration > 15s.
explicit: true— this shot depicts sex. You must describe the act concretely in the shot'sscript/beat(positions, penetration, what each body is doing). A vague line like "they get closer" on an explicit shot attaches no references and renders soft.explicit: false— build-up / story shot (a look, a touch, undressing, afterglow). Rendered from text only; no anatomy references (and you don't want sex bleeding into a build-up shot).
Mark explicit: true only on the shots that should actually show sex, and make those
shots' text spell out the act.
created = client.videos.create(nureta.CreateVideoTaskParams.from_dict({
"model": "seahorse-1080p",
"ratio": "9:16", # vertical
"content": [{"type": "text", "text": "a moonlit rooftop encounter"}],
"segments": [ # must total 16–180s; per-segment 4–15s
{"script": "she steps onto the rooftop, city lights behind her", "duration_seconds": 8},
{"script": "he joins her at the railing; they kiss", "duration_seconds": 10, "explicit": False},
{"script": "<concrete description of the act>", "duration_seconds": 12, "explicit": True},
],
}))
# poll created.id to succeeded → content.video_urlSubmit with mode: "edit"; the task charges at submit and parks at status: "planned"
with a scene draft (the AI director sets explicit per shot for you). Then:
client.videos.edit_plan(task_id, EditPlanParams(...))— tweak a shot'sscript,duration_seconds, or pin afirst_frame/last_frame(matched by 1-basedindex).client.videos.edit_plan_structured(task_id, EditPlanStructuredParams(...))— edit each shot's shortbeat(and overrideexplicit) and let the server re-expand it; top-levelworld/wardrobe/characterset a shared style. Returns202; poll untilscene.reexpand_status == "ready".client.videos.render(task_id)— commit to the full render.client.videos.cancel(task_id)— cancel a not-yet-rendered draft for an immediate full refund (otherwise an un-rendered draft auto-refunds after 24h).
created = client.videos.create(nureta.CreateVideoTaskParams.from_dict({
"model": "seahorse-1080p", "duration": 60, "mode": "edit",
"content": [{"type": "text", "text": "a slow bedroom scene"}],
}))
# ... poll created.id to status "planned", inspect task.scene.segments, edit, then:
client.videos.render(created.id)Legal/safety: explicit generation is for adult (18+) content you are authorized to create.
callback_urls must be HTTPS and must not target private/loopback ranges.
Every video task carries a step so you can show real progress while status is still pending:
task.step.code # queued | generating_script | generating_visuals | pending_rendering
# | rendering | stitching | done | failed | cancelled | reexpanding | reexpand_failed
task.step.done, task.step.total # clips completed / total (while rendering)status is one of pending | planned | succeeded | failed | cancelled. The step lags the true
phase by up to ~30s.
Pass callback_url (and optional callback_secret) on creation and Nureta POSTs the terminal
task object to you when it finishes — same JSON as retrieve_*. Delivery is at-least-once
(dedupe by id); when a secret is set, verify the X-Tokenstore-Signature HMAC-SHA256 over
timestamp + "." + rawBody. Webhook receiving is your web server's job, not this SDK's.
API errors raise nureta.exceptions.ApiException subclasses; the JSON body carries a code:
AuthenticationError · InvalidParameter · ModelNotFound · AccountOverdueError ·
RateLimitExceeded (429) · InternalServiceError (generation failed — you're refunded).
from nureta.exceptions import ApiException
try:
client.videos.create(params)
except ApiException as e:
print(e.status, e.body)Prices are live from the public model endpoints (no auth):
for m in client.models.list().models:
print(m.id, m.resolution, m.price_usd_per_sec)
# image / script model lists: client.images.list_models(), client.scripts.list_models()| Model | Resolution | USD/sec | USD/5s |
|---|---|---|---|
seahorse-480p |
480p | $0.1888 | $0.9440 |
seahorse-720p |
720p | $0.2773 | $1.3864 |
seahorse-1080p |
1080p | $0.5428 | $2.7139 |
seahorse-image |
1K/2K/4K | — | $0.05 / image |
seahorse-script |
— | — | $0.01 / script |
Video price scales linearly with duration. Legacy seahorse-fast-<res> ids still work as aliases.
src/nureta/
_base.py shared base layer: config, retries/timeout policy, param coercion
client.py hand-written sync client (Nureta)
_async_client.py hand-written async client (AsyncNureta) — reuses generated serialize/deserialize
types.py TypedDict request params
api/ models/ ... generated from openapi.yml (low-level transport: api_client.py, rest.py)
openapi.yml API spec — source of truth
api.md resource/method reference
scripts/generate.sh regenerates src/nureta/ (preserves the 4 hand-written files)
tests/ offline tests (incl. async + retries)
Regenerate after editing the spec:
./scripts/generate.sh # needs: brew install openapi-generatorIt generates into a temp dir and copies only the package into src/nureta/, so the
repo root stays clean and the hand-written client.py is preserved.