@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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render Command
|
|
3
|
+
*
|
|
4
|
+
* Renders a composition to video with various codec and quality options.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* framely render my-video output.mp4 --codec h264 --crf 18
|
|
8
|
+
* framely render my-video --props '{"name": "World"}'
|
|
9
|
+
* framely render my-video --sequence --image-format png
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import ora from 'ora';
|
|
16
|
+
import { createBrowser, closeBrowser } from '../utils/browser.js';
|
|
17
|
+
import { renderVideo, renderVideoParallel, renderSequence, renderGif, mixAudio } from '../utils/render.js';
|
|
18
|
+
import { getCodecConfig } from '../utils/codecs.js';
|
|
19
|
+
import { loadProps } from '../utils/props.js';
|
|
20
|
+
import { createLogger } from '../utils/logger.js';
|
|
21
|
+
import {
|
|
22
|
+
validateCrf,
|
|
23
|
+
validateScale,
|
|
24
|
+
validateFps,
|
|
25
|
+
validateDimension,
|
|
26
|
+
validateQuality,
|
|
27
|
+
validateFrontendUrl,
|
|
28
|
+
} from '../utils/validate.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse frame range string to { start, end }.
|
|
32
|
+
* @param {string} range - e.g., "0-100"
|
|
33
|
+
* @returns {{ start: number, end: number }|null}
|
|
34
|
+
*/
|
|
35
|
+
function parseFrameRange(range) {
|
|
36
|
+
if (!range) return null;
|
|
37
|
+
const match = range.match(/^(\d+)-(\d+)$/);
|
|
38
|
+
if (!match) return null;
|
|
39
|
+
return { start: parseInt(match[1], 10), end: parseInt(match[2], 10) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Main render command handler.
|
|
44
|
+
*/
|
|
45
|
+
export async function renderCommand(compositionId, output, options) {
|
|
46
|
+
const spinner = ora();
|
|
47
|
+
const log = createLogger(options.logLevel);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// ─── Parse Options ───
|
|
51
|
+
const codec = options.codec || 'h264';
|
|
52
|
+
const codecConfig = getCodecConfig(codec);
|
|
53
|
+
|
|
54
|
+
if (!codecConfig) {
|
|
55
|
+
console.error(chalk.red(`Unknown codec: ${codec}`));
|
|
56
|
+
console.log(chalk.gray('Available codecs: h264, h265, vp8, vp9, prores, gif'));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const crf = validateCrf(parseInt(options.crf, 10), codec);
|
|
61
|
+
const concurrency = parseInt(options.concurrency, 10);
|
|
62
|
+
const scale = validateScale(parseFloat(options.scale));
|
|
63
|
+
const frameRange = parseFrameRange(options.frames);
|
|
64
|
+
const inputProps = loadProps(options.props, options.propsFile);
|
|
65
|
+
validateFrontendUrl(options.frontendUrl, options.allowRemote);
|
|
66
|
+
if (options.fps) validateFps(options.fps);
|
|
67
|
+
if (options.width) validateDimension(options.width, 'width');
|
|
68
|
+
if (options.height) validateDimension(options.height, 'height');
|
|
69
|
+
if (options.quality) validateQuality(options.quality);
|
|
70
|
+
|
|
71
|
+
// ─── Determine Output Path ───
|
|
72
|
+
const outputDir = path.resolve(options.outputDir);
|
|
73
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
74
|
+
|
|
75
|
+
let outputPath;
|
|
76
|
+
if (output) {
|
|
77
|
+
outputPath = path.resolve(output);
|
|
78
|
+
} else if (options.sequence) {
|
|
79
|
+
outputPath = path.join(outputDir, compositionId);
|
|
80
|
+
} else {
|
|
81
|
+
const ext = codecConfig.extension;
|
|
82
|
+
outputPath = path.join(outputDir, `${compositionId}-${Date.now()}.${ext}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Print Configuration ───
|
|
86
|
+
console.log(chalk.cyan('\n🎬 Framely Render\n'));
|
|
87
|
+
console.log(chalk.white(' Composition:'), chalk.yellow(compositionId));
|
|
88
|
+
console.log(chalk.white(' Codec: '), chalk.yellow(codec));
|
|
89
|
+
if (codecConfig.supportsCrf) {
|
|
90
|
+
console.log(chalk.white(' Quality: '), chalk.yellow(`CRF ${crf}`));
|
|
91
|
+
}
|
|
92
|
+
if (options.width || options.height) {
|
|
93
|
+
console.log(chalk.white(' Resolution: '), chalk.yellow(`${options.width || 'auto'}x${options.height || 'auto'}`));
|
|
94
|
+
}
|
|
95
|
+
if (scale !== 1) {
|
|
96
|
+
console.log(chalk.white(' Scale: '), chalk.yellow(`${scale}x`));
|
|
97
|
+
}
|
|
98
|
+
if (frameRange) {
|
|
99
|
+
console.log(chalk.white(' Frames: '), chalk.yellow(`${frameRange.start}-${frameRange.end}`));
|
|
100
|
+
}
|
|
101
|
+
if (concurrency > 1 && !options.sequence && codec !== 'gif') {
|
|
102
|
+
console.log(chalk.white(' Concurrency:'), chalk.yellow(`${concurrency} workers`));
|
|
103
|
+
}
|
|
104
|
+
if (Object.keys(inputProps).length > 0) {
|
|
105
|
+
console.log(chalk.white(' Props: '), chalk.gray(JSON.stringify(inputProps)));
|
|
106
|
+
}
|
|
107
|
+
console.log(chalk.white(' Output: '), chalk.green(outputPath));
|
|
108
|
+
console.log('');
|
|
109
|
+
|
|
110
|
+
// ─── Launch Browser ───
|
|
111
|
+
spinner.start('Launching browser...');
|
|
112
|
+
const { browser, page } = await createBrowser({
|
|
113
|
+
width: options.width ? parseInt(options.width, 10) : undefined,
|
|
114
|
+
height: options.height ? parseInt(options.height, 10) : undefined,
|
|
115
|
+
scale,
|
|
116
|
+
});
|
|
117
|
+
spinner.succeed('Browser ready');
|
|
118
|
+
|
|
119
|
+
// ─── Load Composition ───
|
|
120
|
+
spinner.start('Loading composition...');
|
|
121
|
+
const renderUrl = buildRenderUrl(options.frontendUrl, compositionId, inputProps);
|
|
122
|
+
await page.goto(renderUrl, { waitUntil: 'domcontentloaded' });
|
|
123
|
+
|
|
124
|
+
// Wait for app to be ready
|
|
125
|
+
await page.waitForFunction('window.__ready === true', { timeout: 30000 });
|
|
126
|
+
|
|
127
|
+
// Get composition metadata
|
|
128
|
+
const metadata = await page.evaluate(() => ({
|
|
129
|
+
width: window.__compositionWidth || 1920,
|
|
130
|
+
height: window.__compositionHeight || 1080,
|
|
131
|
+
fps: window.__compositionFps || 30,
|
|
132
|
+
durationInFrames: window.__compositionDurationInFrames || 300,
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
spinner.succeed(`Composition loaded: ${metadata.width}x${metadata.height} @ ${metadata.fps}fps`);
|
|
136
|
+
|
|
137
|
+
// Apply overrides
|
|
138
|
+
const width = options.width ? parseInt(options.width, 10) : metadata.width;
|
|
139
|
+
const height = options.height ? parseInt(options.height, 10) : metadata.height;
|
|
140
|
+
const fps = options.fps ? parseInt(options.fps, 10) : metadata.fps;
|
|
141
|
+
let startFrame = 0;
|
|
142
|
+
let endFrame = metadata.durationInFrames - 1;
|
|
143
|
+
|
|
144
|
+
if (frameRange) {
|
|
145
|
+
startFrame = frameRange.start;
|
|
146
|
+
endFrame = Math.min(frameRange.end, metadata.durationInFrames - 1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (startFrame > endFrame) {
|
|
150
|
+
console.error(chalk.red(`\nError: Start frame (${startFrame}) must be <= end frame (${endFrame})\n`));
|
|
151
|
+
await closeBrowser(browser);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const totalFrames = endFrame - startFrame + 1;
|
|
156
|
+
|
|
157
|
+
console.log(chalk.gray(`\n Rendering ${totalFrames} frames (${startFrame}-${endFrame})\n`));
|
|
158
|
+
|
|
159
|
+
// ─── Render ───
|
|
160
|
+
const startTime = Date.now();
|
|
161
|
+
let lastProgress = -1;
|
|
162
|
+
|
|
163
|
+
const onProgress = (frame, total) => {
|
|
164
|
+
const progress = Math.floor((frame / total) * 100);
|
|
165
|
+
if (progress !== lastProgress) {
|
|
166
|
+
lastProgress = progress;
|
|
167
|
+
const bar = createProgressBar(progress, 30);
|
|
168
|
+
process.stdout.write(`\r ${bar} ${progress}% (frame ${frame}/${total})`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (options.sequence) {
|
|
173
|
+
// Render as image sequence
|
|
174
|
+
await renderSequence({
|
|
175
|
+
page,
|
|
176
|
+
outputDir: outputPath,
|
|
177
|
+
startFrame,
|
|
178
|
+
endFrame,
|
|
179
|
+
width,
|
|
180
|
+
height,
|
|
181
|
+
fps,
|
|
182
|
+
imageFormat: options.imageFormat,
|
|
183
|
+
quality: parseInt(options.quality, 10),
|
|
184
|
+
onProgress,
|
|
185
|
+
});
|
|
186
|
+
} else if (codec === 'gif') {
|
|
187
|
+
// GIF uses 2-pass palette rendering for better quality
|
|
188
|
+
log.verbose('Using 2-pass GIF rendering with palette generation');
|
|
189
|
+
await renderGif({
|
|
190
|
+
page,
|
|
191
|
+
outputPath,
|
|
192
|
+
startFrame,
|
|
193
|
+
endFrame,
|
|
194
|
+
width,
|
|
195
|
+
height,
|
|
196
|
+
fps,
|
|
197
|
+
onProgress,
|
|
198
|
+
});
|
|
199
|
+
} else if (concurrency > 1) {
|
|
200
|
+
// Parallel rendering with multiple browser instances
|
|
201
|
+
log.verbose(`Starting parallel render with ${concurrency} workers`);
|
|
202
|
+
await closeBrowser(browser); // Close the initial browser, parallel uses its own pool
|
|
203
|
+
await renderVideoParallel({
|
|
204
|
+
renderUrl,
|
|
205
|
+
outputPath,
|
|
206
|
+
startFrame,
|
|
207
|
+
endFrame,
|
|
208
|
+
width,
|
|
209
|
+
height,
|
|
210
|
+
fps,
|
|
211
|
+
codec,
|
|
212
|
+
crf,
|
|
213
|
+
concurrency,
|
|
214
|
+
muted: options.muted,
|
|
215
|
+
onProgress,
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
// Single-threaded video render
|
|
219
|
+
await renderVideo({
|
|
220
|
+
page,
|
|
221
|
+
outputPath,
|
|
222
|
+
startFrame,
|
|
223
|
+
endFrame,
|
|
224
|
+
width,
|
|
225
|
+
height,
|
|
226
|
+
fps,
|
|
227
|
+
codec,
|
|
228
|
+
crf,
|
|
229
|
+
muted: options.muted,
|
|
230
|
+
onProgress,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Audio Mixing ───
|
|
235
|
+
if (!options.muted && codec !== 'gif' && !options.sequence) {
|
|
236
|
+
try {
|
|
237
|
+
// Check if there are audio tracks registered in the page
|
|
238
|
+
const audioTracks = concurrency > 1 ? null : await page.evaluate(() => {
|
|
239
|
+
return window.__FRAMELY_AUDIO_TRACKS || null;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (audioTracks && audioTracks.length > 0) {
|
|
243
|
+
log.verbose(`Mixing ${audioTracks.length} audio track(s)...`);
|
|
244
|
+
spinner.start('Mixing audio...');
|
|
245
|
+
const tempOutput = outputPath.replace(/(\.[^.]+)$/, '-with-audio$1');
|
|
246
|
+
await mixAudio({ videoPath: outputPath, audioTracks, outputPath: tempOutput, fps });
|
|
247
|
+
// Replace original with mixed version
|
|
248
|
+
fs.renameSync(tempOutput, outputPath);
|
|
249
|
+
spinner.succeed('Audio mixed');
|
|
250
|
+
}
|
|
251
|
+
} catch (audioErr) {
|
|
252
|
+
log.warn(`Warning: Audio mixing failed: ${audioErr.message}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
257
|
+
console.log(`\n\n${chalk.green('✓')} Render complete in ${chalk.cyan(duration + 's')}`);
|
|
258
|
+
console.log(chalk.gray(` Output: ${outputPath}\n`));
|
|
259
|
+
|
|
260
|
+
// ─── Cleanup ───
|
|
261
|
+
if (concurrency <= 1) {
|
|
262
|
+
await closeBrowser(browser);
|
|
263
|
+
}
|
|
264
|
+
process.exit(0);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
spinner.fail('Render failed');
|
|
267
|
+
console.error(chalk.red(`\nError: ${error.message}\n`));
|
|
268
|
+
if (log.isVerbose) {
|
|
269
|
+
console.error(error.stack);
|
|
270
|
+
}
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build render URL with props.
|
|
277
|
+
*/
|
|
278
|
+
function buildRenderUrl(baseUrl, compositionId, props) {
|
|
279
|
+
const url = new URL(baseUrl);
|
|
280
|
+
url.searchParams.set('renderMode', 'true');
|
|
281
|
+
url.searchParams.set('composition', compositionId);
|
|
282
|
+
if (Object.keys(props).length > 0) {
|
|
283
|
+
url.searchParams.set('props', encodeURIComponent(JSON.stringify(props)));
|
|
284
|
+
}
|
|
285
|
+
return url.toString();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Create ASCII progress bar.
|
|
290
|
+
*/
|
|
291
|
+
function createProgressBar(percent, width) {
|
|
292
|
+
const filled = Math.floor((percent / 100) * width);
|
|
293
|
+
const empty = width - filled;
|
|
294
|
+
return chalk.cyan('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
|
|
295
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Still Command
|
|
3
|
+
*
|
|
4
|
+
* Renders a single frame from a composition as an image.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* framely still my-video --frame 100 --format png
|
|
8
|
+
* framely still my-video output.jpg --frame 50 --quality 90
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import ora from 'ora';
|
|
15
|
+
import { createBrowser, closeBrowser, setFrame } from '../utils/browser.js';
|
|
16
|
+
import { loadProps } from '../utils/props.js';
|
|
17
|
+
import {
|
|
18
|
+
validateScale,
|
|
19
|
+
validateQuality,
|
|
20
|
+
validateFrontendUrl,
|
|
21
|
+
validateDimension,
|
|
22
|
+
} from '../utils/validate.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Main still command handler.
|
|
26
|
+
*/
|
|
27
|
+
export async function stillCommand(compositionId, output, options) {
|
|
28
|
+
const spinner = ora();
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const frame = parseInt(options.frame, 10);
|
|
32
|
+
const format = options.format || 'png';
|
|
33
|
+
const quality = validateQuality(parseInt(options.quality, 10));
|
|
34
|
+
const scale = validateScale(parseFloat(options.scale));
|
|
35
|
+
const inputProps = loadProps(options.props, options.propsFile);
|
|
36
|
+
validateFrontendUrl(options.frontendUrl, options.allowRemote);
|
|
37
|
+
if (options.width) validateDimension(options.width, 'width');
|
|
38
|
+
if (options.height) validateDimension(options.height, 'height');
|
|
39
|
+
|
|
40
|
+
// Validate format
|
|
41
|
+
if (!['png', 'jpeg', 'jpg'].includes(format)) {
|
|
42
|
+
console.error(chalk.red(`Invalid format: ${format}`));
|
|
43
|
+
console.log(chalk.gray('Available formats: png, jpeg'));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const actualFormat = format === 'jpg' ? 'jpeg' : format;
|
|
48
|
+
|
|
49
|
+
// ─── Determine Output Path ───
|
|
50
|
+
let outputPath;
|
|
51
|
+
if (output) {
|
|
52
|
+
outputPath = path.resolve(output);
|
|
53
|
+
} else {
|
|
54
|
+
const ext = actualFormat === 'jpeg' ? 'jpg' : actualFormat;
|
|
55
|
+
outputPath = path.resolve(`${compositionId}-frame${frame}.${ext}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Ensure output directory exists
|
|
59
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
60
|
+
|
|
61
|
+
// ─── Print Configuration ───
|
|
62
|
+
console.log(chalk.cyan('\n📸 Framely Still\n'));
|
|
63
|
+
console.log(chalk.white(' Composition:'), chalk.yellow(compositionId));
|
|
64
|
+
console.log(chalk.white(' Frame: '), chalk.yellow(frame));
|
|
65
|
+
console.log(chalk.white(' Format: '), chalk.yellow(actualFormat));
|
|
66
|
+
if (actualFormat === 'jpeg') {
|
|
67
|
+
console.log(chalk.white(' Quality: '), chalk.yellow(`${quality}%`));
|
|
68
|
+
}
|
|
69
|
+
if (options.width || options.height) {
|
|
70
|
+
console.log(chalk.white(' Resolution: '), chalk.yellow(`${options.width || 'auto'}x${options.height || 'auto'}`));
|
|
71
|
+
}
|
|
72
|
+
if (scale !== 1) {
|
|
73
|
+
console.log(chalk.white(' Scale: '), chalk.yellow(`${scale}x`));
|
|
74
|
+
}
|
|
75
|
+
if (Object.keys(inputProps).length > 0) {
|
|
76
|
+
console.log(chalk.white(' Props: '), chalk.gray(JSON.stringify(inputProps)));
|
|
77
|
+
}
|
|
78
|
+
console.log(chalk.white(' Output: '), chalk.green(outputPath));
|
|
79
|
+
console.log('');
|
|
80
|
+
|
|
81
|
+
// ─── Launch Browser ───
|
|
82
|
+
spinner.start('Launching browser...');
|
|
83
|
+
const { browser, page } = await createBrowser({
|
|
84
|
+
width: options.width ? parseInt(options.width, 10) : undefined,
|
|
85
|
+
height: options.height ? parseInt(options.height, 10) : undefined,
|
|
86
|
+
scale,
|
|
87
|
+
});
|
|
88
|
+
spinner.succeed('Browser ready');
|
|
89
|
+
|
|
90
|
+
// ─── Load Composition ───
|
|
91
|
+
spinner.start('Loading composition...');
|
|
92
|
+
const renderUrl = buildRenderUrl(options.frontendUrl, compositionId, inputProps);
|
|
93
|
+
await page.goto(renderUrl, { waitUntil: 'domcontentloaded' });
|
|
94
|
+
|
|
95
|
+
// Wait for app to be ready
|
|
96
|
+
await page.waitForFunction('window.__ready === true', { timeout: 30000 });
|
|
97
|
+
|
|
98
|
+
// Get composition metadata
|
|
99
|
+
const metadata = await page.evaluate(() => ({
|
|
100
|
+
width: window.__compositionWidth || 1920,
|
|
101
|
+
height: window.__compositionHeight || 1080,
|
|
102
|
+
fps: window.__compositionFps || 30,
|
|
103
|
+
durationInFrames: window.__compositionDurationInFrames || 300,
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
spinner.succeed(`Composition loaded: ${metadata.width}x${metadata.height}`);
|
|
107
|
+
|
|
108
|
+
// Validate frame number
|
|
109
|
+
if (frame < 0 || frame >= metadata.durationInFrames) {
|
|
110
|
+
console.error(chalk.red(`\nError: Frame ${frame} is out of range (0-${metadata.durationInFrames - 1})\n`));
|
|
111
|
+
await closeBrowser(browser);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Capture Frame ───
|
|
116
|
+
spinner.start(`Capturing frame ${frame}...`);
|
|
117
|
+
|
|
118
|
+
// Set the frame (handles delayRender automatically)
|
|
119
|
+
await setFrame(page, frame);
|
|
120
|
+
|
|
121
|
+
// Capture the frame
|
|
122
|
+
const element = page.locator('#render-container');
|
|
123
|
+
|
|
124
|
+
const screenshotOptions = {
|
|
125
|
+
type: actualFormat,
|
|
126
|
+
path: outputPath,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (actualFormat === 'jpeg') {
|
|
130
|
+
screenshotOptions.quality = quality;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await element.screenshot(screenshotOptions);
|
|
134
|
+
|
|
135
|
+
spinner.succeed('Frame captured');
|
|
136
|
+
|
|
137
|
+
// ─── Report Success ───
|
|
138
|
+
const stats = fs.statSync(outputPath);
|
|
139
|
+
const sizeKB = (stats.size / 1024).toFixed(1);
|
|
140
|
+
|
|
141
|
+
console.log(`\n${chalk.green('✓')} Still saved (${sizeKB} KB)`);
|
|
142
|
+
console.log(chalk.gray(` Output: ${outputPath}\n`));
|
|
143
|
+
|
|
144
|
+
// ─── Cleanup ───
|
|
145
|
+
await closeBrowser(browser);
|
|
146
|
+
process.exit(0);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
spinner.fail('Capture failed');
|
|
149
|
+
console.error(chalk.red(`\nError: ${error.message}\n`));
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build render URL with props.
|
|
156
|
+
*/
|
|
157
|
+
function buildRenderUrl(baseUrl, compositionId, props) {
|
|
158
|
+
const url = new URL(baseUrl);
|
|
159
|
+
url.searchParams.set('renderMode', 'true');
|
|
160
|
+
url.searchParams.set('composition', compositionId);
|
|
161
|
+
if (Object.keys(props).length > 0) {
|
|
162
|
+
url.searchParams.set('props', encodeURIComponent(JSON.stringify(props)));
|
|
163
|
+
}
|
|
164
|
+
return url.toString();
|
|
165
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Check Node.js version before importing anything
|
|
4
|
+
var nodeVersion = process.versions.node.split('.').map(Number);
|
|
5
|
+
if (nodeVersion[0] < 18) {
|
|
6
|
+
console.error('Error: Framely CLI requires Node.js 18 or later.');
|
|
7
|
+
console.error('Current version: ' + process.version);
|
|
8
|
+
console.error('Please upgrade Node.js: https://nodejs.org/');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Framely CLI
|
|
14
|
+
*
|
|
15
|
+
* Commands:
|
|
16
|
+
* framely render <composition-id> [output] - Render a composition to video
|
|
17
|
+
* framely still <composition-id> [output] - Render a single frame as image
|
|
18
|
+
* framely preview - Start the preview server
|
|
19
|
+
* framely compositions - List available compositions
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { program } from 'commander';
|
|
23
|
+
import { renderCommand } from './commands/render.js';
|
|
24
|
+
import { stillCommand } from './commands/still.js';
|
|
25
|
+
import { previewCommand } from './commands/preview.js';
|
|
26
|
+
import { compositionsCommand } from './commands/compositions.js';
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.name('framely')
|
|
30
|
+
.description('Framely CLI - Programmatic video creation')
|
|
31
|
+
.version('0.1.0');
|
|
32
|
+
|
|
33
|
+
// ─── Render Command ───────────────────────────────────────────────────────────
|
|
34
|
+
program
|
|
35
|
+
.command('render <composition-id>')
|
|
36
|
+
.description('Render a composition to video')
|
|
37
|
+
.argument('[output]', 'Output file path')
|
|
38
|
+
.option('--codec <codec>', 'Video codec (h264, h265, vp8, vp9, prores, gif)', 'h264')
|
|
39
|
+
.option('--crf <number>', 'Constant Rate Factor (0-51, lower = better quality)', '18')
|
|
40
|
+
.option('--fps <number>', 'Frames per second')
|
|
41
|
+
.option('--width <number>', 'Video width in pixels')
|
|
42
|
+
.option('--height <number>', 'Video height in pixels')
|
|
43
|
+
.option('--frames <range>', 'Frame range to render (e.g., "0-100")')
|
|
44
|
+
.option('--props <json>', 'Input props as JSON string')
|
|
45
|
+
.option('--props-file <path>', 'Path to JSON file with input props')
|
|
46
|
+
.option('--concurrency <number>', 'Number of parallel browser instances', '2')
|
|
47
|
+
.option('--output-dir <path>', 'Output directory', './outputs')
|
|
48
|
+
.option('--sequence', 'Output as image sequence instead of video')
|
|
49
|
+
.option('--image-format <format>', 'Image format for sequence (png, jpeg)', 'png')
|
|
50
|
+
.option('--quality <number>', 'JPEG quality (0-100)', '80')
|
|
51
|
+
.option('--scale <number>', 'Scale factor for output dimensions', '1')
|
|
52
|
+
.option('--preset <preset>', 'FFmpeg encoding preset (ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow)', 'fast')
|
|
53
|
+
.option('--timeout <ms>', 'Timeout in ms for delayRender and page readiness', '30000')
|
|
54
|
+
.option('--muted', 'Disable audio in output')
|
|
55
|
+
.option('--frontend-url <url>', 'Frontend URL for rendering', 'http://localhost:3000')
|
|
56
|
+
.option('--allow-remote', 'Allow rendering from non-localhost URLs')
|
|
57
|
+
.option('--log-level <level>', 'Log level (error, warn, info, verbose)', 'info')
|
|
58
|
+
.action(renderCommand);
|
|
59
|
+
|
|
60
|
+
// ─── Still Command ────────────────────────────────────────────────────────────
|
|
61
|
+
program
|
|
62
|
+
.command('still <composition-id>')
|
|
63
|
+
.description('Render a single frame as an image')
|
|
64
|
+
.argument('[output]', 'Output file path')
|
|
65
|
+
.option('--frame <number>', 'Frame number to render', '0')
|
|
66
|
+
.option('--format <format>', 'Image format (png, jpeg)', 'png')
|
|
67
|
+
.option('--quality <number>', 'JPEG quality (0-100)', '80')
|
|
68
|
+
.option('--width <number>', 'Image width in pixels')
|
|
69
|
+
.option('--height <number>', 'Image height in pixels')
|
|
70
|
+
.option('--scale <number>', 'Scale factor for output dimensions', '1')
|
|
71
|
+
.option('--props <json>', 'Input props as JSON string')
|
|
72
|
+
.option('--props-file <path>', 'Path to JSON file with input props')
|
|
73
|
+
.option('--frontend-url <url>', 'Frontend URL for rendering', 'http://localhost:3000')
|
|
74
|
+
.option('--allow-remote', 'Allow rendering from non-localhost URLs')
|
|
75
|
+
.action(stillCommand);
|
|
76
|
+
|
|
77
|
+
// ─── Preview Command ──────────────────────────────────────────────────────────
|
|
78
|
+
program
|
|
79
|
+
.command('preview')
|
|
80
|
+
.description('Start the development preview server')
|
|
81
|
+
.option('--port <number>', 'Server port', '3000')
|
|
82
|
+
.option('--no-open', 'Do not open browser automatically')
|
|
83
|
+
.action(previewCommand);
|
|
84
|
+
|
|
85
|
+
// ─── Compositions Command ─────────────────────────────────────────────────────
|
|
86
|
+
program
|
|
87
|
+
.command('compositions')
|
|
88
|
+
.description('List all available compositions')
|
|
89
|
+
.option('--json', 'Output as JSON')
|
|
90
|
+
.option('--frontend-url <url>', 'Frontend URL', 'http://localhost:3000')
|
|
91
|
+
.action(compositionsCommand);
|
|
92
|
+
|
|
93
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codellyson/framely-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Framely - programmatic video creation with React",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"framely": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"commands/",
|
|
13
|
+
"utils/",
|
|
14
|
+
"studio/"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node index.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"chalk": "^5.3.0",
|
|
21
|
+
"commander": "^12.0.0",
|
|
22
|
+
"ora": "^8.0.1",
|
|
23
|
+
"playwright": "^1.41.0",
|
|
24
|
+
"vite": "^5.4.0",
|
|
25
|
+
"@vitejs/plugin-react": "^4.2.1"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@codellyson/framely": "workspace:*",
|
|
29
|
+
"react": "^18.2.0",
|
|
30
|
+
"react-dom": "^18.2.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": "^18.0.0",
|
|
34
|
+
"react-dom": "^18.0.0",
|
|
35
|
+
"@codellyson/framely": "^0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/codellyson/framely.git",
|
|
43
|
+
"directory": "packages/cli"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/codellyson/framely#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/codellyson/framely/issues"
|
|
48
|
+
},
|
|
49
|
+
"author": "codellyson",
|
|
50
|
+
"keywords": [
|
|
51
|
+
"video",
|
|
52
|
+
"rendering",
|
|
53
|
+
"animation",
|
|
54
|
+
"react",
|
|
55
|
+
"cli",
|
|
56
|
+
"ffmpeg",
|
|
57
|
+
"playwright"
|
|
58
|
+
],
|
|
59
|
+
"license": "MIT"
|
|
60
|
+
}
|