Metadata-Version: 2.5
Name: martini-client
Version: 0.2.0
Summary: The official Python client for the Martini API: generations, workflow runs, asset uploads, and the projects and canvases they land in.
Project-URL: Homepage, https://www.martini.film
Project-URL: Documentation, https://www.martini.film/docs/api
Project-URL: Source, https://github.com/c47-inc/martini/tree/main/packages/martini-python
Author-email: Martini <support@martini.film>
License-Expression: MIT
License-File: LICENSE
Keywords: api,generation,martini,sdk,video
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Description-Content-Type: text/markdown

# `martini-client`

The official Python client for the Martini API: generate images and video, run saved workflows, upload reusable
media, and find the projects and canvases they land in. Sync and async, fully typed, one dependency (`httpx`).

```bash
pip install martini-client
```

Requires Python 3.10 or later.

```python
from martini_client import Martini

martini = Martini()  # reads MARTINI_API_KEY

generation = martini.generations.subscribe(
    "bytedance/seedance-2.5/text-to-video",
    input={
        "prompt": "A practical miniature moon base at blue hour.",
        "duration": "auto",
        "resolution": "720p",
    },
    idempotency_key="shot-42",
)

print(generation.result()["video"]["url"], generation.olive_cost)  # raises GenerationFailedError if it failed
```

`subscribe()` returns whatever the generation settled as — inspect `generation.succeeded`, `.output`, and `.error`
when you would rather branch than raise; `result()` is the typed accessor (the `concurrent.futures` idiom).

## For coding agents

The whole contract in one place. If you are an agent integrating Martini, this section is enough.

- **Credentials.** `MARTINI_API_KEY` (`mtn_live_…`) in the environment; the key is a server-side secret. Confirm it
  with `martini.me()`. Point at a staging or local API with `MARTINI_API_URL`.
- **What you can call.** `martini.models()` lists the endpoint aliases this key can invoke, with pricing; pass
  `.endpoint` values to `generations.submit()`. Hard-coding an alias from `martini_client.ENDPOINTS` also works.
- **One call to generate.** `martini.generations.subscribe(endpoint, input={...})` submits and polls until the
  generation settles. It **returns** a `Generation` whose `status` is `completed`, `failed`, or `cancelled` — it
  does not raise on failure. Call `generation.result()` for the output (typed; raises `GenerationFailedError`
  with the `error` attached if it failed), or inspect `succeeded` / `output` / `error` to branch instead.
- **Model input** is a plain dict in the model's own field names (`prompt`, `image_url`, `duration`, ...). The
  server validates strictly: an unknown field is `UNSUPPORTED_INPUT`, a bad value `INVALID_INPUT`. The
  `TypedDict`s in `martini_client.types` spell out every field per endpoint; annotate the dict with one to have
  a type checker catch mistakes before the request.
- **Always pass `idempotency_key`** for anything you might retry; re-submitting with the same key returns the same
  generation instead of charging twice, and it makes the request safe for the client to retry on transport errors.
- **Errors** are `MartiniAPIError` subclasses keyed by HTTP status (`RateLimitError`, `InsufficientOlivesError`,
  `NotFoundError`, ...), each carrying the stable `code`. Retry only `RateLimitError` (after `retry_after`
  seconds) and `ServerError`; never retry validation, authentication, or balance errors.
- **Workflows.** `martini.workflows.subscribe(workflow_id, bins={...}, variables={...})` runs a saved workflow and
  returns `WorkflowSettled(run, results)`; `results.ready` are downloadable outputs with presigned `url`s that
  expire after `expires_in` seconds — fetch them promptly.
- **Where things land.** Without a `project_id`, generations and uploads go to the key's default project and runs
  to the workspace's Workflow runs project. To target a project the user works in, `martini.projects.list()`
  (optionally `query="Pilot"`) lists the organization's projects the key's user can see, each with `can_edit`;
  `martini.projects.canvases(project_id)` lists its canvases, `.default` being the one a generation lands on
  when no `canvas_id` is given.
- **Media in.** `martini.assets.upload("file.mp4")` returns an `Asset`; put `asset.url` in `image_url`,
  `video_urls`, and so on. Or pass public HTTPS URLs / `data:` URLs directly in those fields.

## Generations

`martini.generations` is the Martini-native generation API (`/v1/generations`). One resource describes a
generation from draft to terminal state; `output` is on the same object once `status == "completed"`, so you
poll one thing.

