From e5212a0cbf2802abdf02b4254a36a2994ba6c970 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:33:45 +0100 Subject: [PATCH 1/4] feat(render-farm): write another account's bucket and upload files without replacing --- apps/render-farm/src/recovery.test.ts | 1 + apps/render-farm/src/s3.ts | 85 ++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/apps/render-farm/src/recovery.test.ts b/apps/render-farm/src/recovery.test.ts index 59125145c6..0cc98f8bc9 100644 --- a/apps/render-farm/src/recovery.test.ts +++ b/apps/render-farm/src/recovery.test.ts @@ -43,6 +43,7 @@ function store() { completed++; object = { size: 10 }; if (lostResponse) throw new Error("lost completion response"); + return true; }, }; return { diff --git a/apps/render-farm/src/s3.ts b/apps/render-farm/src/s3.ts index b78da5a664..029a34444e 100644 --- a/apps/render-farm/src/s3.ts +++ b/apps/render-farm/src/s3.ts @@ -15,6 +15,12 @@ export type S3Config = { virtualHost: boolean; /** Use the EC2 instance role (IMDSv2) instead of static keys. */ imds?: boolean; + /** + * Canned ACL for new objects. Writing into another account's bucket needs + * `bucket-owner-full-control`, or its owner cannot read what we wrote + * unless the bucket enforces bucket ownership. + */ + acl?: string; }; export function s3ConfigFromEnv(env = process.env): S3Config { @@ -35,6 +41,23 @@ export function s3ConfigFromEnv(env = process.env): S3Config { }; } +/** + * The bucket holding recordings and receiving exports. Defaults to the render + * farm's own bucket; `RF_MEDIA_S3_*` points it at the product's bucket, which + * may belong to another account (same credentials, granted by its policy). + */ +export function mediaS3ConfigFromEnv(env = process.env): S3Config { + const base = s3ConfigFromEnv(env); + if (!env.RF_MEDIA_S3_BUCKET) return base; + return { + ...base, + endpoint: env.RF_MEDIA_S3_ENDPOINT || base.endpoint, + region: env.RF_MEDIA_S3_REGION || base.region, + bucket: env.RF_MEDIA_S3_BUCKET, + acl: "bucket-owner-full-control", + }; +} + const EMPTY_SHA = createHash("sha256").update("").digest("hex"); function encodeKey(key: string) { @@ -342,17 +365,24 @@ export class S3 { return new Uint8Array(await response.arrayBuffer()); } + private writeHeaders(contentType?: string): Record { + return { + ...(contentType ? { "content-type": contentType } : {}), + ...(this.config.acl ? { "x-amz-acl": this.config.acl } : {}), + }; + } + async put(key: string, body: Uint8Array | string, contentType?: string) { await this.send("PUT", key, { body, - headers: contentType ? { "content-type": contentType } : {}, + headers: this.writeHeaders(contentType), }); } async createMultipart(key: string, contentType: string) { const response = await this.send("POST", key, { query: { uploads: "" }, - headers: { "content-type": contentType }, + headers: this.writeHeaders(contentType), }); const text = await response.text(); const match = text.match(/([^<]+)<\/UploadId>/); @@ -375,10 +405,15 @@ export class S3 { return etag; } + /** + * With `ifNoneMatch`, S3 refuses to replace an existing object: returns + * false (412, or 409 while another completion races this one). + */ async completeMultipart( key: string, uploadId: string, parts: { partNumber: number; etag: string }[], + options: { ifNoneMatch?: boolean } = {}, ) { const body = `${parts .sort((a, b) => a.partNumber - b.partNumber) @@ -390,12 +425,18 @@ export class S3 { const response = await this.send("POST", key, { query: { uploadId }, body, - headers: { "content-type": "application/xml" }, + headers: { + "content-type": "application/xml", + ...(options.ifNoneMatch ? { "if-none-match": "*" } : {}), + }, + expect: options.ifNoneMatch ? [200, 409, 412] : undefined, }); + if (response.status === 409 || response.status === 412) return false; const text = await response.text(); if (text.includes("")) { throw new Error(`complete multipart failed: ${text.slice(0, 300)}`); } + return true; } async listParts(key: string, uploadId: string) { @@ -425,6 +466,44 @@ export class S3 { return parts; } + /** + * Streams a local file into `key` in 64 MiB parts. With `ifNoneMatch` an + * object already there wins and is kept. Returns the stored object's size. + */ + async uploadFile( + key: string, + path: string, + contentType: string, + options: { ifNoneMatch?: boolean } = {}, + ) { + const file = Bun.file(path); + const partSize = 64 << 20; + const uploadId = await this.createMultipart(key, contentType); + try { + const parts: { partNumber: number; etag: string }[] = []; + for (let start = 0; start === 0 || start < file.size; start += partSize) { + const body = new Uint8Array( + await file.slice(start, start + partSize).arrayBuffer(), + ); + const partNumber = parts.length + 1; + parts.push({ + partNumber, + etag: await this.uploadPart(key, uploadId, partNumber, body), + }); + } + if (await this.completeMultipart(key, uploadId, parts, options)) { + return file.size; + } + await this.abortMultipart(key, uploadId).catch(() => {}); + const existing = await this.head(key); + if (!existing) throw new Error(`${key} was being written concurrently`); + return existing.size; + } catch (error) { + await this.abortMultipart(key, uploadId).catch(() => {}); + throw error; + } + } + async abortMultipart(key: string, uploadId: string) { await this.send("DELETE", key, { query: { uploadId }, From 79e6fa69040b1b6e0169bf4e16b2da01f0a0721c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:33:45 +0100 Subject: [PATCH 2/4] feat(render-farm): export into a recording's folder with GPU source transcodes and signed callbacks --- apps/render-farm/README.md | 25 ++ apps/render-farm/src/coordinator.test.ts | 197 +++++++++- apps/render-farm/src/coordinator.ts | 441 +++++++++++++++++++++-- apps/render-farm/src/protocol.ts | 28 ++ apps/render-farm/src/transcode.test.ts | 40 ++ apps/render-farm/src/transcode.ts | 53 +++ apps/render-farm/src/validate.test.ts | 90 +++++ apps/render-farm/src/validate.ts | 84 ++++- apps/render-farm/src/worker.ts | 131 ++++++- 9 files changed, 1041 insertions(+), 48 deletions(-) create mode 100644 apps/render-farm/src/transcode.test.ts create mode 100644 apps/render-farm/src/transcode.ts diff --git a/apps/render-farm/README.md b/apps/render-farm/README.md index 5b86cd9772..bfcd346445 100644 --- a/apps/render-farm/README.md +++ b/apps/render-farm/README.md @@ -74,6 +74,10 @@ expires `hls/` and `jobs/` objects. | `RF_TOKEN` | required | Bearer token between clients, coordinator and workers | | `RF_S3_ENDPOINT`, `RF_S3_BUCKET`, `RF_S3_REGION` | required | Bucket holding recordings, outputs, HLS and the journal | | `RF_S3_IMDS` | off | Use the instance role instead of `RF_S3_ACCESS_KEY_ID`/`RF_S3_SECRET_ACCESS_KEY` | +| `RF_MEDIA_S3_BUCKET` (`_REGION`, `_ENDPOINT`) | the `RF_S3_*` bucket | Bucket holding recordings and receiving exports; may belong to another account whose policy grants the farm's role. Objects are written `bucket-owner-full-control`; the journal stays in `RF_S3_BUCKET` | +| `RF_CALLBACK_HOSTS` | none | Host suffixes a job's `callbackUrl` may use (e.g. `vercel.app,cap.so`) | +| `RF_CALLBACK_SECRET` | `RF_TOKEN` | HMAC key for callback signatures | +| `RF_TRANSCODE_ENCODER` | `h264_nvenc` | Encoder for source transcodes (`libx264` without a GPU) | | `RF_COORDINATOR_URL` | `http://127.0.0.1:8080` | Coordinator address (workers) and its advertised URL | | `RF_SLOTS` / `RF_AUDIO_SLOTS` | `5` / `4` | Render slots and audio lanes per worker (tuned on one L4 with 8 vCPUs) | | `RF_LOCAL_AUDIO_SLOTS` | `2` | Audio lanes on the coordinator | @@ -92,6 +96,27 @@ expires `hls/` and `jobs/` objects. | `CAP_DECODER_READAHEAD` | `8` | Frames each decoder decodes ahead of the renderer | | `RF_HOT_SWAP` | off | Development: pull the engine, app and tuning from the bucket's `bin/` pointers | +## Product integration + +A job can export a recording that lives in the product's bucket: + +- `sourceRoot` names the recording's folder (e.g. `/