@codellyson/framely-cli 0.1.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.
Files changed (40) hide show
  1. package/commands/compositions.js +135 -0
  2. package/commands/preview.js +889 -0
  3. package/commands/render.js +295 -0
  4. package/commands/still.js +165 -0
  5. package/index.js +93 -0
  6. package/package.json +60 -0
  7. package/studio/App.css +605 -0
  8. package/studio/App.jsx +185 -0
  9. package/studio/CompositionsView.css +399 -0
  10. package/studio/CompositionsView.jsx +327 -0
  11. package/studio/PropsEditor.css +195 -0
  12. package/studio/PropsEditor.tsx +176 -0
  13. package/studio/RenderDialog.tsx +476 -0
  14. package/studio/ShareDialog.tsx +200 -0
  15. package/studio/index.ts +19 -0
  16. package/studio/player/Player.css +199 -0
  17. package/studio/player/Player.jsx +355 -0
  18. package/studio/styles/design-system.css +592 -0
  19. package/studio/styles/dialogs.css +420 -0
  20. package/studio/templates/AnimatedGradient.jsx +99 -0
  21. package/studio/templates/InstagramStory.jsx +172 -0
  22. package/studio/templates/LowerThird.jsx +139 -0
  23. package/studio/templates/ProductShowcase.jsx +162 -0
  24. package/studio/templates/SlideTransition.jsx +211 -0
  25. package/studio/templates/SocialIntro.jsx +122 -0
  26. package/studio/templates/SubscribeAnimation.jsx +186 -0
  27. package/studio/templates/TemplateCard.tsx +58 -0
  28. package/studio/templates/TemplateFilters.tsx +97 -0
  29. package/studio/templates/TemplatePreviewDialog.tsx +196 -0
  30. package/studio/templates/TemplatesMarketplace.css +686 -0
  31. package/studio/templates/TemplatesMarketplace.tsx +172 -0
  32. package/studio/templates/TextReveal.jsx +134 -0
  33. package/studio/templates/UseTemplateDialog.tsx +154 -0
  34. package/studio/templates/index.ts +45 -0
  35. package/utils/browser.js +188 -0
  36. package/utils/codecs.js +200 -0
  37. package/utils/logger.js +35 -0
  38. package/utils/props.js +42 -0
  39. package/utils/render.js +447 -0
  40. package/utils/validate.js +148 -0
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Render Utilities
3
+ *
4
+ * Core rendering functions for video and image sequence output.
5
+ */
6
+
7
+ import { spawn } from 'child_process';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import crypto from 'crypto';
11
+ import { setFrame, createBrowser, closeBrowser } from './browser.js';
12
+ import { getCodecArgs, getAudioArgs } from './codecs.js';
13
+
14
+ /** Selector for the render container element. */
15
+ const RENDER_CONTAINER = '#render-container';
16
+
17
+ /** Default JPEG quality for frame capture. */
18
+ const DEFAULT_SCREENSHOT_QUALITY = 90;
19
+
20
+ /**
21
+ * Render a video from a page.
22
+ *
23
+ * @param {object} options
24
+ * @param {Page} options.page - Playwright page
25
+ * @param {string} options.outputPath - Output file path
26
+ * @param {number} options.startFrame - First frame to render
27
+ * @param {number} options.endFrame - Last frame to render
28
+ * @param {number} options.width - Video width
29
+ * @param {number} options.height - Video height
30
+ * @param {number} options.fps - Frames per second
31
+ * @param {string} options.codec - Codec identifier
32
+ * @param {number} options.crf - Quality (CRF value)
33
+ * @param {boolean} options.muted - Disable audio
34
+ * @param {number} [options.screenshotQuality] - JPEG quality for frame capture (1-100)
35
+ * @param {function} options.onProgress - Progress callback
36
+ * @returns {Promise<string>} Output path
37
+ */
38
+ export async function renderVideo({
39
+ page,
40
+ outputPath,
41
+ startFrame,
42
+ endFrame,
43
+ width,
44
+ height,
45
+ fps,
46
+ codec = 'h264',
47
+ crf = 18,
48
+ muted = false,
49
+ screenshotQuality = DEFAULT_SCREENSHOT_QUALITY,
50
+ onProgress,
51
+ }) {
52
+ const totalFrames = endFrame - startFrame + 1;
53
+
54
+ // Build FFmpeg arguments
55
+ const ffmpegArgs = [
56
+ '-y', // Overwrite output
57
+ '-f', 'image2pipe', // Input: piped images
58
+ '-c:v', 'mjpeg', // Input codec: JPEG
59
+ '-framerate', String(fps), // Input framerate
60
+ '-i', '-', // Read from stdin
61
+ ...getCodecArgs(codec, { crf, fps, width, height }),
62
+ ];
63
+
64
+ // Add audio arguments if not muted (placeholder for when audio is extracted)
65
+ if (!muted) {
66
+ // Audio will be mixed in a second pass or via temp file
67
+ // For now, we create a silent video
68
+ }
69
+
70
+ ffmpegArgs.push(outputPath);
71
+
72
+ // Start FFmpeg process
73
+ const ffmpegProcess = spawn('ffmpeg', ffmpegArgs);
74
+
75
+ // Track FFmpeg errors
76
+ let ffmpegError = '';
77
+ ffmpegProcess.stderr.on('data', (data) => {
78
+ ffmpegError += data.toString();
79
+ });
80
+
81
+ const ffmpegDone = new Promise((resolve, reject) => {
82
+ ffmpegProcess.on('close', (code) => {
83
+ if (code === 0) resolve();
84
+ else reject(new Error(`FFmpeg failed (code ${code}): ${ffmpegError.slice(-500)}`));
85
+ });
86
+ ffmpegProcess.on('error', reject);
87
+ });
88
+
89
+ // Render each frame
90
+ for (let frame = startFrame; frame <= endFrame; frame++) {
91
+ // Set frame and wait for render
92
+ await setFrame(page, frame);
93
+
94
+ // Capture screenshot (JPEG is faster to encode and smaller to pipe)
95
+ const element = page.locator(RENDER_CONTAINER);
96
+ const screenshot = await element.screenshot({ type: 'jpeg', quality: screenshotQuality });
97
+
98
+ // Pipe to FFmpeg
99
+ const canWrite = ffmpegProcess.stdin.write(screenshot);
100
+ if (!canWrite) {
101
+ await new Promise((resolve) => ffmpegProcess.stdin.once('drain', resolve));
102
+ }
103
+
104
+ // Report progress
105
+ if (onProgress) onProgress(frame - startFrame + 1, totalFrames);
106
+ }
107
+
108
+ // Finalize
109
+ ffmpegProcess.stdin.end();
110
+ await ffmpegDone;
111
+
112
+ return outputPath;
113
+ }
114
+
115
+ /**
116
+ * Render an image sequence from a page.
117
+ *
118
+ * @param {object} options
119
+ * @param {Page} options.page - Playwright page
120
+ * @param {string} options.outputDir - Output directory
121
+ * @param {number} options.startFrame - First frame to render
122
+ * @param {number} options.endFrame - Last frame to render
123
+ * @param {number} options.width - Image width
124
+ * @param {number} options.height - Image height
125
+ * @param {number} options.fps - Frames per second (for naming)
126
+ * @param {string} options.imageFormat - 'png' or 'jpeg'
127
+ * @param {number} options.quality - JPEG quality (0-100)
128
+ * @param {function} options.onProgress - Progress callback
129
+ * @returns {Promise<string>} Output directory
130
+ */
131
+ export async function renderSequence({
132
+ page,
133
+ outputDir,
134
+ startFrame,
135
+ endFrame,
136
+ _width,
137
+ _height,
138
+ _fps,
139
+ imageFormat = 'png',
140
+ quality = 80,
141
+ onProgress,
142
+ }) {
143
+ const totalFrames = endFrame - startFrame + 1;
144
+
145
+ // Ensure output directory exists
146
+ fs.mkdirSync(outputDir, { recursive: true });
147
+
148
+ // Calculate padding for frame numbers
149
+ const padding = String(endFrame).length;
150
+ const ext = imageFormat === 'jpeg' ? 'jpg' : imageFormat;
151
+
152
+ // Render each frame
153
+ for (let frame = startFrame; frame <= endFrame; frame++) {
154
+ // Set frame and wait for render
155
+ await setFrame(page, frame);
156
+
157
+ // Build filename with zero-padded frame number
158
+ const frameNum = String(frame).padStart(padding, '0');
159
+ const filename = `frame-${frameNum}.${ext}`;
160
+ const outputPath = path.join(outputDir, filename);
161
+
162
+ // Capture screenshot
163
+ const element = page.locator(RENDER_CONTAINER);
164
+ const screenshotOptions = {
165
+ type: imageFormat,
166
+ path: outputPath,
167
+ };
168
+
169
+ if (imageFormat === 'jpeg') {
170
+ screenshotOptions.quality = quality;
171
+ }
172
+
173
+ await element.screenshot(screenshotOptions);
174
+
175
+ // Report progress
176
+ if (onProgress) onProgress(frame - startFrame + 1, totalFrames);
177
+ }
178
+
179
+ return outputDir;
180
+ }
181
+
182
+ /**
183
+ * Render a GIF with palette optimization.
184
+ *
185
+ * @param {object} options
186
+ * @param {Page} options.page - Playwright page
187
+ * @param {string} options.outputPath - Output file path
188
+ * @param {number} options.startFrame - First frame to render
189
+ * @param {number} options.endFrame - Last frame to render
190
+ * @param {number} options.width - GIF width
191
+ * @param {number} options.height - GIF height
192
+ * @param {number} options.fps - Frames per second
193
+ * @param {number} options.loop - Loop count (0 = infinite)
194
+ * @param {function} options.onProgress - Progress callback
195
+ * @returns {Promise<string>} Output path
196
+ */
197
+ export async function renderGif({
198
+ page,
199
+ outputPath,
200
+ startFrame,
201
+ endFrame,
202
+ width,
203
+ height,
204
+ fps = 15,
205
+ loop = 0,
206
+ onProgress,
207
+ }) {
208
+ const totalFrames = endFrame - startFrame + 1;
209
+ const tempDir = path.join(path.dirname(outputPath), '.framely-temp-' + Date.now());
210
+ fs.mkdirSync(tempDir, { recursive: true });
211
+
212
+ try {
213
+ // First pass: render frames to temp directory
214
+ const padding = String(endFrame).length;
215
+
216
+ for (let frame = startFrame; frame <= endFrame; frame++) {
217
+ await setFrame(page, frame);
218
+
219
+ const frameNum = String(frame - startFrame).padStart(padding, '0');
220
+ const framePath = path.join(tempDir, `frame-${frameNum}.png`);
221
+
222
+ const element = page.locator(RENDER_CONTAINER);
223
+ await element.screenshot({ type: 'png', path: framePath });
224
+
225
+ if (onProgress) onProgress(frame - startFrame + 1, totalFrames);
226
+ }
227
+
228
+ // Second pass: generate palette and create GIF
229
+ const paletteFile = path.join(tempDir, 'palette.png');
230
+ const inputPattern = path.join(tempDir, `frame-%0${padding}d.png`);
231
+
232
+ // Generate palette
233
+ await runFFmpeg([
234
+ '-i', inputPattern,
235
+ '-vf', `fps=${fps},scale=${width}:${height}:flags=lanczos,palettegen=max_colors=256`,
236
+ '-y', paletteFile,
237
+ ]);
238
+
239
+ // Create GIF using palette
240
+ await runFFmpeg([
241
+ '-i', inputPattern,
242
+ '-i', paletteFile,
243
+ '-lavfi', `fps=${fps},scale=${width}:${height}:flags=lanczos[x];[x][1:v]paletteuse=dither=sierra2_4a`,
244
+ '-loop', String(loop),
245
+ '-y', outputPath,
246
+ ]);
247
+
248
+ return outputPath;
249
+ } finally {
250
+ // Cleanup temp directory
251
+ fs.rmSync(tempDir, { recursive: true, force: true });
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Mix audio tracks into video.
257
+ *
258
+ * @param {object} options
259
+ * @param {string} options.videoPath - Path to video file
260
+ * @param {Array<{ path: string, startFrame: number, volume: number }>} options.audioTracks
261
+ * @param {string} options.outputPath - Output path
262
+ * @param {number} options.fps - Video FPS (for timing)
263
+ * @returns {Promise<string>} Output path
264
+ */
265
+ export async function mixAudio({
266
+ videoPath,
267
+ audioTracks,
268
+ outputPath,
269
+ fps,
270
+ }) {
271
+ if (!audioTracks || audioTracks.length === 0) {
272
+ // No audio to mix, just copy
273
+ fs.copyFileSync(videoPath, outputPath);
274
+ return outputPath;
275
+ }
276
+
277
+ // Build FFmpeg filter for mixing audio tracks
278
+ const inputs = ['-i', videoPath];
279
+ const filters = [];
280
+ const audioInputs = [];
281
+
282
+ audioTracks.forEach((track, i) => {
283
+ const inputIndex = i + 1;
284
+ inputs.push('-i', track.path);
285
+
286
+ const delay = Math.round((track.startFrame / fps) * 1000);
287
+ const volume = track.volume != null ? track.volume : 1;
288
+
289
+ // Delay and adjust volume
290
+ filters.push(`[${inputIndex}:a]adelay=${delay}|${delay},volume=${volume}[a${i}]`);
291
+ audioInputs.push(`[a${i}]`);
292
+ });
293
+
294
+ // Mix all audio tracks
295
+ const mixFilter = audioInputs.join('') + `amix=inputs=${audioTracks.length}:duration=longest[aout]`;
296
+ filters.push(mixFilter);
297
+
298
+ const ffmpegArgs = [
299
+ ...inputs,
300
+ '-filter_complex', filters.join(';'),
301
+ '-map', '0:v',
302
+ '-map', '[aout]',
303
+ '-c:v', 'copy',
304
+ ...getAudioArgs(),
305
+ '-y', outputPath,
306
+ ];
307
+
308
+ await runFFmpeg(ffmpegArgs);
309
+ return outputPath;
310
+ }
311
+
312
+ /**
313
+ * Render a video using parallel browser instances.
314
+ *
315
+ * Divides the frame range into chunks, renders each chunk in a separate
316
+ * browser instance as a PNG sequence, then stitches them with FFmpeg.
317
+ *
318
+ * @param {object} options
319
+ * @param {string} options.renderUrl - URL to load in each browser
320
+ * @param {string} options.outputPath - Output file path
321
+ * @param {number} options.startFrame - First frame
322
+ * @param {number} options.endFrame - Last frame
323
+ * @param {number} options.width - Video width
324
+ * @param {number} options.height - Video height
325
+ * @param {number} options.fps - Frames per second
326
+ * @param {string} options.codec - Codec identifier
327
+ * @param {number} options.crf - Quality
328
+ * @param {number} options.concurrency - Number of parallel workers
329
+ * @param {boolean} options.muted - Disable audio
330
+ * @param {function} options.onProgress - Progress callback
331
+ * @returns {Promise<string>} Output path
332
+ */
333
+ export async function renderVideoParallel({
334
+ renderUrl,
335
+ outputPath,
336
+ startFrame,
337
+ endFrame,
338
+ width,
339
+ height,
340
+ fps,
341
+ codec = 'h264',
342
+ crf = 18,
343
+ concurrency = 2,
344
+ _muted = false,
345
+ onProgress,
346
+ }) {
347
+ const totalFrames = endFrame - startFrame + 1;
348
+ const chunkSize = Math.ceil(totalFrames / concurrency);
349
+ const tempDir = path.join(path.dirname(outputPath), `.framely-parallel-${crypto.randomUUID().slice(0, 8)}`);
350
+ fs.mkdirSync(tempDir, { recursive: true });
351
+
352
+ // Calculate padding for frame filenames
353
+ const padding = String(endFrame).length;
354
+
355
+ try {
356
+ // Divide into chunks
357
+ const chunks = [];
358
+ for (let i = 0; i < concurrency; i++) {
359
+ const chunkStart = startFrame + i * chunkSize;
360
+ const chunkEnd = Math.min(chunkStart + chunkSize - 1, endFrame);
361
+ if (chunkStart > endFrame) break;
362
+ chunks.push({ start: chunkStart, end: chunkEnd, index: i });
363
+ }
364
+
365
+ // Track progress across all workers
366
+ let framesRendered = 0;
367
+
368
+ // Render each chunk in parallel
369
+ await Promise.all(
370
+ chunks.map(async (chunk) => {
371
+ const { browser, page } = await createBrowser({ width, height, scale: 1 });
372
+
373
+ try {
374
+ await page.goto(renderUrl, { waitUntil: 'domcontentloaded' });
375
+ await page.waitForFunction('window.__ready === true', { timeout: 30000 });
376
+
377
+ for (let frame = chunk.start; frame <= chunk.end; frame++) {
378
+ await setFrame(page, frame);
379
+
380
+ const frameNum = String(frame).padStart(padding, '0');
381
+ const framePath = path.join(tempDir, `frame-${frameNum}.png`);
382
+
383
+ const element = page.locator(RENDER_CONTAINER);
384
+ await element.screenshot({ type: 'png', path: framePath });
385
+
386
+ framesRendered++;
387
+ if (onProgress) onProgress(framesRendered, totalFrames);
388
+ }
389
+ } finally {
390
+ await closeBrowser(browser);
391
+ }
392
+ })
393
+ );
394
+
395
+ // Stitch frames with FFmpeg
396
+ const inputPattern = path.join(tempDir, `frame-%0${padding}d.png`);
397
+ const ffmpegArgs = [
398
+ '-y',
399
+ '-framerate', String(fps),
400
+ '-i', inputPattern,
401
+ ...getCodecArgs(codec, { crf, fps, width, height }),
402
+ outputPath,
403
+ ];
404
+
405
+ await runFFmpeg(ffmpegArgs);
406
+ return outputPath;
407
+ } finally {
408
+ // Clean up temp directory
409
+ fs.rmSync(tempDir, { recursive: true, force: true });
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Run FFmpeg command.
415
+ *
416
+ * @param {string[]} args - FFmpeg arguments
417
+ * @returns {Promise<void>}
418
+ */
419
+ function runFFmpeg(args) {
420
+ return new Promise((resolve, reject) => {
421
+ const proc = spawn('ffmpeg', args);
422
+
423
+ let stderr = '';
424
+ proc.stderr.on('data', (data) => {
425
+ stderr += data.toString();
426
+ // Cap stderr buffer at 10KB
427
+ if (stderr.length > 10000) {
428
+ stderr = stderr.slice(-10000);
429
+ }
430
+ });
431
+
432
+ proc.on('close', (code) => {
433
+ if (code === 0) resolve();
434
+ else reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-2000)}`));
435
+ });
436
+
437
+ proc.on('error', reject);
438
+ });
439
+ }
440
+
441
+ export default {
442
+ renderVideo,
443
+ renderVideoParallel,
444
+ renderSequence,
445
+ renderGif,
446
+ mixAudio,
447
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Input Validation Utilities
3
+ *
4
+ * Validates numeric CLI options and URLs to prevent invalid configurations.
5
+ */
6
+
7
+ import chalk from 'chalk';
8
+
9
+ /**
10
+ * Validate CRF value (0-51).
11
+ * @param {number} crf
12
+ * @param {string} codec
13
+ * @returns {number} validated CRF
14
+ */
15
+ export function validateCrf(crf, codec) {
16
+ if (isNaN(crf)) {
17
+ throw new Error(`Invalid CRF value: must be a number`);
18
+ }
19
+ if (codec === 'prores') {
20
+ // ProRes doesn't use CRF
21
+ return crf;
22
+ }
23
+ if (crf < 0 || crf > 51) {
24
+ throw new Error(`CRF must be between 0 and 51, got ${crf}`);
25
+ }
26
+ return crf;
27
+ }
28
+
29
+ /**
30
+ * Validate port number (1024-65535).
31
+ * @param {number|string} port
32
+ * @returns {number} validated port
33
+ */
34
+ export function validatePort(port) {
35
+ const num = typeof port === 'string' ? parseInt(port, 10) : port;
36
+ if (isNaN(num) || num < 1024 || num > 65535) {
37
+ throw new Error(`Port must be between 1024 and 65535, got ${port}`);
38
+ }
39
+ return num;
40
+ }
41
+
42
+ /**
43
+ * Validate a dimension value (positive integer).
44
+ * @param {number|string} value
45
+ * @param {string} name - e.g., "width" or "height"
46
+ * @returns {number} validated dimension
47
+ */
48
+ export function validateDimension(value, name) {
49
+ const num = typeof value === 'string' ? parseInt(value, 10) : value;
50
+ if (isNaN(num) || num <= 0) {
51
+ throw new Error(`${name} must be a positive integer, got ${value}`);
52
+ }
53
+ if (num > 7680) {
54
+ throw new Error(`${name} exceeds maximum of 7680, got ${num}`);
55
+ }
56
+ return num;
57
+ }
58
+
59
+ /**
60
+ * Validate FPS (1-120).
61
+ * @param {number|string} fps
62
+ * @returns {number} validated FPS
63
+ */
64
+ export function validateFps(fps) {
65
+ const num = typeof fps === 'string' ? parseInt(fps, 10) : fps;
66
+ if (isNaN(num) || num < 1 || num > 120) {
67
+ throw new Error(`FPS must be between 1 and 120, got ${fps}`);
68
+ }
69
+ return num;
70
+ }
71
+
72
+ /**
73
+ * Validate JPEG quality (0-100).
74
+ * @param {number|string} quality
75
+ * @returns {number} validated quality
76
+ */
77
+ export function validateQuality(quality) {
78
+ const num = typeof quality === 'string' ? parseInt(quality, 10) : quality;
79
+ if (isNaN(num) || num < 0 || num > 100) {
80
+ throw new Error(`Quality must be between 0 and 100, got ${quality}`);
81
+ }
82
+ return num;
83
+ }
84
+
85
+ /**
86
+ * Validate scale factor (0.1-10).
87
+ * @param {number|string} scale
88
+ * @returns {number} validated scale
89
+ */
90
+ export function validateScale(scale) {
91
+ const num = typeof scale === 'string' ? parseFloat(scale) : scale;
92
+ if (isNaN(num) || num < 0.1 || num > 10) {
93
+ throw new Error(`Scale must be between 0.1 and 10, got ${scale}`);
94
+ }
95
+ return num;
96
+ }
97
+
98
+ /**
99
+ * Validate a frontend URL. Must be http/https and localhost/127.0.0.1
100
+ * unless allowRemote is true.
101
+ * @param {string} urlStr
102
+ * @param {boolean} [allowRemote=false]
103
+ * @returns {string} validated URL
104
+ */
105
+ export function validateFrontendUrl(urlStr, allowRemote = false) {
106
+ let parsed;
107
+ try {
108
+ parsed = new URL(urlStr);
109
+ } catch {
110
+ throw new Error(`Invalid frontend URL: ${urlStr}`);
111
+ }
112
+
113
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
114
+ throw new Error(`Frontend URL must use http or https, got ${parsed.protocol}`);
115
+ }
116
+
117
+ const localHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1'];
118
+ if (!allowRemote && !localHosts.includes(parsed.hostname)) {
119
+ throw new Error(
120
+ `Frontend URL must be localhost for security. Got ${parsed.hostname}.\n` +
121
+ `Use --allow-remote to render from remote URLs.`
122
+ );
123
+ }
124
+
125
+ if (!allowRemote && !localHosts.includes(parsed.hostname)) {
126
+ console.warn(chalk.yellow(`Warning: Rendering from remote URL: ${urlStr}`));
127
+ }
128
+
129
+ return urlStr;
130
+ }
131
+
132
+ /**
133
+ * Validate frame range (start < end).
134
+ * @param {number} startFrame
135
+ * @param {number} endFrame
136
+ * @param {number} durationInFrames
137
+ */
138
+ export function validateFrameRange(startFrame, endFrame, durationInFrames) {
139
+ if (startFrame < 0) {
140
+ throw new Error(`Start frame must be >= 0, got ${startFrame}`);
141
+ }
142
+ if (endFrame >= durationInFrames) {
143
+ throw new Error(`End frame must be < ${durationInFrames}, got ${endFrame}`);
144
+ }
145
+ if (startFrame > endFrame) {
146
+ throw new Error(`Start frame (${startFrame}) must be <= end frame (${endFrame})`);
147
+ }
148
+ }