Spaces:
Configuration error
Configuration error
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
- lib/music.js +90 -12
- 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 |
-
{
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
}
|
|
|
|
| 40 |
return destPath;
|
| 41 |
}
|
| 42 |
|
| 43 |
-
async function
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
export function listTracks() {
|
| 50 |
-
return MUSIC_LIBRARY.map(({ 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
|
|
|
|
| 25 |
proc.stdout?.on('data', (d) => {
|
| 26 |
-
const
|
|
|
|
|
|
|
| 27 |
if (m) onProgress?.(parseInt(m[1]) / parseInt(m[2]));
|
| 28 |
});
|
| 29 |
-
proc.stderr?.on('data', (d) =>
|
|
|
|
|
|
|
|
|
|
| 30 |
proc.on('close', (code) => {
|
| 31 |
if (code === 0) { onProgress?.(1); resolve(outputFile); }
|
| 32 |
-
else
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|