@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/LICENSE +21 -0
- package/README.md +300 -0
- package/package.json +57 -0
- package/src/diagram.js +1118 -0
- package/src/video.js +388 -0
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, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, ''');
|
|
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 };
|