@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,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
+ }