@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,889 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview Command
|
|
3
|
+
*
|
|
4
|
+
* Starts the development preview server with hot reloading
|
|
5
|
+
* and a local render API for rendering from the studio UI.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* framely preview
|
|
9
|
+
* framely preview --port 3001
|
|
10
|
+
* framely preview --no-open
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import http from 'http';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import { createRequire } from 'module';
|
|
19
|
+
import { createBrowser, closeBrowser } from '../utils/browser.js';
|
|
20
|
+
import { renderVideo } from '../utils/render.js';
|
|
21
|
+
import { getCodecConfig } from '../utils/codecs.js';
|
|
22
|
+
import { createServer as createViteServer } from 'vite';
|
|
23
|
+
import react from '@vitejs/plugin-react';
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Mock templates data for the marketplace API
|
|
29
|
+
* In production, this would come from a database
|
|
30
|
+
*/
|
|
31
|
+
const MOCK_TEMPLATES = [
|
|
32
|
+
{
|
|
33
|
+
id: 'social-intro-1',
|
|
34
|
+
name: 'Modern Social Intro',
|
|
35
|
+
description: 'Clean, modern intro animation perfect for social media videos.',
|
|
36
|
+
category: 'intro-outro',
|
|
37
|
+
tags: ['intro', 'social', 'modern', 'minimal'],
|
|
38
|
+
preview: { thumbnail: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=400&h=225&fit=crop' },
|
|
39
|
+
author: { id: 'framely', name: 'Framely Team', verified: true },
|
|
40
|
+
bundleUrl: 'https://cdn.framely.dev/templates/social-intro-1/bundle.js',
|
|
41
|
+
version: '1.0.0',
|
|
42
|
+
width: 1080, height: 1920, fps: 30, durationInFrames: 90,
|
|
43
|
+
defaultProps: { title: 'Your Title Here', subtitle: 'Subtitle text', accentColor: '#6366f1' },
|
|
44
|
+
downloads: 1250, rating: 4.8, featured: true,
|
|
45
|
+
createdAt: '2024-01-15T00:00:00Z', updatedAt: '2024-01-20T00:00:00Z',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'youtube-subscribe',
|
|
49
|
+
name: 'Subscribe Animation',
|
|
50
|
+
description: 'Eye-catching subscribe button animation with bell notification.',
|
|
51
|
+
category: 'social-media',
|
|
52
|
+
tags: ['youtube', 'subscribe', 'animation', 'cta'],
|
|
53
|
+
preview: { thumbnail: 'https://images.unsplash.com/photo-1611162616475-46b635cb6868?w=400&h=225&fit=crop' },
|
|
54
|
+
author: { id: 'framely', name: 'Framely Team', verified: true },
|
|
55
|
+
bundleUrl: 'https://cdn.framely.dev/templates/youtube-subscribe/bundle.js',
|
|
56
|
+
version: '1.0.0',
|
|
57
|
+
width: 1920, height: 1080, fps: 30, durationInFrames: 120,
|
|
58
|
+
defaultProps: { channelName: 'Your Channel', buttonColor: '#FF0000', showBell: true },
|
|
59
|
+
downloads: 2340, rating: 4.9, featured: true,
|
|
60
|
+
createdAt: '2024-01-10T00:00:00Z', updatedAt: '2024-01-18T00:00:00Z',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'lower-third-1',
|
|
64
|
+
name: 'Clean Lower Third',
|
|
65
|
+
description: 'Professional lower third with smooth slide-in animation.',
|
|
66
|
+
category: 'lower-thirds',
|
|
67
|
+
tags: ['lower-third', 'professional', 'news'],
|
|
68
|
+
preview: { thumbnail: 'https://images.unsplash.com/photo-1478737270239-2f02b77fc618?w=400&h=225&fit=crop' },
|
|
69
|
+
author: { id: 'motion-pro', name: 'Motion Pro', verified: true },
|
|
70
|
+
bundleUrl: 'https://cdn.framely.dev/templates/lower-third-1/bundle.js',
|
|
71
|
+
version: '1.2.0',
|
|
72
|
+
width: 1920, height: 1080, fps: 30, durationInFrames: 150,
|
|
73
|
+
defaultProps: { name: 'John Doe', title: 'CEO & Founder', accentColor: '#3b82f6' },
|
|
74
|
+
downloads: 890, rating: 4.6, featured: false,
|
|
75
|
+
createdAt: '2024-01-08T00:00:00Z', updatedAt: '2024-01-15T00:00:00Z',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'text-reveal-1',
|
|
79
|
+
name: 'Kinetic Text Reveal',
|
|
80
|
+
description: 'Dynamic text reveal with character-by-character animation.',
|
|
81
|
+
category: 'text-animations',
|
|
82
|
+
tags: ['text', 'kinetic', 'reveal', 'typography'],
|
|
83
|
+
preview: { thumbnail: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?w=400&h=225&fit=crop' },
|
|
84
|
+
author: { id: 'type-master', name: 'Type Master', verified: false },
|
|
85
|
+
bundleUrl: 'https://cdn.framely.dev/templates/text-reveal-1/bundle.js',
|
|
86
|
+
version: '1.0.0',
|
|
87
|
+
width: 1920, height: 1080, fps: 60, durationInFrames: 180,
|
|
88
|
+
defaultProps: { text: 'Your text here', fontSize: 120, color: '#ffffff' },
|
|
89
|
+
downloads: 1567, rating: 4.7, featured: false,
|
|
90
|
+
createdAt: '2024-01-05T00:00:00Z', updatedAt: '2024-01-12T00:00:00Z',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'gradient-bg-1',
|
|
94
|
+
name: 'Animated Gradient',
|
|
95
|
+
description: 'Mesmerizing animated gradient background with smooth color transitions.',
|
|
96
|
+
category: 'backgrounds',
|
|
97
|
+
tags: ['background', 'gradient', 'animated', 'loop'],
|
|
98
|
+
preview: { thumbnail: 'https://images.unsplash.com/photo-1557682250-33bd709cbe85?w=400&h=225&fit=crop' },
|
|
99
|
+
author: { id: 'framely', name: 'Framely Team', verified: true },
|
|
100
|
+
bundleUrl: 'https://cdn.framely.dev/templates/gradient-bg-1/bundle.js',
|
|
101
|
+
version: '1.0.0',
|
|
102
|
+
width: 1920, height: 1080, fps: 30, durationInFrames: 300,
|
|
103
|
+
defaultProps: { colors: ['#6366f1', '#8b5cf6', '#d946ef'], speed: 1 },
|
|
104
|
+
downloads: 3210, rating: 4.5, featured: false,
|
|
105
|
+
createdAt: '2024-01-03T00:00:00Z', updatedAt: '2024-01-10T00:00:00Z',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'promo-slide-1',
|
|
109
|
+
name: 'Product Showcase',
|
|
110
|
+
description: 'Professional product showcase template with dynamic transitions.',
|
|
111
|
+
category: 'marketing',
|
|
112
|
+
tags: ['product', 'showcase', 'promo', 'ecommerce'],
|
|
113
|
+
preview: { thumbnail: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=400&h=225&fit=crop' },
|
|
114
|
+
author: { id: 'ad-studio', name: 'Ad Studio', verified: true },
|
|
115
|
+
bundleUrl: 'https://cdn.framely.dev/templates/promo-slide-1/bundle.js',
|
|
116
|
+
version: '2.0.0',
|
|
117
|
+
width: 1080, height: 1080, fps: 30, durationInFrames: 180,
|
|
118
|
+
defaultProps: { productName: 'Product Name', price: '$99.99', brandColor: '#10b981' },
|
|
119
|
+
downloads: 756, rating: 4.4, featured: true,
|
|
120
|
+
createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-08T00:00:00Z',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'presentation-1',
|
|
124
|
+
name: 'Slide Transition Pack',
|
|
125
|
+
description: 'Collection of smooth slide transitions for presentations.',
|
|
126
|
+
category: 'presentation',
|
|
127
|
+
tags: ['presentation', 'slides', 'transition', 'corporate'],
|
|
128
|
+
preview: { thumbnail: 'https://images.unsplash.com/photo-1531538606174-0f90ff5dce83?w=400&h=225&fit=crop' },
|
|
129
|
+
author: { id: 'slide-pro', name: 'Slide Pro', verified: false },
|
|
130
|
+
bundleUrl: 'https://cdn.framely.dev/templates/presentation-1/bundle.js',
|
|
131
|
+
version: '1.1.0',
|
|
132
|
+
width: 1920, height: 1080, fps: 30, durationInFrames: 60,
|
|
133
|
+
defaultProps: { transitionType: 'slide', direction: 'left' },
|
|
134
|
+
downloads: 445, rating: 4.3, featured: false,
|
|
135
|
+
createdAt: '2023-12-28T00:00:00Z', updatedAt: '2024-01-05T00:00:00Z',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'instagram-story',
|
|
139
|
+
name: 'Story Template',
|
|
140
|
+
description: 'Trendy Instagram story template with animated stickers and text effects.',
|
|
141
|
+
category: 'social-media',
|
|
142
|
+
tags: ['instagram', 'story', 'social', 'trendy'],
|
|
143
|
+
preview: { thumbnail: 'https://images.unsplash.com/photo-1611262588024-d12430b98920?w=400&h=225&fit=crop' },
|
|
144
|
+
author: { id: 'social-creator', name: 'Social Creator', verified: true },
|
|
145
|
+
bundleUrl: 'https://cdn.framely.dev/templates/instagram-story/bundle.js',
|
|
146
|
+
version: '1.0.0',
|
|
147
|
+
width: 1080, height: 1920, fps: 30, durationInFrames: 150,
|
|
148
|
+
defaultProps: { headline: 'New Post!', backgroundColor: '#f472b6' },
|
|
149
|
+
downloads: 1890, rating: 4.6, featured: false,
|
|
150
|
+
createdAt: '2023-12-25T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z',
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse JSON request body.
|
|
156
|
+
*/
|
|
157
|
+
function parseBody(req) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
req.on('data', (chunk) => { chunks.push(chunk); });
|
|
161
|
+
req.on('end', () => {
|
|
162
|
+
try {
|
|
163
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
164
|
+
} catch {
|
|
165
|
+
reject(new Error('Invalid JSON'));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
req.on('error', reject);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Start the render API server.
|
|
174
|
+
*
|
|
175
|
+
* @param {number} apiPort - Port to listen on
|
|
176
|
+
* @param {string} frontendUrl - Frontend URL for loading compositions
|
|
177
|
+
* @param {string} outputsDir - Directory to store rendered files
|
|
178
|
+
* @returns {http.Server}
|
|
179
|
+
*/
|
|
180
|
+
export function startRenderApi(apiPort, frontendUrl, outputsDir, publicDir) {
|
|
181
|
+
fs.mkdirSync(outputsDir, { recursive: true });
|
|
182
|
+
|
|
183
|
+
const server = http.createServer((req, res) => {
|
|
184
|
+
// CORS headers
|
|
185
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
186
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
187
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
188
|
+
|
|
189
|
+
if (req.method === 'OPTIONS') {
|
|
190
|
+
res.writeHead(204);
|
|
191
|
+
res.end();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// POST /api/render — render video
|
|
196
|
+
if (req.method === 'POST' && req.url === '/api/render') {
|
|
197
|
+
handleRender(req, res, frontendUrl, outputsDir);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// POST /api/still — render single frame
|
|
202
|
+
if (req.method === 'POST' && req.url === '/api/still') {
|
|
203
|
+
handleStill(req, res, frontendUrl, outputsDir);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// GET /api/assets — list project static assets
|
|
208
|
+
if (req.method === 'GET' && req.url === '/api/assets') {
|
|
209
|
+
handleListAssets(req, res, publicDir);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// GET /api/templates/categories — list template categories
|
|
214
|
+
if (req.method === 'GET' && req.url === '/api/templates/categories') {
|
|
215
|
+
handleTemplateCategories(req, res);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// GET /api/templates/:id — get single template
|
|
220
|
+
if (req.method === 'GET' && req.url.match(/^\/api\/templates\/[^/]+$/)) {
|
|
221
|
+
const id = req.url.replace('/api/templates/', '');
|
|
222
|
+
handleGetTemplate(req, res, id);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// GET /api/templates — list templates with filters
|
|
227
|
+
if (req.method === 'GET' && req.url.startsWith('/api/templates')) {
|
|
228
|
+
handleListTemplates(req, res);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// GET /outputs/* — serve rendered files
|
|
233
|
+
if (req.method === 'GET' && req.url.startsWith('/outputs/')) {
|
|
234
|
+
handleOutputFile(req, res, outputsDir);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
239
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
server.listen(apiPort, () => {
|
|
243
|
+
console.log(chalk.gray(' Render API: ') + chalk.green(`http://localhost:${apiPort}`));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return server;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Handle POST /api/render.
|
|
251
|
+
* Streams NDJSON progress events.
|
|
252
|
+
*/
|
|
253
|
+
async function handleRender(req, res, frontendUrl, outputsDir) {
|
|
254
|
+
let body;
|
|
255
|
+
try {
|
|
256
|
+
body = await parseBody(req);
|
|
257
|
+
} catch {
|
|
258
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
259
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const compositionId = body.compositionId;
|
|
264
|
+
const codec = body.codec || 'h264';
|
|
265
|
+
const codecConfig = getCodecConfig(codec);
|
|
266
|
+
|
|
267
|
+
if (!codecConfig) {
|
|
268
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
269
|
+
res.end(JSON.stringify({ error: `Unknown codec: ${codec}` }));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const crf = body.crf != null ? body.crf : 18;
|
|
274
|
+
const scale = body.scale || 1;
|
|
275
|
+
const width = Math.round((body.width || 1920) * scale);
|
|
276
|
+
const height = Math.round((body.height || 1080) * scale);
|
|
277
|
+
const fps = body.fps || 30;
|
|
278
|
+
const startFrame = body.startFrame || 0;
|
|
279
|
+
const endFrame = body.endFrame != null ? body.endFrame : (body.durationInFrames ? body.durationInFrames - 1 : 299);
|
|
280
|
+
const muted = body.muted || false;
|
|
281
|
+
|
|
282
|
+
const ext = codecConfig.extension;
|
|
283
|
+
const filename = `${compositionId}-${Date.now()}.${ext}`;
|
|
284
|
+
const outputPath = path.join(outputsDir, filename);
|
|
285
|
+
|
|
286
|
+
// Stream NDJSON progress
|
|
287
|
+
res.writeHead(200, {
|
|
288
|
+
'Content-Type': 'application/x-ndjson',
|
|
289
|
+
'Cache-Control': 'no-cache',
|
|
290
|
+
'Connection': 'keep-alive',
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
function sendEvent(data) {
|
|
294
|
+
res.write(JSON.stringify(data) + '\n');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
sendEvent({ type: 'start', compositionId, codec, frames: endFrame - startFrame + 1 });
|
|
298
|
+
|
|
299
|
+
let browser = null;
|
|
300
|
+
const startTime = Date.now();
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
sendEvent({ type: 'status', message: 'Launching browser...' });
|
|
304
|
+
const { browser: b, page } = await createBrowser({ width, height, scale: 1 });
|
|
305
|
+
browser = b;
|
|
306
|
+
|
|
307
|
+
sendEvent({ type: 'status', message: 'Loading composition...' });
|
|
308
|
+
const renderUrl = new URL(frontendUrl);
|
|
309
|
+
renderUrl.searchParams.set('renderMode', 'true');
|
|
310
|
+
renderUrl.searchParams.set('composition', compositionId);
|
|
311
|
+
// For templates, pass the original template ID so the renderer can find the component
|
|
312
|
+
if (body.templateId) {
|
|
313
|
+
renderUrl.searchParams.set('templateId', body.templateId);
|
|
314
|
+
}
|
|
315
|
+
// Pass composition dimensions for template rendering
|
|
316
|
+
renderUrl.searchParams.set('width', String(width));
|
|
317
|
+
renderUrl.searchParams.set('height', String(height));
|
|
318
|
+
renderUrl.searchParams.set('fps', String(fps));
|
|
319
|
+
renderUrl.searchParams.set('durationInFrames', String(endFrame - startFrame + 1));
|
|
320
|
+
if (body.inputProps && Object.keys(body.inputProps).length > 0) {
|
|
321
|
+
renderUrl.searchParams.set('props', encodeURIComponent(JSON.stringify(body.inputProps)));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await page.goto(renderUrl.toString(), { waitUntil: 'domcontentloaded' });
|
|
325
|
+
await page.waitForFunction('window.__ready === true', { timeout: 30000 });
|
|
326
|
+
|
|
327
|
+
sendEvent({ type: 'status', message: 'Rendering frames...' });
|
|
328
|
+
|
|
329
|
+
await renderVideo({
|
|
330
|
+
page,
|
|
331
|
+
outputPath,
|
|
332
|
+
startFrame,
|
|
333
|
+
endFrame,
|
|
334
|
+
width,
|
|
335
|
+
height,
|
|
336
|
+
fps,
|
|
337
|
+
codec,
|
|
338
|
+
crf,
|
|
339
|
+
muted,
|
|
340
|
+
onProgress: (frame, total) => {
|
|
341
|
+
const percent = Math.floor((frame / total) * 100);
|
|
342
|
+
sendEvent({ type: 'progress', frame, total, percent });
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const durationMs = Date.now() - startTime;
|
|
347
|
+
sendEvent({
|
|
348
|
+
type: 'complete',
|
|
349
|
+
outputPath,
|
|
350
|
+
downloadUrl: `/outputs/${filename}`,
|
|
351
|
+
filename,
|
|
352
|
+
durationMs,
|
|
353
|
+
});
|
|
354
|
+
} catch (err) {
|
|
355
|
+
sendEvent({ type: 'error', message: err.message });
|
|
356
|
+
} finally {
|
|
357
|
+
if (browser) {
|
|
358
|
+
await closeBrowser(browser);
|
|
359
|
+
}
|
|
360
|
+
res.end();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Handle POST /api/still.
|
|
366
|
+
*/
|
|
367
|
+
async function handleStill(req, res, frontendUrl, outputsDir) {
|
|
368
|
+
let body;
|
|
369
|
+
try {
|
|
370
|
+
body = await parseBody(req);
|
|
371
|
+
} catch {
|
|
372
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
373
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const compositionId = body.compositionId;
|
|
378
|
+
const frame = body.frame || 0;
|
|
379
|
+
const format = body.format || 'png';
|
|
380
|
+
const quality = body.quality || 80;
|
|
381
|
+
const scale = body.scale || 1;
|
|
382
|
+
const width = Math.round((body.width || 1920) * scale);
|
|
383
|
+
const height = Math.round((body.height || 1080) * scale);
|
|
384
|
+
|
|
385
|
+
const ext = format === 'jpeg' ? 'jpg' : format;
|
|
386
|
+
const filename = `${compositionId}-frame${frame}.${ext}`;
|
|
387
|
+
const outputPath = path.join(outputsDir, filename);
|
|
388
|
+
|
|
389
|
+
let browser = null;
|
|
390
|
+
try {
|
|
391
|
+
const { browser: b, page } = await createBrowser({ width, height, scale: 1 });
|
|
392
|
+
browser = b;
|
|
393
|
+
|
|
394
|
+
const renderUrl = new URL(frontendUrl);
|
|
395
|
+
renderUrl.searchParams.set('renderMode', 'true');
|
|
396
|
+
renderUrl.searchParams.set('composition', compositionId);
|
|
397
|
+
if (body.inputProps && Object.keys(body.inputProps).length > 0) {
|
|
398
|
+
renderUrl.searchParams.set('props', encodeURIComponent(JSON.stringify(body.inputProps)));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
await page.goto(renderUrl.toString(), { waitUntil: 'domcontentloaded' });
|
|
402
|
+
await page.waitForFunction('window.__ready === true', { timeout: 30000 });
|
|
403
|
+
|
|
404
|
+
// Set frame
|
|
405
|
+
await page.evaluate((f) => { window.__setFrame(f); }, frame);
|
|
406
|
+
await page.waitForFunction(() => {
|
|
407
|
+
const dr = window.__FRAMELY_DELAY_RENDER;
|
|
408
|
+
return !dr || dr.pendingCount === 0;
|
|
409
|
+
}, { timeout: 30000 });
|
|
410
|
+
|
|
411
|
+
// Capture
|
|
412
|
+
const element = page.locator('#render-container');
|
|
413
|
+
const screenshotOptions = { type: format, path: outputPath };
|
|
414
|
+
if (format === 'jpeg') {
|
|
415
|
+
screenshotOptions.quality = quality;
|
|
416
|
+
}
|
|
417
|
+
await element.screenshot(screenshotOptions);
|
|
418
|
+
|
|
419
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
420
|
+
res.end(JSON.stringify({
|
|
421
|
+
outputPath,
|
|
422
|
+
downloadUrl: `/outputs/${filename}`,
|
|
423
|
+
filename,
|
|
424
|
+
}));
|
|
425
|
+
} catch (err) {
|
|
426
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
427
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
428
|
+
} finally {
|
|
429
|
+
if (browser) {
|
|
430
|
+
await closeBrowser(browser);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Handle GET /api/assets — list files in the public directory.
|
|
437
|
+
*/
|
|
438
|
+
function handleListAssets(req, res, publicDir) {
|
|
439
|
+
if (!publicDir || !fs.existsSync(publicDir)) {
|
|
440
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
441
|
+
res.end(JSON.stringify({ assets: [], publicDir: null }));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const assets = [];
|
|
446
|
+
|
|
447
|
+
function walkDir(dir, prefix) {
|
|
448
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
449
|
+
for (const entry of entries) {
|
|
450
|
+
const fullPath = path.join(dir, entry.name);
|
|
451
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
452
|
+
|
|
453
|
+
if (entry.isDirectory()) {
|
|
454
|
+
walkDir(fullPath, relativePath);
|
|
455
|
+
} else {
|
|
456
|
+
const stat = fs.statSync(fullPath);
|
|
457
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
458
|
+
assets.push({
|
|
459
|
+
name: entry.name,
|
|
460
|
+
path: relativePath,
|
|
461
|
+
size: stat.size,
|
|
462
|
+
extension: ext,
|
|
463
|
+
type: getAssetType(ext),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
walkDir(publicDir, '');
|
|
470
|
+
|
|
471
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
472
|
+
res.end(JSON.stringify({ assets, publicDir }));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Classify file type from extension.
|
|
477
|
+
*/
|
|
478
|
+
function getAssetType(ext) {
|
|
479
|
+
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.bmp', '.ico'];
|
|
480
|
+
const videoExts = ['.mp4', '.webm', '.mov', '.avi', '.mkv'];
|
|
481
|
+
const audioExts = ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.m4a'];
|
|
482
|
+
const fontExts = ['.woff', '.woff2', '.ttf', '.otf', '.eot'];
|
|
483
|
+
const dataExts = ['.json', '.csv', '.xml', '.txt'];
|
|
484
|
+
|
|
485
|
+
if (imageExts.includes(ext)) return 'image';
|
|
486
|
+
if (videoExts.includes(ext)) return 'video';
|
|
487
|
+
if (audioExts.includes(ext)) return 'audio';
|
|
488
|
+
if (fontExts.includes(ext)) return 'font';
|
|
489
|
+
if (dataExts.includes(ext)) return 'data';
|
|
490
|
+
return 'other';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Handle GET /outputs/* — serve rendered files.
|
|
495
|
+
*/
|
|
496
|
+
function handleOutputFile(req, res, outputsDir) {
|
|
497
|
+
// Decode URL and resolve path
|
|
498
|
+
let filename;
|
|
499
|
+
try {
|
|
500
|
+
filename = decodeURIComponent(req.url.replace('/outputs/', ''));
|
|
501
|
+
} catch {
|
|
502
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
503
|
+
res.end(JSON.stringify({ error: 'Invalid URL encoding' }));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Resolve both paths to absolute and verify the file is within outputsDir
|
|
508
|
+
const resolvedOutputs = path.resolve(outputsDir);
|
|
509
|
+
const filePath = path.resolve(outputsDir, filename);
|
|
510
|
+
|
|
511
|
+
if (!filePath.startsWith(resolvedOutputs + path.sep) && filePath !== resolvedOutputs) {
|
|
512
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
513
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!fs.existsSync(filePath)) {
|
|
517
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
518
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const stat = fs.statSync(filePath);
|
|
523
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
524
|
+
const mimeTypes = {
|
|
525
|
+
'.mp4': 'video/mp4',
|
|
526
|
+
'.webm': 'video/webm',
|
|
527
|
+
'.mov': 'video/quicktime',
|
|
528
|
+
'.gif': 'image/gif',
|
|
529
|
+
'.png': 'image/png',
|
|
530
|
+
'.jpg': 'image/jpeg',
|
|
531
|
+
'.jpeg': 'image/jpeg',
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
res.writeHead(200, {
|
|
535
|
+
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
|
536
|
+
'Content-Length': stat.size,
|
|
537
|
+
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
fs.createReadStream(filePath).pipe(res);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Handle GET /api/templates — list templates with filters.
|
|
545
|
+
*/
|
|
546
|
+
function handleListTemplates(req, res) {
|
|
547
|
+
const url = new URL(req.url, `http://localhost`);
|
|
548
|
+
const category = url.searchParams.get('category');
|
|
549
|
+
const search = url.searchParams.get('search');
|
|
550
|
+
const featured = url.searchParams.get('featured');
|
|
551
|
+
const sortBy = url.searchParams.get('sortBy') || 'newest';
|
|
552
|
+
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
|
553
|
+
const pageSize = parseInt(url.searchParams.get('pageSize') || '12', 10);
|
|
554
|
+
|
|
555
|
+
let filtered = [...MOCK_TEMPLATES];
|
|
556
|
+
|
|
557
|
+
// Filter by category
|
|
558
|
+
if (category) {
|
|
559
|
+
filtered = filtered.filter(t => t.category === category);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Filter by search
|
|
563
|
+
if (search) {
|
|
564
|
+
const searchLower = search.toLowerCase();
|
|
565
|
+
filtered = filtered.filter(t =>
|
|
566
|
+
t.name.toLowerCase().includes(searchLower) ||
|
|
567
|
+
t.description.toLowerCase().includes(searchLower) ||
|
|
568
|
+
t.tags.some(tag => tag.toLowerCase().includes(searchLower))
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Filter by featured
|
|
573
|
+
if (featured === 'true') {
|
|
574
|
+
filtered = filtered.filter(t => t.featured);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Sort
|
|
578
|
+
if (sortBy === 'popular') {
|
|
579
|
+
filtered.sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
|
|
580
|
+
} else if (sortBy === 'rating') {
|
|
581
|
+
filtered.sort((a, b) => (b.rating || 0) - (a.rating || 0));
|
|
582
|
+
} else {
|
|
583
|
+
// newest
|
|
584
|
+
filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Paginate
|
|
588
|
+
const start = (page - 1) * pageSize;
|
|
589
|
+
const templates = filtered.slice(start, start + pageSize);
|
|
590
|
+
|
|
591
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
592
|
+
res.end(JSON.stringify({
|
|
593
|
+
templates,
|
|
594
|
+
total: filtered.length,
|
|
595
|
+
page,
|
|
596
|
+
pageSize,
|
|
597
|
+
hasMore: start + pageSize < filtered.length,
|
|
598
|
+
}));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Handle GET /api/templates/:id — get single template.
|
|
603
|
+
*/
|
|
604
|
+
function handleGetTemplate(req, res, id) {
|
|
605
|
+
const template = MOCK_TEMPLATES.find(t => t.id === id);
|
|
606
|
+
if (!template) {
|
|
607
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
608
|
+
res.end(JSON.stringify({ error: 'Template not found' }));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
612
|
+
res.end(JSON.stringify(template));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Handle GET /api/templates/categories — list categories with counts.
|
|
617
|
+
*/
|
|
618
|
+
function handleTemplateCategories(req, res) {
|
|
619
|
+
const counts = {};
|
|
620
|
+
MOCK_TEMPLATES.forEach(t => {
|
|
621
|
+
counts[t.category] = (counts[t.category] || 0) + 1;
|
|
622
|
+
});
|
|
623
|
+
const categories = Object.entries(counts).map(([category, count]) => ({ category, count }));
|
|
624
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
625
|
+
res.end(JSON.stringify(categories));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Find the user's entry file in their project.
|
|
630
|
+
*/
|
|
631
|
+
function findUserEntry(projectDir) {
|
|
632
|
+
const candidates = [
|
|
633
|
+
'src/index.jsx',
|
|
634
|
+
'src/index.tsx',
|
|
635
|
+
'src/index.js',
|
|
636
|
+
'src/index.ts',
|
|
637
|
+
];
|
|
638
|
+
for (const candidate of candidates) {
|
|
639
|
+
const fullPath = path.resolve(projectDir, candidate);
|
|
640
|
+
if (fs.existsSync(fullPath)) {
|
|
641
|
+
return fullPath;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Vite plugin that provides the studio UI via a virtual entry module.
|
|
649
|
+
* Combines the user's entry file (which calls registerRoot()) with
|
|
650
|
+
* the studio UI bootstrap code.
|
|
651
|
+
*/
|
|
652
|
+
function framelyStudioPlugin(userEntryPath, studioDir) {
|
|
653
|
+
const VIRTUAL_ENTRY_ID = 'virtual:framely-entry';
|
|
654
|
+
const RESOLVED_VIRTUAL_ENTRY_ID = '\0' + VIRTUAL_ENTRY_ID;
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
name: 'framely-studio',
|
|
658
|
+
|
|
659
|
+
configureServer(server) {
|
|
660
|
+
// Serve a virtual index.html for the root route
|
|
661
|
+
server.middlewares.use(async (req, res, next) => {
|
|
662
|
+
const urlPath = req.url.split('?')[0];
|
|
663
|
+
if (urlPath === '/' || urlPath === '/index.html') {
|
|
664
|
+
const html = `<!DOCTYPE html>
|
|
665
|
+
<html lang="en">
|
|
666
|
+
<head>
|
|
667
|
+
<meta charset="UTF-8" />
|
|
668
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
669
|
+
<title>Framely Studio</title>
|
|
670
|
+
</head>
|
|
671
|
+
<body>
|
|
672
|
+
<div id="root"></div>
|
|
673
|
+
<script type="module" src="/@id/__x00__${VIRTUAL_ENTRY_ID}"></script>
|
|
674
|
+
</body>
|
|
675
|
+
</html>`;
|
|
676
|
+
const transformed = await server.transformIndexHtml(req.url, html);
|
|
677
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
678
|
+
res.end(transformed);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
next();
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
resolveId(id) {
|
|
686
|
+
if (id === VIRTUAL_ENTRY_ID) return RESOLVED_VIRTUAL_ENTRY_ID;
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
load(id) {
|
|
690
|
+
if (id === RESOLVED_VIRTUAL_ENTRY_ID) {
|
|
691
|
+
// Normalize paths for import statements (use forward slashes)
|
|
692
|
+
const userEntry = userEntryPath.replace(/\\/g, '/');
|
|
693
|
+
const appPath = path.join(studioDir, 'App.jsx').replace(/\\/g, '/');
|
|
694
|
+
const cssPath = path.join(studioDir, 'styles', 'design-system.css').replace(/\\/g, '/');
|
|
695
|
+
const appCssPath = path.join(studioDir, 'App.css').replace(/\\/g, '/');
|
|
696
|
+
|
|
697
|
+
return `
|
|
698
|
+
import '${userEntry}';
|
|
699
|
+
import '${cssPath}';
|
|
700
|
+
import '${appCssPath}';
|
|
701
|
+
import React from 'react';
|
|
702
|
+
import ReactDOM from 'react-dom/client';
|
|
703
|
+
import App from '${appPath}';
|
|
704
|
+
|
|
705
|
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
706
|
+
React.createElement(React.StrictMode, null, React.createElement(App))
|
|
707
|
+
);
|
|
708
|
+
`;
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Vite plugin that serves /api/assets from the public/ directory.
|
|
716
|
+
* Used as a fallback when accessed directly (not via render API proxy).
|
|
717
|
+
*/
|
|
718
|
+
function assetsApiPlugin(publicDir) {
|
|
719
|
+
return {
|
|
720
|
+
name: 'framely-assets-api',
|
|
721
|
+
configureServer(server) {
|
|
722
|
+
server.middlewares.use((req, res, next) => {
|
|
723
|
+
if (req.url !== '/api/assets') return next();
|
|
724
|
+
|
|
725
|
+
if (!publicDir || !fs.existsSync(publicDir)) {
|
|
726
|
+
res.setHeader('Content-Type', 'application/json');
|
|
727
|
+
res.end(JSON.stringify({ assets: [] }));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const assets = [];
|
|
732
|
+
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.bmp', '.ico'];
|
|
733
|
+
const videoExts = ['.mp4', '.webm', '.mov', '.avi', '.mkv'];
|
|
734
|
+
const audioExts = ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.m4a'];
|
|
735
|
+
const fontExts = ['.woff', '.woff2', '.ttf', '.otf', '.eot'];
|
|
736
|
+
const dataExts = ['.json', '.csv', '.xml', '.txt'];
|
|
737
|
+
|
|
738
|
+
function getType(ext) {
|
|
739
|
+
if (imageExts.includes(ext)) return 'image';
|
|
740
|
+
if (videoExts.includes(ext)) return 'video';
|
|
741
|
+
if (audioExts.includes(ext)) return 'audio';
|
|
742
|
+
if (fontExts.includes(ext)) return 'font';
|
|
743
|
+
if (dataExts.includes(ext)) return 'data';
|
|
744
|
+
return 'other';
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function walk(dir, prefix) {
|
|
748
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
749
|
+
for (const entry of entries) {
|
|
750
|
+
const full = path.join(dir, entry.name);
|
|
751
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
752
|
+
if (entry.isDirectory()) {
|
|
753
|
+
walk(full, rel);
|
|
754
|
+
} else {
|
|
755
|
+
const stat = fs.statSync(full);
|
|
756
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
757
|
+
assets.push({
|
|
758
|
+
name: entry.name,
|
|
759
|
+
path: rel,
|
|
760
|
+
size: stat.size,
|
|
761
|
+
extension: ext,
|
|
762
|
+
type: getType(ext),
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
walk(publicDir, '');
|
|
769
|
+
res.setHeader('Content-Type', 'application/json');
|
|
770
|
+
res.end(JSON.stringify({ assets }));
|
|
771
|
+
});
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Main preview command handler.
|
|
778
|
+
*
|
|
779
|
+
* Uses Vite's JavaScript API to create a dev server that combines
|
|
780
|
+
* the studio UI (bundled in the CLI) with the user's compositions.
|
|
781
|
+
*/
|
|
782
|
+
export async function previewCommand(options) {
|
|
783
|
+
const port = parseInt(options.port || '3000', 10);
|
|
784
|
+
const apiPort = port + 1;
|
|
785
|
+
const shouldOpen = options.open !== false;
|
|
786
|
+
const projectDir = process.cwd();
|
|
787
|
+
|
|
788
|
+
console.log(chalk.cyan('\n🎬 Framely Studio\n'));
|
|
789
|
+
|
|
790
|
+
// Find the user's entry file
|
|
791
|
+
const userEntry = findUserEntry(projectDir);
|
|
792
|
+
if (!userEntry) {
|
|
793
|
+
console.error(chalk.red('Error: Could not find entry file.'));
|
|
794
|
+
console.log(chalk.gray('Expected one of: src/index.jsx, src/index.tsx, src/index.js, src/index.ts'));
|
|
795
|
+
console.log(chalk.gray('Run `npx create-framely my-project` to create a new Framely project.\n'));
|
|
796
|
+
process.exit(1);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const studioDir = path.resolve(__dirname, '../studio');
|
|
800
|
+
const outputsDir = path.resolve(projectDir, 'outputs');
|
|
801
|
+
const publicDir = path.resolve(projectDir, 'public');
|
|
802
|
+
|
|
803
|
+
console.log(chalk.white(' Project: '), chalk.gray(projectDir));
|
|
804
|
+
console.log(chalk.white(' Entry: '), chalk.gray(path.relative(projectDir, userEntry)));
|
|
805
|
+
console.log(chalk.white(' Port: '), chalk.yellow(String(port)));
|
|
806
|
+
console.log(chalk.white(' URL: '), chalk.green(`http://localhost:${port}`));
|
|
807
|
+
console.log('');
|
|
808
|
+
|
|
809
|
+
// Start the render API
|
|
810
|
+
const frontendUrl = `http://localhost:${port}`;
|
|
811
|
+
const apiServer = startRenderApi(apiPort, frontendUrl, outputsDir, publicDir);
|
|
812
|
+
|
|
813
|
+
console.log(chalk.cyan(' Starting studio...\n'));
|
|
814
|
+
|
|
815
|
+
// Resolve react/react-dom/framely from the user's project directory
|
|
816
|
+
// This is needed because virtual modules don't have a filesystem location
|
|
817
|
+
// for Vite to resolve bare imports from.
|
|
818
|
+
const require = createRequire(path.resolve(projectDir, 'package.json'));
|
|
819
|
+
function tryResolve(pkg) {
|
|
820
|
+
try {
|
|
821
|
+
return path.dirname(require.resolve(`${pkg}/package.json`));
|
|
822
|
+
} catch {
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const reactDir = tryResolve('react');
|
|
828
|
+
const reactDomDir = tryResolve('react-dom');
|
|
829
|
+
const framelyDir = tryResolve('framely');
|
|
830
|
+
|
|
831
|
+
const aliases = [];
|
|
832
|
+
if (reactDir) aliases.push({ find: 'react', replacement: reactDir });
|
|
833
|
+
if (reactDomDir) aliases.push({ find: 'react-dom', replacement: reactDomDir });
|
|
834
|
+
if (framelyDir) aliases.push({ find: 'framely', replacement: framelyDir });
|
|
835
|
+
|
|
836
|
+
// Create Vite dev server with the studio plugin
|
|
837
|
+
const server = await createViteServer({
|
|
838
|
+
root: projectDir,
|
|
839
|
+
plugins: [
|
|
840
|
+
react(),
|
|
841
|
+
framelyStudioPlugin(userEntry, studioDir),
|
|
842
|
+
assetsApiPlugin(publicDir),
|
|
843
|
+
],
|
|
844
|
+
server: {
|
|
845
|
+
port,
|
|
846
|
+
host: '0.0.0.0',
|
|
847
|
+
open: shouldOpen,
|
|
848
|
+
proxy: {
|
|
849
|
+
'/api/render': {
|
|
850
|
+
target: `http://localhost:${apiPort}`,
|
|
851
|
+
changeOrigin: true,
|
|
852
|
+
},
|
|
853
|
+
'/api/still': {
|
|
854
|
+
target: `http://localhost:${apiPort}`,
|
|
855
|
+
changeOrigin: true,
|
|
856
|
+
},
|
|
857
|
+
'/api/templates': {
|
|
858
|
+
target: `http://localhost:${apiPort}`,
|
|
859
|
+
changeOrigin: true,
|
|
860
|
+
},
|
|
861
|
+
'/outputs': {
|
|
862
|
+
target: `http://localhost:${apiPort}`,
|
|
863
|
+
changeOrigin: true,
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
optimizeDeps: {
|
|
868
|
+
include: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', 'framely'],
|
|
869
|
+
},
|
|
870
|
+
resolve: {
|
|
871
|
+
alias: aliases,
|
|
872
|
+
dedupe: ['react', 'react-dom'],
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
await server.listen();
|
|
877
|
+
server.printUrls();
|
|
878
|
+
|
|
879
|
+
// Handle process signals
|
|
880
|
+
const cleanup = async () => {
|
|
881
|
+
console.log(chalk.gray('\n\n Shutting down...\n'));
|
|
882
|
+
await server.close();
|
|
883
|
+
apiServer.close();
|
|
884
|
+
process.exit(0);
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
process.on('SIGINT', cleanup);
|
|
888
|
+
process.on('SIGTERM', cleanup);
|
|
889
|
+
}
|