# Cinema projects through the films API — script, storyboard, render, media, export

> The scripted side of Cinema. What a v2 film project looks like on /v1/films, how the render route reads the script, how media is re-signed, how a cut is saved with the export route and published.

Published 2026-09-24 · eroq.ai — canonical: https://eroq.ai/blog/cinema-projects-with-the-films-api


Everything the Cinema workspace does goes through `/v1/films`, and the studio is a client of it like any other. This guide is for the developer who wants to build or automate films: the project format the routes keep opaque, the render call that reads the script, the media map, the export route, and the caps. It assumes you have read the [storyboard-rendering guide](/blog/render-a-storyboard-with-the-films-api) for the v1 basics; this one is what v2 adds.

## The project

A film row has a `title`, a `cover_url`, a `published_creation_id` and a `data` document. `data` is the whole project — the routes validate its size (400 KB) and normalize it on read, and otherwise store what you send. The v2 shape:

```json
{
  "v": 2,
  "look": {
    "model": "seedance-2-0-mini",
    "aspect": "16:9",
    "resolution": "720p",
    "audio": true,
    "seed": 4242,
    "filmType": "noir", "era": "1960s", "tempo": "tense",
    "cameraType": "35mm film", "lens": "anamorphic", "aperture": "f/1.4",
    "palette": "neon noir", "lighting": "practicals",
    "chain": true,
    "negative": ""
  },
  "scenes": [
    {
      "id": "s1", "heading": "EXT. JAZZ CLUB — NIGHT",
      "prompt": "A detective in a rain-soaked trench coat waits under a flickering neon sign, slow push-in.",
      "shot": "push-in", "seconds": 5, "cast": ["char:9f3…"], "takes": 1,
      "dialogue": [{ "id": "l1", "speaker": "char:9f3…", "manner": "whispering", "line": "It's still lit." }],
      "options": [{ "libraryId": "cr_…", "contentType": "video/mp4", "seconds": 5 }],
      "pick": 0
    }
  ],
  "script": [
    { "id": "b1", "type": "h1", "html": "Rooftop night" },
    { "id": "b2", "type": "scene", "sceneId": "s1" },
    { "id": "b3", "type": "p", "html": "Rain machine on for this one." }
  ],
  "edit": { "auto": true, "clips": [], "audio": [], "titles": [], "fadeOut": 0 }
}
```

A scene's lines of dialogue live on the scene (`dialogue`: speaker as `char:<id>` or a free name, manner, line); every other script block is a note and never reaches an engine. Four rules the normalizer enforces so you do not have to: every scene has exactly one `scene` block in the script (orphans are dropped, missing ones appended), the scenes are ordered by the script, an early draft's `dialogue` blocks are folded into the scene above them (one written before any scene becomes a note), and every `libraryId` in `options` is a creation id — **media is never stored in the project**, only pointed at. A v1 draft (`look` + `scenes` with `prompt/shot/seconds/cast/takes`) normalizes into this shape on read, so old integrations keep working.

Caps: 24 scenes, 30 seconds per scene, 8 options per scene, 30 lines of dialogue per scene, 400 blocks, 80 clips, 40 audio clips, 40 titles, 4,000 characters per prompt or block.

## Create, read, update

```http
POST /v1/films
{ "title": "Rooftop night", "data": { … } }

GET /v1/films              → cards: scenes, rendered, seconds, aspect, cover, preview
GET /v1/films/{id}         → the row + data + media
PATCH /v1/films/{id}       → title and/or data (whole document)
DELETE /v1/films/{id}      → the draft; renders stay in the library
```

`GET /v1/films/{id}` returns a **`media`** map next to the project: every take, clip and sound the project points at, keyed `creation:<id>` or `upload:<id>`, with a fresh signed URL, content type, length and thumbnail. Your storage is private, so URLs expire; the map is how a client gets working links without owning a key per file. To refresh a subset — before a signed URL lapses, or when a new file joins the bin — `POST /v1/films/media` with `{ "keys": ["creation:…", "upload:…"] }` (1 to 300) returns the same shape for those keys. Unknown or foreign keys are simply absent.

