@twick/cloud-export-video 0.14.10 → 0.14.11
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/bin/twick-export-video.js +3 -1
- package/core/renderer.js +2 -2
- package/package.json +9 -8
- package/platform/aws/Dockerfile +11 -0
- package/platform/aws/handler.js +175 -21
|
@@ -77,7 +77,9 @@ Examples:
|
|
|
77
77
|
const image = rest[0];
|
|
78
78
|
const dir = rest[1] || 'twick-export-video-aws';
|
|
79
79
|
if (!image) throw new Error('Image name required. e.g., my-repo:latest');
|
|
80
|
-
|
|
80
|
+
// Build for linux/amd64 platform to avoid creating multi-arch manifest index
|
|
81
|
+
// This reduces the number of artifacts pushed to the registry
|
|
82
|
+
await run('docker', ['build', '--platform', 'linux/amd64', '-t', image, dir]);
|
|
81
83
|
return;
|
|
82
84
|
}
|
|
83
85
|
|
package/core/renderer.js
CHANGED
|
@@ -37,8 +37,8 @@ const renderTwickVideo = async (variables, settings) => {
|
|
|
37
37
|
name: "@twick/core/wasm",
|
|
38
38
|
},
|
|
39
39
|
size: {
|
|
40
|
-
x: variables.properties.width,
|
|
41
|
-
y: variables.properties.height,
|
|
40
|
+
x: variables.input.properties.width,
|
|
41
|
+
y: variables.input.properties.height,
|
|
42
42
|
},
|
|
43
43
|
},
|
|
44
44
|
puppeteer: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twick/cloud-export-video",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.11",
|
|
4
4
|
"description": "Twick cloud function for exporting video with platform-specific templates (AWS Lambda container)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "core/renderer.js",
|
|
@@ -43,13 +43,14 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@sparticuz/chromium": "^129.0.0",
|
|
46
|
-
"@twick/2d": "0.14.
|
|
47
|
-
"@twick/core": "0.14.
|
|
48
|
-
"@twick/ffmpeg": "0.14.
|
|
49
|
-
"@twick/renderer": "0.14.
|
|
50
|
-
"@twick/ui": "0.14.
|
|
51
|
-
"@twick/vite-plugin": "0.14.
|
|
52
|
-
"@twick/visualizer": "0.14.
|
|
46
|
+
"@twick/2d": "0.14.11",
|
|
47
|
+
"@twick/core": "0.14.11",
|
|
48
|
+
"@twick/ffmpeg": "0.14.11",
|
|
49
|
+
"@twick/renderer": "0.14.11",
|
|
50
|
+
"@twick/ui": "0.14.11",
|
|
51
|
+
"@twick/vite-plugin": "0.14.11",
|
|
52
|
+
"@twick/visualizer": "0.14.11",
|
|
53
|
+
"@aws-sdk/client-s3": "^3.620.0",
|
|
53
54
|
"ffmpeg-static": "^5.2.0",
|
|
54
55
|
"fluent-ffmpeg": "^2.1.3"
|
|
55
56
|
},
|
package/platform/aws/Dockerfile
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
FROM docker.io/revideo/aws-lambda-base-image:latest
|
|
2
2
|
|
|
3
|
+
ARG TWICK_DIST_TAG=latest
|
|
4
|
+
|
|
3
5
|
# Copy package files for better caching
|
|
4
6
|
COPY package.json package-lock.json* ./
|
|
5
7
|
|
|
6
8
|
RUN npm install
|
|
7
9
|
|
|
10
|
+
RUN npm install --no-save \
|
|
11
|
+
"@twick/2d@${TWICK_DIST_TAG}" \
|
|
12
|
+
"@twick/core@${TWICK_DIST_TAG}" \
|
|
13
|
+
"@twick/ffmpeg@${TWICK_DIST_TAG}" \
|
|
14
|
+
"@twick/renderer@${TWICK_DIST_TAG}" \
|
|
15
|
+
"@twick/ui@${TWICK_DIST_TAG}" \
|
|
16
|
+
"@twick/vite-plugin@${TWICK_DIST_TAG}" \
|
|
17
|
+
"@twick/visualizer@${TWICK_DIST_TAG}"
|
|
18
|
+
|
|
8
19
|
RUN npx puppeteer browsers install chrome
|
|
9
20
|
|
|
10
21
|
# Copy source code
|
package/platform/aws/handler.js
CHANGED
|
@@ -1,8 +1,89 @@
|
|
|
1
|
+
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
1
2
|
import renderTwickVideo from '../../core/renderer.js';
|
|
2
3
|
|
|
4
|
+
const s3Client = new S3Client({
|
|
5
|
+
region: process.env.EXPORT_VIDEO_S3_REGION || process.env.AWS_REGION || 'us-east-1',
|
|
6
|
+
endpoint: process.env.EXPORT_VIDEO_S3_ENDPOINT || undefined,
|
|
7
|
+
forcePathStyle: process.env.EXPORT_VIDEO_S3_FORCE_PATH_STYLE === 'true',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const ensureEnvVar = (name) => {
|
|
11
|
+
const value = process.env[name];
|
|
12
|
+
if (!value) {
|
|
13
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const normalizePrefix = (prefix = '') => {
|
|
19
|
+
if (!prefix) {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
return prefix.endsWith('/') ? prefix : `${prefix}/`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const sanitizeIdentifier = (value) => {
|
|
26
|
+
if (!value) {
|
|
27
|
+
return 'twick-video';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let sanitized = String(value)
|
|
31
|
+
.trim()
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9-_]+/g, '-')
|
|
34
|
+
.replace(/-{2,}/g, '-');
|
|
35
|
+
|
|
36
|
+
// Remove leading and trailing dashes using string methods (avoids ReDoS)
|
|
37
|
+
// Find first non-dash character
|
|
38
|
+
let start = 0;
|
|
39
|
+
while (start < sanitized.length && sanitized[start] === '-') {
|
|
40
|
+
start++;
|
|
41
|
+
}
|
|
42
|
+
// Find last non-dash character
|
|
43
|
+
let end = sanitized.length;
|
|
44
|
+
while (end > start && sanitized[end - 1] === '-') {
|
|
45
|
+
end--;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return sanitized.slice(start, end);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const buildObjectKey = (projectData, uniqueSuffix) => {
|
|
52
|
+
const projectIdentifier =
|
|
53
|
+
projectData?.properties?.id ??
|
|
54
|
+
projectData?.id ??
|
|
55
|
+
projectData?.project?.properties?.id;
|
|
56
|
+
|
|
57
|
+
const baseName = sanitizeIdentifier(projectIdentifier);
|
|
58
|
+
const suffix = uniqueSuffix ?? Date.now();
|
|
59
|
+
|
|
60
|
+
return `${baseName}-${suffix}.mp4`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const encodeS3Key = (key) =>
|
|
64
|
+
key
|
|
65
|
+
.split('/')
|
|
66
|
+
.map((segment) => encodeURIComponent(segment))
|
|
67
|
+
.join('/');
|
|
68
|
+
|
|
69
|
+
const buildPublicUrl = ({ bucket, key, region, baseUrl }) => {
|
|
70
|
+
if (baseUrl) {
|
|
71
|
+
const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
72
|
+
return `${trimmedBase}/${encodeS3Key(key)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const encodedKey = encodeS3Key(key);
|
|
76
|
+
|
|
77
|
+
if (!region || region === 'us-east-1') {
|
|
78
|
+
return `https://${bucket}.s3.amazonaws.com/${encodedKey}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return `https://${bucket}.s3.${region}.amazonaws.com/${encodedKey}`;
|
|
82
|
+
};
|
|
83
|
+
|
|
3
84
|
/**
|
|
4
85
|
* Handler for processing video project data with files
|
|
5
|
-
*
|
|
86
|
+
*
|
|
6
87
|
* Expected JSON payload:
|
|
7
88
|
* {
|
|
8
89
|
* "project": { ... }, // Video project JSON object
|
|
@@ -15,13 +96,21 @@ import renderTwickVideo from '../../core/renderer.js';
|
|
|
15
96
|
* ]
|
|
16
97
|
* }
|
|
17
98
|
*
|
|
18
|
-
*
|
|
99
|
+
* Environment variables:
|
|
100
|
+
* - EXPORT_VIDEO_S3_BUCKET (required): Destination S3 bucket for rendered videos
|
|
101
|
+
* - EXPORT_VIDEO_S3_PREFIX (optional): Key prefix to prepend before the generated object key
|
|
102
|
+
* - EXPORT_VIDEO_S3_REGION (optional): Region for the S3 client (defaults to AWS_REGION or us-east-1)
|
|
103
|
+
* - EXPORT_VIDEO_PUBLIC_BASE_URL (optional): Custom base URL for returned video links
|
|
104
|
+
* - EXPORT_VIDEO_S3_ENDPOINT (optional): Custom endpoint for S3-compatible storage
|
|
105
|
+
* - EXPORT_VIDEO_S3_FORCE_PATH_STYLE (optional): Set to "true" to force path-style URLs
|
|
106
|
+
*
|
|
107
|
+
* Returns: JSON payload containing the uploaded video URL and metadata
|
|
19
108
|
*/
|
|
20
109
|
|
|
21
110
|
export const handler = async (event) => {
|
|
22
111
|
console.log('Video processor function invoked');
|
|
23
112
|
console.log('Event:', JSON.stringify(event));
|
|
24
|
-
const projectData = event.arguments
|
|
113
|
+
const projectData = event.arguments || {};
|
|
25
114
|
|
|
26
115
|
try {
|
|
27
116
|
// Validate required fields
|
|
@@ -56,37 +145,28 @@ export const handler = async (event) => {
|
|
|
56
145
|
// Render the video using Twick renderer
|
|
57
146
|
console.log('Starting video rendering...' );
|
|
58
147
|
|
|
148
|
+
const fs = await import('fs');
|
|
59
149
|
let renderedVideoPath;
|
|
60
150
|
let videoBuffer;
|
|
61
151
|
|
|
62
152
|
try {
|
|
63
153
|
// Render the video
|
|
64
154
|
renderedVideoPath = await renderTwickVideo(projectData, {
|
|
65
|
-
outFile: `video-${projectData.properties?.
|
|
155
|
+
outFile: `video-${projectData.input?.properties?.requestId || `video-${Date.now()}`}.mp4`,
|
|
66
156
|
});
|
|
67
157
|
|
|
68
158
|
// Read the rendered video file
|
|
69
|
-
const fs = await import('fs');
|
|
70
159
|
videoBuffer = fs.readFileSync(renderedVideoPath);
|
|
71
160
|
|
|
72
161
|
console.log('Video rendered successfully:', renderedVideoPath);
|
|
73
162
|
console.log('Video size:', videoBuffer.length, 'bytes');
|
|
74
|
-
|
|
75
|
-
// Clean up the temporary file
|
|
76
|
-
try {
|
|
77
|
-
fs.unlinkSync(renderedVideoPath);
|
|
78
|
-
console.log('Temporary video file cleaned up');
|
|
79
|
-
} catch (cleanupError) {
|
|
80
|
-
console.warn('Failed to clean up temporary file:', cleanupError);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
163
|
} catch (renderError) {
|
|
84
164
|
console.error('Video rendering failed:', renderError);
|
|
85
165
|
|
|
86
166
|
// Fallback to text file if rendering fails
|
|
87
167
|
const errorText = `Video Processing Error
|
|
88
168
|
======================
|
|
89
|
-
Request ID: ${projectData.properties?.
|
|
169
|
+
Request ID: ${projectData.input?.properties?.requestId || `video-${Date.now()}`}
|
|
90
170
|
Timestamp: ${new Date().toISOString()}
|
|
91
171
|
Status: Rendering Failed
|
|
92
172
|
|
|
@@ -107,19 +187,93 @@ ${mediaFiles.map((file, index) => ` ${index + 1}. ${file.filename} (${file.data
|
|
|
107
187
|
};
|
|
108
188
|
}
|
|
109
189
|
|
|
110
|
-
|
|
190
|
+
const bucket = ensureEnvVar('EXPORT_VIDEO_S3_BUCKET');
|
|
191
|
+
const prefix = normalizePrefix(process.env.EXPORT_VIDEO_S3_PREFIX || '');
|
|
192
|
+
const region = process.env.EXPORT_VIDEO_S3_REGION || process.env.AWS_REGION || 'us-east-1';
|
|
193
|
+
const baseUrl = process.env.EXPORT_VIDEO_PUBLIC_BASE_URL;
|
|
194
|
+
const uniqueSuffix = Date.now();
|
|
195
|
+
const objectKey = `${prefix}${buildObjectKey(projectData, uniqueSuffix)}`;
|
|
196
|
+
|
|
197
|
+
console.log('Uploading rendered video to S3...', {
|
|
198
|
+
bucket,
|
|
199
|
+
key: objectKey,
|
|
200
|
+
region,
|
|
201
|
+
size: videoBuffer.length,
|
|
202
|
+
baseUrl: baseUrl ? '[redacted]' : undefined,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await s3Client.send(
|
|
207
|
+
new PutObjectCommand({
|
|
208
|
+
Bucket: bucket,
|
|
209
|
+
Key: objectKey,
|
|
210
|
+
Body: videoBuffer,
|
|
211
|
+
ContentType: 'video/mp4',
|
|
212
|
+
ContentLength: videoBuffer.length,
|
|
213
|
+
Metadata: {
|
|
214
|
+
'project-id': String(
|
|
215
|
+
projectData?.input?.properties?.requestId ??
|
|
216
|
+
projectData?.input?.properties?.requestId ??
|
|
217
|
+
`video-${Date.now()}`
|
|
218
|
+
),
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
console.log('Video uploaded to S3 successfully');
|
|
223
|
+
} catch (uploadError) {
|
|
224
|
+
console.error('Video upload failed:', uploadError);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
if (renderedVideoPath) {
|
|
228
|
+
fs.unlinkSync(renderedVideoPath);
|
|
229
|
+
}
|
|
230
|
+
} catch (cleanupError) {
|
|
231
|
+
console.warn('Failed to clean up temporary file after upload failure:', cleanupError);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
statusCode: 500,
|
|
236
|
+
headers: {
|
|
237
|
+
'Content-Type': 'application/json',
|
|
238
|
+
'Access-Control-Allow-Origin': '*',
|
|
239
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
240
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
241
|
+
},
|
|
242
|
+
body: JSON.stringify({
|
|
243
|
+
error: 'Failed to upload rendered video',
|
|
244
|
+
message: uploadError instanceof Error ? uploadError.message : 'Unknown error',
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
if (renderedVideoPath) {
|
|
251
|
+
fs.unlinkSync(renderedVideoPath);
|
|
252
|
+
console.log('Temporary video file cleaned up');
|
|
253
|
+
}
|
|
254
|
+
} catch (cleanupError) {
|
|
255
|
+
console.warn('Failed to clean up temporary file:', cleanupError);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const videoUrl = buildPublicUrl({ bucket, key: objectKey, region, baseUrl });
|
|
259
|
+
console.log('Video available at URL:', videoUrl);
|
|
260
|
+
|
|
261
|
+
// Return the video file metadata with URL
|
|
111
262
|
return {
|
|
112
263
|
statusCode: 200,
|
|
113
264
|
headers: {
|
|
114
|
-
'Content-Type': '
|
|
115
|
-
'Content-Disposition': `attachment; filename="processed-video.mp4"`,
|
|
116
|
-
'Content-Length': videoBuffer.length,
|
|
265
|
+
'Content-Type': 'application/json',
|
|
117
266
|
'Access-Control-Allow-Origin': '*',
|
|
118
267
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
119
268
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
120
269
|
},
|
|
121
|
-
body:
|
|
122
|
-
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
url: videoUrl,
|
|
272
|
+
bucket,
|
|
273
|
+
key: objectKey,
|
|
274
|
+
size: videoBuffer.length,
|
|
275
|
+
contentType: 'video/mp4',
|
|
276
|
+
}),
|
|
123
277
|
};
|
|
124
278
|
} catch (error) {
|
|
125
279
|
console.error('Error processing video project:', error);
|