@run-cloud/sdk 0.20.0 → 0.22.0

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.
@@ -0,0 +1,1050 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash } from "node:crypto";
3
+ export const REMOTION_IMAGE_REGISTRY = "us-docker.pkg.dev/remotion-dev/production/render";
4
+ const REMOTION_DIR = "/tmp/runcloud-remotion";
5
+ const RUNNER_PATH = `${REMOTION_DIR}/runner.mjs`;
6
+ const PLANNER_PATH = `${REMOTION_DIR}/planner.mjs`;
7
+ const PLAN_PATH = `${REMOTION_DIR}/plan.json`;
8
+ const MANIFEST_PATH = `${REMOTION_DIR}/manifest.json`;
9
+ const PROGRESS_PATH = `${REMOTION_DIR}/progress.json`;
10
+ const STARTED_PATH = `${REMOTION_DIR}/started`;
11
+ const LOG_PATH = `${REMOTION_DIR}/render.log`;
12
+ export class RemotionClient {
13
+ sandboxes;
14
+ constructor(sandboxes) {
15
+ this.sandboxes = sandboxes;
16
+ }
17
+ /**
18
+ * Start a detached render. The caller owns every returned sandbox until it
19
+ * calls cancel(), or until the finite sandbox timeouts expire.
20
+ */
21
+ async start(options) {
22
+ return this.startWithSignal(options);
23
+ }
24
+ async startWithSignal(options, signal) {
25
+ const job = renderJob(options);
26
+ const image = resolveRemotionImage(options);
27
+ throwIfAborted(signal);
28
+ if (options.distributed) {
29
+ return this.startDistributed(options, job, image, signal);
30
+ }
31
+ const created = [];
32
+ try {
33
+ const sandbox = await this.createSandbox(options, image);
34
+ created.push(sandbox);
35
+ throwIfAborted(signal);
36
+ const handle = handleForSandboxes([sandbox.id]);
37
+ await prepareSandbox(this.sandboxes, sandbox, options.startupTimeoutSeconds ?? 15 * 60, signal);
38
+ await startGuestJob(this.sandboxes, sandbox.id, job, REMOTION_RUNNER_SOURCE, signal);
39
+ return handle;
40
+ }
41
+ catch (error) {
42
+ return destroyAfterFailure(this.sandboxes, created.map((sandbox) => sandbox.id), error, "Remotion render setup");
43
+ }
44
+ }
45
+ createSandbox(options, image, index) {
46
+ const suffix = index === undefined ? undefined : `chunk-${index}`;
47
+ return this.sandboxes.create({
48
+ image,
49
+ cpu: options.cpu ?? 8,
50
+ memory: options.memory ?? 16_384,
51
+ disk: options.disk ?? 40,
52
+ idlePauseSeconds: 0,
53
+ timeoutSeconds: options.timeoutSeconds ?? 3_600,
54
+ region: options.region,
55
+ name: options.name && suffix ? `${options.name}-${suffix}` : options.name,
56
+ idempotencyKey: options.idempotencyKey && suffix
57
+ ? `${options.idempotencyKey}:${suffix}`
58
+ : options.idempotencyKey,
59
+ });
60
+ }
61
+ async startDistributed(options, baseJob, image, signal) {
62
+ const distributed = validateDistributedOptions(options.distributed);
63
+ const created = [];
64
+ try {
65
+ throwIfAborted(signal);
66
+ const coordinator = await this.createSandbox(options, image, 0);
67
+ created.push(coordinator);
68
+ throwIfAborted(signal);
69
+ await prepareSandbox(this.sandboxes, coordinator, options.startupTimeoutSeconds ?? 15 * 60, signal);
70
+ const plan = await planComposition(this.sandboxes, coordinator.id, baseJob, signal);
71
+ const chunkRanges = distributeFrames(plan.durationInFrames, distributed);
72
+ for (let index = 1; index < chunkRanges.length; index += 1) {
73
+ throwIfAborted(signal);
74
+ created.push(await this.createSandbox(options, image, index));
75
+ throwIfAborted(signal);
76
+ }
77
+ await Promise.all(created.slice(1).map((sandbox) => prepareSandbox(this.sandboxes, sandbox, options.startupTimeoutSeconds ?? 15 * 60, signal)));
78
+ const handle = handleForSandboxes(created.map((sandbox) => sandbox.id));
79
+ const chunkOutputs = await Promise.all(chunkRanges.map(async (frameRange, index) => {
80
+ throwIfAborted(signal);
81
+ const output = await distributed.createChunkOutput({
82
+ renderId: handle.renderId,
83
+ index,
84
+ frameRange,
85
+ sandboxCount: chunkRanges.length,
86
+ });
87
+ throwIfAborted(signal);
88
+ validateChunkOutput(output, baseJob.codec, baseJob.renderOptions.muted === true, index);
89
+ return output;
90
+ }));
91
+ throwIfAborted(signal);
92
+ await this.sandboxes.writeFile(coordinator.id, MANIFEST_PATH, new TextEncoder().encode(JSON.stringify({ sandboxIds: handle.sandboxIds })), { mode: 0o600 });
93
+ throwIfAborted(signal);
94
+ const framesPerChunk = chunkRanges[0][1] - chunkRanges[0][0] + 1;
95
+ const chunkJobs = chunkRanges.map((frameRange, index) => {
96
+ const media = chunkMediaPlan(baseJob.codec, baseJob.renderOptions.audioCodec, baseJob.renderOptions.muted === true, framesPerChunk);
97
+ return {
98
+ ...baseJob,
99
+ mode: "distributed",
100
+ coordinator: index === 0,
101
+ sandboxCount: chunkRanges.length,
102
+ downloadTimeoutSeconds: distributed.downloadTimeoutSeconds ?? options.timeoutSeconds ?? 3_600,
103
+ chunk: {
104
+ index,
105
+ frameRange,
106
+ framesPerChunk,
107
+ compositionDurationInFrames: plan.durationInFrames,
108
+ fps: plan.fps,
109
+ videoFile: media.hasVideo ? `${REMOTION_DIR}/chunk-${index}.${extensionForChunkCodec(media.codec)}` : null,
110
+ audioFile: media.hasAudio ? `${REMOTION_DIR}/chunk-${index}.${extensionForAudioCodec(media.audioCodec)}` : null,
111
+ codec: media.codec,
112
+ audioCodec: media.audioCodec,
113
+ forSeamlessAacConcatenation: media.forSeamlessAacConcatenation,
114
+ output: chunkOutputs[index],
115
+ },
116
+ ...(index === 0 ? {
117
+ chunks: chunkRanges.map((_range, chunkIndex) => ({
118
+ index: chunkIndex,
119
+ videoFile: media.hasVideo
120
+ ? `${REMOTION_DIR}/chunk-${chunkIndex}.${extensionForChunkCodec(media.codec)}`
121
+ : null,
122
+ audioFile: media.hasAudio
123
+ ? `${REMOTION_DIR}/chunk-${chunkIndex}.${extensionForAudioCodec(media.audioCodec)}`
124
+ : null,
125
+ output: chunkOutputs[chunkIndex],
126
+ })),
127
+ } : {}),
128
+ };
129
+ });
130
+ await Promise.all(created.map((sandbox, index) => writeGuestJob(this.sandboxes, sandbox.id, chunkJobs[index], REMOTION_DISTRIBUTED_RUNNER_SOURCE, signal)));
131
+ await Promise.all(created.slice(1).map((sandbox, offset) => launchGuestJob(this.sandboxes, sandbox.id, chunkJobs[offset + 1], signal)));
132
+ await launchGuestJob(this.sandboxes, coordinator.id, chunkJobs[0], signal);
133
+ return handle;
134
+ }
135
+ catch (error) {
136
+ return destroyAfterFailure(this.sandboxes, created.map((sandbox) => sandbox.id), error, "Distributed Remotion render setup");
137
+ }
138
+ }
139
+ async getProgress(render) {
140
+ const handle = await resolveRenderHandle(this.sandboxes, render);
141
+ let bytes;
142
+ try {
143
+ bytes = await this.sandboxes.readFile(handle.sandboxId, PROGRESS_PATH);
144
+ }
145
+ catch (error) {
146
+ const sandbox = await this.sandboxes.get(handle.sandboxId);
147
+ if (["destroyed", "destroying", "stopped", "interrupted"].includes(sandbox.state)) {
148
+ return {
149
+ ...handle,
150
+ stage: "expired",
151
+ progress: 0,
152
+ done: true,
153
+ outputFile: null,
154
+ outputSizeInBytes: null,
155
+ errors: [{ message: `render sandbox is ${sandbox.state}` }],
156
+ fatalErrorEncountered: true,
157
+ updatedAt: new Date().toISOString(),
158
+ };
159
+ }
160
+ // The runner creates progress.json immediately. Before that, a live
161
+ // sandbox is simply still starting; transport errors from get() are not
162
+ // swallowed and remain visible to the caller.
163
+ return startingProgress(handle);
164
+ }
165
+ const progress = progressPayload(handle, JSON.parse(new TextDecoder().decode(bytes)));
166
+ if (progress.done || handle.sandboxIds.length === 1)
167
+ return progress;
168
+ const workerErrors = (await Promise.all(handle.sandboxIds.slice(1).map(async (sandboxId) => {
169
+ try {
170
+ const workerBytes = await this.sandboxes.readFile(sandboxId, PROGRESS_PATH);
171
+ const worker = progressPayload(handleForSandboxes([sandboxId]), JSON.parse(new TextDecoder().decode(workerBytes)));
172
+ return worker.fatalErrorEncountered ? worker.errors : [];
173
+ }
174
+ catch {
175
+ return [];
176
+ }
177
+ }))).flat();
178
+ if (workerErrors.length === 0)
179
+ return progress;
180
+ return {
181
+ ...progress,
182
+ stage: "error",
183
+ done: true,
184
+ errors: workerErrors,
185
+ fatalErrorEncountered: true,
186
+ updatedAt: new Date().toISOString(),
187
+ };
188
+ }
189
+ /** Wait for a render and always stop billing when it reaches a terminal state. */
190
+ async render(options, waitOptions = {}) {
191
+ const handle = await this.startWithSignal(options, waitOptions.signal);
192
+ let renderError;
193
+ try {
194
+ for (;;) {
195
+ if (waitOptions.signal?.aborted)
196
+ throw waitOptions.signal.reason ?? new Error("render aborted");
197
+ const progress = await this.getProgress(handle);
198
+ if (progress.done) {
199
+ if (progress.fatalErrorEncountered) {
200
+ throw new RemotionRenderFailedError(progress);
201
+ }
202
+ return progress;
203
+ }
204
+ await abortableDelay(waitOptions.pollIntervalMs ?? 1_000, waitOptions.signal);
205
+ }
206
+ }
207
+ catch (error) {
208
+ renderError = error;
209
+ throw error;
210
+ }
211
+ finally {
212
+ try {
213
+ await this.cancel(handle);
214
+ }
215
+ catch (cleanupError) {
216
+ if (renderError) {
217
+ throw new AggregateError([renderError, cleanupError], `Remotion render failed and sandbox ${handle.sandboxId} could not be destroyed`);
218
+ }
219
+ throw cleanupError;
220
+ }
221
+ }
222
+ }
223
+ async cancel(render) {
224
+ const handle = await resolveRenderHandle(this.sandboxes, render);
225
+ const results = await Promise.allSettled([...new Set(handle.sandboxIds)].map((sandboxId) => this.sandboxes.destroy(sandboxId)));
226
+ const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
227
+ if (failures.length > 0) {
228
+ throw new AggregateError(failures, `failed to destroy ${failures.length} Remotion sandbox(es)`);
229
+ }
230
+ }
231
+ }
232
+ export function remotionImageForVersion(version) {
233
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
234
+ throw new TypeError("remotionVersion must be an exact semantic version");
235
+ }
236
+ return `${REMOTION_IMAGE_REGISTRY}:${version}`;
237
+ }
238
+ function resolveRemotionImage(options) {
239
+ if (options.image)
240
+ return options.image;
241
+ if (!options.remotionVersion) {
242
+ throw new TypeError("remotionVersion is required unless a custom image is supplied");
243
+ }
244
+ return remotionImageForVersion(options.remotionVersion);
245
+ }
246
+ async function prepareSandbox(sandboxes, sandbox, startupTimeoutSeconds, signal) {
247
+ await waitForSandboxReady(sandboxes, sandbox, startupTimeoutSeconds, signal);
248
+ throwIfAborted(signal);
249
+ const prepare = await sandboxes.exec(sandbox.id, ["/bin/mkdir", "-p", REMOTION_DIR], { signal });
250
+ throwIfAborted(signal);
251
+ if (prepare.exitCode !== 0) {
252
+ throw new Error(`failed to prepare Remotion render: ${prepare.stderr || `exit ${prepare.exitCode}`}`);
253
+ }
254
+ }
255
+ async function writeGuestJob(sandboxes, sandboxId, job, runnerSource, signal) {
256
+ throwIfAborted(signal);
257
+ const encodedJob = new TextEncoder().encode(JSON.stringify(job));
258
+ const jobHash = createHash("sha256").update(encodedJob).digest("hex");
259
+ const jobPath = `${REMOTION_DIR}/job-${jobHash}.json`;
260
+ await sandboxes.writeFile(sandboxId, RUNNER_PATH, new TextEncoder().encode(runnerSource), { mode: 0o700 });
261
+ throwIfAborted(signal);
262
+ await sandboxes.writeFile(sandboxId, jobPath, encodedJob, { mode: 0o600 });
263
+ throwIfAborted(signal);
264
+ return jobPath;
265
+ }
266
+ async function launchGuestJob(sandboxes, sandboxId, job, signal) {
267
+ throwIfAborted(signal);
268
+ const encodedJob = new TextEncoder().encode(JSON.stringify(job));
269
+ const jobHash = createHash("sha256").update(encodedJob).digest("hex");
270
+ const jobPath = `${REMOTION_DIR}/job-${jobHash}.json`;
271
+ const start = await sandboxes.exec(sandboxId, [
272
+ "/bin/sh",
273
+ "-c",
274
+ `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`,
275
+ ], { signal });
276
+ throwIfAborted(signal);
277
+ if (start.exitCode !== 0) {
278
+ throw new Error(`failed to start Remotion render: ${start.stderr || `exit ${start.exitCode}`}`);
279
+ }
280
+ }
281
+ async function startGuestJob(sandboxes, sandboxId, job, runnerSource, signal) {
282
+ await writeGuestJob(sandboxes, sandboxId, job, runnerSource, signal);
283
+ await launchGuestJob(sandboxes, sandboxId, job, signal);
284
+ }
285
+ async function planComposition(sandboxes, sandboxId, job, signal) {
286
+ throwIfAborted(signal);
287
+ await sandboxes.writeFile(sandboxId, PLANNER_PATH, new TextEncoder().encode(REMOTION_PLANNER_SOURCE), { mode: 0o700 });
288
+ throwIfAborted(signal);
289
+ const planJob = new TextEncoder().encode(JSON.stringify({
290
+ serveUrl: job.serveUrl,
291
+ composition: job.composition,
292
+ inputProps: job.inputProps,
293
+ envVariables: job.renderOptions.envVariables,
294
+ planPath: PLAN_PATH,
295
+ }));
296
+ await sandboxes.writeFile(sandboxId, `${REMOTION_DIR}/plan-job.json`, planJob, { mode: 0o600 });
297
+ throwIfAborted(signal);
298
+ const result = await sandboxes.exec(sandboxId, ["node", PLANNER_PATH, `${REMOTION_DIR}/plan-job.json`], { signal });
299
+ if (result.exitCode !== 0) {
300
+ throw new Error(`failed to plan Remotion render: ${result.stderr || `exit ${result.exitCode}`}`);
301
+ }
302
+ throwIfAborted(signal);
303
+ const raw = JSON.parse(new TextDecoder().decode(await sandboxes.readFile(sandboxId, PLAN_PATH)));
304
+ throwIfAborted(signal);
305
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
306
+ throw new Error("Remotion composition plan is invalid");
307
+ }
308
+ const plan = raw;
309
+ for (const key of ["durationInFrames", "fps", "width", "height"]) {
310
+ if (typeof plan[key] !== "number" || !Number.isFinite(plan[key]) || plan[key] <= 0) {
311
+ throw new Error(`Remotion composition plan has invalid ${key}`);
312
+ }
313
+ }
314
+ return plan;
315
+ }
316
+ function validateDistributedOptions(options) {
317
+ if (typeof options.createChunkOutput !== "function") {
318
+ throw new TypeError("distributed.createChunkOutput must be a function");
319
+ }
320
+ if (options.sandboxCount !== undefined && options.framesPerSandbox !== undefined) {
321
+ throw new TypeError("set either distributed.sandboxCount or framesPerSandbox, not both");
322
+ }
323
+ if (options.sandboxCount === undefined && options.framesPerSandbox === undefined) {
324
+ throw new TypeError("distributed.sandboxCount or framesPerSandbox is required");
325
+ }
326
+ if (options.sandboxCount !== undefined) {
327
+ assertIntegerInRange(options.sandboxCount, 2, 200, "distributed.sandboxCount");
328
+ }
329
+ if (options.framesPerSandbox !== undefined) {
330
+ assertIntegerInRange(options.framesPerSandbox, 1, Number.MAX_SAFE_INTEGER, "distributed.framesPerSandbox");
331
+ }
332
+ if (options.downloadTimeoutSeconds !== undefined &&
333
+ (!Number.isFinite(options.downloadTimeoutSeconds) || options.downloadTimeoutSeconds <= 0)) {
334
+ throw new RangeError("distributed.downloadTimeoutSeconds must be a positive number");
335
+ }
336
+ return options;
337
+ }
338
+ function assertIntegerInRange(value, minimum, maximum, name) {
339
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
340
+ throw new RangeError(`${name} must be an integer between ${minimum} and ${maximum}`);
341
+ }
342
+ }
343
+ export function distributeFrames(durationInFrames, options) {
344
+ assertIntegerInRange(durationInFrames, 1, Number.MAX_SAFE_INTEGER, "durationInFrames");
345
+ let framesPerChunk;
346
+ if (options.sandboxCount !== undefined) {
347
+ assertIntegerInRange(options.sandboxCount, 2, 200, "distributed.sandboxCount");
348
+ framesPerChunk = Math.ceil(durationInFrames / options.sandboxCount);
349
+ }
350
+ else {
351
+ assertIntegerInRange(options.framesPerSandbox, 1, Number.MAX_SAFE_INTEGER, "distributed.framesPerSandbox");
352
+ framesPerChunk = options.framesPerSandbox;
353
+ }
354
+ const sandboxCount = Math.ceil(durationInFrames / framesPerChunk);
355
+ if (sandboxCount > 200) {
356
+ throw new RangeError(`distributed render would create ${sandboxCount} sandboxes; maximum is 200`);
357
+ }
358
+ if (sandboxCount < 2) {
359
+ throw new RangeError("distributed render needs at least 2 sandboxes for this composition");
360
+ }
361
+ return Array.from({ length: sandboxCount }, (_, index) => [
362
+ index * framesPerChunk,
363
+ Math.min(durationInFrames - 1, (index + 1) * framesPerChunk - 1),
364
+ ]);
365
+ }
366
+ function validateChunkOutput(output, codec, muted, index) {
367
+ const hasVideo = !isAudioCodec(codec);
368
+ const hasAudio = hasAudioOutput(codec, muted);
369
+ if (hasVideo)
370
+ validateChunkLocation(output?.video, `distributed chunk ${index} video`);
371
+ if (hasAudio)
372
+ validateChunkLocation(output?.audio, `distributed chunk ${index} audio`);
373
+ }
374
+ function validateChunkLocation(output, name) {
375
+ assertHttpUrl(output?.uploadUrl, `${name}.uploadUrl`);
376
+ assertHttpUrl(output?.downloadUrl, `${name}.downloadUrl`);
377
+ validateHeaders(output?.headers ?? {});
378
+ validateHeaders(output?.downloadHeaders ?? {});
379
+ }
380
+ function isAudioCodec(codec) {
381
+ return codec === "mp3" || codec === "aac" || codec === "wav";
382
+ }
383
+ function hasAudioOutput(codec, muted) {
384
+ return isAudioCodec(codec) || (!muted && codec !== "gif");
385
+ }
386
+ function chunkMediaPlan(codec, requestedAudioCodec, muted, framesPerChunk) {
387
+ const hasVideo = !isAudioCodec(codec);
388
+ const hasAudio = hasAudioOutput(codec, muted);
389
+ if (!hasVideo) {
390
+ return {
391
+ hasVideo: false,
392
+ hasAudio: true,
393
+ codec,
394
+ audioCodec: codec,
395
+ forSeamlessAacConcatenation: codec === "aac",
396
+ };
397
+ }
398
+ const finalAudioCodec = requestedAudioCodec ?? defaultAudioCodec(codec);
399
+ const usePcmChunks = hasAudio && framesPerChunk >= 4 && finalAudioCodec !== "aac";
400
+ return {
401
+ hasVideo,
402
+ hasAudio,
403
+ codec: codec === "h264" || codec === "gif" ? "h264-ts" : codec,
404
+ audioCodec: hasAudio ? (usePcmChunks ? "pcm-16" : finalAudioCodec) : null,
405
+ forSeamlessAacConcatenation: hasAudio && !usePcmChunks,
406
+ };
407
+ }
408
+ function defaultAudioCodec(codec) {
409
+ if (codec === "vp8" || codec === "vp9")
410
+ return "opus";
411
+ if (codec === "prores")
412
+ return "pcm-16";
413
+ return "aac";
414
+ }
415
+ function extensionForChunkCodec(codec) {
416
+ if (codec === "h264-ts")
417
+ return "ts";
418
+ if (codec === "vp8" || codec === "vp9")
419
+ return "webm";
420
+ if (codec === "prores")
421
+ return "mov";
422
+ return "mp4";
423
+ }
424
+ function extensionForAudioCodec(codec) {
425
+ if (codec === "pcm-16")
426
+ return "wav";
427
+ return codec ?? "aac";
428
+ }
429
+ async function destroyAfterFailure(sandboxes, sandboxIds, error, context) {
430
+ const cleanup = await Promise.allSettled([...new Set(sandboxIds)].map((sandboxId) => sandboxes.destroy(sandboxId)));
431
+ const failures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
432
+ if (failures.length > 0) {
433
+ throw new AggregateError([error, ...failures], `${context} failed and ${failures.length} sandbox(es) could not be destroyed`);
434
+ }
435
+ throw error;
436
+ }
437
+ async function waitForSandboxReady(sandboxes, initial, timeoutSeconds, signal) {
438
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
439
+ throw new RangeError("startupTimeoutSeconds must be a positive number");
440
+ }
441
+ const deadline = Date.now() + timeoutSeconds * 1_000;
442
+ let sandbox = initial;
443
+ let hasPolled = false;
444
+ for (;;) {
445
+ throwIfAborted(signal);
446
+ if (sandbox.state === "running")
447
+ return;
448
+ if (["interrupted", "destroying", "destroyed", "stopped"].includes(sandbox.state)) {
449
+ throw new Error(`Remotion sandbox entered terminal state ${sandbox.state}`);
450
+ }
451
+ if (!["building_image", "pending", "starting"].includes(sandbox.state)) {
452
+ throw new Error(`Remotion sandbox entered unexpected state ${sandbox.state}`);
453
+ }
454
+ if (Date.now() >= deadline) {
455
+ throw new Error(`Remotion sandbox did not become ready within ${timeoutSeconds} seconds`);
456
+ }
457
+ if (hasPolled)
458
+ await abortableDelay(1_000, signal);
459
+ sandbox = await sandboxes.get(sandbox.id);
460
+ throwIfAborted(signal);
461
+ hasPolled = true;
462
+ }
463
+ }
464
+ export class RemotionRenderFailedError extends Error {
465
+ progress;
466
+ constructor(progress) {
467
+ super(progress.errors[0]?.message ?? "Remotion render failed");
468
+ this.progress = progress;
469
+ this.name = "RemotionRenderFailedError";
470
+ }
471
+ }
472
+ function renderJob(options) {
473
+ assertHttpUrl(options.serveUrl, "serveUrl");
474
+ assertHttpUrl(options.output?.uploadUrl, "output.uploadUrl");
475
+ if (options.output.downloadUrl)
476
+ assertHttpUrl(options.output.downloadUrl, "output.downloadUrl");
477
+ if (!options.composition?.trim())
478
+ throw new TypeError("composition must not be empty");
479
+ assertJson(options.inputProps ?? {}, "inputProps");
480
+ assertJson(options.envVariables ?? {}, "envVariables");
481
+ validateHeaders(options.output.headers ?? {});
482
+ const codec = options.codec ?? "h264";
483
+ if (isAudioCodec(codec) && options.muted === true) {
484
+ throw new TypeError("muted cannot be true for audio-only codecs");
485
+ }
486
+ const contentType = options.output.contentType ?? contentTypeForCodec(codec);
487
+ return {
488
+ serveUrl: options.serveUrl,
489
+ composition: options.composition,
490
+ inputProps: options.inputProps ?? {},
491
+ codec,
492
+ outputFile: `${REMOTION_DIR}/output.${extensionForCodec(codec)}`,
493
+ output: {
494
+ uploadUrl: options.output.uploadUrl,
495
+ downloadUrl: options.output.downloadUrl ?? null,
496
+ headers: options.output.headers ?? {},
497
+ contentType,
498
+ },
499
+ renderOptions: compact({
500
+ concurrency: options.concurrency,
501
+ envVariables: options.envVariables,
502
+ crf: options.crf,
503
+ imageFormat: options.imageFormat,
504
+ pixelFormat: options.pixelFormat,
505
+ scale: options.scale,
506
+ muted: options.muted,
507
+ audioCodec: options.audioCodec,
508
+ metadata: options.metadata,
509
+ }),
510
+ };
511
+ }
512
+ function compact(value) {
513
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
514
+ }
515
+ function assertHttpUrl(raw, name) {
516
+ if (!raw)
517
+ throw new TypeError(`${name} is required`);
518
+ let url;
519
+ try {
520
+ url = new URL(raw);
521
+ }
522
+ catch {
523
+ throw new TypeError(`${name} must be an HTTP(S) URL`);
524
+ }
525
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
526
+ throw new TypeError(`${name} must be an HTTP(S) URL without embedded credentials`);
527
+ }
528
+ }
529
+ function assertJson(value, name) {
530
+ try {
531
+ JSON.stringify(value);
532
+ }
533
+ catch {
534
+ throw new TypeError(`${name} must be JSON serializable`);
535
+ }
536
+ }
537
+ function validateHeaders(headers) {
538
+ for (const [name, value] of Object.entries(headers)) {
539
+ if (!name || /[\r\n]/.test(name) || /[\r\n]/.test(value)) {
540
+ throw new TypeError("output headers must not contain newlines");
541
+ }
542
+ }
543
+ }
544
+ function extensionForCodec(codec) {
545
+ if (codec === "vp8" || codec === "vp9")
546
+ return "webm";
547
+ if (codec === "gif" || codec === "mp3" || codec === "aac" || codec === "wav")
548
+ return codec;
549
+ if (codec === "prores")
550
+ return "mov";
551
+ return "mp4";
552
+ }
553
+ function contentTypeForCodec(codec) {
554
+ if (codec === "vp8" || codec === "vp9")
555
+ return "video/webm";
556
+ if (codec === "gif")
557
+ return "image/gif";
558
+ if (codec === "prores")
559
+ return "video/quicktime";
560
+ if (codec === "mp3")
561
+ return "audio/mpeg";
562
+ if (codec === "aac")
563
+ return "audio/aac";
564
+ if (codec === "wav")
565
+ return "audio/wav";
566
+ return "video/mp4";
567
+ }
568
+ function handleForSandboxes(sandboxIds) {
569
+ const sandboxId = sandboxIds[0];
570
+ if (!sandboxId)
571
+ throw new TypeError("at least one Remotion sandbox is required");
572
+ return {
573
+ renderId: sandboxIds.length === 1
574
+ ? `render_${sandboxId}`
575
+ : `render_group_${Buffer.from(JSON.stringify(sandboxIds)).toString("base64url")}`,
576
+ sandboxId,
577
+ sandboxIds,
578
+ sandboxCount: sandboxIds.length,
579
+ };
580
+ }
581
+ function handleForRenderId(renderId) {
582
+ if (renderId.startsWith("render_group_")) {
583
+ try {
584
+ const encoded = renderId.slice("render_group_".length);
585
+ const sandboxIds = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
586
+ if (!Array.isArray(sandboxIds) ||
587
+ sandboxIds.length < 2 ||
588
+ sandboxIds.length > 200 ||
589
+ sandboxIds.some((id) => typeof id !== "string" || id.length === 0) ||
590
+ new Set(sandboxIds).size !== sandboxIds.length) {
591
+ throw new TypeError("invalid run.cloud distributed Remotion renderId");
592
+ }
593
+ return handleForSandboxes(sandboxIds);
594
+ }
595
+ catch (error) {
596
+ if (error instanceof TypeError && error.message.startsWith("invalid run.cloud"))
597
+ throw error;
598
+ throw new TypeError("invalid run.cloud distributed Remotion renderId");
599
+ }
600
+ }
601
+ if (!renderId.startsWith("render_") || renderId.length === "render_".length) {
602
+ throw new TypeError("invalid run.cloud Remotion renderId");
603
+ }
604
+ return handleForSandboxes([renderId.slice("render_".length)]);
605
+ }
606
+ async function resolveRenderHandle(sandboxes, render) {
607
+ if (typeof render !== "string" && render.sandboxIds?.length > 1)
608
+ return render;
609
+ const handle = typeof render === "string"
610
+ ? handleForRenderId(render)
611
+ : {
612
+ ...render,
613
+ sandboxIds: render.sandboxIds ?? [render.sandboxId],
614
+ sandboxCount: render.sandboxCount ?? render.sandboxIds?.length ?? 1,
615
+ };
616
+ if (handle.sandboxIds.length > 1)
617
+ return handle;
618
+ try {
619
+ const raw = JSON.parse(new TextDecoder().decode(await sandboxes.readFile(handle.sandboxId, MANIFEST_PATH)));
620
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
621
+ return handle;
622
+ const sandboxIds = raw["sandboxIds"];
623
+ if (!Array.isArray(sandboxIds) ||
624
+ sandboxIds.length === 0 ||
625
+ sandboxIds.some((id) => typeof id !== "string" || id.length === 0)) {
626
+ return handle;
627
+ }
628
+ return {
629
+ ...handle,
630
+ sandboxIds: sandboxIds,
631
+ sandboxCount: sandboxIds.length,
632
+ };
633
+ }
634
+ catch {
635
+ return handle;
636
+ }
637
+ }
638
+ function startingProgress(handle) {
639
+ return {
640
+ ...handle,
641
+ stage: "starting",
642
+ progress: 0,
643
+ done: false,
644
+ outputFile: null,
645
+ outputSizeInBytes: null,
646
+ errors: [],
647
+ fatalErrorEncountered: false,
648
+ updatedAt: new Date().toISOString(),
649
+ };
650
+ }
651
+ function progressPayload(handle, raw) {
652
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
653
+ throw new Error("render progress file is invalid");
654
+ }
655
+ const value = raw;
656
+ const stage = value["stage"];
657
+ const validStages = [
658
+ "starting",
659
+ "selecting-composition",
660
+ "rendering",
661
+ "downloading",
662
+ "combining",
663
+ "uploading",
664
+ "done",
665
+ "error",
666
+ ];
667
+ if (typeof stage !== "string" || !validStages.includes(stage)) {
668
+ throw new Error("render progress stage is invalid");
669
+ }
670
+ const progress = typeof value["progress"] === "number"
671
+ ? Math.max(0, Math.min(1, value["progress"]))
672
+ : 0;
673
+ const errors = Array.isArray(value["errors"])
674
+ ? value["errors"].flatMap((entry) => {
675
+ if (!entry || typeof entry !== "object")
676
+ return [];
677
+ const error = entry;
678
+ return typeof error["message"] === "string"
679
+ ? [{
680
+ message: error["message"],
681
+ ...(typeof error["name"] === "string" ? { name: error["name"] } : {}),
682
+ ...(typeof error["stack"] === "string" ? { stack: error["stack"] } : {}),
683
+ }]
684
+ : [];
685
+ })
686
+ : [];
687
+ const terminal = stage === "done" || stage === "error";
688
+ return {
689
+ ...handle,
690
+ sandboxCount: typeof value["sandboxCount"] === "number"
691
+ ? value["sandboxCount"]
692
+ : handle.sandboxCount,
693
+ stage: stage,
694
+ progress,
695
+ done: terminal,
696
+ outputFile: typeof value["outputFile"] === "string" ? value["outputFile"] : null,
697
+ outputSizeInBytes: typeof value["outputSizeInBytes"] === "number"
698
+ ? value["outputSizeInBytes"]
699
+ : null,
700
+ errors,
701
+ fatalErrorEncountered: stage === "error",
702
+ updatedAt: typeof value["updatedAt"] === "string" ? value["updatedAt"] : new Date().toISOString(),
703
+ };
704
+ }
705
+ async function abortableDelay(milliseconds, signal) {
706
+ throwIfAborted(signal);
707
+ await new Promise((resolve, reject) => {
708
+ const onAbort = () => {
709
+ clearTimeout(timeout);
710
+ reject(signal?.reason ?? new Error("render aborted"));
711
+ };
712
+ const timeout = setTimeout(() => {
713
+ signal?.removeEventListener("abort", onAbort);
714
+ resolve();
715
+ }, milliseconds);
716
+ signal?.addEventListener("abort", onAbort, { once: true });
717
+ });
718
+ }
719
+ function throwIfAborted(signal) {
720
+ if (signal?.aborted)
721
+ throw signal.reason ?? new Error("render aborted");
722
+ }
723
+ /**
724
+ * Self-contained guest runner written into the sandbox by the SDK. The image
725
+ * supplies @remotion/renderer and its native browser/FFmpeg dependencies.
726
+ */
727
+ export const REMOTION_PLANNER_SOURCE = String.raw `
728
+ import {readFile, writeFile} from 'node:fs/promises';
729
+ import {createRequire} from 'node:module';
730
+
731
+ const loadRenderer = async () => {
732
+ try {
733
+ return await import('@remotion/renderer');
734
+ } catch (initialError) {
735
+ for (const root of ['/usr/app', '/app', '/workspace']) {
736
+ try {
737
+ return createRequire(root + '/package.json')('@remotion/renderer');
738
+ } catch {}
739
+ }
740
+ throw initialError;
741
+ }
742
+ };
743
+
744
+ const {selectComposition} = await loadRenderer();
745
+ const jobPath = process.argv[2];
746
+ if (!jobPath) throw new Error('render plan job path is required');
747
+ const job = JSON.parse(await readFile(jobPath, 'utf8'));
748
+ const composition = await selectComposition({
749
+ serveUrl: job.serveUrl,
750
+ id: job.composition,
751
+ inputProps: job.inputProps,
752
+ envVariables: job.envVariables,
753
+ logLevel: 'error',
754
+ });
755
+ await writeFile(job.planPath, JSON.stringify({
756
+ durationInFrames: composition.durationInFrames,
757
+ fps: composition.fps,
758
+ width: composition.width,
759
+ height: composition.height,
760
+ }), {mode: 0o600});
761
+ `.trimStart();
762
+ export const REMOTION_RUNNER_SOURCE = String.raw `
763
+ import {createReadStream} from 'node:fs';
764
+ import {readFile, rename, stat, writeFile} from 'node:fs/promises';
765
+ import {dirname, join} from 'node:path';
766
+ import {createRequire} from 'node:module';
767
+
768
+ const loadRenderer = async () => {
769
+ try {
770
+ return await import('@remotion/renderer');
771
+ } catch (initialError) {
772
+ for (const root of ['/usr/app', '/app', '/workspace']) {
773
+ try {
774
+ return createRequire(root + '/package.json')('@remotion/renderer');
775
+ } catch {}
776
+ }
777
+ throw initialError;
778
+ }
779
+ };
780
+
781
+ const jobPath = process.argv[2];
782
+ if (!jobPath) throw new Error('render job path is required');
783
+ const job = JSON.parse(await readFile(jobPath, 'utf8'));
784
+ const progressPath = join(dirname(jobPath), 'progress.json');
785
+ let state = {
786
+ stage: 'starting',
787
+ progress: 0,
788
+ outputFile: null,
789
+ outputSizeInBytes: null,
790
+ errors: [],
791
+ updatedAt: new Date().toISOString(),
792
+ };
793
+ let writes = Promise.resolve();
794
+
795
+ const persist = async (patch) => {
796
+ state = {...state, ...patch, updatedAt: new Date().toISOString()};
797
+ const snapshot = JSON.stringify(state);
798
+ writes = writes.then(async () => {
799
+ const temporary = progressPath + '.tmp';
800
+ await writeFile(temporary, snapshot, {mode: 0o600});
801
+ await rename(temporary, progressPath);
802
+ });
803
+ await writes;
804
+ };
805
+
806
+ try {
807
+ await persist({stage: 'selecting-composition'});
808
+ const {selectComposition, renderMedia} = await loadRenderer();
809
+ const composition = await selectComposition({
810
+ serveUrl: job.serveUrl,
811
+ id: job.composition,
812
+ inputProps: job.inputProps,
813
+ envVariables: job.renderOptions.envVariables,
814
+ });
815
+
816
+ await persist({stage: 'rendering'});
817
+ await renderMedia({
818
+ ...job.renderOptions,
819
+ serveUrl: job.serveUrl,
820
+ composition,
821
+ codec: job.codec,
822
+ inputProps: job.inputProps,
823
+ outputLocation: job.outputFile,
824
+ onProgress: ({progress}) => void persist({stage: 'rendering', progress}),
825
+ });
826
+ await writes;
827
+
828
+ const output = await stat(job.outputFile);
829
+ await persist({stage: 'uploading', progress: 1, outputSizeInBytes: output.size});
830
+ const response = await fetch(job.output.uploadUrl, {
831
+ method: 'PUT',
832
+ headers: {
833
+ 'content-type': job.output.contentType,
834
+ 'content-length': String(output.size),
835
+ ...job.output.headers,
836
+ },
837
+ body: createReadStream(job.outputFile),
838
+ duplex: 'half',
839
+ });
840
+ if (!response.ok) {
841
+ const body = (await response.text()).slice(0, 500);
842
+ throw new Error('S3-compatible upload failed with HTTP ' + response.status + (body ? ': ' + body : ''));
843
+ }
844
+ await persist({
845
+ stage: 'done',
846
+ progress: 1,
847
+ outputFile: job.output.downloadUrl,
848
+ outputSizeInBytes: output.size,
849
+ });
850
+ } catch (error) {
851
+ await persist({
852
+ stage: 'error',
853
+ errors: [{
854
+ message: error instanceof Error ? error.message : String(error),
855
+ name: error instanceof Error ? error.name : undefined,
856
+ stack: error instanceof Error ? error.stack : undefined,
857
+ }],
858
+ });
859
+ process.exitCode = 1;
860
+ }
861
+ `.trimStart();
862
+ export const REMOTION_DISTRIBUTED_RUNNER_SOURCE = String.raw `
863
+ import {createReadStream, createWriteStream} from 'node:fs';
864
+ import {readFile, rename, stat, writeFile} from 'node:fs/promises';
865
+ import {dirname, join} from 'node:path';
866
+ import {createRequire} from 'node:module';
867
+ import {pipeline} from 'node:stream/promises';
868
+ import {Readable} from 'node:stream';
869
+
870
+ const loadRenderer = async () => {
871
+ try {
872
+ return await import('@remotion/renderer');
873
+ } catch (initialError) {
874
+ for (const root of ['/usr/app', '/app', '/workspace']) {
875
+ try {
876
+ return createRequire(root + '/package.json')('@remotion/renderer');
877
+ } catch {}
878
+ }
879
+ throw initialError;
880
+ }
881
+ };
882
+
883
+ const jobPath = process.argv[2];
884
+ if (!jobPath) throw new Error('distributed render job path is required');
885
+ const job = JSON.parse(await readFile(jobPath, 'utf8'));
886
+ const progressPath = join(dirname(jobPath), 'progress.json');
887
+ let state = {
888
+ stage: 'starting',
889
+ progress: 0,
890
+ sandboxCount: job.sandboxCount,
891
+ chunkIndex: job.chunk.index,
892
+ outputFile: null,
893
+ outputSizeInBytes: null,
894
+ errors: [],
895
+ updatedAt: new Date().toISOString(),
896
+ };
897
+ let writes = Promise.resolve();
898
+
899
+ const persist = async (patch) => {
900
+ state = {...state, ...patch, updatedAt: new Date().toISOString()};
901
+ const snapshot = JSON.stringify(state);
902
+ writes = writes.then(async () => {
903
+ const temporary = progressPath + '.tmp';
904
+ await writeFile(temporary, snapshot, {mode: 0o600});
905
+ await rename(temporary, progressPath);
906
+ });
907
+ await writes;
908
+ };
909
+
910
+ const upload = async (file, destination) => {
911
+ if (!file || !destination) return 0;
912
+ const info = await stat(file);
913
+ const response = await fetch(destination.uploadUrl, {
914
+ method: 'PUT',
915
+ headers: {
916
+ 'content-type': destination.contentType || 'application/octet-stream',
917
+ 'content-length': String(info.size),
918
+ ...destination.headers,
919
+ },
920
+ body: createReadStream(file),
921
+ duplex: 'half',
922
+ });
923
+ if (!response.ok) {
924
+ const body = (await response.text()).slice(0, 500);
925
+ throw new Error('chunk upload failed with HTTP ' + response.status + (body ? ': ' + body : ''));
926
+ }
927
+ return info.size;
928
+ };
929
+
930
+ const download = async (source, destination, deadline) => {
931
+ let lastStatus = null;
932
+ while (Date.now() < deadline) {
933
+ const response = await fetch(source.downloadUrl, {headers: source.downloadHeaders || {}});
934
+ lastStatus = response.status;
935
+ if (response.ok && response.body) {
936
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(destination, {mode: 0o600}));
937
+ return;
938
+ }
939
+ await response.body?.cancel();
940
+ await new Promise((resolve) => setTimeout(resolve, 1000));
941
+ }
942
+ throw new Error('chunk did not become readable before timeout' + (lastStatus ? ' (last HTTP ' + lastStatus + ')' : ''));
943
+ };
944
+
945
+ try {
946
+ await persist({stage: 'selecting-composition'});
947
+ const {selectComposition, renderMedia, combineChunks} = await loadRenderer();
948
+ const composition = await selectComposition({
949
+ serveUrl: job.serveUrl,
950
+ id: job.composition,
951
+ inputProps: job.inputProps,
952
+ envVariables: job.renderOptions.envVariables,
953
+ });
954
+ if (composition.durationInFrames !== job.chunk.compositionDurationInFrames ||
955
+ composition.fps !== job.chunk.fps) {
956
+ throw new Error('composition metadata changed after the distributed render was planned');
957
+ }
958
+
959
+ const {metadata, muted, ...chunkRenderOptions} = job.renderOptions;
960
+ await persist({stage: 'rendering'});
961
+ await renderMedia({
962
+ ...chunkRenderOptions,
963
+ serveUrl: job.serveUrl,
964
+ composition,
965
+ codec: job.chunk.codec,
966
+ inputProps: job.inputProps,
967
+ outputLocation: job.chunk.videoFile || job.chunk.audioFile,
968
+ separateAudioTo: job.chunk.videoFile && job.chunk.audioFile ? job.chunk.audioFile : undefined,
969
+ frameRange: job.chunk.frameRange,
970
+ compositionStart: 0,
971
+ enforceAudioTrack: Boolean(job.chunk.audioFile),
972
+ muted: muted === true,
973
+ audioCodec: job.chunk.audioCodec,
974
+ forSeamlessAacConcatenation: job.chunk.forSeamlessAacConcatenation,
975
+ numberOfGifLoops: null,
976
+ preferLossless: false,
977
+ onProgress: ({progress}) => void persist({
978
+ stage: 'rendering',
979
+ progress: job.coordinator ? progress * 0.8 / job.sandboxCount : progress,
980
+ }),
981
+ });
982
+ await writes;
983
+
984
+ await persist({stage: 'uploading'});
985
+ await Promise.all([
986
+ upload(job.chunk.videoFile, job.chunk.output.video),
987
+ upload(job.chunk.audioFile, job.chunk.output.audio),
988
+ ]);
989
+
990
+ if (!job.coordinator) {
991
+ await persist({stage: 'done', progress: 1});
992
+ } else {
993
+ const chunks = job.chunks;
994
+ if (!Array.isArray(chunks) || chunks.length !== job.sandboxCount) {
995
+ throw new Error('coordinator chunk manifest is invalid');
996
+ }
997
+ const deadline = Date.now() + job.downloadTimeoutSeconds * 1000;
998
+ let downloaded = 1;
999
+ await persist({stage: 'downloading', progress: 0.8 * downloaded / job.sandboxCount});
1000
+ for (const chunk of chunks.slice(1)) {
1001
+ await Promise.all([
1002
+ chunk.videoFile ? download(chunk.output.video, chunk.videoFile, deadline) : undefined,
1003
+ chunk.audioFile ? download(chunk.output.audio, chunk.audioFile, deadline) : undefined,
1004
+ ]);
1005
+ downloaded += 1;
1006
+ await persist({stage: 'downloading', progress: 0.8 * downloaded / job.sandboxCount});
1007
+ }
1008
+
1009
+ await persist({stage: 'combining', progress: 0.8});
1010
+ await combineChunks({
1011
+ outputLocation: job.outputFile,
1012
+ videoFiles: chunks.flatMap((chunk) => chunk.videoFile ? [chunk.videoFile] : []),
1013
+ audioFiles: chunks.flatMap((chunk) => chunk.audioFile ? [chunk.audioFile] : []),
1014
+ codec: job.codec,
1015
+ fps: job.chunk.fps,
1016
+ framesPerChunk: job.chunk.framesPerChunk,
1017
+ preferLossless: false,
1018
+ compositionDurationInFrames: job.chunk.compositionDurationInFrames,
1019
+ audioCodec: job.renderOptions.audioCodec ?? null,
1020
+ metadata: job.renderOptions.metadata ?? null,
1021
+ onProgress: ({totalProgress}) => void persist({
1022
+ stage: 'combining',
1023
+ progress: 0.8 + totalProgress * 0.15,
1024
+ }),
1025
+ });
1026
+ await writes;
1027
+
1028
+ const output = await stat(job.outputFile);
1029
+ await persist({stage: 'uploading', progress: 0.95, outputSizeInBytes: output.size});
1030
+ await upload(job.outputFile, job.output);
1031
+ await persist({
1032
+ stage: 'done',
1033
+ progress: 1,
1034
+ outputFile: job.output.downloadUrl,
1035
+ outputSizeInBytes: output.size,
1036
+ });
1037
+ }
1038
+ } catch (error) {
1039
+ await persist({
1040
+ stage: 'error',
1041
+ errors: [{
1042
+ message: error instanceof Error ? error.message : String(error),
1043
+ name: error instanceof Error ? error.name : undefined,
1044
+ stack: error instanceof Error ? error.stack : undefined,
1045
+ }],
1046
+ });
1047
+ process.exitCode = 1;
1048
+ }
1049
+ `.trimStart();
1050
+ //# sourceMappingURL=remotion.js.map