# Assets and storage

> Keep footage, audio and other media out of git on your own S3-compatible bucket (Cloudflare R2, S3, B2, MinIO), pinned by sha256 in framefields.json, served through a custom domain or CDN.

Footage and audio are too big for git. framefields keeps them on a bucket you own and pins each file in `framefields.json` by its sha256, the way a lockfile pins packages: the repo stays small, every clone and every render gets exactly the same bytes, and nothing about your film's code changes.

```bash
npx framefields assets add assets/footage   # upload, pin, gitignore
npx framefields assets pull                 # in a fresh clone: download and verify
```

Any S3-compatible bucket works. **Cloudflare R2** is the one we recommend: it charges nothing for downloads, so cloning, CI and cloud renders cost you storage only.

## Set up a bucket

1. Create a bucket (in R2: **R2 → Create bucket**) and an API token that can read and write it (**Manage R2 API tokens**, Object Read & Write, scoped to the bucket).
2. Optionally, make it public through a **custom domain** (R2: bucket **Settings → Custom Domains**), or any CDN in front of it. Then anyone can pull your assets, and renders started from the dashboard can read them, without keys.
3. Add it to `framefields.json`:

```json
{
  "storage": {
    "remotes": {
      "media": {
        "endpoint": "https://<account-id>.r2.cloudflarestorage.com",
        "bucket": "films",
        "prefix": "launch/",
        "publicUrl": "https://media.example.com"
      }
    }
  }
}
```

| Field | |
| --- | --- |
| `endpoint` | The bucket's S3 API URL. R2: `https://<account-id>.r2.cloudflarestorage.com` (EU jurisdiction: `…eu.r2.cloudflarestorage.com`). AWS: `https://s3.<region>.amazonaws.com`. |
| `bucket` | The bucket name. |
| `region` | SigV4 region. Default `auto`, which R2 uses; AWS needs the bucket's region. |
| `prefix` | Optional folder inside the bucket, so several projects can share one. |
| `publicUrl` | Optional public base URL: a custom domain, a CDN or the `r2.dev` URL. Reads use it without keys. |

Several remotes are fine, for example a public one for media you're happy to share and a private one for raw footage. `"default": "media"` names the one new assets go to; `--remote raw` picks another.

4. Give the CLI the keys through the environment, never the file:

```bash
export FRAMEFIELDS_STORAGE_MEDIA_ACCESS_KEY_ID=…
export FRAMEFIELDS_STORAGE_MEDIA_SECRET_ACCESS_KEY=…
```

The names follow the remote (`raw-footage` → `FRAMEFIELDS_STORAGE_RAW_FOOTAGE_…`). Without them, the standard `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are used.

## Commands

| Command | |
| --- | --- |
| `npx framefields assets add <path…>` | Hash files (a folder means every file in it), upload what the bucket lacks, pin them under `assets` and add them to `.gitignore`. Adding a changed file again pins the new version. |
| `npx framefields assets pull` | Download missing assets and check every byte. Files you changed locally are left alone unless `--overwrite`. |
| `npx framefields assets push` | Upload pinned assets the bucket lacks, from your local files. |
| `npx framefields assets status` | Each asset: `ok`, `missing` or `changed`. `--json` for scripts. |

`add` writes entries like this; commit `framefields.json` and `.gitignore`:

```json
"assets": {
  "assets/footage/intro.mp4": { "sha256": "9f2c…", "size": 48213992 }
}
```

An entry can also carry a fixed `url` instead of a remote, for public media you didn't upload (stock footage, a sample): it is downloaded from there and checked against the same sha256.

## Custom domains and CDNs

Objects are stored by content, at `<prefix>sha256/<ab>/<sha256>`, so they never change. Uploads are marked `Cache-Control: public, max-age=31536000, immutable` and carry their real `Content-Type`, so a custom domain or CDN can cache them forever and browsers can play them directly: `https://media.example.com/launch/sha256/9f/9f2c…`.

Projects sharing a bucket and prefix store a file once, and uploading it again is skipped.

## Preview from the bucket

`npx framefields preview` doesn't need the files on disk. When a pinned asset is missing (a fresh clone, an agent working in a cloud sandbox), the preview sends the page to the bucket for it: the remote's `publicUrl`, or a URL signed with the remote's keys from the environment. The browser streams it from there, seeking with byte ranges, so nothing is downloaded into the project, and the soundtrack is mixed from the same URLs.

The engine reads media with `fetch`, so the bucket must allow the preview's origin (CORS). In R2: bucket **Settings → CORS policy**:

```json
[
  {
    "AllowedOrigins": ["*"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["Range"],
    "ExposeHeaders": ["Content-Length", "Content-Range", "Accept-Ranges", "ETag"],
    "MaxAgeSeconds": 86400
  }
]
```

`"*"` suits a public bucket: anyone can read it anyway. For a private one, list exact origins instead: the preview's (`http://127.0.0.1:<port>`, printed when it starts; the port is stable per project) and your agent sandbox's or tunnel's. The policy covers the bucket's custom domain and signed URLs alike. On S3 it is the same rules under **Permissions → CORS**.

Without CORS the page shows a fetch error for the clip. `npx framefields assets pull` is the fallback: with the files on disk, the preview serves them itself.

R2 signed URLs work only on the S3 API host (`<account-id>.r2.cloudflarestorage.com`), not a custom domain: the custom domain is for public media, and private remotes are read through signatures.

## Render here or in the cloud

`npx framefields render <name>` renders on this machine and first downloads pinned assets that are missing; `npx framefields render <name> --cloud` renders the same named render on framefields GPUs, to the same output file.

## In cloud renders

[Cloud renders](/docs/guides/cloud-rendering/) download pinned assets on every GPU before rendering:

- `framefields render --cloud` and `renderCloud` first upload anything the bucket lacks, so a forgotten `push` doesn't fail the render.
- Assets on a remote with a `publicUrl` are read from it. Assets on a private remote get read URLs signed on your machine, valid for 24 hours; your keys are never sent.
- Workers cache assets by hash, so a worker that renders the project again reuses them. On R2, the downloads every GPU of a split render makes cost nothing, unlike LFS bandwidth from a git host.
- Renders started from the dashboard have no machine to sign from, so they read only remotes with a `publicUrl`.

## Git LFS

Projects that already use Git LFS keep working: the render worker pulls LFS before it downloads pinned assets, and `framefields init` still writes LFS `.gitattributes`. LFS has two costs a bucket avoids: every clone and every render worker downloads the objects from your git host, which bills that bandwidth to the repo owner (an open-source film pays for everyone who clones it), and a clone without `git lfs pull` holds pointer files that decode as blank frames.

To move, `npx framefields assets add` the LFS-tracked files, remove their patterns from `.gitattributes`, and `git rm --cached` them.

## When something fails

| Code | Cause | Fix |
| --- | --- | --- |
| `ASSET_NOT_UPLOADED` | A pinned asset isn't on the bucket | `npx framefields assets push` from a machine that has it |
| `ASSET_HASH_MISMATCH` | The bucket or URL serves other bytes than `framefields.json` pins | Push the right file, or `assets add` the new version |
| `ASSET_PRIVATE` | A dashboard render, and the remote has no `publicUrl` | Render from the CLI, or add a `publicUrl` |
| `ASSET_FORBIDDEN` | The custom domain doesn't serve the bucket, or a signed URL expired | Check the domain is connected to the bucket; render again |
| `ASSET_MANIFEST_INVALID` | `storage` or `assets` is malformed | `npx framefields assets status` shows the error |
