@brainwav/diagram 1.0.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/src/video.js ADDED
@@ -0,0 +1,388 @@
1
+ const { chromium } = require('playwright');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const crypto = require('crypto');
6
+ const { execFileSync } = require('child_process');
7
+ const { pathToFileURL } = require('url');
8
+ const chalk = require('chalk');
9
+ const { getFfmpegCommandCandidates } = require('./utils/commands');
10
+
11
+ // Escape HTML to prevent injection
12
+ function escapeHtml(str) {
13
+ if (str == null) return '';
14
+ return String(str)
15
+ .replace(/&/g, '&')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&#39;');
20
+ }
21
+
22
+ async function generateVideo(mermaidCode, outputPath, options = {}) {
23
+ // Use null prototype to prevent prototype pollution
24
+ const defaults = Object.assign(Object.create(null), {
25
+ duration: 5,
26
+ fps: 30,
27
+ width: 1280,
28
+ height: 720,
29
+ theme: 'dark'
30
+ });
31
+ const opts = Object.assign(Object.create(null), defaults, options);
32
+
33
+ // Validate and sanitize inputs
34
+ const duration = parseInt(opts.duration, 10);
35
+ const fps = parseInt(opts.fps, 10);
36
+ const width = parseInt(opts.width, 10);
37
+ const height = parseInt(opts.height, 10);
38
+ const theme = String(opts.theme);
39
+
40
+ if (isNaN(duration) || duration < 1 || duration > 60) {
41
+ throw new Error('Duration must be between 1 and 60 seconds');
42
+ }
43
+ if (isNaN(fps) || fps < 1 || fps > 60) {
44
+ throw new Error('FPS must be between 1 and 60');
45
+ }
46
+ if (isNaN(width) || width < 100 || width > 3840) {
47
+ throw new Error('Width must be between 100 and 3840');
48
+ }
49
+ if (isNaN(height) || height < 100 || height > 2160) {
50
+ throw new Error('Height must be between 100 and 2160');
51
+ }
52
+ // Validate theme is allowed
53
+ const allowedThemes = ['dark', 'light', 'forest', 'neutral', 'default'];
54
+ if (!allowedThemes.includes(theme)) {
55
+ throw new Error(`Theme must be one of: ${allowedThemes.join(', ')}`);
56
+ }
57
+
58
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'diagram-video-'), { mode: 0o700 });
59
+ const framesDir = path.join(tempDir, 'frames');
60
+ fs.mkdirSync(framesDir, { recursive: true });
61
+
62
+ let browser = null;
63
+
64
+ try {
65
+ console.log(chalk.blue('🎬 Starting video generation...'));
66
+ console.log(chalk.gray(` Resolution: ${width}x${height}`));
67
+ console.log(chalk.gray(` Duration: ${duration}s @ ${fps}fps`));
68
+
69
+ // Create HTML page with mermaid (escaped to prevent XSS)
70
+ const htmlContent = `<!DOCTYPE html>
71
+ <html>
72
+ <head>
73
+ <meta charset="UTF-8">
74
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
75
+ <style>
76
+ body {
77
+ margin: 0;
78
+ background: ${theme === 'dark' ? '#1a1a2e' : '#ffffff'};
79
+ display: flex;
80
+ justify-content: center;
81
+ align-items: center;
82
+ min-height: 100vh;
83
+ font-family: system-ui, -apple-system, sans-serif;
84
+ }
85
+ #diagram {
86
+ opacity: 0;
87
+ transform: scale(0.95);
88
+ transition: opacity 0.5s ease, transform 0.5s ease;
89
+ }
90
+ #diagram.ready {
91
+ opacity: 1;
92
+ transform: scale(1);
93
+ }
94
+ .mermaid {
95
+ display: flex;
96
+ justify-content: center;
97
+ align-items: center;
98
+ }
99
+ .loading {
100
+ color: ${theme === 'dark' ? '#fff' : '#333'};
101
+ font-size: 18px;
102
+ text-align: center;
103
+ }
104
+ </style>
105
+ </head>
106
+ <body>
107
+ <div id="loading" class="loading">Generating diagram...</div>
108
+ <div id="diagram" class="mermaid">
109
+ ${escapeHtml(mermaidCode)}
110
+ </div>
111
+ <script>
112
+ mermaid.initialize({
113
+ startOnLoad: true,
114
+ theme: '${escapeHtml(theme)}',
115
+ securityLevel: 'strict'
116
+ });
117
+
118
+ // Fade in when ready
119
+ setTimeout(() => {
120
+ document.getElementById('loading').style.display = 'none';
121
+ document.getElementById('diagram').classList.add('ready');
122
+ }, 500);
123
+ </script>
124
+ </body>
125
+ </html>`;
126
+
127
+ const htmlPath = path.join(tempDir, 'diagram.html');
128
+ fs.writeFileSync(htmlPath, htmlContent);
129
+
130
+ // Launch browser with timeout
131
+ console.log(chalk.blue('🌐 Launching browser...'));
132
+ browser = await chromium.launch({ timeout: 60000 });
133
+ const page = await browser.newPage({
134
+ viewport: { width, height }
135
+ });
136
+
137
+ const fileUrl = pathToFileURL(htmlPath).href;
138
+ await page.goto(fileUrl, { timeout: 30000 });
139
+
140
+ // Wait for mermaid to render
141
+ await page.waitForSelector('#diagram.ready', { timeout: 30000 });
142
+
143
+ // Additional wait for SVG to be fully rendered
144
+ await page.waitForTimeout(1000);
145
+
146
+ console.log(chalk.blue('📸 Capturing frames...'));
147
+
148
+ const totalFrames = duration * fps;
149
+ const showProgress = !!process.stdout.isTTY;
150
+
151
+ for (let i = 0; i < totalFrames; i++) {
152
+ const framePath = path.join(framesDir, `frame-${String(i).padStart(4, '0')}.png`);
153
+ await page.screenshot({ path: framePath, type: 'png' });
154
+
155
+ // Progress indicator
156
+ if (showProgress && (i % fps === 0 || i === totalFrames - 1)) {
157
+ const progress = Math.round(((i + 1) / totalFrames) * 100);
158
+ process.stdout.write(`\r ${progress}% (${i + 1}/${totalFrames} frames)`);
159
+ }
160
+ }
161
+
162
+ if (showProgress) {
163
+ console.log(''); // New line after progress
164
+ }
165
+ await browser.close();
166
+ browser = null;
167
+
168
+ // Compile video with ffmpeg
169
+ console.log(chalk.blue('🎞️ Compiling video...'));
170
+
171
+ const ext = path.extname(outputPath).toLowerCase();
172
+
173
+ // Find ffmpeg FIRST (moved before codec detection)
174
+ let ffmpegCmd = null;
175
+ const possiblePaths = getFfmpegCommandCandidates(process.platform, os.homedir());
176
+ for (const candidate of possiblePaths) {
177
+ try {
178
+ execFileSync(candidate, ['-version'], { stdio: 'pipe', windowsHide: true });
179
+ ffmpegCmd = candidate;
180
+ break;
181
+ } catch (e) {
182
+ // Keep searching
183
+ }
184
+ }
185
+ if (!ffmpegCmd) {
186
+ throw new Error('ffmpeg not found. Install with: brew install ffmpeg (Mac) or apt install ffmpeg (Linux)');
187
+ }
188
+
189
+ // Auto-detect available codec using ffmpeg encoder list
190
+ let codec = 'mpeg4';
191
+ let encodersOutput = '';
192
+ try {
193
+ encodersOutput = execFileSync(ffmpegCmd, ['-encoders'], {
194
+ stdio: ['ignore', 'pipe', 'pipe'],
195
+ encoding: 'utf8',
196
+ windowsHide: true
197
+ });
198
+ } catch (e) {
199
+ // Keep mpeg4 fallback
200
+ }
201
+
202
+ if (ext === '.webm' && /libvpx-vp9/.test(encodersOutput)) {
203
+ codec = 'libvpx-vp9';
204
+ } else if (/libx264/.test(encodersOutput)) {
205
+ codec = 'libx264';
206
+ } else if (/h264_videotoolbox/.test(encodersOutput)) {
207
+ codec = 'h264_videotoolbox';
208
+ }
209
+
210
+ const pixFmt = 'yuv420p';
211
+
212
+ // Build ffmpeg command safely using execFileSync
213
+ const args = [
214
+ '-y',
215
+ '-framerate', String(fps),
216
+ '-i', path.join(framesDir, 'frame-%04d.png'),
217
+ '-c:v', codec,
218
+ '-pix_fmt', pixFmt
219
+ ];
220
+
221
+ // Skip -crf for newer ffmpeg versions that don't support it
222
+ // Use -b:v instead for bitrate control if needed
223
+
224
+ // Add resolution filter for compatibility (validated integers)
225
+ const safeWidth = Math.max(100, Math.min(3840, width));
226
+ const safeHeight = Math.max(100, Math.min(2160, height));
227
+ args.push('-vf', `scale=${safeWidth}:${safeHeight}:force_original_aspect_ratio=decrease,pad=${safeWidth}:${safeHeight}:(ow-iw)/2:(oh-ih)/2:black`);
228
+
229
+ args.push(outputPath);
230
+
231
+ execFileSync(ffmpegCmd, args, { stdio: 'pipe', windowsHide: true });
232
+ if (!fs.existsSync(outputPath)) {
233
+ throw new Error('Video file was not created');
234
+ }
235
+
236
+ console.log(chalk.green('✅ Video saved:'), outputPath);
237
+
238
+ // Get file size
239
+ const stats = fs.statSync(outputPath);
240
+ const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
241
+ console.log(chalk.gray(` Size: ${sizeMB} MB`));
242
+
243
+ // Cleanup with error handling
244
+ try {
245
+ fs.rmSync(tempDir, { recursive: true, force: true });
246
+ } catch (e) {
247
+ console.warn(chalk.yellow('⚠️ Failed to clean up temp directory:'), tempDir);
248
+ }
249
+
250
+ return { outputPath };
251
+
252
+ } catch (error) {
253
+ // Cleanup on error
254
+ if (browser) {
255
+ try { await browser.close(); } catch (e) {}
256
+ }
257
+
258
+ // Don't delete temp dir on error so user can debug
259
+ console.log(chalk.yellow('⚠️ Error occurred. Temp files kept at:'), tempDir);
260
+
261
+ throw error;
262
+ } finally {
263
+ // Ensure browser is closed
264
+ if (browser) {
265
+ try { await browser.close(); } catch (e) {}
266
+ }
267
+ }
268
+ }
269
+
270
+ async function generateAnimatedSVG(mermaidCode, outputPath, options = {}) {
271
+ const { theme = 'dark' } = options;
272
+
273
+ console.log(chalk.blue('✨ Generating animated SVG...'));
274
+
275
+ // Validate theme
276
+ const allowedThemes = ['dark', 'light', 'forest', 'neutral', 'default'];
277
+ const safeTheme = allowedThemes.includes(theme) ? theme : 'dark';
278
+
279
+ // Escape the mermaid code for HTML
280
+ const escapedCode = escapeHtml(mermaidCode);
281
+
282
+ const htmlContent = `<!DOCTYPE html>
283
+ <html>
284
+ <head>
285
+ <meta charset="UTF-8">
286
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
287
+ <style>
288
+ body { margin: 0; background: transparent; }
289
+ </style>
290
+ </head>
291
+ <body>
292
+ <div class="mermaid">
293
+ ${escapedCode}
294
+ </div>
295
+ <script>
296
+ mermaid.initialize({
297
+ startOnLoad: true,
298
+ theme: '${safeTheme}',
299
+ securityLevel: 'loose'
300
+ });
301
+ </script>
302
+ </body>
303
+ </html>`;
304
+
305
+ let browser = null;
306
+ let tempFile = null;
307
+
308
+ try {
309
+ browser = await chromium.launch({ timeout: 60000 });
310
+ const page = await browser.newPage();
311
+
312
+ const randomId = crypto.randomBytes(16).toString('hex');
313
+ tempFile = path.join(os.tmpdir(), `diagram-${Date.now()}-${randomId}.html`);
314
+ fs.writeFileSync(tempFile, htmlContent);
315
+
316
+ const fileUrl = pathToFileURL(tempFile).href;
317
+ await page.goto(fileUrl, { timeout: 30000 });
318
+ await page.waitForSelector('.mermaid svg', { timeout: 30000 });
319
+ await page.waitForTimeout(500);
320
+
321
+ // Extract SVG and add animation
322
+ const svgContent = await page.evaluate(() => {
323
+ const svg = document.querySelector('.mermaid svg');
324
+ if (!svg) return null;
325
+
326
+ // Add CSS animation
327
+ const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
328
+ const nodes = document.querySelectorAll('.node');
329
+ const edges = document.querySelectorAll('.edgePath');
330
+
331
+ let css = `
332
+ .node {
333
+ opacity: 0;
334
+ animation: fadeIn 0.5s ease forwards;
335
+ }
336
+ .edgePath {
337
+ opacity: 0;
338
+ animation: fadeIn 0.3s ease forwards;
339
+ }
340
+ `;
341
+
342
+ for (let i = 0; i < nodes.length; i++) {
343
+ css += `.node:nth-of-type(${i + 1}) { animation-delay: ${i * 0.1}s; }\n`;
344
+ }
345
+ for (let i = 0; i < edges.length; i++) {
346
+ css += `.edgePath:nth-of-type(${i + 1}) { animation-delay: ${(i + 1) * 0.15}s; }\n`;
347
+ }
348
+
349
+ css += `
350
+ @keyframes fadeIn {
351
+ to { opacity: 1; }
352
+ }
353
+ `;
354
+
355
+ style.textContent = css;
356
+ svg.appendChild(style);
357
+
358
+ return svg.outerHTML;
359
+ });
360
+
361
+ await browser.close();
362
+ browser = null;
363
+
364
+ if (tempFile && fs.existsSync(tempFile)) {
365
+ fs.unlinkSync(tempFile);
366
+ }
367
+
368
+ if (svgContent) {
369
+ fs.writeFileSync(outputPath, svgContent);
370
+ console.log(chalk.green('✅ Animated SVG saved:'), outputPath);
371
+ } else {
372
+ throw new Error('Failed to generate SVG - no SVG element found');
373
+ }
374
+
375
+ return { outputPath };
376
+
377
+ } catch (error) {
378
+ if (browser) {
379
+ try { await browser.close(); } catch (e) {}
380
+ }
381
+ if (tempFile && fs.existsSync(tempFile)) {
382
+ try { fs.unlinkSync(tempFile); } catch (e) {}
383
+ }
384
+ throw error;
385
+ }
386
+ }
387
+
388
+ module.exports = { generateVideo, generateAnimatedSVG };