```python
submitted = martini.generations.submit(
    "nano-banana-2",
    input={"prompt": "A graphite storyboard frame of a desert observatory.", "resolution": "1K"},
    project_id=project_id,  # optional: override the key's default project
    idempotency_key="frame-042",  # optional but recommended
)
current = martini.generations.status(submitted.id)  # output is here once completed
settled = martini.generations.wait(submitted.id)  # poll until completed | failed | cancelled
cancelled = martini.generations.cancel(submitted.id)  # 409 REQUEST_ALREADY_TERMINAL once settled
```

`subscribe()` is `submit()` + `wait()`. Both `wait()` and `subscribe()` accept `poll_interval` (seconds, default 2,
backing off to 15), `timeout` (default 30 minutes; raises `MartiniTimeoutError`, the generation keeps running on
Martini), and `on_update` (called with every observation).

### Drafts

`mode="draft"` creates an editable canvas node without charging or contacting a provider. Start it later; the
run uses the draft's latest settings from the Martini canvas.

```python
draft = martini.generations.submit("nano-banana-2", input={"prompt": "…"}, mode="draft")
print(draft.status, draft.links.generate)  # "draft", "/v1/generations/<id>/generate"
started = martini.generations.generate(draft.id)  # -> pending
```

A draft never settles on its own; `wait()` on one raises `DraftNotStartedError` pointing you at `generate()`.

### The `Generation` object

| Attribute | Meaning |
| --- | --- |
| `id` | Public request id; also the canvas asset the generation lands on. |
| `endpoint` | The alias it was submitted against. |
| `status` | `draft`, `pending`, `running`, `completed`, `failed`, or `cancelled`. `is_terminal`, `succeeded` are convenience properties. |
| `output` | The model's output in its fal field names once completed (`output["video"]["url"]`, `output["images"][0]["url"]`); otherwise `None`. URLs are durable Martini storage URLs. `result()` returns it typed, or raises. |
| `error` | `ErrorDetail(code, message)` when failed or cancelled; otherwise `None`. |
| `olive_cost` | Snapshotted charge in olives; `None` only while a concurrent idempotent submit is still being priced. |
| `warnings` | Non-blocking input advisories (unbound reference images). The generation was still accepted. |
| `martini` | `GenerationPlacement(preview_url, project_id, canvas_id, asset_id)` — open `preview_url` to see it in Martini. |
| `created_at`, `started_at`, `completed_at` | ISO timestamps. |

### Typed inputs

With a literal endpoint, `submit()`/`subscribe()` narrow `input` to that endpoint's `TypedDict` (your editor
completes the fields) and type the returned `output`. To have `mypy`/`pyright` reject a misspelled field or an
out-of-range value as well, annotate the input with its `TypedDict` — an unannotated dict literal is accepted
as a plain mapping, and the server rejects it at submit time instead (`UNSUPPORTED_INPUT`, `INVALID_INPUT`):

```python
from martini_client.types import Seedance25ReferenceToVideoInput

input: Seedance25ReferenceToVideoInput = {
    "prompt": "@Image1 is the lead. Inside @Image2, @Image1 turns to camera. Follow the move from @Video1.",
    "image_urls": [character_url, location_url],
    "video_urls": [camera_guide_url],
    "duration": "8",
    "resolution": "720p",
}
generation = martini.generations.subscribe("bytedance/seedance-2.5/reference-to-video", input=input)
```

Reference arrays are positional: `image_urls[0]` is `@Image1`, `video_urls[0]` is `@Video1`, `audio_urls[0]` is
`@Audio1`. Bare names do not bind media. Multiple video references and every audio reference must be bound;
an unbound image is accepted with an advisory in `warnings`.

### Discovering models

```python
catalog = martini.models()
for model in catalog.with_capability("generate-video"):
    print(model.endpoint, model.billable_unit, model.model.pricing.display, model.pricing)
```

Only aliases your key can invoke are listed, with your organization's negotiated multipliers when any.

## Workflows and runs

A **saved workflow** is your workspace's reusable, versioned machinery; a **placed workflow** is a copy on a
canvas. A **run** is one execution.

