@run-cloud/sdk 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { CreateSandboxOptions, ExecOptions, ExecResult, Sandbox } from "./index.js";
1
+ import type { CreateSandboxOptions, ExecOptions, ExecResult, Sandbox, Snapshot } from "./index.js";
2
2
  export declare const REMOTION_IMAGE_REGISTRY = "us-docker.pkg.dev/remotion-dev/production/render";
3
3
  export type RemotionCodec = "h264" | "h265" | "vp8" | "vp9" | "gif" | "prores" | "mp3" | "aac" | "wav";
4
4
  export type RemotionRenderStage = "starting" | "selecting-composition" | "rendering" | "downloading" | "combining" | "uploading" | "done" | "error" | "expired";
@@ -13,7 +13,7 @@ export interface RemotionS3Output {
13
13
  contentType?: string;
14
14
  }
15
15
  export interface RemotionS3ChunkOutput extends Omit<RemotionS3Output, "downloadUrl"> {
16
- /** Presigned GET URL used by the coordinator sandbox after this chunk is uploaded. */
16
+ /** Presigned GET URL used to retrieve this chunk after it is uploaded. */
17
17
  downloadUrl: string;
18
18
  /** Optional headers required by the signed GET. */
19
19
  downloadHeaders?: Record<string, string>;
@@ -37,7 +37,7 @@ export interface RemotionDistributedOptions {
37
37
  framesPerSandbox?: number;
38
38
  /** Called once per planned chunk so the customer can presign temporary objects in their own S3. */
39
39
  createChunkOutput(plan: RemotionDistributedChunkPlan): RemotionDistributedChunkOutput | Promise<RemotionDistributedChunkOutput>;
40
- /** How long the coordinator waits for each temporary chunk to become readable. */
40
+ /** How long to wait for each temporary chunk to become readable. */
41
41
  downloadTimeoutSeconds?: number;
42
42
  }
43
43
  export interface RemotionRenderOptions {
@@ -46,10 +46,12 @@ export interface RemotionRenderOptions {
46
46
  inputProps?: Record<string, unknown>;
47
47
  codec?: RemotionCodec;
48
48
  output: RemotionS3Output;
49
- /** Exact Remotion version used to select Remotion's public renderer image. */
49
+ /** Exact Remotion version installed into Remotion's matching public runtime image. */
50
50
  remotionVersion?: string;
51
51
  /** Custom image containing @remotion/renderer, Chrome, and FFmpeg. */
52
52
  image?: string;
53
+ /** Organization-owned runtime snapshot returned by createRuntimeSnapshot(). */
54
+ snapshotId?: string;
53
55
  cpu?: number;
54
56
  memory?: number;
55
57
  disk?: number;
@@ -67,12 +69,24 @@ export interface RemotionRenderOptions {
67
69
  muted?: boolean;
68
70
  audioCodec?: string;
69
71
  metadata?: Record<string, string>;
70
- /** Split one video across multiple run.cloud sandboxes and combine it in the first sandbox. */
72
+ /** Split one video across multiple run.cloud sandboxes and combine the frame ranges. */
71
73
  distributed?: RemotionDistributedOptions;
72
74
  }
75
+ export interface CreateRemotionRuntimeSnapshotOptions {
76
+ /** Exact Remotion version installed into the organization-owned snapshot. */
77
+ remotionVersion: string;
78
+ cpu?: number;
79
+ memory?: number;
80
+ disk?: number;
81
+ startupTimeoutSeconds?: number;
82
+ timeoutSeconds?: number;
83
+ region?: string;
84
+ name?: string;
85
+ label?: string;
86
+ }
73
87
  export interface RemotionRenderHandle {
74
88
  renderId: string;
75
- /** Coordinator sandbox. Kept for compatibility with single-sandbox callers. */
89
+ /** Primary sandbox. Kept for compatibility with single-sandbox callers. */
76
90
  sandboxId: string;
77
91
  /** Every sandbox allocated to this render. */
78
92
  sandboxIds: string[];
@@ -106,16 +120,40 @@ export interface RemotionSandboxApi {
106
120
  writeFile(id: string, path: string, content: Uint8Array, options?: {
107
121
  mode?: number;
108
122
  }): Promise<void>;
123
+ snapshot?(id: string, options?: {
124
+ label?: string;
125
+ timeoutSeconds?: number;
126
+ pollIntervalMs?: number;
127
+ }): Promise<Snapshot>;
109
128
  destroy(id: string): Promise<void>;
110
129
  }
130
+ export interface RemotionSnapshotApi {
131
+ restore(id: string, options?: {
132
+ name?: string;
133
+ region?: string;
134
+ cpu?: number;
135
+ memory?: number;
136
+ disk?: number;
137
+ idlePauseSeconds?: number;
138
+ timeoutSeconds?: number;
139
+ idempotencyKey?: string;
140
+ }): Promise<Sandbox>;
141
+ }
111
142
  export declare class RemotionClient {
112
143
  private readonly sandboxes;
113
- constructor(sandboxes: RemotionSandboxApi);
144
+ private readonly snapshots?;
145
+ constructor(sandboxes: RemotionSandboxApi, snapshots?: RemotionSnapshotApi | undefined);
146
+ /**
147
+ * Install a version-pinned renderer once and preserve it as an
148
+ * organization-owned snapshot for subsequent render workers.
149
+ */
150
+ createRuntimeSnapshot(options: CreateRemotionRuntimeSnapshotOptions): Promise<Snapshot>;
114
151
  /**
115
152
  * Start a detached render. The caller owns every returned sandbox until it
116
153
  * calls cancel(), or until the finite sandbox timeouts expire.
117
154
  */
118
155
  start(options: RemotionRenderOptions): Promise<RemotionRenderHandle>;
156
+ private startWithSignal;
119
157
  private createSandbox;
120
158
  private startDistributed;
121
159
  getProgress(render: string | RemotionRenderHandle): Promise<RemotionRenderProgress>;
@@ -130,8 +168,9 @@ export declare class RemotionRenderFailedError extends Error {
130
168
  constructor(progress: RemotionRenderProgress);
131
169
  }
132
170
  /**
133
- * Self-contained guest runner written into the sandbox by the SDK. The image
134
- * supplies @remotion/renderer and its native browser/FFmpeg dependencies.
171
+ * Self-contained guest runner written into the sandbox by the SDK. The SDK
172
+ * installs @remotion/renderer into the public image, while custom images may
173
+ * supply it from one of the conventional application roots below.
135
174
  */
136
175
  export declare const REMOTION_PLANNER_SOURCE: string;
137
176
  export declare const REMOTION_RUNNER_SOURCE: string;
package/dist/remotion.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import { createHash } from "node:crypto";
2
3
  export const REMOTION_IMAGE_REGISTRY = "us-docker.pkg.dev/remotion-dev/production/render";
3
4
  const REMOTION_DIR = "/tmp/runcloud-remotion";
@@ -8,34 +9,118 @@ const MANIFEST_PATH = `${REMOTION_DIR}/manifest.json`;
8
9
  const PROGRESS_PATH = `${REMOTION_DIR}/progress.json`;
9
10
  const STARTED_PATH = `${REMOTION_DIR}/started`;
10
11
  const LOG_PATH = `${REMOTION_DIR}/render.log`;
12
+ const REMOTION_RUNTIME_ROOT = "/opt/remotion";
13
+ const REMOTION_IMAGE_BROWSER_CACHE = "/usr/app/node_modules/.remotion";
11
14
  export class RemotionClient {
12
15
  sandboxes;
13
- constructor(sandboxes) {
16
+ snapshots;
17
+ constructor(sandboxes, snapshots) {
14
18
  this.sandboxes = sandboxes;
19
+ this.snapshots = snapshots;
20
+ }
21
+ /**
22
+ * Install a version-pinned renderer once and preserve it as an
23
+ * organization-owned snapshot for subsequent render workers.
24
+ */
25
+ async createRuntimeSnapshot(options) {
26
+ if (!this.sandboxes.snapshot) {
27
+ throw new Error("createRuntimeSnapshot requires a sandbox API with snapshot support");
28
+ }
29
+ const image = remotionImageForVersion(options.remotionVersion);
30
+ const sandbox = await this.sandboxes.create({
31
+ image,
32
+ cpu: options.cpu ?? 2,
33
+ memory: options.memory ?? 4_096,
34
+ disk: options.disk ?? 20,
35
+ idlePauseSeconds: 0,
36
+ timeoutSeconds: options.timeoutSeconds ?? 15 * 60,
37
+ region: options.region,
38
+ name: options.name,
39
+ });
40
+ let result;
41
+ let operationError;
42
+ try {
43
+ await prepareSandbox(this.sandboxes, sandbox, options.startupTimeoutSeconds ?? 15 * 60, options.remotionVersion);
44
+ const flush = await this.sandboxes.exec(sandbox.id, ["sync"]);
45
+ if (flush.exitCode !== 0) {
46
+ throw new Error(`failed to flush the Remotion runtime before snapshotting: ${flush.stderr || `exit ${flush.exitCode}`}`);
47
+ }
48
+ result = await this.sandboxes.snapshot(sandbox.id, {
49
+ label: options.label ?? `runcloud-remotion-${options.remotionVersion}`,
50
+ timeoutSeconds: options.startupTimeoutSeconds ?? 15 * 60,
51
+ });
52
+ }
53
+ catch (error) {
54
+ operationError = error;
55
+ }
56
+ try {
57
+ await this.sandboxes.destroy(sandbox.id);
58
+ }
59
+ catch (cleanupError) {
60
+ if (operationError) {
61
+ throw new AggregateError([operationError, cleanupError], "Remotion runtime snapshot failed and its bootstrap sandbox could not be destroyed");
62
+ }
63
+ throw cleanupError;
64
+ }
65
+ if (operationError)
66
+ throw operationError;
67
+ return result;
15
68
  }
16
69
  /**
17
70
  * Start a detached render. The caller owns every returned sandbox until it
18
71
  * calls cancel(), or until the finite sandbox timeouts expire.
19
72
  */
20
73
  async start(options) {
74
+ return this.startWithSignal(options);
75
+ }
76
+ async startWithSignal(options, signal) {
21
77
  const job = renderJob(options);
22
78
  const image = resolveRemotionImage(options);
79
+ const runtimeVersion = options.image || options.snapshotId
80
+ ? undefined
81
+ : options.remotionVersion;
82
+ throwIfAborted(signal);
23
83
  if (options.distributed) {
24
- return this.startDistributed(options, job, image);
84
+ return this.startDistributed(options, job, image, signal);
25
85
  }
26
- const sandbox = await this.createSandbox(options, image);
27
- const handle = handleForSandboxes([sandbox.id]);
86
+ const created = [];
28
87
  try {
29
- await prepareSandbox(this.sandboxes, sandbox, options.startupTimeoutSeconds ?? 15 * 60);
30
- await startGuestJob(this.sandboxes, sandbox.id, job, REMOTION_RUNNER_SOURCE);
88
+ const sandbox = await this.createSandbox(options, image);
89
+ created.push(sandbox);
90
+ throwIfAborted(signal);
91
+ const handle = handleForSandboxes([sandbox.id]);
92
+ await prepareSandbox(this.sandboxes, sandbox, options.startupTimeoutSeconds ?? 15 * 60, runtimeVersion, signal);
93
+ await startGuestJob(this.sandboxes, sandbox.id, job, REMOTION_RUNNER_SOURCE, signal);
31
94
  return handle;
32
95
  }
33
96
  catch (error) {
34
- return destroyAfterFailure(this.sandboxes, [sandbox.id], error, "Remotion render setup");
97
+ return destroyAfterFailure(this.sandboxes, created.map((sandbox) => sandbox.id), error, "Remotion render setup");
35
98
  }
36
99
  }
37
100
  createSandbox(options, image, index) {
38
101
  const suffix = index === undefined ? undefined : `chunk-${index}`;
102
+ const name = options.name && suffix ? `${options.name}-${suffix}` : options.name;
103
+ if (options.snapshotId) {
104
+ if (!this.snapshots) {
105
+ throw new Error("snapshotId requires a RemotionClient configured with snapshots");
106
+ }
107
+ return this.snapshots.restore(options.snapshotId, {
108
+ cpu: options.cpu ?? 8,
109
+ memory: options.memory ?? 16_384,
110
+ disk: options.disk ?? 40,
111
+ idlePauseSeconds: 0,
112
+ timeoutSeconds: options.timeoutSeconds ?? 3_600,
113
+ ...(options.idempotencyKey
114
+ ? {
115
+ idempotencyKey: suffix
116
+ ? `${options.idempotencyKey}:${suffix}`
117
+ : options.idempotencyKey,
118
+ }
119
+ : {}),
120
+ region: options.region,
121
+ name,
122
+ });
123
+ }
39
124
  return this.sandboxes.create({
40
125
  image,
41
126
  cpu: options.cpu ?? 8,
@@ -44,38 +129,45 @@ export class RemotionClient {
44
129
  idlePauseSeconds: 0,
45
130
  timeoutSeconds: options.timeoutSeconds ?? 3_600,
46
131
  region: options.region,
47
- name: options.name && suffix ? `${options.name}-${suffix}` : options.name,
132
+ name,
48
133
  idempotencyKey: options.idempotencyKey && suffix
49
134
  ? `${options.idempotencyKey}:${suffix}`
50
135
  : options.idempotencyKey,
51
136
  });
52
137
  }
53
- async startDistributed(options, baseJob, image) {
138
+ async startDistributed(options, baseJob, image, signal) {
54
139
  const distributed = validateDistributedOptions(options.distributed);
55
140
  const created = [];
56
141
  try {
142
+ throwIfAborted(signal);
57
143
  const coordinator = await this.createSandbox(options, image, 0);
58
144
  created.push(coordinator);
59
- await prepareSandbox(this.sandboxes, coordinator, options.startupTimeoutSeconds ?? 15 * 60);
60
- const plan = await planComposition(this.sandboxes, coordinator.id, baseJob);
145
+ throwIfAborted(signal);
146
+ await prepareSandbox(this.sandboxes, coordinator, options.startupTimeoutSeconds ?? 15 * 60, options.image || options.snapshotId ? undefined : options.remotionVersion, signal);
147
+ const plan = await planComposition(this.sandboxes, coordinator.id, baseJob, signal);
61
148
  const chunkRanges = distributeFrames(plan.durationInFrames, distributed);
62
- const storageRenderId = handleForSandboxes([coordinator.id]).renderId;
149
+ for (let index = 1; index < chunkRanges.length; index += 1) {
150
+ throwIfAborted(signal);
151
+ created.push(await this.createSandbox(options, image, index));
152
+ throwIfAborted(signal);
153
+ }
154
+ await Promise.all(created.slice(1).map((sandbox) => prepareSandbox(this.sandboxes, sandbox, options.startupTimeoutSeconds ?? 15 * 60, options.image || options.snapshotId ? undefined : options.remotionVersion, signal)));
155
+ const handle = handleForSandboxes(created.map((sandbox) => sandbox.id));
63
156
  const chunkOutputs = await Promise.all(chunkRanges.map(async (frameRange, index) => {
157
+ throwIfAborted(signal);
64
158
  const output = await distributed.createChunkOutput({
65
- renderId: storageRenderId,
159
+ renderId: handle.renderId,
66
160
  index,
67
161
  frameRange,
68
162
  sandboxCount: chunkRanges.length,
69
163
  });
164
+ throwIfAborted(signal);
70
165
  validateChunkOutput(output, baseJob.codec, baseJob.renderOptions.muted === true, index);
71
166
  return output;
72
167
  }));
73
- for (let index = 1; index < chunkRanges.length; index += 1) {
74
- created.push(await this.createSandbox(options, image, index));
75
- }
76
- await Promise.all(created.slice(1).map((sandbox) => prepareSandbox(this.sandboxes, sandbox, options.startupTimeoutSeconds ?? 15 * 60)));
77
- const handle = handleForSandboxes(created.map((sandbox) => sandbox.id));
168
+ throwIfAborted(signal);
78
169
  await this.sandboxes.writeFile(coordinator.id, MANIFEST_PATH, new TextEncoder().encode(JSON.stringify({ sandboxIds: handle.sandboxIds })), { mode: 0o600 });
170
+ throwIfAborted(signal);
79
171
  const framesPerChunk = chunkRanges[0][1] - chunkRanges[0][0] + 1;
80
172
  const chunkJobs = chunkRanges.map((frameRange, index) => {
81
173
  const media = chunkMediaPlan(baseJob.codec, baseJob.renderOptions.audioCodec, baseJob.renderOptions.muted === true, framesPerChunk);
@@ -112,9 +204,9 @@ export class RemotionClient {
112
204
  } : {}),
113
205
  };
114
206
  });
115
- await Promise.all(created.map((sandbox, index) => writeGuestJob(this.sandboxes, sandbox.id, chunkJobs[index], REMOTION_DISTRIBUTED_RUNNER_SOURCE)));
116
- await Promise.all(created.slice(1).map((sandbox, offset) => launchGuestJob(this.sandboxes, sandbox.id, chunkJobs[offset + 1])));
117
- await launchGuestJob(this.sandboxes, coordinator.id, chunkJobs[0]);
207
+ await Promise.all(created.map((sandbox, index) => writeGuestJob(this.sandboxes, sandbox.id, chunkJobs[index], REMOTION_DISTRIBUTED_RUNNER_SOURCE, signal)));
208
+ await Promise.all(created.slice(1).map((sandbox, offset) => launchGuestJob(this.sandboxes, sandbox.id, chunkJobs[offset + 1], signal)));
209
+ await launchGuestJob(this.sandboxes, coordinator.id, chunkJobs[0], signal);
118
210
  return handle;
119
211
  }
120
212
  catch (error) {
@@ -173,7 +265,7 @@ export class RemotionClient {
173
265
  }
174
266
  /** Wait for a render and always stop billing when it reaches a terminal state. */
175
267
  async render(options, waitOptions = {}) {
176
- const handle = await this.start(options);
268
+ const handle = await this.startWithSignal(options, waitOptions.signal);
177
269
  let renderError;
178
270
  try {
179
271
  for (;;) {
@@ -221,29 +313,74 @@ export function remotionImageForVersion(version) {
221
313
  return `${REMOTION_IMAGE_REGISTRY}:${version}`;
222
314
  }
223
315
  function resolveRemotionImage(options) {
316
+ if (options.snapshotId !== undefined) {
317
+ if (!options.snapshotId.trim()) {
318
+ throw new TypeError("snapshotId must not be empty");
319
+ }
320
+ if (options.image) {
321
+ throw new TypeError("set either snapshotId or image, not both");
322
+ }
323
+ if (options.remotionVersion) {
324
+ throw new TypeError("snapshotId already pins the runtime; omit remotionVersion");
325
+ }
326
+ return undefined;
327
+ }
224
328
  if (options.image)
225
329
  return options.image;
226
330
  if (!options.remotionVersion) {
227
- throw new TypeError("remotionVersion is required unless a custom image is supplied");
331
+ throw new TypeError("remotionVersion is required unless a custom image or runtime snapshot is supplied");
228
332
  }
229
333
  return remotionImageForVersion(options.remotionVersion);
230
334
  }
231
- async function prepareSandbox(sandboxes, sandbox, startupTimeoutSeconds) {
232
- await waitForSandboxReady(sandboxes, sandbox, startupTimeoutSeconds);
233
- const prepare = await sandboxes.exec(sandbox.id, ["/bin/mkdir", "-p", REMOTION_DIR]);
335
+ async function prepareSandbox(sandboxes, sandbox, startupTimeoutSeconds, remotionVersion, signal) {
336
+ await waitForSandboxReady(sandboxes, sandbox, startupTimeoutSeconds, signal);
337
+ throwIfAborted(signal);
338
+ const prepare = await sandboxes.exec(sandbox.id, ["/bin/mkdir", "-p", REMOTION_DIR], { signal });
339
+ throwIfAborted(signal);
234
340
  if (prepare.exitCode !== 0) {
235
341
  throw new Error(`failed to prepare Remotion render: ${prepare.stderr || `exit ${prepare.exitCode}`}`);
236
342
  }
343
+ if (!remotionVersion)
344
+ return;
345
+ const install = await sandboxes.exec(sandbox.id, [
346
+ "npm",
347
+ "install",
348
+ "--prefix",
349
+ REMOTION_RUNTIME_ROOT,
350
+ "--no-audit",
351
+ "--no-fund",
352
+ "--no-package-lock",
353
+ "--omit=dev",
354
+ `@remotion/renderer@${remotionVersion}`,
355
+ ], { signal, timeoutSeconds: startupTimeoutSeconds });
356
+ throwIfAborted(signal);
357
+ if (install.exitCode !== 0) {
358
+ throw new Error(`failed to install Remotion renderer ${remotionVersion}: ${install.stderr || `exit ${install.exitCode}`}`);
359
+ }
360
+ const browserCache = await sandboxes.exec(sandbox.id, [
361
+ "/bin/ln",
362
+ "-sfn",
363
+ REMOTION_IMAGE_BROWSER_CACHE,
364
+ `${REMOTION_RUNTIME_ROOT}/node_modules/.remotion`,
365
+ ], { signal });
366
+ throwIfAborted(signal);
367
+ if (browserCache.exitCode !== 0) {
368
+ throw new Error(`failed to expose the Remotion browser cache: ${browserCache.stderr || `exit ${browserCache.exitCode}`}`);
369
+ }
237
370
  }
238
- async function writeGuestJob(sandboxes, sandboxId, job, runnerSource) {
371
+ async function writeGuestJob(sandboxes, sandboxId, job, runnerSource, signal) {
372
+ throwIfAborted(signal);
239
373
  const encodedJob = new TextEncoder().encode(JSON.stringify(job));
240
374
  const jobHash = createHash("sha256").update(encodedJob).digest("hex");
241
375
  const jobPath = `${REMOTION_DIR}/job-${jobHash}.json`;
242
376
  await sandboxes.writeFile(sandboxId, RUNNER_PATH, new TextEncoder().encode(runnerSource), { mode: 0o700 });
377
+ throwIfAborted(signal);
243
378
  await sandboxes.writeFile(sandboxId, jobPath, encodedJob, { mode: 0o600 });
379
+ throwIfAborted(signal);
244
380
  return jobPath;
245
381
  }
246
- async function launchGuestJob(sandboxes, sandboxId, job) {
382
+ async function launchGuestJob(sandboxes, sandboxId, job, signal) {
383
+ throwIfAborted(signal);
247
384
  const encodedJob = new TextEncoder().encode(JSON.stringify(job));
248
385
  const jobHash = createHash("sha256").update(encodedJob).digest("hex");
249
386
  const jobPath = `${REMOTION_DIR}/job-${jobHash}.json`;
@@ -251,17 +388,20 @@ async function launchGuestJob(sandboxes, sandboxId, job) {
251
388
  "/bin/sh",
252
389
  "-c",
253
390
  `mkdir -p ${REMOTION_DIR} && if (set -C; : > ${STARTED_PATH}) 2>/dev/null; then nohup node ${RUNNER_PATH} ${jobPath} >${LOG_PATH} 2>&1 </dev/null & fi`,
254
- ]);
391
+ ], { signal });
392
+ throwIfAborted(signal);
255
393
  if (start.exitCode !== 0) {
256
394
  throw new Error(`failed to start Remotion render: ${start.stderr || `exit ${start.exitCode}`}`);
257
395
  }
258
396
  }
259
- async function startGuestJob(sandboxes, sandboxId, job, runnerSource) {
260
- await writeGuestJob(sandboxes, sandboxId, job, runnerSource);
261
- await launchGuestJob(sandboxes, sandboxId, job);
397
+ async function startGuestJob(sandboxes, sandboxId, job, runnerSource, signal) {
398
+ await writeGuestJob(sandboxes, sandboxId, job, runnerSource, signal);
399
+ await launchGuestJob(sandboxes, sandboxId, job, signal);
262
400
  }
263
- async function planComposition(sandboxes, sandboxId, job) {
401
+ async function planComposition(sandboxes, sandboxId, job, signal) {
402
+ throwIfAborted(signal);
264
403
  await sandboxes.writeFile(sandboxId, PLANNER_PATH, new TextEncoder().encode(REMOTION_PLANNER_SOURCE), { mode: 0o700 });
404
+ throwIfAborted(signal);
265
405
  const planJob = new TextEncoder().encode(JSON.stringify({
266
406
  serveUrl: job.serveUrl,
267
407
  composition: job.composition,
@@ -270,11 +410,14 @@ async function planComposition(sandboxes, sandboxId, job) {
270
410
  planPath: PLAN_PATH,
271
411
  }));
272
412
  await sandboxes.writeFile(sandboxId, `${REMOTION_DIR}/plan-job.json`, planJob, { mode: 0o600 });
273
- const result = await sandboxes.exec(sandboxId, ["node", PLANNER_PATH, `${REMOTION_DIR}/plan-job.json`]);
413
+ throwIfAborted(signal);
414
+ const result = await sandboxes.exec(sandboxId, ["node", PLANNER_PATH, `${REMOTION_DIR}/plan-job.json`], { signal });
274
415
  if (result.exitCode !== 0) {
275
416
  throw new Error(`failed to plan Remotion render: ${result.stderr || `exit ${result.exitCode}`}`);
276
417
  }
418
+ throwIfAborted(signal);
277
419
  const raw = JSON.parse(new TextDecoder().decode(await sandboxes.readFile(sandboxId, PLAN_PATH)));
420
+ throwIfAborted(signal);
278
421
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
279
422
  throw new Error("Remotion composition plan is invalid");
280
423
  }
@@ -338,7 +481,7 @@ export function distributeFrames(durationInFrames, options) {
338
481
  }
339
482
  function validateChunkOutput(output, codec, muted, index) {
340
483
  const hasVideo = !isAudioCodec(codec);
341
- const hasAudio = !muted && codec !== "gif";
484
+ const hasAudio = hasAudioOutput(codec, muted);
342
485
  if (hasVideo)
343
486
  validateChunkLocation(output?.video, `distributed chunk ${index} video`);
344
487
  if (hasAudio)
@@ -353,9 +496,12 @@ function validateChunkLocation(output, name) {
353
496
  function isAudioCodec(codec) {
354
497
  return codec === "mp3" || codec === "aac" || codec === "wav";
355
498
  }
499
+ function hasAudioOutput(codec, muted) {
500
+ return isAudioCodec(codec) || (!muted && codec !== "gif");
501
+ }
356
502
  function chunkMediaPlan(codec, requestedAudioCodec, muted, framesPerChunk) {
357
503
  const hasVideo = !isAudioCodec(codec);
358
- const hasAudio = !muted && codec !== "gif";
504
+ const hasAudio = hasAudioOutput(codec, muted);
359
505
  if (!hasVideo) {
360
506
  return {
361
507
  hasVideo: false,
@@ -404,7 +550,7 @@ async function destroyAfterFailure(sandboxes, sandboxIds, error, context) {
404
550
  }
405
551
  throw error;
406
552
  }
407
- async function waitForSandboxReady(sandboxes, initial, timeoutSeconds) {
553
+ async function waitForSandboxReady(sandboxes, initial, timeoutSeconds, signal) {
408
554
  if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
409
555
  throw new RangeError("startupTimeoutSeconds must be a positive number");
410
556
  }
@@ -412,6 +558,7 @@ async function waitForSandboxReady(sandboxes, initial, timeoutSeconds) {
412
558
  let sandbox = initial;
413
559
  let hasPolled = false;
414
560
  for (;;) {
561
+ throwIfAborted(signal);
415
562
  if (sandbox.state === "running")
416
563
  return;
417
564
  if (["interrupted", "destroying", "destroyed", "stopped"].includes(sandbox.state)) {
@@ -424,8 +571,9 @@ async function waitForSandboxReady(sandboxes, initial, timeoutSeconds) {
424
571
  throw new Error(`Remotion sandbox did not become ready within ${timeoutSeconds} seconds`);
425
572
  }
426
573
  if (hasPolled)
427
- await abortableDelay(1_000);
574
+ await abortableDelay(1_000, signal);
428
575
  sandbox = await sandboxes.get(sandbox.id);
576
+ throwIfAborted(signal);
429
577
  hasPolled = true;
430
578
  }
431
579
  }
@@ -448,6 +596,9 @@ function renderJob(options) {
448
596
  assertJson(options.envVariables ?? {}, "envVariables");
449
597
  validateHeaders(options.output.headers ?? {});
450
598
  const codec = options.codec ?? "h264";
599
+ if (isAudioCodec(codec) && options.muted === true) {
600
+ throw new TypeError("muted cannot be true for audio-only codecs");
601
+ }
451
602
  const contentType = options.output.contentType ?? contentTypeForCodec(codec);
452
603
  return {
453
604
  serveUrl: options.serveUrl,
@@ -535,13 +686,34 @@ function handleForSandboxes(sandboxIds) {
535
686
  if (!sandboxId)
536
687
  throw new TypeError("at least one Remotion sandbox is required");
537
688
  return {
538
- renderId: `render_${sandboxId}`,
689
+ renderId: sandboxIds.length === 1
690
+ ? `render_${sandboxId}`
691
+ : `render_group_${Buffer.from(JSON.stringify(sandboxIds)).toString("base64url")}`,
539
692
  sandboxId,
540
693
  sandboxIds,
541
694
  sandboxCount: sandboxIds.length,
542
695
  };
543
696
  }
544
697
  function handleForRenderId(renderId) {
698
+ if (renderId.startsWith("render_group_")) {
699
+ try {
700
+ const encoded = renderId.slice("render_group_".length);
701
+ const sandboxIds = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
702
+ if (!Array.isArray(sandboxIds) ||
703
+ sandboxIds.length < 2 ||
704
+ sandboxIds.length > 200 ||
705
+ sandboxIds.some((id) => typeof id !== "string" || id.length === 0) ||
706
+ new Set(sandboxIds).size !== sandboxIds.length) {
707
+ throw new TypeError("invalid run.cloud distributed Remotion renderId");
708
+ }
709
+ return handleForSandboxes(sandboxIds);
710
+ }
711
+ catch (error) {
712
+ if (error instanceof TypeError && error.message.startsWith("invalid run.cloud"))
713
+ throw error;
714
+ throw new TypeError("invalid run.cloud distributed Remotion renderId");
715
+ }
716
+ }
545
717
  if (!renderId.startsWith("render_") || renderId.length === "render_".length) {
546
718
  throw new TypeError("invalid run.cloud Remotion renderId");
547
719
  }
@@ -557,6 +729,8 @@ async function resolveRenderHandle(sandboxes, render) {
557
729
  sandboxIds: render.sandboxIds ?? [render.sandboxId],
558
730
  sandboxCount: render.sandboxCount ?? render.sandboxIds?.length ?? 1,
559
731
  };
732
+ if (handle.sandboxIds.length > 1)
733
+ return handle;
560
734
  try {
561
735
  const raw = JSON.parse(new TextDecoder().decode(await sandboxes.readFile(handle.sandboxId, MANIFEST_PATH)));
562
736
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -645,8 +819,7 @@ function progressPayload(handle, raw) {
645
819
  };
646
820
  }
647
821
  async function abortableDelay(milliseconds, signal) {
648
- if (signal?.aborted)
649
- throw signal.reason ?? new Error("render aborted");
822
+ throwIfAborted(signal);
650
823
  await new Promise((resolve, reject) => {
651
824
  const onAbort = () => {
652
825
  clearTimeout(timeout);
@@ -659,9 +832,14 @@ async function abortableDelay(milliseconds, signal) {
659
832
  signal?.addEventListener("abort", onAbort, { once: true });
660
833
  });
661
834
  }
835
+ function throwIfAborted(signal) {
836
+ if (signal?.aborted)
837
+ throw signal.reason ?? new Error("render aborted");
838
+ }
662
839
  /**
663
- * Self-contained guest runner written into the sandbox by the SDK. The image
664
- * supplies @remotion/renderer and its native browser/FFmpeg dependencies.
840
+ * Self-contained guest runner written into the sandbox by the SDK. The SDK
841
+ * installs @remotion/renderer into the public image, while custom images may
842
+ * supply it from one of the conventional application roots below.
665
843
  */
666
844
  export const REMOTION_PLANNER_SOURCE = String.raw `
667
845
  import {readFile, writeFile} from 'node:fs/promises';
@@ -671,10 +849,14 @@ const loadRenderer = async () => {
671
849
  try {
672
850
  return await import('@remotion/renderer');
673
851
  } catch (initialError) {
674
- for (const root of ['/usr/app', '/app', '/workspace']) {
852
+ for (const root of ['/opt/remotion', '/usr/app', '/app', '/workspace']) {
675
853
  try {
676
854
  return createRequire(root + '/package.json')('@remotion/renderer');
677
- } catch {}
855
+ } catch (error) {
856
+ if (error?.code !== 'MODULE_NOT_FOUND' || !String(error.message).includes('@remotion/renderer')) {
857
+ throw error;
858
+ }
859
+ }
678
860
  }
679
861
  throw initialError;
680
862
  }
@@ -708,17 +890,19 @@ const loadRenderer = async () => {
708
890
  try {
709
891
  return await import('@remotion/renderer');
710
892
  } catch (initialError) {
711
- for (const root of ['/usr/app', '/app', '/workspace']) {
893
+ for (const root of ['/opt/remotion', '/usr/app', '/app', '/workspace']) {
712
894
  try {
713
895
  return createRequire(root + '/package.json')('@remotion/renderer');
714
- } catch {}
896
+ } catch (error) {
897
+ if (error?.code !== 'MODULE_NOT_FOUND' || !String(error.message).includes('@remotion/renderer')) {
898
+ throw error;
899
+ }
900
+ }
715
901
  }
716
902
  throw initialError;
717
903
  }
718
904
  };
719
905
 
720
- const {selectComposition, renderMedia} = await loadRenderer();
721
-
722
906
  const jobPath = process.argv[2];
723
907
  if (!jobPath) throw new Error('render job path is required');
724
908
  const job = JSON.parse(await readFile(jobPath, 'utf8'));
@@ -746,6 +930,7 @@ const persist = async (patch) => {
746
930
 
747
931
  try {
748
932
  await persist({stage: 'selecting-composition'});
933
+ const {selectComposition, renderMedia} = await loadRenderer();
749
934
  const composition = await selectComposition({
750
935
  serveUrl: job.serveUrl,
751
936
  id: job.composition,
@@ -811,16 +996,19 @@ const loadRenderer = async () => {
811
996
  try {
812
997
  return await import('@remotion/renderer');
813
998
  } catch (initialError) {
814
- for (const root of ['/usr/app', '/app', '/workspace']) {
999
+ for (const root of ['/opt/remotion', '/usr/app', '/app', '/workspace']) {
815
1000
  try {
816
1001
  return createRequire(root + '/package.json')('@remotion/renderer');
817
- } catch {}
1002
+ } catch (error) {
1003
+ if (error?.code !== 'MODULE_NOT_FOUND' || !String(error.message).includes('@remotion/renderer')) {
1004
+ throw error;
1005
+ }
1006
+ }
818
1007
  }
819
1008
  throw initialError;
820
1009
  }
821
1010
  };
822
1011
 
823
- const {selectComposition, renderMedia, combineChunks} = await loadRenderer();
824
1012
  const jobPath = process.argv[2];
825
1013
  if (!jobPath) throw new Error('distributed render job path is required');
826
1014
  const job = JSON.parse(await readFile(jobPath, 'utf8'));
@@ -885,6 +1073,7 @@ const download = async (source, destination, deadline) => {
885
1073
 
886
1074
  try {
887
1075
  await persist({stage: 'selecting-composition'});
1076
+ const {selectComposition, renderMedia, combineChunks} = await loadRenderer();
888
1077
  const composition = await selectComposition({
889
1078
  serveUrl: job.serveUrl,
890
1079
  id: job.composition,