@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.
- package/commands/compositions.js +135 -0
- package/commands/preview.js +889 -0
- package/commands/render.js +295 -0
- package/commands/still.js +165 -0
- package/index.js +93 -0
- package/package.json +60 -0
- package/studio/App.css +605 -0
- package/studio/App.jsx +185 -0
- package/studio/CompositionsView.css +399 -0
- package/studio/CompositionsView.jsx +327 -0
- package/studio/PropsEditor.css +195 -0
- package/studio/PropsEditor.tsx +176 -0
- package/studio/RenderDialog.tsx +476 -0
- package/studio/ShareDialog.tsx +200 -0
- package/studio/index.ts +19 -0
- package/studio/player/Player.css +199 -0
- package/studio/player/Player.jsx +355 -0
- package/studio/styles/design-system.css +592 -0
- package/studio/styles/dialogs.css +420 -0
- package/studio/templates/AnimatedGradient.jsx +99 -0
- package/studio/templates/InstagramStory.jsx +172 -0
- package/studio/templates/LowerThird.jsx +139 -0
- package/studio/templates/ProductShowcase.jsx +162 -0
- package/studio/templates/SlideTransition.jsx +211 -0
- package/studio/templates/SocialIntro.jsx +122 -0
- package/studio/templates/SubscribeAnimation.jsx +186 -0
- package/studio/templates/TemplateCard.tsx +58 -0
- package/studio/templates/TemplateFilters.tsx +97 -0
- package/studio/templates/TemplatePreviewDialog.tsx +196 -0
- package/studio/templates/TemplatesMarketplace.css +686 -0
- package/studio/templates/TemplatesMarketplace.tsx +172 -0
- package/studio/templates/TextReveal.jsx +134 -0
- package/studio/templates/UseTemplateDialog.tsx +154 -0
- package/studio/templates/index.ts +45 -0
- package/utils/browser.js +188 -0
- package/utils/codecs.js +200 -0
- package/utils/logger.js +35 -0
- package/utils/props.js +42 -0
- package/utils/render.js +447 -0
- package/utils/validate.js +148 -0
package/utils/render.js
ADDED
|
@@ -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
|
+
}
|