```python
workflows = martini.workflows.list()  # saved; .list(all=True) adds placed ones
workflow = workflows.find("Script to Video")  # by name (case-insensitive) or id
print(workflow.inputs.bins, workflow.inputs.variables)

script = martini.assets.upload("script.pdf.png")  # inputs are assets; upload first

settled = martini.workflows.subscribe(
    workflow.id,
    bins={"Script": [script.asset_id]},  # a saved workflow starts from empty bins: pin every input bin
    variables={"Tone": "deadpan"},
    fingerprint=workflow.fingerprint,  # refuse to run if the machinery changed since you read it
    idempotency_key="job-7",
)
for output in settled.results.ready:
    print(output.filename, output.url)  # presigned; valid for output.expires_in seconds
```

`workflows.run()` starts without waiting; `runs.status(run_id)` (with `activity=True` for each step's event trail),
`runs.results(run_id)` (the takes settled so far, at any time), and `runs.wait(run_id)` poll an existing run;
`runs.resume(run_id)` retries a `failed` run's failed step with the same inputs (`ConflictError` for any other status). `subscribe()` and
`wait()` return a `failed` or `cancelled` run with its `error` rather than raising; `.raise_for_status()` turns
it into a `WorkflowRunFailedError`. `olive_budget` caps generation spend; omit it to let Martini arm the rail from
the estimate. Runs started here auto-approve.

## Projects and canvases

A **project** is where generations, uploads, and runs land; a **canvas** is one board inside it. `martini.projects`
lists the organization's projects the key's user can see — the same set the MCP connector's `get_projects` shows
them — so an id picked here works wherever a `project_id` or `canvas_id` is accepted.

```python
projects = martini.projects.list(query="Pilot")  # ranked: exact id or name, then prefix, contains, every word
pilot = projects.find("Pilot")  # by name (case-insensitive) or id; .editable keeps the ones a run can go to
canvases = martini.projects.canvases(pilot.id)
canvas = canvases.default  # where the project opens, and where a generation without canvas_id lands

martini.generations.submit("nano-banana-2", input={"prompt": "…"}, project_id=pilot.id, canvas_id=canvas.id)
```

`list()` takes `query` (a name or id, or a fragment of either), `exact_name=True` (keep exact name matches only),
and `limit` (1–50, default 20); `ProjectList.truncated` says whether more matched. Each `Project` carries
`can_edit` — `False` means the user can only view it, and naming it for a run raises `PROJECT_WRITE_FORBIDDEN` —
and `open_in_martini`, a link to it in the app. A project of another organization, or one the user cannot read,
raises `NotFoundError` with `PROJECT_NOT_FOUND` from `canvases()` (never a 403, so ids cannot be probed). A project
whose document cannot be opened raises `ConflictError` with `PROJECT_DOCUMENT_TOO_LARGE` (durable; see Errors) or
`ServerError` with `PROJECT_DOCUMENT_UNAVAILABLE` (retry).

## Assets

```python
asset = martini.assets.upload("reference.mp4")  # path, bytes, or a binary file object
asset = martini.assets.upload(data, filename="ref.png", content_type="image/png")
asset = martini.assets.upload("ref.png", project_id=..., canvas_id=...)  # another project you can edit
asset = martini.assets.upload("ref.png", wait=False)  # return after complete; status queued/processing
```

`upload()` is prepare → PUT directly to storage → complete → poll until processed. Limits: 10 MB JPEG/PNG/WebP,
30 MB MP4, 15 MB MP3/WAV. When direct storage is unavailable it raises `StorageUnavailableError` — it does not
switch transports on its own; `martini.assets.upload_base64(...)` (same arguments) is the explicit alternative.
The lower-level steps are exposed too: `prepare_upload(files)`, `complete_upload(asset_ids)`, `get(asset_id)`,
`wait(asset_id)`.

## Errors

Every non-2xx answer raises a subclass of `MartiniAPIError` chosen by HTTP status; branch on the class, then on
the stable `code` when you need the exact cause.

| Class | Status | Typical codes |
| --- | --- | --- |
| `InvalidRequestError` | 400, 422 | `INVALID_REQUEST`, `INVALID_INPUT`, `UNSUPPORTED_INPUT`, `INVALID_MEDIA`, `MEDIA_TOO_LARGE`, `WORKFLOW_BIN_REQUIRED`, `WORKFLOW_VARIABLE_NOT_FOUND` |
| `AuthenticationError` | 401 | `INVALID_API_KEY`, `API_KEY_REVOKED` |
| `InsufficientOlivesError` | 402 | `INSUFFICIENT_OLIVES`, `SPEND_CAP_EXCEEDED` |
| `AccessDeniedError` | 403 | `PROJECT_ACCESS_DENIED`, `PROJECT_WRITE_FORBIDDEN`, `GENERATION_API_DISABLED`, `WORKFLOWS_NOT_ENABLED` |
| `NotFoundError` | 404 | `ENDPOINT_NOT_REGISTERED`, `REQUEST_NOT_FOUND`, `PROJECT_NOT_FOUND`, `CANVAS_NOT_FOUND`, `WORKFLOW_NOT_FOUND`, `WORKFLOW_RUN_NOT_FOUND` |
| `ConflictError` | 409 | `REQUEST_ALREADY_TERMINAL`, `DRAFT_ALREADY_GENERATING`, `WORKFLOW_CHANGED`, `PROJECT_DOCUMENT_TOO_LARGE` |
| `RateLimitError` | 429 | `RATE_LIMITED` — `retry_after` carries the server's hint in seconds |
| `ServerError` | 5xx | `GENERATION_SUBMISSION_FAILED`, `GENERATION_API_ACCESS_UNAVAILABLE`, `PROJECT_DOCUMENT_UNAVAILABLE` |

```python
from martini_client import ConflictError, RateLimitError

try:
    run = martini.workflows.run(workflow.id, bins={"References": [asset_id]}, fingerprint=fingerprint)
except ConflictError as error:
    if error.code == "WORKFLOW_CHANGED":
        ...  # re-read the workflow; error.body["fingerprint"] is the current one
except RateLimitError as error:
    time.sleep(error.retry_after or 10)
```

`error.body` holds the full JSON for fields beyond `error`/`code` (the `inputs` a workflow accepts, the current
`fingerprint`). Outside the API: `MartiniTransportError` (no response after retries), `MartiniTimeoutError`
(a `wait`/`subscribe` outlasted `timeout`), `AssetUploadError`/`StorageUnavailableError` (uploads),
`GenerationFailedError`/`WorkflowRunFailedError` (from `raise_for_status()`), `ConfigurationError` (bad
constructor arguments). All derive from `MartiniError`.

`PROJECT_DOCUMENT_TOO_LARGE` (409) is durable, not transient: the project cannot generate, and its canvases cannot
be listed, until support rebuilds its document. Surface the message and stop.

## Configuration

```python
martini = Martini(
    api_key="mtn_live_…",  # default: MARTINI_API_KEY
    base_url="https://api.martini.film",  # default: MARTINI_API_URL, then production; HTTPS except localhost
    api_version="2026-08-13",  # X-Martini-API-Version; default is the version this release targets
    timeout=60.0,  # seconds per request
    max_retries=2,  # for GETs and idempotent submits, on transport errors / 429 / 5xx
    http_client=httpx.Client(...),  # optional: proxies, custom transports; you own its lifetime
)
```

Retries back off exponentially with jitter (0.5 s, 1 s, 2 s, … capped at 8 s) and honour `Retry-After`. A
`POST` without an `idempotency_key` is never retried automatically — the server could not tell a retry from a
second request. `with Martini() as martini:` closes the connection pool on exit.

### Async

```python
from martini_client import AsyncMartini

async with AsyncMartini() as martini:
    generation = await martini.generations.subscribe("nano-banana-2", input={"prompt": "…"})
```

Same methods, same arguments, `await`ed.

## Supported methods

- `me()`, `models()`
- `generations.submit()`, `.status()`, `.cancel()`, `.generate()`, `.wait()`, `.subscribe()`
- `projects.list()`, `.canvases()`
- `workflows.list()`, `.get()`, `.create()`, `.run()`, `.subscribe()`
- `runs.status()`, `.results()`, `.wait()`
- `assets.upload()`, `.upload_base64()`, `.prepare_upload()`, `.complete_upload()`, `.get()`, `.wait()`

Wire reference: [Generation API](https://www.martini.film/docs/api) and
[Workflows API](https://www.martini.film/docs/workflows). The JavaScript client is
[`@martini-film/client`](https://www.npmjs.com/package/@martini-film/client).

The API key is a server-side secret. Do not embed it in notebooks you share, client-side code, or public
environment variables.
