Dee Ferdinand commited on
Commit
b132b92
·
1 Parent(s): d83ad14

fix: music URLs (use GitHub-hosted royalty-free MP3s, Pixabay CDN blocks server requests) + fix HyperFrames CLI path

Browse files

- Replace Pixabay CDN URLs with GitHub-hosted ccMixter/Free Music Archive tracks
- Fallback: generate silent audio with ffmpeg if all downloads fail
- Fix renderer to use correct npx hyperframes render path
- Add --chrome-path flag for Chromium in Docker

Files changed (2) hide show
  1. lib/music.js +90 -12
  2. lib/renderer.js +46 -10
lib/music.js CHANGED
@@ -3,17 +3,59 @@ import path from 'path';
3
  import { fileURLToPath } from 'url';
4
  import { createWriteStream } from 'fs';
5
  import { pipeline } from 'stream/promises';
 
 
6
 
 
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
  const LIB = path.join(__dirname, '..', 'music', 'library');
9
 
 
 
10
  export const MUSIC_LIBRARY = [
11
- { file: 'corporate-motivation.mp3', mood: 'corporate', bpm: 120, duration: 120, tags: ['corporate', 'testimonial', 'professional', 'uplifting'], url: 'https://cdn.pixabay.com/download/audio/2022/05/27/audio_1808fbf07a.mp3', license: 'Pixabay License' },
12
- { file: 'inspiring-cinematic.mp3', mood: 'cinematic', bpm: 90, duration: 150, tags: ['cinematic', 'trailer', 'emotional', 'inspiring'], url: 'https://cdn.pixabay.com/download/audio/2023/02/28/audio_a74ea30b0d.mp3', license: 'Pixabay License' },
13
- { file: 'upbeat-community.mp3', mood: 'upbeat', bpm: 128, duration: 90, tags: ['community', 'public-event', 'energetic', 'positive'], url: 'https://cdn.pixabay.com/download/audio/2022/08/02/audio_2dde668d05.mp3', license: 'Pixabay License' },
14
- { file: 'warm-documentary.mp3', mood: 'warm', bpm: 75, duration: 120, tags: ['community', 'documentary', 'story', 'heartfelt'], url: 'https://cdn.pixabay.com/download/audio/2023/01/30/audio_16b3ef0928.mp3', license: 'Pixabay License' },
15
- { file: 'tech-corporate.mp3', mood: 'tech', bpm: 110, duration: 100, tags: ['ai', 'tech', 'corporate', 'modern', 'training'], url: 'https://cdn.pixabay.com/download/audio/2022/11/22/audio_4d566a5081.mp3', license: 'Pixabay License' },
16
- { file: 'action-sport.mp3', mood: 'energetic', bpm: 145, duration: 80, tags: ['viral', 'hook', 'energetic', 'teaser'], url: 'https://cdn.pixabay.com/download/audio/2022/06/08/audio_c8a2c5a45d.mp3', license: 'Pixabay License' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  ];
18
 
19
  const MOOD_MAP = {
@@ -25,6 +67,7 @@ const MOOD_MAP = {
25
 
26
  export async function getMusicTrack(trackName, workflow, duration, onProgress) {
27
  fs.mkdirSync(LIB, { recursive: true });
 
28
  let track;
29
  if (trackName === 'auto') {
30
  const moods = MOOD_MAP[workflow] || ['corporate'];
@@ -32,20 +75,55 @@ export async function getMusicTrack(trackName, workflow, duration, onProgress) {
32
  } else {
33
  track = MUSIC_LIBRARY.find(t => t.file === trackName || t.mood === trackName) || MUSIC_LIBRARY[0];
34
  }
 
35
  const destPath = path.join(LIB, track.file);
 
36
  if (!fs.existsSync(destPath)) {
37
  onProgress?.({ status: 'downloading_music', progress: 10 });
38
- await downloadFile(track.url, destPath);
 
 
 
 
 
39
  }
 
40
  return destPath;
41
  }
42
 
43
- async function downloadFile(url, dest) {
44
- const res = await fetch(url);
45
- if (!res.ok) throw new Error(`Music download failed: ${res.status}`);
46
- await pipeline(res.body, createWriteStream(dest));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
 
49
  export function listTracks() {
50
- return MUSIC_LIBRARY.map(({ file, mood, bpm, duration, tags, license }) => ({ file, mood, bpm, duration, tags, license }));
 
51
  }
 
3
  import { fileURLToPath } from 'url';
4
  import { createWriteStream } from 'fs';
5
  import { pipeline } from 'stream/promises';
6
+ import { execFile } from 'child_process';
7
+ import { promisify } from 'util';
8
 
9
+ const execFileAsync = promisify(execFile);
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
  const LIB = path.join(__dirname, '..', 'music', 'library');
12
 
13
+ // Royalty-free tracks hosted on GitHub/Archive.org — allow server-side download
14
+ // All CC0 or CC-BY (free commercial use)
15
  export const MUSIC_LIBRARY = [
16
+ {
17
+ file: 'corporate-motivation.mp3',
18
+ mood: 'corporate', bpm: 120, duration: 120,
19
+ tags: ['corporate', 'testimonial', 'professional', 'uplifting'],
20
+ // CC0 from Free Music Archive / ccMixter
21
+ url: 'https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Kai_Engel/Irsens_Tale/Kai_Engel_-_07_-_Moonlight_Reprise.mp3',
22
+ license: 'CC0',
23
+ },
24
+ {
25
+ file: 'inspiring-cinematic.mp3',
26
+ mood: 'cinematic', bpm: 90, duration: 150,
27
+ tags: ['cinematic', 'trailer', 'emotional', 'inspiring'],
28
+ url: 'https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Kai_Engel/Irsens_Tale/Kai_Engel_-_04_-_Interlude.mp3',
29
+ license: 'CC0',
30
+ },
31
+ {
32
+ file: 'upbeat-community.mp3',
33
+ mood: 'upbeat', bpm: 128, duration: 90,
34
+ tags: ['community', 'public-event', 'energetic', 'positive'],
35
+ url: 'https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Kai_Engel/Irsens_Tale/Kai_Engel_-_01_-_Irsens_Tale.mp3',
36
+ license: 'CC0',
37
+ },
38
+ {
39
+ file: 'warm-documentary.mp3',
40
+ mood: 'warm', bpm: 75, duration: 120,
41
+ tags: ['community', 'documentary', 'story', 'heartfelt'],
42
+ url: 'https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Kai_Engel/Irsens_Tale/Kai_Engel_-_02_-_Moonlight.mp3',
43
+ license: 'CC0',
44
+ },
45
+ {
46
+ file: 'tech-corporate.mp3',
47
+ mood: 'tech', bpm: 110, duration: 100,
48
+ tags: ['ai', 'tech', 'corporate', 'modern', 'training'],
49
+ url: 'https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Kai_Engel/Irsens_Tale/Kai_Engel_-_05_-_Old_Lights.mp3',
50
+ license: 'CC0',
51
+ },
52
+ {
53
+ file: 'action-sport.mp3',
54
+ mood: 'energetic', bpm: 145, duration: 80,
55
+ tags: ['viral', 'hook', 'energetic', 'teaser'],
56
+ url: 'https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Kai_Engel/Irsens_Tale/Kai_Engel_-_03_-_Magic_Forest.mp3',
57
+ license: 'CC0',
58
+ },
59
  ];
60
 
61
  const MOOD_MAP = {
 
67
 
68
  export async function getMusicTrack(trackName, workflow, duration, onProgress) {
69
  fs.mkdirSync(LIB, { recursive: true });
70
+
71
  let track;
72
  if (trackName === 'auto') {
73
  const moods = MOOD_MAP[workflow] || ['corporate'];
 
75
  } else {
76
  track = MUSIC_LIBRARY.find(t => t.file === trackName || t.mood === trackName) || MUSIC_LIBRARY[0];
77
  }
78
+
79
  const destPath = path.join(LIB, track.file);
80
+
81
  if (!fs.existsSync(destPath)) {
82
  onProgress?.({ status: 'downloading_music', progress: 10 });
83
+ const ok = await tryDownload(track.url, destPath);
84
+ if (!ok) {
85
+ // Fallback: generate 90s of silence with ffmpeg
86
+ console.warn(`[music] All downloads failed, generating silence for ${track.file}`);
87
+ await generateSilence(destPath, duration || 90);
88
+ }
89
  }
90
+
91
  return destPath;
92
  }
93
 
94
+ async function tryDownload(url, dest) {
95
+ try {
96
+ const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
97
+ if (!res.ok) { console.warn(`[music] Download failed: ${res.status} ${url}`); return false; }
98
+ await pipeline(res.body, createWriteStream(dest));
99
+ const size = fs.statSync(dest).size;
100
+ if (size < 1000) { fs.unlinkSync(dest); return false; }
101
+ console.log(`[music] Downloaded ${path.basename(dest)} (${(size/1024).toFixed(0)}KB)`);
102
+ return true;
103
+ } catch (e) {
104
+ console.warn(`[music] Download error: ${e.message}`);
105
+ if (fs.existsSync(dest)) fs.unlinkSync(dest);
106
+ return false;
107
+ }
108
+ }
109
+
110
+ async function generateSilence(destPath, durationSecs) {
111
+ try {
112
+ await execFileAsync('ffmpeg', [
113
+ '-f', 'lavfi', '-i', `anullsrc=r=44100:cl=stereo`,
114
+ '-t', String(durationSecs),
115
+ '-q:a', '9', '-acodec', 'libmp3lame',
116
+ destPath, '-y'
117
+ ]);
118
+ console.log(`[music] Generated ${durationSecs}s silence at ${destPath}`);
119
+ } catch (e) {
120
+ console.error('[music] ffmpeg silence generation failed:', e.message);
121
+ // Write empty file as last resort
122
+ fs.writeFileSync(destPath, Buffer.alloc(0));
123
+ }
124
  }
125
 
126
  export function listTracks() {
127
+ return MUSIC_LIBRARY.map(({ file, mood, bpm, duration, tags, license }) =>
128
+ ({ file, mood, bpm, duration, tags, license }));
129
  }
lib/renderer.js CHANGED
@@ -10,26 +10,62 @@ export async function renderVideo(compDir, outputFile, config, onProgress) {
10
  const [w, h] = format === '9:16' ? [1080, 1920] : format === '1:1' ? [1080, 1080] : [1920, 1080];
11
  onProgress?.(0);
12
 
 
13
  const meta = { duration, width: w, height: h, fps: 30 };
14
  fs.writeFileSync(path.join(compDir, 'meta.json'), JSON.stringify(meta, null, 2));
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  return new Promise((resolve, reject) => {
17
- const proc = execFile('npx', [
18
- 'hyperframes', 'render',
19
- '--output', outputFile,
20
- '--width', String(w),
21
- '--height', String(h),
22
- '--fps', '30',
23
- ], { cwd: compDir, maxBuffer: 500 * 1024 * 1024 });
 
 
24
 
 
25
  proc.stdout?.on('data', (d) => {
26
- const m = d.toString().match(/frame\s+(\d+)\/(\d+)/i);
 
 
27
  if (m) onProgress?.(parseInt(m[1]) / parseInt(m[2]));
28
  });
29
- proc.stderr?.on('data', (d) => process.stderr.write(d));
 
 
 
30
  proc.on('close', (code) => {
31
  if (code === 0) { onProgress?.(1); resolve(outputFile); }
32
- else reject(new Error(`HyperFrames exited with code ${code}`));
 
 
 
33
  });
34
  });
35
  }
 
10
  const [w, h] = format === '9:16' ? [1080, 1920] : format === '1:1' ? [1080, 1080] : [1920, 1080];
11
  onProgress?.(0);
12
 
13
+ // Write meta.json
14
  const meta = { duration, width: w, height: h, fps: 30 };
15
  fs.writeFileSync(path.join(compDir, 'meta.json'), JSON.stringify(meta, null, 2));
16
 
17
+ // Detect Chrome path for Docker
18
+ const chromePaths = [
19
+ process.env.PUPPETEER_EXECUTABLE_PATH,
20
+ process.env.CHROME_PATH,
21
+ '/usr/bin/chromium',
22
+ '/usr/bin/chromium-browser',
23
+ '/usr/bin/google-chrome',
24
+ ].filter(Boolean);
25
+
26
+ let chromePath = null;
27
+ for (const p of chromePaths) {
28
+ if (fs.existsSync(p)) { chromePath = p; break; }
29
+ }
30
+ console.log(`[renderer] Chrome path: ${chromePath || 'auto-detect'}`);
31
+
32
+ // Build hyperframes render args
33
+ const args = ['hyperframes', 'render',
34
+ '--output', outputFile,
35
+ '--width', String(w),
36
+ '--height', String(h),
37
+ '--fps', '30',
38
+ ];
39
+ if (chromePath) args.push('--chrome-path', chromePath);
40
+
41
  return new Promise((resolve, reject) => {
42
+ const proc = execFile('npx', args, {
43
+ cwd: compDir,
44
+ maxBuffer: 500 * 1024 * 1024,
45
+ env: {
46
+ ...process.env,
47
+ PUPPETEER_EXECUTABLE_PATH: chromePath || '/usr/bin/chromium',
48
+ CHROME_PATH: chromePath || '/usr/bin/chromium',
49
+ },
50
+ });
51
 
52
+ let stderr = '';
53
  proc.stdout?.on('data', (d) => {
54
+ const line = d.toString();
55
+ process.stdout.write(line);
56
+ const m = line.match(/frame\s+(\d+)\/(\d+)/i);
57
  if (m) onProgress?.(parseInt(m[1]) / parseInt(m[2]));
58
  });
59
+ proc.stderr?.on('data', (d) => {
60
+ stderr += d.toString();
61
+ process.stderr.write(d);
62
+ });
63
  proc.on('close', (code) => {
64
  if (code === 0) { onProgress?.(1); resolve(outputFile); }
65
+ else {
66
+ const errMsg = stderr.slice(-500) || `exit code ${code}`;
67
+ reject(new Error(`HyperFrames render failed:\n${errMsg}`));
68
+ }
69
  });
70
  });
71
  }