# Render Worker & Media Services

The self-hosted render path that turns a saved editor composition into a finished
MP4/WebM master — plus the ffmpeg-powered media services (audio mastering, upscale,
background removal, vectorize, clip-proxy) that ride along in the same worker
package. No AWS, no Lambda.

> **Status: LIVE in production since 2026-07-06.** `RENDER_ENABLED=true` is durable
> on the API app, the fleet worker has been proven end-to-end (a real composition
> was rendered and landed on MinIO), and the full Auto-Clips pipeline
> (detect → cut → render → captions/voice → library) runs against it. For the
> production container layout, the two gates that must stay set, monitoring, and
> the recovery runbook, see [auto-clips-infra.md](auto-clips-infra.md) — this page
> covers the code.

**On this page**

- [Overview: two render modes](#overview-two-render-modes)
- [Where it fits](#where-it-fits)
- [The enqueue surface (`src/render.js`)](#the-enqueue-surface-srcrenderjs)
- [Retry, DLQ, and the dead-job reaper (migration 017)](#retry-dlq-and-the-dead-job-reaper-migration-017)
- [The production renderer (`render-worker/worker.js`)](#the-production-renderer-render-workerworkerjs)
- [The alternate mode: `src/render-worker.js` + `RENDER_WORKER_URL`](#the-alternate-mode-srcrender-workerjs--render_worker_url)
- [`editor/render-service.mjs` — the HTTP render service](#editorrender-servicemjs--the-http-render-service)
- [Preview must equal export — Remotion lockstep](#preview-must-equal-export--remotion-lockstep)
- [Watermark policy](#watermark-policy)
- [Master audio FX mapping](#master-audio-fx-mapping)
- [Co-hosted media services](#co-hosted-media-services)
- [Configuration](#configuration)
- [Deploying](#deploying)
- [Testing](#testing)
- [Operational notes & gotchas](#operational-notes--gotchas)
- [Licensing](#licensing)
- [Related docs](#related-docs)

---

## Overview: two render modes

There are two implementations of "drain `render_jobs` and render with
`@remotion/renderer`". Both claim from the same table with the same atomic
claim/lease/retry SQL; they differ in where the Remotion bundle lives and what
post-render steps they run.

| Mode | What runs | Where the Remotion bundle lives | Status |
|------|-----------|--------------------------------|--------|
| **Production (fleet worker)** | [`render-worker/worker.js`](../../render-worker/worker.js) — a standalone container that polls `render_jobs` directly over Postgres | **In-container**: the Docker image copies `editor/src` and bundles `editor/src/remotion/index.ts` itself (comp id `Main`) | **LIVE** — the `corereflex-render-worker` container on the App box (see [auto-clips-infra.md](auto-clips-infra.md)) |
| **Alternate / local (HTTP split)** | [`src/render-worker.js`](../../src/render-worker.js) — the dependency-free in-app loop — with [`src/render-engine.js`](../../src/render-engine.js) as its `renderFn`, POSTing each job to `RENDER_WORKER_URL` | On whatever serves `RENDER_WORKER_URL` — in practice [`editor/render-service.mjs`](../../editor/render-service.mjs), a small `@remotion/renderer` HTTP service inside the editor package | Shipped and tested; used for local end-to-end runs (`RENDER_MOCK=true` needs no Remotion at all) and available as a deploy topology |

The `render-worker/` package pins `@remotion/bundler` / `@remotion/renderer`
**4.0.483 — the exact version the editor pins** (`editor/package.json`), so
preview and export stay reproducible. See
[Preview must equal export](#preview-must-equal-export--remotion-lockstep).

The `render-worker/` package is also the home of the **media services** — small
HTTP endpoints co-hosted in the worker process (or split into their own
containers) because the worker box is the only one with ffmpeg/ffprobe:
[audio-fx](#audio-fx--per-clip-mastering-dsp), [upscale](#upscale--sr-backend),
[remove-bg](#remove-bg), [vectorize](#vectorize), and
[clip-proxy](#clip-proxy--long-video-prepare-for-auto-clips).

---

## Where it fits

The render worker proper has **no HTTP surface for rendering**: it is a
worker-type process that drains a queue. All coordination happens through the
shared Postgres database and the shared object store (MinIO on the fleet) — the
*same* database and bucket the API app and the editor use. The co-hosted media
services *do* listen on ports, each gated by `RENDER_SHARED_SECRET`. See
[architecture.md](architecture.md) for the deployable topology and
[data-model.md](data-model.md) for the `render_jobs` / `compositions` / `assets`
schema.

```
editor "Render"  ─POST /api/render──▶  src/render.js  ─INSERT render_jobs (queued)─▶  Postgres
                                          ▲                                              │
editor poller  ─GET /api/render/:id───────┘                                              │ claim (attempts+1, locked_at)
                                                                                         ▼
                                                       render-worker/worker.js  (container, N replicas safe)
                                                            │ bundle editor/src/remotion once
                                                            │ selectComposition + renderMedia (h264)
                                                            │   └ onProgress → error->progress + locked_at heartbeat
                                                            │ master audio-FX ffmpeg pass (best-effort)
                                                            ▼
                                                       presigned PUT → MinIO  (renders/<compositionId>/<jobId>.mp4)
                                                            │
                                                       assets INSERT → render_jobs.output_asset_id, succeeded
                                                            │
                                                       auto-ENHANCE (upscaleTarget → sibling asset, best-effort)
```

In the alternate mode the middle block is replaced by
`src/render-worker.js → src/render-engine.js ─POST─▶ RENDER_WORKER_URL`
(`editor/render-service.mjs`), with the file landing via the app's
`putObject` under `renders/<workspace_id>/<jobId>.<ext>` instead.

---

## The enqueue surface (`src/render.js`)

`POST /api/render` → `meterUsage(user, 'usage:render')` → `enqueueRender(user, input)` →
**202 Accepted** with a `jobId`. Before it queues anything, `enqueueRender`
enforces, in order:

1. **`RENDER_ENABLED !== 'true'` → 503.** The honesty gate: never accept a render
   that no worker can drain. This is now **satisfied in production** —
   `RENDER_ENABLED=true` lives durably in the API app's Coolify env store (one of
   the [two gates that must stay set](auto-clips-infra.md#two-gates-that-must-stay-set)).
   The gate still protects fresh/local deploys that have no worker yet.
2. **`compositionId` must be a UUID → 400.**
3. **`codec` ∈ `{h264, vp8}`** (default `h264`) → 400 otherwise.
4. **`upscaleTarget` ∈ `{auto, 720p, 1080p, 4k, 8k}` or absent** → 400 otherwise
   (`validateUpscaleTarget`). When present, the worker runs the
   [auto-ENHANCE post-render step](#7-auto-enhance-the-upscaletarget-post-render-step).
5. **Workspace ownership.** The composition must belong to the caller's workspace
   (`compositions → campaigns.workspace_id`) → **404** if not. Same
   tenant-isolation join the workers use when they load the composition.

It then inserts a `render_jobs` row with `status='queued'`, a per-render
`cost_estimate_cents` (from `RENDER_COST_ESTIMATE_CENTS`, default `0`,
finance-tunable without a code change), and stashes the request options on the
row's free-form `error` jsonb column — the workers read them back at claim time:

```sql
insert into render_jobs (composition_id, provider_route, status, cost_estimate_cents, error)
values ($1, 'remotion-self-hosted', 'queued', $3,
        jsonb_strip_nulls(jsonb_build_object('codec', $4::text, 'upscaleTarget', $5::text)))
returning id
```

### Status reporting (`getRenderJob`)

`GET /api/render/:jobId` re-joins to `campaigns` so a user can only read jobs in
their own workspace (404 otherwise). It returns:

```js
{ status, progress, qa, outputUrl, upscaledUrl, error }
```

- **`progress`** is fine-grained when a worker has persisted one (`error->progress`,
  a 0..1 value), else derived from `status` (`queued→0`, `running→0.5`,
  `succeeded→1`). The editor's poller advances either way.
- **`qa`** is the QA15 readiness report when the worker attached one under
  `error->qa` (currently the in-house loop path — see
  [QA15 analysis](#qa15-analysis-of-finished-renders)); `null` otherwise.
- **`outputUrl`** is non-null only on `succeeded`, served through the **signed
  asset proxy** (`signedAssetUrl(object_key)`) — never the fleet-internal MinIO
  endpoint. See [../ASSET-SERVING.md](../ASSET-SERVING.md).
- **`upscaledUrl`** is the auto-ENHANCE sibling master, if one was produced — the
  worker records its object key under `error->upscale.key` and this signs it
  through the same proxy.
- **`error`** is the human message only when `status='failed'`.

Shorts reuse this exact path: `createShort` ([`src/shorts.js`](../../src/shorts.js))
reframes a finished composition into a vertical short and calls `enqueueRender`,
so a short is just another `render_jobs` row. The Auto-Clips pipeline
(`POST /api/auto-clips`) also lands its clip renders through this same queue.

---

## Retry, DLQ, and the dead-job reaper (migration 017)

[`migrations/017_render_job_retry.sql`](../../migrations/017_render_job_retry.sql)
added the two columns that make the queue crash-safe, plus a claim index:

```sql
alter table render_jobs add column if not exists attempts integer not null default 0;
alter table render_jobs add column if not exists locked_at timestamptz;
create index if not exists render_jobs_claim_idx on render_jobs (status, created_at);
```

The semantics (identical in both worker implementations):

- **Claim = lease.** Every claim stamps `locked_at = now()` and increments
  `attempts`. The claim query selects `status='queued'` **or** a stale `running`
  row (`locked_at < now() - interval '15 minutes'` and
  `attempts < RENDER_MAX_ATTEMPTS`, default **3**) — so a job whose worker died
  mid-render is reclaimed by another replica once its lease lapses, bounded by
  attempts so a job that keeps crashing the worker isn't reclaimed forever.
- **Heartbeat.** During a render, the progress callback re-stamps `locked_at`
  alongside `error->progress` — a healthy render that runs *longer* than the
  15-minute lease is never reclaimed and double-rendered.
- **Bounded retry on failure.** A caught render failure with
  `1 ≤ attempts < RENDER_MAX_ATTEMPTS` is **requeued** (`status='queued'`,
  `locked_at=null`) with the message merged into the error jsonb so enqueue-time
  fields (`codec`, `upscaleTarget`) survive the retry. Once attempts hit the cap
  the row goes terminal `failed` — the DLQ.
- **The DLQ reaper** (`reapDeadRenderJobs` in [`src/render.js`](../../src/render.js))
  catches the one case retries can't: a worker that dies *hard* (OOM/SIGKILL)
  bumps `attempts` at claim time but never runs the failure handler. Once
  attempts are exhausted **and** the lease has lapsed, such a row is excluded
  from reclaim yet never terminal — a frozen-at-50% orphan that hangs the editor
  poller. The reaper flips those to `failed` with an explanatory message. It runs
  best-effort on every automation scheduler tick
  ([`src/automation.js`](../../src/automation.js) `startScheduler`, default every
  60s via `AUTOMATION_TICK_MS`).

---

## The production renderer (`render-worker/worker.js`)

The [`render-worker/`](../../render-worker/) package is the production render
path: a standalone container (its own deploy unit — the repo-root app image does
not include it) that talks straight to the shared Postgres and MinIO. In prod it
runs as the `corereflex-render-worker` container on the App box with
`--restart unless-stopped` (see [auto-clips-infra.md](auto-clips-infra.md) for
why it is currently a manual `docker run` rather than a Coolify-managed app).

| File | Purpose |
|------|---------|
| `worker.js` | The poll loop: claim → load manifest → bundle → render → audio-FX → upload → asset row → succeed → auto-ENHANCE. Also boots the co-hosted media services (port-gated). |
| `props-from-manifest.js` | Maps `compositions.manifest` (the editor `UndoableState` IR) → composition `inputProps` (`{tracks, items, assets, compositionWidth, compositionHeight, fontInfos:{}}`) |
| `audio-fx-chain.js` | Pure FX-knobs → ffmpeg `-af` chain mapping (see [the table below](#master-audio-fx-mapping)); also `AUDIO_FX.md` documents the chain |
| `storage.js` | Dependency-free SigV4 presign + PUT to MinIO (ported from root `src/storage.js`) |
| `Dockerfile` | node:20 + Chromium shared libs + ffmpeg + the editor source + the worker and all service files |
| `.env.example` | Core env (DATABASE_URL + ASSET_* MinIO + render tuning) |

The loop, per job:

### 1. Claim (atomic, replica-safe, leased)

```sql
update render_jobs
set status = 'running', attempts = attempts + 1, locked_at = now(), updated_at = now()
where id = (
  select id from render_jobs
  where status = 'queued'
     or (status = 'running' and attempts < $MAX and locked_at < now() - interval '15 minutes')
  order by created_at asc for update skip locked limit 1
)
returning id, composition_id, attempts, error
```

`FOR UPDATE SKIP LOCKED` is what makes **horizontal scaling safe**: run as many
replicas as you like and each queued job is claimed by exactly one worker — no
double-renders, no external lock service. The optional `upscaleTarget` is read
back from the claimed row's `error` jsonb.

> The fleet worker currently renders **h264 MP4 only** — it does not read the
> requested `codec` back from the row (the in-house loop does). A `vp8` enqueue
> renders as h264 through this path. Known gap.

### 2. Load the composition, workspace, and plan

```sql
select c.manifest, ca.workspace_id, acc.plan
from compositions c
join campaigns ca on ca.id = c.campaign_id
join workspaces ws on ws.id = ca.workspace_id
join accounts acc on acc.id = ws.account_id
where c.id = $1
```

This fetches the editor's `manifest` IR, the owning `workspace_id` (assets are
workspace-scoped and the worker runs service-role SQL, so it is responsible for
its own tenant scoping), and the account **plan** for the
[watermark decision](#watermark-policy). An empty timeline
(`manifestHasContent` false) fails fast with a clear message rather than
rendering a black video.

### 3. Bundle once, render with Remotion

The worker bundles **`editor/src/remotion/index.ts`** — the *same* entry the
browser editor uses (`registerRoot(Root)`, composition id `Main`,
`REMOTION_ENTRY`/`REMOTION_COMP_ID` overridable) — **once per process** and
reuses the served bundle across jobs (bundling is the slow part; a failed bundle
resets the promise so a later job can retry). Then per job:

- `inputProps = { ...inputPropsFromManifest(manifest), watermark }`
- `selectComposition()` runs `Root.tsx`'s `calculateMetadata`, which resolves
  width/height/fps/durationInFrames **and** `fontInfos` from the manifest items —
  so the worker passes `fontInfos:{}` and lets the bundle compute them.
- `renderMedia({ codec: 'h264', colorSpace: 'bt709', x264Preset, concurrency,
  timeoutInMilliseconds, chromiumOptions: { gl: 'angle' } })` to a temp MP4.
  `gl:'angle'` is required for headless Chromium as root in a container.

**Progress persistence + heartbeat**: the `renderMedia` `onProgress` callback
persists the 0..1 value under `error->progress` (what `getRenderJob` reads so the
editor poller advances past a frozen 0.5) **and** re-stamps `locked_at`, throttled
to ~5% steps to spare the DB. This is the lease heartbeat described
[above](#retry-dlq-and-the-dead-job-reaper-migration-017).

### 4. Master audio FX pass (best-effort)

If the manifest's master-FX rack has any active knob, one ffmpeg pass bakes the
[DSP chain](#master-audio-fx-mapping) into the export: audio re-encoded (aac
256k), **video stream-copied**, `-movflags +faststart` so browsers can start
playback before the whole MP4 downloads. If ffmpeg fails (e.g. a silent
composition with no audio stream), the worker ships the un-processed render
rather than failing the job.

### 5. Land the file + register the asset

`uploadFile` (render-worker `storage.js`) presigns a SigV4 PUT and uploads to:

```
renders/<compositionId>/<jobId>.mp4        (prefix via RENDER_OUTPUT_PREFIX)
```

(The in-house loop keys by workspace instead: `renders/<workspace_id>/<jobId>.<ext>`.)

`insertAsset` writes a workspace-scoped `assets` row **idempotently**
(`ON CONFLICT (bucket, object_key) DO UPDATE`) with provenance
`{ source: 'render-worker', provider_route, render_job_id }`.

### 6. Flip terminal

On success: `status='succeeded'`, `output_asset_id` linked, `error = null`.
On any thrown error: `markFailed` runs the
[bounded retry / DLQ logic](#retry-dlq-and-the-dead-job-reaper-migration-017) —
requeue with the error message *merged* into the existing jsonb (so `codec` /
`upscaleTarget` survive), or terminal `failed` with `{message, stack, at}`. The
temp dir is always removed.

### 7. Auto-ENHANCE — the `upscaleTarget` post-render step

If the job was enqueued with an `upscaleTarget`, `runEnhancePass` runs **after**
the job is already marked succeeded and **never throws** — a failed ENHANCE is
logged and the native render stays the official output (this invariant is
unit-tested; the pass is dependency-injected).

- It calls the same `processUpscale` the [upscale service](#upscale--sr-backend)
  exposes, in **local-file mode**: ffmpeg reads the master straight from the temp
  path just rendered (`srcPath`) — no re-fetch of the public URL, which may not
  be anonymously GET-able in prod.
- The result is always a **sibling asset** (provenance ties it to the source +
  the resolved plan); the native master is never overwritten — native-4K stays
  the quality floor.
- On success it records `{key, assetId, target, backend}` under
  `error->upscale`, which is what `getRenderJob` signs into `upscaledUrl`.
- A passthrough (`already at or above target`, or the SR backend selected but not
  configured) records nothing — honest, never a fake upscale.

### The drain loop

`pollOnce` claims and processes one job; `main()` pre-bundles on boot (so the
first job is fast and bundle errors surface early), starts any port-gated
[co-hosted services](#co-hosted-media-services), then loops — sleeping
`POLL_INTERVAL_MS` (default **5000ms**) when the queue is empty and backing off on
poll errors instead of hot-looping. `SIGINT`/`SIGTERM` finish the current job,
close the pool, then exit. The module is **import-safe**: pool/storage/bundle/
signal handlers only initialize when invoked as a script, so unit tests can
import `claimNextJob` / `markFailed` / `runEnhancePass` with a mock client.

---

## The alternate mode: `src/render-worker.js` + `RENDER_WORKER_URL`

The in-app worker is the same claim/render/land loop implemented with **zero new
dependencies** — the actual frame rendering is injected as `renderFn`, which is
what makes the whole loop unit-testable with a mock DB and a fake `renderFn`.

| Module | Role |
|--------|------|
| [`src/render-worker.js`](../../src/render-worker.js) | `processNextJob` (claim → load → `renderFn` → `putObject` → asset → terminal) + `runWorkerLoop` (drain, idle sleep 3000ms, back-off on infra errors) + a `node src/render-worker.js` CLI entry that loads the engine via `RENDER_ENGINE` (default `./render-engine.js`) |
| [`src/render-engine.js`](../../src/render-engine.js) | The prod `renderFn` for this mode: POSTs `{compositionId, manifest, hyperframesHtml, codec}` to `RENDER_WORKER_URL` with the `x-render-token` shared secret, bounded by `RENDER_TIMEOUT_MS` (default 30 min), and returns `{body, contentType, byteSize, qaSignals?, analysis?}` (QA can ride back on `x-qa-signals` / `x-render-analysis` response headers). `RENDER_MOCK=true` completes jobs without any Remotion service (re-uses the first clip's bytes) so the whole enqueue→worker→asset→download pipeline is testable end-to-end. |

The `renderFn` contract:

```js
renderFn({ compositionId, manifest, hyperframesHtml, codec, onProgress })
  -> { body: Buffer, contentType, byteSize }   // also accepts { buffer }; byteSize derived
```

This path honors the requested `codec` (`vp8` → WebM) and additionally runs three
post-render steps the fleet worker does not (yet):

### QA15 analysis of finished renders

After the file lands, the loop builds a **QA15 readiness report** for the
finished master and stores it on the job (`error->qa`) *and* in the asset's
provenance:

- [`src/render-qa-analyzer.js`](../../src/render-qa-analyzer.js)
  (`withRenderAnalysis`) — if the engine didn't already supply QA evidence, it
  analyzes the rendered video via `analyzeClip` (the shared Gemini media-analysis
  seam in `src/qa-analyzer.js`), fetching it through the signed asset proxy.
  Best-effort: analysis failure never fails the render.
- [`src/render-qa.js`](../../src/render-qa.js) (`createRenderQaReport`) — merges
  analyzer signals with deterministic checks derived from the manifest itself
  (timeline continuity for `visual_smooth_transitions`; message/conversion cues
  regex-scored from on-screen/script text, only when the copy is substantive;
  audio/vision thresholds from the analysis) and scores them with the shared
  `QA15` rubric (`scoreVideoReadiness`) into `{score, max, band, publishable,
  signals, evidence}`. Items on audio-session tracks are excluded — they are
  never rendered, so they must not feed QA signals.

`getRenderJob` surfaces the report as `qa`, and the editor shows it.

### Content credentials + automations

- **CoreReflex Content Credentials**: `tryMintCredential` signs the rendered
  bytes (AI-generated only if the manifest actually references `generated/`
  media — a pure passthrough of user footage is not claimed as AI). Best-effort;
  a missing `CREDENTIAL_SIGNING_KEY` never fails a render.
- **`render_succeeded` automation trigger**: fired best-effort with
  `{jobId, compositionId, assetId, objectKey, assetUrl (signed), qaScore/qaBand/
  qaPublishable}` — the `publish_social` automation action templates
  `{{assetUrl}}` from this. Loaded via dynamic `import` to avoid the
  automation↔CRM import cycle.

> **Delta to be aware of:** in production the *fleet* worker renders, and it does
> **not** attach QA reports, mint credentials, or fire `render_succeeded`
> automations — those steps currently exist only in this in-house loop. See
> the gaps list at the end of this doc's source review if you're picking this up.

---

## `editor/render-service.mjs` — the HTTP render service

[`editor/render-service.mjs`](../../editor/render-service.mjs) is the piece that
backs `RENDER_WORKER_URL` in the alternate mode: a tiny (~120-line)
`node:http` service **inside the editor package** — which means its Remotion
version is the editor's by construction, no separate pin to keep in lockstep.
Run it with `npm run render-service` from `editor/`.

- **Contract** (matches `src/render-engine.js`):
  `POST { compositionId?, manifest, codec }` → `200` with `video/mp4` or
  `video/webm` bytes; `GET /health` → `{ok: true, composition}`.
- Bundles `editor/src/remotion/index.ts` **once** and reuses the serve URL;
  renders with `inputProps = manifest` directly (the composition's inputProps
  *are* the stored manifest — which is why the in-house worker bakes `watermark`
  into the manifest before calling the engine).
- **Auth**: when `RENDER_SHARED_SECRET` is set, requests must carry a matching
  `x-render-token` header. The service fetches asset URLs found in the posted
  manifest server-side, so an unauthenticated endpoint would be an SSRF/DoS
  vector.
- **Tuning**: `PORT` (default 8088), `RENDER_COMPOSITION_ID` (default `Main`),
  `RENDER_CONCURRENCY`, and the encoder tier `RENDER_CRF` / `RENDER_X264_PRESET`
  (unset → Remotion defaults). Payloads are capped at 16 MiB.

CPU is fine (no GPU required); more cores = more parallel Chromium tabs = faster
renders.

---

## Preview must equal export — Remotion lockstep

Whatever renders — the fleet worker's in-container bundle or the HTTP render
service — must bundle the **same** Remotion entry the browser editor uses
(`editor/src/remotion/index.ts`, composition id `Main`) and build the same
inputProps shape from the manifest. Two pins keep preview and export
reproducible:

- **Remotion version** — the editor and `render-worker/package.json` are both
  pinned to **4.0.483** and must move together. This is not theoretical: a
  4.0.463 ≠ 4.0.482 mismatch has broken the video bundle before. `npm test`
  includes a lockstep check; bump both sides in one commit.
- **The manifest → props mapping** — `render-worker/props-from-manifest.js` is
  the one small auditable file that maps `UndoableState`
  (`editor/src/editor/state/types.ts`) to inputProps. If the manifest shape
  changes, update it in lockstep.

**If you change the composition in the editor (anything under
`editor/src/remotion` or the `editor/src/editor/*` it imports), redeploy the
fleet worker too** — its image copies `editor/src` at build time, so a stale
worker renders a stale composition that won't match the in-editor preview.

---

## Watermark policy

The watermark decision is made **server-side from the account plan loaded with
the composition — never from the (editor-controlled) manifest**. It is
default-deny: `PRO_PLANS = {pro, enterprise}` get a clean master; free / unknown
/ `null` plans get the brand watermark baked into the export. Both paths agree:

- The fleet worker passes `watermark` in `inputProps`
  (`Root.tsx` `defaultProps` has `watermark:false`; `calculateMetadata` spreads
  it through to the render).
- The in-house loop bakes `watermark` into the manifest it hands `renderFn` —
  necessary because `render-service.mjs` renders `inputProps = manifest`.

This mirrors the billing plan model in [data-model.md](data-model.md) and
[billing.md](billing.md).

---

## Master audio FX mapping

The composition's master-FX rack is a **real-DSP, baked-into-the-export** pass —
the counterpart to the CSS/Web-Audio preview the editor renders live. `manifest`
carries up to seven one-knob effects, each `0..100`; the worker maps the active
ones (skipping any `0`) into a single comma-separated ffmpeg `-af` chain and runs
**one** ffmpeg pass that re-encodes only the audio (video stream-copied). The
mapping ([`render-worker/audio-fx-chain.js`](../../render-worker/audio-fx-chain.js),
documented in [`render-worker/AUDIO_FX.md`](../../render-worker/AUDIO_FX.md)) is
pure and deterministic, so it is fully unit-testable in isolation. The same chain
powers the per-clip [audio-fx service](#audio-fx--per-clip-mastering-dsp).

The chain order is mastering-sane — **limiter last** so it catches peaks
introduced by everything upstream:

| Order | Knob | FFmpeg filter | Mapping |
|-------|------|---------------|---------|
| 1 | `denoise` | `afftdn` | `nr = denoise × 0.3` dB, `nf=-25` |
| 2 | `deEss` | `deesser` | `i = deEss / 100` |
| 3 | `compress` | `acompressor` | `ratio = 1 + compress×0.05`, `attack=20`, `release=250`, `makeup = 1 + compress×0.01` |
| 4 | `reverb` | `aecho` | wet delay/decay scaled by `reverb` |
| 5 | `maximize` | `loudnorm` | target `I` sweeps −16 LUFS (gentle) → −9 LUFS (loud), `TP=-1.5`, `LRA=11` |
| 6 | `clip` | `volume,asoftclip=type=tanh` | pre-gain into a tanh soft-clipper for harmonic saturation |
| 7 | `limit` | `alimiter` | `limit = 1 − limit×0.004` |

The pass is **best-effort**: if ffmpeg fails — e.g. a silent composition with no
audio stream — the worker ships the un-processed render rather than failing the
job.

---

## Co-hosted media services

The worker package hosts five small HTTP services alongside the render loop —
they live here because the worker box is the only one with ffmpeg/ffprobe (and,
for vectorize, the VTracer binary). Each is **opt-in via a port env var** on the
worker and reached from the API app via a matching **URL env var** (the app-side
adapters live in `src/*-engine.js` and throw a clear, actionable error when the
URL is unset; the API routes 503 honestly *before metering* instead).

They all share the same shape:

- **Auth**: `x-render-token` must match `RENDER_SHARED_SECRET` (the same secret
  as the render HTTP path). They fetch URLs server-side, so an open endpoint is
  an SSRF/DoS risk.
- **SSRF guards**: the app validates the source is a public https URL
  (`safePublicUrl` blocks loopback/link-local/private/metadata hosts) *and* the
  service refuses to follow redirects (`redirect: 'manual'`) so a 3xx can't
  bounce to an internal host that was never checked. Size caps on every fetch.
- **Honest passthrough**: when a backend isn't configured (no SR service, no
  segmentation service, tracer not enabled) the service returns the original URL
  untouched — never a faked result.
- **Sibling assets**: derivatives are written to **content-addressed** MinIO
  keys and registered as *sibling* `assets` rows (idempotent on
  `(bucket, object_key)`); sources are never overwritten.
- **Health**: `GET /health` → `{ok: true, service}`.
- **Fleet-internal URLs**: the app re-signs each result through the asset proxy
  (`proxiedDerivative` in `src/api.js`) before handing it to the browser.

| Service | Worker env (port) | App env (URL) | Endpoint | App route | Backend gate |
|---------|------------------|---------------|----------|-----------|--------------|
| audio-fx | `AUDIO_FX_PORT` | `AUDIO_FX_URL` | `POST /audio-fx` | `POST /api/audio/fx` | always (ffmpeg in-image) |
| upscale | `UPSCALE_PORT` | `UPSCALE_URL` | `POST /upscale` | `POST /api/upscale` | ffmpeg default; `UPSCALER=sr` + `SR_SERVICE_URL` for the SR flagship |
| remove-bg | `REMOVE_BG_PORT` | `REMOVE_BG_URL` | `POST /remove-bg` | `POST /api/image/remove-bg` | `SEGMENT_SERVICE_URL` |
| vectorize | `VECTORIZE_PORT` | `VECTORIZE_URL` | `POST /vectorize` | `POST /api/image/vectorize` | `VECTORIZE_ENABLED=true` + the `vtracer` binary |
| clip-proxy | `CLIP_PROXY_PORT` | `CLIP_PROXY_URL` | `POST /clip-proxy` | `POST /api/highlights/proxy` | always (ffmpeg in-image); **secret required to boot** |
| frame-extract | `FRAME_EXTRACT_PORT` | `FRAME_EXTRACT_URL` | `POST /frame-extract` | *(internal — `src/producer.js` continuity)* | always (ffmpeg in-image); **secret required to boot**. Prod: `:8093`, `FRAME_EXTRACT_URL` set on the API app |
| assemble | `ASSEMBLE_PORT` | `ASSEMBLE_URL` | `POST /assemble` | recipe step `assemble.segments` (`src/assemble-engine.js`) | always (ffmpeg in-image); **secret required to boot**. Prod: `:8094`, `ASSEMBLE_URL=http://corereflex-render-worker:8094` |

Each app route also has a `*_MOCK=true` escape hatch (passthrough) so the
apply→use loop is testable without the worker, and rate limits per user.

### audio-fx — per-clip mastering DSP

[`render-worker/audio-fx-service.js`](../../render-worker/audio-fx-service.js).
The editor sends one clip's audio source + its FX rack; the service runs the
**same** DSP chain as the master export through ffmpeg (audio-only, `-vn`, aac
256k) and stores the processed result as a content-addressed `.m4a` derivative
(`audio-fx/<workspace>/<fxKey>.m4a`; the key includes the source URL so distinct
clips with identical FX never collide). The editor then plays that derivative URL
in **both** the preview Player and the export render (the URL rides in the
manifest) — preview == export. An all-off rack is a passthrough. The `assets` row
provenance is `{source: 'audio-fx', fx_key, original}`. Cap:
`AUDIO_FX_MAX_BYTES` (default 100 MiB). App adapter:
[`src/audio-fx-engine.js`](../../src/audio-fx-engine.js).

### upscale + SR backend

[`render-worker/upscale-service.js`](../../render-worker/upscale-service.js) +
the pure policy in [`render-worker/upscale.js`](../../render-worker/upscale.js)
(a verbatim mirror of `src/upscale.js` — the worker is a separate deploy unit, so
keep the two in lockstep).

- ffprobes the real source dimensions, then `resolveUpscalePlan` decides: `auto`
  lifts sub-1080 to 1080 and leaves ≥1080 alone; explicit tiers
  (`720p/1080p/4k/8k`) are clamped to the deploy ceiling `UPSCALE_MAX_TIER`
  (default `4k`; 8K is opt-in) so the plan's target label is honest; scale is
  capped at 4× (beyond that interpolation reads as mush).
- **v1 backend (default)**: a real ffmpeg pass — lanczos resample + a gentle
  detail-preserving unsharp, `libx264` with `UPSCALE_CRF` (default 18) /
  `UPSCALE_X264_PRESET`, `+faststart`, audio stream-copied.
- **SR flagship backend**: `UPSCALER=sr` routes to a super-resolution model
  service via [`render-worker/sr-backend.js`](../../render-worker/sr-backend.js)
  (`SR_SERVICE_URL` + `SR_SERVICE_TOKEN`, timeout `SR_TIMEOUT_MS` default 30
  min). The model service is deployed separately (infra, not this repo). If
  `sr` is selected but the URL isn't set, the result is an honest passthrough
  (`backend: 'sr-unavailable'`) — never a fabricated "super-resolution".
- Two input modes: URL mode (the `/api/upscale` path fetches a public https
  source) and **local-file mode** (`srcPath`) used by the render loop's
  [auto-ENHANCE](#7-auto-enhance-the-upscaletarget-post-render-step) so the
  just-rendered master is read from disk. The backend is part of the
  content-addressed key + provenance, so an ffmpeg master and an SR master never
  share an object key. Cap: `UPSCALE_MAX_BYTES` (default 2 GiB). App adapter:
  [`src/upscale-engine.js`](../../src/upscale-engine.js).

### remove-bg

[`render-worker/remove-bg-service.js`](../../render-worker/remove-bg-service.js) +
pure helpers in [`render-worker/remove-bg.js`](../../render-worker/remove-bg.js).
Fetches a source image, gets a grayscale subject mask from the pluggable
segmentation backend (`SEGMENT_SERVICE_URL` + `SEGMENT_SERVICE_TOKEN`, timeout
`SEGMENT_TIMEOUT_MS` — a Vertex segmentation proxy or a self-hosted model; the
worker stays vendor-agnostic), then composites the mask as an alpha channel with
ffmpeg (`alphamerge`) into a transparent-PNG sibling asset
(`remove-bg/<workspace>/<hash>.png`, provenance `{source: 'remove-bg'}`). No
backend configured → honest passthrough. Cap: `REMOVE_BG_MAX_BYTES` (default 64
MiB). App adapter: [`src/remove-bg-engine.js`](../../src/remove-bg-engine.js).

### vectorize

[`render-worker/vectorize-service.js`](../../render-worker/vectorize-service.js) +
pure helpers in [`render-worker/vectorize.js`](../../render-worker/vectorize.js).
Traces a raster logo to a clean SVG with **VTracer** (visioncortex, MIT — a
third-party binary we host, not our tech) — color mode + spline curves — and
uploads it as an `image/svg+xml` sibling asset
(`vectorize/<workspace>/<hash>.svg`). Gated on `VECTORIZE_ENABLED=true` (the
binary must be in the image); otherwise honest passthrough. Cap:
`VECTORIZE_MAX_BYTES` (default 32 MiB). App adapter:
[`src/vectorize-engine.js`](../../src/vectorize-engine.js).

### clip-proxy — long-video prepare for Auto-Clips

[`render-worker/clip-proxy-service.js`](../../render-worker/clip-proxy-service.js)
+ pure argv/key helpers in
[`render-worker/clip-proxy.js`](../../render-worker/clip-proxy.js). Builds a
small **time-preserving** low-res proxy of a long source video (default 1 fps /
480p long edge / 24k mono audio, `CLIP_PROXY_FPS` / `CLIP_PROXY_HEIGHT` /
`CLIP_PROXY_AUDIO_K` / `CLIP_PROXY_CRF` tunable) so highlight detection can
inline it into Gemini within the inline byte cap — proxy second N == source
second N, so returned timestamps map 1:1 back onto the original. The low-bitrate
mono audio is kept deliberately: reactions/laughs/shouts help locate highlights.

Differences from its siblings:

- **Ephemeral artifact** — no `assets` row is written (it must not clutter the
  Media browser); a storage sweep can prune the `clip-proxy/` prefix later.
- **Fail-closed auth**: `createClipProxyServer` **refuses to start** without a
  secret (the others skip auth when the secret is empty), and compares tokens
  with `timingSafeEqual`.
- **Hardened I/O**: streaming byte cap (`CLIP_PROXY_MAX_BYTES`, default 512 MiB —
  a missing/lying Content-Length can't OOM the box), fetch timeout
  (`CLIP_PROXY_FETCH_TIMEOUT_MS`, default 120s), and a hard-killed ffmpeg timeout
  (`CLIP_PROXY_FFMPEG_TIMEOUT_MS`, default 10 min).

App adapter: [`src/autoclips-proxy.js`](../../src/autoclips-proxy.js). In
production this service runs as a **dedicated zero-dep container**
(`corereflex-clip-proxy` on `:9099`, reusing three files from this package)
rather than port-gated inside the worker — the container layout, env, monitor
cron, and recovery steps are documented in
[auto-clips-infra.md](auto-clips-infra.md).

### frame-extract — Director continuity chaining

[`render-worker/frame-extract-service.js`](../../render-worker/frame-extract-service.js)
+ pure argv/key helpers in
[`render-worker/frame-extract.js`](../../render-worker/frame-extract.js). Pulls a
single frame from a source video — by default the **last** frame (`-sseof -0.5 -i
… -update 1`, frame-exact without knowing the duration) — and uploads it as a
content-addressed JPEG. This is the anchor for **Director continuity**: each
produced shot's final frame conditions the next shot's Veo image-to-video
generation, instead of silently degrading to text-to-video.

- **Gated by `FRAME_EXTRACT_PORT`** (production: `8093`, port-gated inside the
  worker). Fail-closed auth (`x-render-token`, `timingSafeEqual`), the same
  streaming byte-cap + fetch/ffmpeg timeout hardening as clip-proxy, and an
  https-only + redirect-refused SSRF guard.
- **App adapter**: [`src/frame-extract-engine.js`](../../src/frame-extract-engine.js)
  (`FRAME_EXTRACT_URL`, e.g. `http://corereflex-render-worker:8093`). It is
  **soft** — an unset URL or a worker hiccup returns `null` and continuity
  degrades gracefully; it never fails a shot. Wired into
  [`src/producer.js`](../../src/producer.js) `defaultExtractLastFrame`.
- **Ephemeral** — content-addressed JPEG under `frame-extract/`, no `assets` row.
- The pure app modules shared with the in-process worker (QA15 + Content
  Credentials) plus these frame-extract files are COPYied into the image;
  `render-worker/dockerfile-copy.test.js` guards both import graphs.

---

## Configuration

All render config is via environment variables. Secrets are **not** reproduced
here — set real values on the deployment.

### The API app (root server)

| Var | Default | Purpose |
|-----|---------|---------|
| `RENDER_ENABLED` | *(unset)* | Must be `"true"` for `POST /api/render` to accept jobs (503 otherwise). **Set durably in prod.** |
| `RENDER_MAX_ATTEMPTS` | `3` | Retry cap before a job is DLQ'd (read by both workers and the reaper). |
| `RENDER_COST_ESTIMATE_CENTS` | `0` | Per-render cost estimate stamped on the job; finance-tunable. |
| `RENDER_WORKER_URL` | *(unset)* | Alternate mode only: the `@remotion/renderer` HTTP endpoint `src/render-engine.js` POSTs to. |
| `RENDER_TIMEOUT_MS` | `1800000` (30 min) | Alternate mode: per-render POST timeout in the engine adapter. |
| `RENDER_SHARED_SECRET` | *(unset)* | Shared secret sent as `x-render-token` to the render service and all media services. Must match the worker's value. |
| `RENDER_ENGINE` | `./render-engine.js` | Module `node src/render-worker.js` loads for its `renderFn`. |
| `RENDER_MOCK` | *(unset)* | `true` = the engine adapter completes jobs without any Remotion service (dev/e2e). |
| `AUDIO_FX_URL` / `UPSCALE_URL` / `REMOVE_BG_URL` / `VECTORIZE_URL` / `CLIP_PROXY_URL` | *(unset)* | Media-service endpoints on the worker. Unset → the matching API route 503s honestly before metering. |
| `FRAME_EXTRACT_URL` | *(unset)* | The worker's `/frame-extract` endpoint (e.g. `http://corereflex-render-worker:8093`). Unset → Director continuity soft-degrades to text-to-video (never errors). Prod: **set**. |
| `AUDIO_FX_MOCK` / `UPSCALE_MOCK` / `BG_REMOVE_MOCK` / `VECTORIZE_MOCK` / `CLIP_PROXY_MOCK` | *(unset)* | `true` = passthrough for testing without the worker. |
| `CREDENTIAL_SIGNING_KEY` | *(unset)* | Enables Content Credentials on the in-house loop path (best-effort). |
| `ASSET_BUCKET` | `corereflex-assets` | Object-storage bucket for rendered masters (shared with generation/editor). |

The in-house loop reuses the app's storage config
([`src/storage.js`](../../src/storage.js)) and `DATABASE_URL` — see
[configuration.md](configuration.md).

### The fleet worker (`render-worker/` container)

From [`render-worker/.env.example`](../../render-worker/.env.example) plus the
service gates:

| Var | Default | Purpose |
|-----|---------|---------|
| `DATABASE_URL` | *(required)* | The **same** Postgres the API + editor use. `POSTGRES_SSL`, `POSTGRES_POOL_MAX` (4) optional. |
| `ASSET_ENDPOINT` / `ASSET_BUCKET` / `ASSET_ACCESS_KEY_ID` / `ASSET_SECRET_ACCESS_KEY` / `ASSET_REGION` / `ASSET_PUBLIC_BASE_URL` | *(required)* | The **same** MinIO bucket the editor uploader and root `storage.js` use. `ASSET_FORCE_PATH_STYLE` defaults true (MinIO). |
| `REMOTION_ENTRY` | `/app/editor/src/remotion/index.ts` (set in the Dockerfile) | The editor composition entry to bundle. |
| `REMOTION_COMP_ID` | `Main` | Composition id. |
| `PROVIDER_ROUTE` | `self-hosted-remotion` | Stamped into asset provenance. |
| `POLL_INTERVAL_MS` | `5000` | Idle poll interval. |
| `REMOTION_CONCURRENCY` | *(auto)* | Chromium tab parallelism (blank = one per core). |
| `REMOTION_TIMEOUT_MS` | `240000` | Remotion per-render timeout. |
| `REMOTION_X264_PRESET` | `medium` | x264 preset (quality/speed tradeoff). |
| `RENDER_OUTPUT_PREFIX` | `renders/` | Object-key prefix: `renders/<compositionId>/<jobId>.mp4`. |
| `RENDER_MAX_ATTEMPTS` | `3` | Retry cap (keep equal to the app's value). |
| `RENDER_SHARED_SECRET` | *(unset)* | Gates all co-hosted service endpoints (and is **required** for clip-proxy and frame-extract to boot). |
| `AUDIO_FX_PORT` / `UPSCALE_PORT` / `REMOVE_BG_PORT` / `VECTORIZE_PORT` / `CLIP_PROXY_PORT` / `FRAME_EXTRACT_PORT` / `ASSEMBLE_PORT` | *(unset)* | Opt-in: start the matching co-hosted service on this port. Prod runs frame-extract on `:8093`, assemble on `:8094`. |
| `UPSCALER` / `UPSCALE_MAX_TIER` / `UPSCALE_CRF` / `UPSCALE_X264_PRESET` / `SR_SERVICE_URL` / `SR_SERVICE_TOKEN` / `SR_TIMEOUT_MS` | `ffmpeg` / `4k` / `18` / `medium` / — | Upscale backend + governance (see [upscale](#upscale--sr-backend)). |
| `SEGMENT_SERVICE_URL` / `SEGMENT_SERVICE_TOKEN` / `SEGMENT_TIMEOUT_MS` | *(unset)* / — / `60000` | Segmentation backend for remove-bg. |
| `VECTORIZE_ENABLED` | *(unset)* | `true` enables VTracer tracing. |
| `CLIP_PROXY_FPS` / `CLIP_PROXY_HEIGHT` / `CLIP_PROXY_AUDIO_K` / `CLIP_PROXY_CRF` / `CLIP_PROXY_MAX_BYTES` / `CLIP_PROXY_FETCH_TIMEOUT_MS` / `CLIP_PROXY_FFMPEG_TIMEOUT_MS` | `1` / `480` / `24` / `30` / 512 MiB / 120s / 10 min | Clip-proxy tuning + hardening caps. |
| `AUDIO_FX_KEY_PREFIX` / `UPSCALE_KEY_PREFIX` / `REMOVE_BG_KEY_PREFIX` / `VECTORIZE_KEY_PREFIX` / `CLIP_PROXY_KEY_PREFIX` | `audio-fx/` … `clip-proxy/` | Derivative key prefixes. |
| `AUDIO_FX_MAX_BYTES` / `UPSCALE_MAX_BYTES` / `REMOVE_BG_MAX_BYTES` / `VECTORIZE_MAX_BYTES` | 100 MiB / 2 GiB / 64 MiB / 32 MiB | Source-fetch size caps. |

### The HTTP render service (`editor/render-service.mjs`, alternate mode)

`PORT` (8088), `RENDER_COMPOSITION_ID` (`Main`), `RENDER_SHARED_SECRET`,
`RENDER_CONCURRENCY`, `RENDER_CRF`, `RENDER_X264_PRESET`.

---

## Deploying

### The fleet worker (production path)

The Docker image needs `editor/src` to bundle the composition, so **the build
context must be the repo root** (the dir containing both `editor/` and
`render-worker/`) with the Dockerfile at `render-worker/Dockerfile`:

```bash
docker build -f render-worker/Dockerfile -t corereflex-render-worker .
```

Watch the `.dockerignore` in effect: the repo-root one excludes `editor` (for the
*root app* image), which breaks this build (`COPY editor/... not found`). Deploy
from a context where `render-worker/.dockerignore` applies, or swap in a
dedicated ignore file for the worker build step. A test
(`render-worker/dockerfile-copy.test.js`) locks the Dockerfile `COPY` line to the
service file list, so adding a new service file without copying it fails CI.

The image: node:20-bookworm-slim + the Chromium shared libs + ffmpeg + fonts, the
editor's `npm ci` + `editor/src` + `remotion.config.ts`/`tsconfig.json`, then the
worker files. `HOME=/app` gives Remotion a writable Chromium cache. It is a
worker-type process — no exposed port is needed for rendering itself (only the
opt-in service ports), and `SIGTERM` finishes the current job then exits.
Resources: CPU-bound; several vCPUs and ≥4 GB RAM. GPU/NVENC is optional (speed
only, not correctness).

**Current prod reality**: the worker runs as a manual `docker run` container
(`--restart unless-stopped`) rather than a Coolify-managed app, because the
Coolify app's HTTP healthcheck kills a poller that serves no port. The cutover
plan, the clip-proxy container, the `*/5` monitor cron, and the recovery
quick-reference all live in [auto-clips-infra.md](auto-clips-infra.md) — read it
before touching the boxes.

**Scaling**: `FOR UPDATE SKIP LOCKED` + the lease guarantee one-claim-per-job, so
any number of replicas is safe.

### The alternate mode

1. Run `npm run render-service` in `editor/` (or deploy it as its own resource)
   and set `RENDER_SHARED_SECRET` on it.
2. Point the app at it: `RENDER_WORKER_URL`, the same `RENDER_SHARED_SECRET`,
   and `RENDER_ENABLED=true`.
3. Run the loop: `node src/render-worker.js` (loads `render-engine.js` and drains
   `render_jobs`; `SIGINT`/`SIGTERM` stop gracefully). For a no-Remotion dev
   loop, set `RENDER_MOCK=true` instead of deploying anything.

### Enqueuing jobs

Anything that inserts a `render_jobs` row with `status='queued'` gets picked up.
In practice: `enqueueRender` (`POST /api/render`), `createShort`
(`POST /api/shorts`), and the Auto-Clips pipeline (`POST /api/auto-clips`).
Drive progress UIs by polling `GET /api/render/:id`. See
[api-reference.md](api-reference.md).

---

## Testing

- **Root suite** (in-house loop, enqueue surface, QA, engines):
  `npm test` (runs `node --test`). Relevant files:
  `test/render.test.js`, `test/render-worker.test.js`,
  `test/render-engine.test.js`, `test/render-qa.test.js`,
  `test/render-qa-signals.test.js`, `test/upscale.test.js`,
  `test/audio-fx.test.js`, `test/audio-fx-engine.test.js`.
- **Worker package suite** (claim/retry logic, FX chain, all five services,
  Dockerfile COPY lock): `cd render-worker && node --test`. These run without
  ffmpeg / network / a database — every effect is dependency-injected.
- **End-to-end without a GPU**: `RENDER_ENABLED=true RENDER_MOCK=true` exercises
  enqueue → claim → asset → poller → download with canned bytes.

---

## Operational notes & gotchas

- **The `error` jsonb column does triple duty.** Enqueue stashes
  `{codec, upscaleTarget}` in; workers persist `{progress}` (and the in-house
  loop `{qa}`) during/after the run; failures write `{message, ...}` out. Any
  code that rewrites it must **merge**, not replace — the fleet worker's retry
  path was specifically fixed to `coalesce(error,'{}') || …` so enqueue fields
  survive a requeue.
- **The fleet worker renders h264 only** — the requested `vp8` codec is honored
  only on the `src/render-worker.js` path today.
- **QA15 / credentials / `render_succeeded` automations run only on the in-house
  loop path**, not the prod fleet worker. Don't build features assuming prod
  renders fire automations until that's reconciled.
- **Success wipes progress on the fleet worker** (`error = null` on succeed) —
  fine for the poller (status-derived progress is 1), and the ENHANCE record is
  appended *after* the wipe, so `upscaledUrl` still works.
- **Fine-grained progress doubles as the lease heartbeat.** If you change the
  progress throttle, remember it also keeps long renders from being reclaimed.
- **Tenant scoping is the worker's job.** Both workers run service-role SQL, so
  they resolve and stamp `workspace_id` themselves on every asset row.
- **Object-key layouts differ by path**: fleet = `renders/<compositionId>/…`,
  in-house = `renders/<workspace_id>/…`. Storage sweeps and per-workspace quota
  math must not assume one layout.
- **Idempotent asset writes.** `ON CONFLICT (bucket, object_key)` means a re-run
  for the same output updates the existing row rather than erroring.
- **Bundle-once means redeploy-on-composition-change.** The fleet worker bundles
  at boot and reuses the bundle; editor composition changes require a worker
  image rebuild + redeploy.
- **Honesty over silent failure, everywhere**: unset `RENDER_ENABLED` → 503;
  unset service URLs → 503 before metering; unconfigured SR/segmentation/tracer
  backends → passthrough, never fabricated output.

---

## Licensing

Remotion's renderer/bundler require a **paid Remotion Company License** for
organizations over 3 people or that generate revenue — see
[../REMOTION-LICENSE.md](../REMOTION-LICENSE.md). VTracer (vectorize) is MIT and
is hosted third-party tech we orchestrate, with attribution — see the
[third-party model policy](own-the-stack.md).

---

## Related docs

- [README.md](README.md) — dev-docs index
- [auto-clips-infra.md](auto-clips-infra.md) — **production fleet state, gates, monitoring, recovery runbook**
- [architecture.md](architecture.md) — the deployable topology + editor-side FX/grade
- [data-model.md](data-model.md) — `render_jobs`, `compositions`, `assets` schema
- [api-reference.md](api-reference.md) — `POST /api/render`, `GET /api/render/:id`, `POST /api/shorts`, the media-service routes
- [editor.md](editor.md) — the editor SPA whose composition this renders
- [generation-and-models.md](generation-and-models.md) — the AI generation + agentic producer pipeline that produces the clips this worker assembles
- [own-the-stack.md](own-the-stack.md) — the self-hosted voice / avatar / upscale seams the render service deploys alongside
- [configuration.md](configuration.md) — shared storage/DB config
- [../ASSET-SERVING.md](../ASSET-SERVING.md) — signed-proxy playback of private object storage
- [../REMOTION-LICENSE.md](../REMOTION-LICENSE.md) — Remotion Company License status
