- toFileURL and path-basename handling made Windows-safe (backslashes, drive letters) so media/model loading works there - Blender discovery scans Program Files\Blender Foundation; ffmpeg candidates include common Windows paths - build config: win/nsis target (x64, custom install dir), build/icon.ico generated from the 1024 png, dist:win script Cross-compiled from macOS: dist/DJ Visualizer Setup 1.6.0.exe (95MB) with scripts unpacked and models/MediaPipe bundled. Untested on real Windows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
438 lines
17 KiB
JavaScript
438 lines
17 KiB
JavaScript
const { app, BrowserWindow, screen, ipcMain, systemPreferences, shell, dialog, protocol, net } = require('electron');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { execFile } = require('child_process');
|
|
const { pathToFileURL } = require('url');
|
|
|
|
// djvres:// serves the MediaPipe WASM runtime and the bundled pose model to
|
|
// the renderer: fetch() does not work on file:// URLs, and the pose tracker
|
|
// loads its assets via fetch. Registered before app.ready as required.
|
|
protocol.registerSchemesAsPrivileged([{ scheme: 'djvres',
|
|
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true } }]);
|
|
|
|
let controlWin = null; // panel with all the controls (primary screen)
|
|
let outputWin = null; // pure visualization (external screen, fullscreen)
|
|
|
|
function externalDisplay() {
|
|
const primary = screen.getPrimaryDisplay();
|
|
return screen.getAllDisplays().find(d => d.id !== primary.id) || primary;
|
|
}
|
|
|
|
let quitting = false;
|
|
|
|
function createOutputWindow() {
|
|
const primary = screen.getPrimaryDisplay();
|
|
const ext = externalDisplay();
|
|
const hasExternal = ext.id !== primary.id;
|
|
|
|
outputWin = new BrowserWindow({
|
|
width: hasExternal ? ext.bounds.width : 960,
|
|
height: hasExternal ? ext.bounds.height : 600,
|
|
x: hasExternal ? ext.bounds.x : primary.bounds.x + 520,
|
|
y: hasExternal ? ext.bounds.y : primary.bounds.y + 60,
|
|
title: 'DJ Visualizer — Output',
|
|
backgroundColor: '#000000',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
additionalArguments: ['--role=output'],
|
|
// Let audio loaded via IPC play without a click inside the output window.
|
|
autoplayPolicy: 'no-user-gesture-required'
|
|
}
|
|
});
|
|
outputWin.loadFile(path.join(__dirname, 'src', 'output.html'));
|
|
if (hasExternal) {
|
|
outputWin.once('ready-to-show', () => placeOutputOnDisplay(ext, true));
|
|
}
|
|
|
|
// The output window hosts the audio engine and all visual state: closing it
|
|
// by accident (Cmd+W, red button) would kill the show. Ignore close requests
|
|
// while the control panel is open; only app quit may close it.
|
|
outputWin.on('close', (e) => {
|
|
if (!quitting && controlWin && !controlWin.isDestroyed()) e.preventDefault();
|
|
});
|
|
// Safety net: if it dies anyway (e.g. a renderer crash), bring it back.
|
|
outputWin.on('closed', () => {
|
|
outputWin = null;
|
|
if (!quitting && controlWin && !controlWin.isDestroyed()) createOutputWindow();
|
|
});
|
|
outputWin.webContents.on('render-process-gone', () => {
|
|
if (quitting) return;
|
|
if (outputWin && !outputWin.isDestroyed()) outputWin.destroy();
|
|
});
|
|
}
|
|
|
|
function createWindows() {
|
|
const primary = screen.getPrimaryDisplay();
|
|
const ext = externalDisplay();
|
|
const hasExternal = ext.id !== primary.id;
|
|
|
|
console.log(`[DJV] Schermi rilevati: ${screen.getAllDisplays().length}` +
|
|
` — esterno ${hasExternal ? 'SÌ' : 'NO'} (output su ${hasExternal ? 'monitor esterno' : 'principale, finestra'})`);
|
|
|
|
// --- Control window (the panel) ---
|
|
controlWin = new BrowserWindow({
|
|
width: 980,
|
|
height: 1180,
|
|
minWidth: 420,
|
|
x: primary.bounds.x + 60,
|
|
y: primary.bounds.y + 60,
|
|
title: 'DJ Visualizer — Controlli',
|
|
backgroundColor: '#0e0e16',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
additionalArguments: ['--role=control']
|
|
}
|
|
});
|
|
controlWin.loadFile(path.join(__dirname, 'src', 'control.html'));
|
|
|
|
// --- Output window (the visuals) ---
|
|
createOutputWindow();
|
|
|
|
controlWin.on('closed', () => {
|
|
quitting = true; // closing the panel ends the show: let the output close
|
|
controlWin = null;
|
|
if (outputWin) outputWin.close();
|
|
app.quit();
|
|
});
|
|
}
|
|
|
|
async function ensureMicAccess() {
|
|
if (process.platform !== 'darwin') return;
|
|
try {
|
|
const status = systemPreferences.getMediaAccessStatus('microphone');
|
|
if (status !== 'granted') await systemPreferences.askForMediaAccess('microphone');
|
|
} catch (e) { /* getUserMedia will prompt again if needed */ }
|
|
}
|
|
|
|
// Convert a Mixamo FBX animation into a skeleton-only GLB clip via Blender
|
|
// (when installed): lets the user add moves to the dance library from the
|
|
// panel without any manual conversion step.
|
|
function blenderPath() {
|
|
const cands = ['/Applications/Blender.app/Contents/MacOS/Blender',
|
|
'/opt/homebrew/bin/blender', '/usr/local/bin/blender'];
|
|
if (process.platform === 'win32') {
|
|
// Blender installs versioned folders under Program Files
|
|
for (const root of ['C:\\Program Files\\Blender Foundation', 'C:\\Program Files (x86)\\Blender Foundation']) {
|
|
try {
|
|
for (const d of fs.readdirSync(root))
|
|
cands.push(path.join(root, d, 'blender.exe'));
|
|
} catch (e) { /* root missing */ }
|
|
}
|
|
}
|
|
for (const c of cands) if (fs.existsSync(c)) return c;
|
|
return null;
|
|
}
|
|
function convertFbx(fbxPath, scriptName, outSub) {
|
|
const blender = blenderPath();
|
|
if (!blender) return Promise.resolve({ ok: false,
|
|
error: 'Blender non trovato: serve per convertire gli FBX (oppure carica un .glb)' });
|
|
const outDir = path.join(app.getPath('userData'), outSub);
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
const out = path.join(outDir, path.basename(fbxPath).replace(/\.fbx$/i, '') + '.glb');
|
|
// packaged app: the .py must live outside the asar for Blender to read it
|
|
const script = path.join(__dirname, 'scripts', scriptName)
|
|
.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
|
|
return new Promise((resolve) => {
|
|
execFile(blender, ['-b', '-P', script, '--', fbxPath, out],
|
|
{ maxBuffer: 1 << 24, timeout: 300000 }, (err, _so, se) => {
|
|
if (err || !fs.existsSync(out))
|
|
resolve({ ok: false, error: 'conversione fallita: ' + String(se || (err && err.message) || '').slice(-300) });
|
|
else resolve({ ok: true, path: out });
|
|
});
|
|
});
|
|
}
|
|
ipcMain.handle('anim:convert', (_e, p) => convertFbx(p, 'fbx2animglb.py', 'anims'));
|
|
ipcMain.handle('model:convert', (_e, p) => convertFbx(p, 'fbx2modelglb.py', 'models3d'));
|
|
|
|
// Camera permission is requested lazily (only when an interactive preset is
|
|
// activated), not at startup: no camera prompt for users who never use it.
|
|
ipcMain.handle('cam:ensure', async () => {
|
|
if (process.platform !== 'darwin') return true;
|
|
try {
|
|
const status = systemPreferences.getMediaAccessStatus('camera');
|
|
if (status !== 'granted') return await systemPreferences.askForMediaAccess('camera');
|
|
return true;
|
|
} catch (e) { return true; }
|
|
});
|
|
|
|
app.on('before-quit', () => { quitting = true; });
|
|
|
|
app.whenReady().then(async () => {
|
|
// djvres://mp/<file> -> node_modules/@mediapipe/tasks-vision/<file>
|
|
// djvres://models/<file> -> models/<file>
|
|
const RES_ROOTS = {
|
|
mp: path.join(__dirname, 'node_modules', '@mediapipe', 'tasks-vision'),
|
|
models: path.join(__dirname, 'models')
|
|
};
|
|
protocol.handle('djvres', async (req) => {
|
|
try {
|
|
const u = new URL(req.url);
|
|
const root = RES_ROOTS[u.host];
|
|
const rel = decodeURIComponent(u.pathname).replace(/^\//, '');
|
|
if (!root || rel.includes('..')) return new Response('forbidden', { status: 403 });
|
|
const res = await net.fetch(pathToFileURL(path.join(root, rel)).toString());
|
|
const headers = new Headers();
|
|
headers.set('Access-Control-Allow-Origin', '*');
|
|
if (rel.endsWith('.wasm')) headers.set('Content-Type', 'application/wasm');
|
|
else if (rel.endsWith('.js') || rel.endsWith('.mjs')) headers.set('Content-Type', 'text/javascript');
|
|
return new Response(res.body, { status: res.status, headers });
|
|
} catch (e) {
|
|
return new Response('not found', { status: 404 });
|
|
}
|
|
});
|
|
|
|
if (process.platform === 'darwin' && app.dock) {
|
|
try { app.dock.setIcon(path.join(__dirname, 'build', 'icon_1024.png')); } catch (e) { /* non-fatal */ }
|
|
}
|
|
await ensureMicAccess();
|
|
createWindows();
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindows();
|
|
});
|
|
});
|
|
|
|
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
|
|
|
|
// --- IPC routing between the two windows --------------------------------
|
|
|
|
// Control -> Output (commands).
|
|
ipcMain.on('ctl', (_e, msg) => {
|
|
if (outputWin && !outputWin.isDestroyed()) outputWin.webContents.send('ctl', msg);
|
|
});
|
|
|
|
// Output -> Control (reports: meters, device list, play state).
|
|
ipcMain.on('rpt', (_e, msg) => {
|
|
if (controlWin && !controlWin.isDestroyed()) controlWin.webContents.send('rpt', msg);
|
|
});
|
|
|
|
// --- Display management --------------------------------------------------
|
|
|
|
// --- Recording: receive WebM chunks, mux to MP4 at a chosen aspect ratio ---
|
|
let recStream = null, recTempPath = null;
|
|
|
|
function ffmpegPath() {
|
|
const cands = [];
|
|
try { cands.push(require('ffmpeg-static')); } catch (e) { /* optional */ }
|
|
cands.push(process.env.FFMPEG, 'ffmpeg',
|
|
'/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg',
|
|
'C:\\ffmpeg\\bin\\ffmpeg.exe', 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe');
|
|
for (const c of cands) {
|
|
if (!c) continue;
|
|
if (c === 'ffmpeg' || fs.existsSync(c)) return c;
|
|
}
|
|
return 'ffmpeg';
|
|
}
|
|
|
|
function transcodeToMp4(input, output, opts) {
|
|
const w = (opts && opts.w) || 1920;
|
|
const h = (opts && opts.h) || 1080;
|
|
// Cover the target frame then centre-crop, so the chosen aspect is filled
|
|
// without distortion.
|
|
const vf = `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`;
|
|
const args = ['-y', '-i', input, '-vf', vf,
|
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
|
|
'-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', output];
|
|
return new Promise((resolve, reject) => {
|
|
execFile(ffmpegPath(), args, { maxBuffer: 1 << 24 }, (err, stdout, stderr) => {
|
|
if (err) reject(new Error('ffmpeg: ' + String(stderr || err.message).slice(-500)));
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
// Read raw file bytes for the renderer (used to decode waveforms via Web Audio,
|
|
// which needs no external ffmpeg). Returns null if unreadable.
|
|
ipcMain.handle('file:read', async (_e, filePath) => {
|
|
try { return await fs.promises.readFile(filePath); }
|
|
catch (e) { return null; }
|
|
});
|
|
|
|
// Decode a media file to mono 8 kHz PCM via ffmpeg and reduce it to `buckets`
|
|
// peak amplitudes (0..1) for drawing a waveform. Returns null on failure.
|
|
const peaksCache = new Map();
|
|
ipcMain.handle('audio:peaks', async (_e, filePath, buckets) => {
|
|
buckets = buckets || 400;
|
|
const key = filePath + '|' + buckets;
|
|
if (peaksCache.has(key)) return peaksCache.get(key);
|
|
const args = ['-v', 'quiet', '-i', filePath, '-ac', '1', '-ar', '8000', '-f', 's16le', '-'];
|
|
return new Promise((resolve) => {
|
|
execFile(ffmpegPath(), args, { encoding: 'buffer', maxBuffer: 1 << 28 }, (err, stdout) => {
|
|
if (err || !stdout || stdout.length < 4) { resolve(null); return; }
|
|
const u8 = Uint8Array.from(stdout);
|
|
const samples = new Int16Array(u8.buffer, 0, u8.length >> 1);
|
|
const n = samples.length;
|
|
const peaks = new Array(buckets).fill(0);
|
|
const per = Math.max(1, Math.floor(n / buckets));
|
|
for (let b = 0; b < buckets; b++) {
|
|
let max = 0;
|
|
const s = b * per, e = Math.min(n, s + per);
|
|
for (let i = s; i < e; i++) { const v = samples[i] < 0 ? -samples[i] : samples[i]; if (v > max) max = v; }
|
|
peaks[b] = max / 32768;
|
|
}
|
|
// Normalize so the loudest peak fills the height.
|
|
const top = Math.max(0.0001, ...peaks);
|
|
for (let b = 0; b < buckets; b++) peaks[b] = Math.min(1, peaks[b] / top);
|
|
peaksCache.set(key, peaks);
|
|
resolve(peaks);
|
|
});
|
|
});
|
|
});
|
|
|
|
function recordingsDir() {
|
|
const dir = path.join(app.getPath('videos'), 'DJ Visualizer');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
ipcMain.handle('rec:start', () => {
|
|
recTempPath = path.join(app.getPath('temp'), 'djv-rec-' + Date.now() + '.webm');
|
|
recStream = fs.createWriteStream(recTempPath);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.on('rec:chunk', (_e, bytes) => {
|
|
if (recStream && bytes) recStream.write(Buffer.from(bytes));
|
|
});
|
|
|
|
ipcMain.handle('rec:stop', async (_e, opts) => {
|
|
if (!recStream) return { ok: false, error: 'Nessuna registrazione attiva' };
|
|
await new Promise(r => recStream.end(r));
|
|
const temp = recTempPath;
|
|
recStream = null; recTempPath = null;
|
|
try {
|
|
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
|
const outPath = path.join(recordingsDir(), 'rec-' + stamp + '.mp4');
|
|
await transcodeToMp4(temp, outPath, opts);
|
|
try { fs.unlinkSync(temp); } catch (e) { /* ignore */ }
|
|
return { ok: true, path: outPath };
|
|
} catch (e) {
|
|
return { ok: false, error: e.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('rec:openFolder', () => {
|
|
const dir = recordingsDir();
|
|
shell.openPath(dir);
|
|
return dir;
|
|
});
|
|
|
|
// --- Pad bank persistence ---
|
|
const padsFile = () => path.join(app.getPath('userData'), 'pads.json');
|
|
ipcMain.handle('pads:load', () => {
|
|
try { return JSON.parse(fs.readFileSync(padsFile(), 'utf8')); } catch (e) { return null; }
|
|
});
|
|
ipcMain.handle('pads:save', (_e, data) => {
|
|
try { fs.writeFileSync(padsFile(), JSON.stringify(data)); return true; } catch (e) { return false; }
|
|
});
|
|
|
|
const playlistFile = () => path.join(app.getPath('userData'), 'playlist.json');
|
|
ipcMain.handle('playlist:load', () => {
|
|
try { return JSON.parse(fs.readFileSync(playlistFile(), 'utf8')); } catch (e) { return null; }
|
|
});
|
|
ipcMain.handle('playlist:save', (_e, data) => {
|
|
try { fs.writeFileSync(playlistFile(), JSON.stringify(data)); return true; } catch (e) { return false; }
|
|
});
|
|
|
|
// Export/import a playlist to a user-chosen file.
|
|
ipcMain.handle('playlist:export', async (_e, data) => {
|
|
const res = await dialog.showSaveDialog(controlWin, {
|
|
title: 'Salva playlist',
|
|
defaultPath: 'set.djvplaylist.json',
|
|
filters: [{ name: 'Playlist DJ Visualizer', extensions: ['json'] }]
|
|
});
|
|
if (res.canceled || !res.filePath) return { ok: false };
|
|
try { fs.writeFileSync(res.filePath, JSON.stringify(data, null, 2)); return { ok: true, path: res.filePath }; }
|
|
catch (e) { return { ok: false, error: e.message }; }
|
|
});
|
|
ipcMain.handle('playlist:import', async () => {
|
|
const res = await dialog.showOpenDialog(controlWin, {
|
|
title: 'Carica playlist',
|
|
properties: ['openFile'],
|
|
filters: [{ name: 'Playlist', extensions: ['json'] }]
|
|
});
|
|
if (res.canceled || !res.filePaths.length) return null;
|
|
try { return JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8')); } catch (e) { return null; }
|
|
});
|
|
|
|
// Read the bundled svg/ folder and return each SVG as a data: URL.
|
|
ipcMain.handle('svg:listBuiltin', () => {
|
|
try {
|
|
const dir = path.join(__dirname, 'svg');
|
|
return fs.readdirSync(dir)
|
|
.filter(f => f.toLowerCase().endsWith('.svg'))
|
|
.map(f => {
|
|
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
const dataUrl = 'data:image/svg+xml;base64,' + Buffer.from(txt, 'utf8').toString('base64');
|
|
const name = f
|
|
.replace(/\.svg$/i, '')
|
|
.replace(/-svgrepo-com.*$/i, '')
|
|
.replace(/^freesvg[\s-]+/i, '')
|
|
.replace(/[-_]+/g, ' ')
|
|
.trim();
|
|
return { name: name || f, dataUrl };
|
|
});
|
|
} catch (e) { return []; }
|
|
});
|
|
|
|
ipcMain.handle('displays:list', () => {
|
|
const primary = screen.getPrimaryDisplay();
|
|
return screen.getAllDisplays().map((d, i) => ({
|
|
id: d.id,
|
|
index: i,
|
|
label: d.label || `Display ${i + 1}`,
|
|
bounds: d.bounds,
|
|
isPrimary: d.id === primary.id
|
|
}));
|
|
});
|
|
|
|
// Place the output window on a given display. On macOS we use *simple*
|
|
// fullscreen (a borderless window covering the screen) instead of native
|
|
// fullscreen: native fullscreen creates a Space tied to whichever display the
|
|
// window happens to be on and is unreliable when moving between monitors.
|
|
function placeOutputOnDisplay(target, fullscreen) {
|
|
if (!outputWin || !target) return;
|
|
// Leave any current fullscreen first so the window can be repositioned.
|
|
if (process.platform === 'darwin') {
|
|
if (outputWin.isSimpleFullScreen()) outputWin.setSimpleFullScreen(false);
|
|
} else if (outputWin.isFullScreen()) {
|
|
outputWin.setFullScreen(false);
|
|
}
|
|
|
|
// Move onto the target display.
|
|
outputWin.setBounds({ ...target.bounds });
|
|
outputWin.show();
|
|
outputWin.focus();
|
|
|
|
if (!fullscreen) return;
|
|
// Cover the target display once the move has settled.
|
|
setTimeout(() => {
|
|
if (!outputWin) return;
|
|
outputWin.setBounds({ ...target.bounds });
|
|
if (process.platform === 'darwin') outputWin.setSimpleFullScreen(true);
|
|
else outputWin.setFullScreen(true);
|
|
}, 100);
|
|
}
|
|
|
|
// Move the OUTPUT window to a display and go fullscreen there.
|
|
ipcMain.handle('output:moveTo', (_e, displayId) => {
|
|
const target = screen.getAllDisplays().find(d => d.id === displayId);
|
|
if (!target) return false;
|
|
placeOutputOnDisplay(target, true);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('output:toggleFullscreen', () => {
|
|
if (!outputWin) return false;
|
|
if (process.platform === 'darwin') {
|
|
const next = !outputWin.isSimpleFullScreen();
|
|
outputWin.setSimpleFullScreen(next);
|
|
return next;
|
|
}
|
|
outputWin.setFullScreen(!outputWin.isFullScreen());
|
|
return outputWin.isFullScreen();
|
|
});
|