@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.
- package/README.md +32 -13
- package/dist/compat/remotion-lambda.d.ts +3 -2
- package/dist/compat/remotion-lambda.js +7 -2
- package/dist/compat/remotion-lambda.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/remotion.d.ts +48 -9
- package/dist/remotion.js +241 -52
- package/dist/remotion.js.map +1 -1
- package/package.json +1 -1
package/dist/remotion.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
134
|
-
*
|
|
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
|
-
|
|
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
|
|
27
|
-
const handle = handleForSandboxes([sandbox.id]);
|
|
86
|
+
const created = [];
|
|
28
87
|
try {
|
|
29
|
-
await
|
|
30
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
664
|
-
*
|
|
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,
|