`PATCH` replaces `data` whole. Read, modify, write; there is no partial merge, and a stale write wins over a newer one, so serialize your writers.

## Render

```http
POST /v1/films/{id}/render
{ "store": false }
```

One call queues every scene that has a prompt, as one async video job per take, on the look's engine — the same pipeline as `POST /v1/videos/generations`, each charged individually. The response maps each scene id to its job ids, or to the error that kept it from queueing; scenes fail independently, and a charged failure refunds itself. Poll the jobs like any render.

What the route composes per scene, in v2: the scene's slugline, its action and **its `dialogue` lines** (`Mina (whispering) says: "…"`), with the speakers' character sheets and voices joining the references — the same prompt the studio sends. The look rides every scene, including `audio` and `seed`. The engine's caps clamp format, resolution, length and references before billing; a scene that asks for a feature the engine lacks is refused, not silently reduced.

When a take lands, write it into the scene's `options` with its `libraryId` and set `pick`; the studio does this for you, an integration does it after polling. Continuous shots (`look.chain`) are a *client* behaviour: the studio grabs the previous take's last frame, uploads it and passes it as the first reference. On the API, send `references[0]` yourself on a per-scene `POST /v1/videos/generations` if you want the chain; the bulk render route renders the scenes as written.

## The edit and the export

The `edit` document is pure data — clips referencing `{ "kind": "scene", "sceneId" }`, `creation` or `upload`, with in/out trims, transitions, volume and fit; audio clips on three tracks with fades; titles in four styles; a fade-out. The studio renders it to an MP4 **in the browser**; the API does not render edits server-side.

What the API does is keep the result:

```http
POST /v1/films/{id}/export?seconds=42.5&signature=<edit signature>
Content-Type: video/mp4

<raw MP4 bytes>
```

The body is the file, streamed. It lands in the library as a video creation — so it plays, downloads and publishes like any clip — and counts against the workspace's storage like every library file. Free. The `signature` is a hash of the edit at export time; the film records it, and a later change to the edit makes the saved cut stale. A re-export replaces the previous cut when that one was never published and nothing in the edit uses it.

Rate-limited with uploads; a full bucket refuses before anything is read.

## Publish

```http
POST /v1/films/{id}/cover        (multipart file, image ≤ 5 MB, free)
POST /v1/films/{id}/publish
{ "title": "Rooftop night", "segments": ["<the saved cut's URL>"], "durationSeconds": 42 }
```

A film with a saved cut publishes the cut as its single segment; a film without one publishes its picked takes in order. Every segment must be a video in your library. Publication goes through review (`review` starts at `pending` on `GET /v1/films/{id}`); `DELETE /v1/films/{id}/publish` takes it down and keeps the engagement.

## What the API does not do

- Render the edit. The compositor is in the studio; the API stores the result.
- Grab last frames. Chain by sending `references[0]` per scene.
- Serve raw storage URLs. Everything is signed, through `GET /v1/films/{id}` or `POST /v1/films/media`.

## FAQ

### Is the project format documented?

The routes treat `data` as opaque JSON under a size cap and normalize it on read. The shape above is what the studio writes; the fields are stable, additions are optional.

### Does the render route charge for scenes that already have takes?

It renders every scene with a prompt, takes included. To re-render one scene, call `POST /v1/videos/generations` for that scene and write the result into its `options`.

### Can I upload my own footage into a film?

Upload it with `POST /v1/uploads` and reference it from the edit as `{ "kind": "upload", "id": … }`. The media map signs it like any take.

### Why is `store` an option on render?

`store: true` persists every clip to your storage at the Store's rate (2 credits per 10 MB); the default keeps the free library copy. Cinema's takes live in the library either way.

Read the [films reference](/docs/films) for every field, and the [storyboard guide](/blog/render-a-storyboard-with-the-films-api) for the v1 walk-through.
