# ci-transcode API

Asynchronous AV1 transcoding via GitHub Actions + ffmpeg (`libsvtav1`). Source and result files
live only in a private R2 bucket, accessed via short-lived presigned URLs. Neither this service
nor CI ever logs or reports video content, filenames, or metadata.

**Auth**: every `/jobs*` and `/admin/*` endpoint requires `X-Access-Token: <ACCESS_TOKEN>`.
Endpoints that act on a specific job additionally require `Authorization: Bearer <token>` — the
per-job token returned once at creation.

## Flow

1. `POST /jobs` → `{jobId, token, uploadUrl}`
2. `PUT` the source file's bytes to `uploadUrl` directly (bypasses this service)
3. `POST /jobs/{jobId}/commit` → queues for a concurrency slot (max 10), triggers CI
4. `GET /jobs/{jobId}` → poll `status`: queued → dispatching → downloading → transcoding → uploading → done/failed/cancelled
5. `GET /jobs/{jobId}/result` once `done` → presigned download URL

## POST /jobs

| Body (JSON, optional) | Description |
|---|---|
| `crf` | Integer 0-63, default 40 |
| `preset` | Integer 0-13 (SVT-AV1: 0 = slowest/best, 13 = fastest), default 4 |

Out-of-range values are clamped, not rejected; the response echoes what was actually stored.

> **Recommended: `crf: 40, preset: 4`** (the defaults). Preset has only a minor effect on output
> size at a given CRF but a large effect on encode time — preset 4 is a good compression/time
> balance without paying preset 0-2's large time cost for little extra compression. CRF is what
> actually controls file size; tune it first.

```
curl -X POST https://your-worker.example.workers.dev/jobs \
  -H "X-Access-Token: $ACCESS_TOKEN" -H 'content-type: application/json' \
  -d '{"crf": 32, "preset": 4}'
```

```json
{ "jobId": "a1b2c3...", "token": "xxxxx...", "uploadUrl": "https://...",
  "uploadMethod": "PUT", "expiresAt": "...", "crf": 32, "preset": 4 }
```

## PUT <uploadUrl>

`curl -X PUT "$UPLOAD_URL" --data-binary @input.mp4` — presigned R2 URL, valid 1 hour.

## POST /jobs/{jobId}/commit

```
curl -X POST https://your-worker.example.workers.dev/jobs/$JOB_ID/commit \
  -H "X-Access-Token: $ACCESS_TOKEN" -H "Authorization: Bearer $TOKEN"
```

`{ "jobId": "...", "status": "dispatching", "queued": false }`

## GET /jobs/{jobId}

```
curl https://your-worker.example.workers.dev/jobs/$JOB_ID \
  -H "X-Access-Token: $ACCESS_TOKEN" -H "Authorization: Bearer $TOKEN"
```

```json
{ "jobId": "...", "status": "transcoding", "createdAt": "...", "updatedAt": "...",
  "expiresAt": "...", "sourceBytes": 186662455, "resultBytes": null,
  "crf": 32, "preset": 4, "error": null }
```

## GET /jobs/{jobId}/result

Once `done`, returns a one-time presigned download URL (valid 1 hour).

```
curl https://your-worker.example.workers.dev/jobs/$JOB_ID/result \
  -H "X-Access-Token: $ACCESS_TOKEN" -H "Authorization: Bearer $TOKEN"
```

`{ "resultUrl": "https://...", "expiresIn": 3600 }`

## GET /admin/jobs

Every job on the system, any status, newest first — not scoped to one caller.

`curl https://your-worker.example.workers.dev/admin/jobs -H "X-Access-Token: $ACCESS_TOKEN"`

`{ "activeCount": 3, "jobs": [{ "jobId": "...", "status": "transcoding", ..., "ciRunHint": "339..." }] }`

## POST /admin/cancel

Force-stops a job regardless of status: marks it `cancelled`, frees its slot, promotes the next
queued job, and cancels the real GitHub Actions run too if one was already reported.

```
curl -X POST https://your-worker.example.workers.dev/admin/cancel \
  -H "X-Access-Token: $ACCESS_TOKEN" -H 'content-type: application/json' -d '{"jobId": "..."}'
```

`{ "jobId": "...", "cancelled": true, "previousStatus": "transcoding", "githubCancelled": true }`

`githubCancelled` is `null` when no GitHub run was ever reported (nothing to cancel there);
otherwise it's whether that API call succeeded. Either way the job is cancelled and its slot freed
locally regardless.

## State machine

| status | Meaning |
|---|---|
| `awaiting_upload` | Waiting for the source file to be uploaded |
| `queued` | Committed, waiting for a free concurrency slot |
| `dispatching` | Triggering GitHub Actions |
| `downloading` | CI pulling the source from R2 |
| `transcoding` | ffmpeg encoding to AV1 |
| `uploading` | CI pushing the result to R2 |
| `done` | Finished, result available |
| `failed` | `error` names which pipeline step failed |
| `cancelled` | Force-stopped via `POST /admin/cancel` |

No `expired` status — past TTL or evicted under quota pressure, a record is deleted outright:
`GET /jobs/{jobId}` 404s, same as an id that never existed.

## Output

AV1 (`libsvtav1`) video + Opus audio in an MP4 container (faststart) — not Matroska, for broad
player compatibility.

## Limits

- **Source size**: 2 GiB max, enforced on commit via R2 `HEAD`; oversized uploads are deleted and rejected with `413`.
- **Time budget (gatekeeper)**: after downloading the source, CI probes its real duration/resolution and estimates encode time from an empirical preset/resolution model. If the estimate exceeds `MAX_ESTIMATED_CI_SECONDS` (3h default), CI aborts before ever running ffmpeg — no wasted runner time on a config that was never finishing. **If your source is long enough to hit this**, split it into chunks locally (e.g. `ffmpeg -i in.mp4 -c copy -segment_time 600 -f segment part%03d.mp4`), transcode each chunk as its own job, then concatenate the AV1 outputs locally (`ffmpeg -f concat -i list.txt -c copy out.mp4`) — this also parallelizes across the 10 concurrent slots instead of tying up one for hours.
- **Real-time transcoding**: not guaranteed. A real measurement (720p, preset 4, standard 2 vCPU runner) took 18s for a 15s clip (~0.83x). Presets 10-13 get closer to or above 1x at 720p and below; 1080p+ generally won't hit realtime even at preset 13.

## Privacy and security

- R2 is fully private; every access is a short-lived presigned URL. Object keys are random, never the original filename.
- CI receives only the job id, R2 keys, crf, and preset. ffmpeg/ffprobe output (including what the gatekeeper probes) is never logged or reported — only pass/fail crosses back out. A failure names only the pipeline step, never content.
- Source files are deleted immediately on job termination. Results expire after 1 day (TTL) or earlier under LRU eviction once storage exceeds quota.
- A job stuck in `dispatching` for 30+ minutes with no real GitHub run, or in an active CI stage for 6+ hours with no status update, is auto-failed and its slot reclaimed. A sweep also matches each active job's specific GitHub run id against GitHub's real queued/in-progress runs, catching an out-of-band cancellation well before either timeout.
