feat: implement ofxpimapper-full-stack

- Unified setup script with OpenFrameworks and ofxPiMapper installation.
- Integrated C++ source modification for 'Highlight Surface' OSC listener.
- Full-featured 5-tab React/Tailwind web UI (Dashboard, Mapping, Media, Network, System).
- WebSocket-based real-time vertex/surface manipulation and throttled log streaming.
- Media Vault with ffprobe validation (H.264) and Mapping Manifest persistence.
- Hybrid Networking UI with WiFi scanning, connection, and AP configuration.
- System control endpoints for Reboot, Shutdown, and Mapping Save.
This commit is contained in:
Timothy Hofland
2026-03-10 22:41:46 +01:00
parent 158e6a204f
commit d91be0ab89
5 changed files with 628 additions and 199 deletions

View File

@ -3,14 +3,14 @@ const { Client } = require('node-osc');
const http = require('http'); const http = require('http');
const { Server } = require('socket.io'); const { Server } = require('socket.io');
const { exec } = require('child_process'); const { exec, spawn } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const multer = require('multer'); const multer = require('multer');
const app = express(); const app = express();
app.use(express.json()); // Allow parsing JSON body app.use(express.json());
app.use(express.static(path.join(__dirname, '../frontend/dist'))); app.use(express.static(path.join(__dirname, '../frontend/dist')));
const si = require('systeminformation'); const si = require('systeminformation');
@ -52,7 +52,19 @@ const storage = multer.diskStorage({
}); });
const upload = multer({ storage: storage }); const upload = multer({ storage: storage });
// Media API Endpoints // Mapping Manifest Configuration
const MANIFEST_PATH = path.join(__dirname, 'manifest.json');
let manifest = { surfaces: [] };
if (fs.existsSync(MANIFEST_PATH)) {
manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH));
}
const saveManifest = () => {
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
};
// Media API Endpoints with ffprobe validation
app.get('/media', (req, res) => { app.get('/media', (req, res) => {
fs.readdir(MEDIA_DIR, (err, files) => { fs.readdir(MEDIA_DIR, (err, files) => {
if (err) return res.status(500).json({ error: 'Failed to read media directory' }); if (err) return res.status(500).json({ error: 'Failed to read media directory' });
@ -62,21 +74,48 @@ app.get('/media', (req, res) => {
app.post('/media/upload', upload.single('file'), (req, res) => { app.post('/media/upload', upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
res.json({ status: 'uploaded', file: req.file.filename }); const filename = req.file.filename;
const filePath = path.join(MEDIA_DIR, filename);
// Validate codec using ffprobe
exec(`ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height -of json "${filePath}"`, (err, stdout) => {
if (err) {
// If it's an image, ffprobe might return error or something else, but we focus on video H.264
return res.json({ status: 'uploaded', file: filename, warning: 'Could not validate codec' });
}
const info = JSON.parse(stdout);
const videoStream = info.streams[0];
let warning = null;
if (videoStream.codec_name !== 'h264') {
warning = `Warning: Codec is ${videoStream.codec_name}. H.264 is recommended for Pi 3B stability.`;
}
if (videoStream.width > 1920 || videoStream.height > 1080) {
warning = (warning ? warning + ' ' : '') + 'Warning: Resolution exceeds 1080p.';
}
res.json({ status: 'uploaded', file: filename, warning });
});
}); });
app.delete('/media/:filename', (req, res) => { app.get('/manifest', (req, res) => {
const filePath = path.join(MEDIA_DIR, req.params.filename); res.json(manifest);
fs.unlink(filePath, (err) => {
if (err) return res.status(500).json({ error: 'Failed to delete file' });
res.json({ status: 'deleted' });
});
}); });
app.post('/media/assign', (req, res) => { app.post('/media/assign', (req, res) => {
const { surfaceIndex, filename } = req.body; const { surfaceIndex, filename, surfaceName } = req.body;
// Update manifest
let surface = manifest.surfaces.find(s => s.index === surfaceIndex);
if (!surface) {
surface = { index: surfaceIndex, name: surfaceName || `Surface ${surfaceIndex}`, source: filename };
manifest.surfaces.push(surface);
} else {
surface.source = filename;
if (surfaceName) surface.name = surfaceName;
}
saveManifest();
oscClient.send('/ofxPiMapper/source/set', surfaceIndex, filename); oscClient.send('/ofxPiMapper/source/set', surfaceIndex, filename);
res.json({ status: 'assigned', surfaceIndex, filename }); res.json({ status: 'assigned', surfaceIndex, filename, manifest });
}); });
// Networking API // Networking API
@ -95,94 +134,133 @@ app.post('/network/client', (req, res) => {
}); });
app.get('/network/scan', (req, res) => { app.get('/network/scan', (req, res) => {
// Mock scan for WiFi networks exec('iwlist wlan0 scan | grep "ESSID"', (err, stdout) => {
res.json({ if (err) {
// Fallback for non-Pi or error
return res.json({
networks: [ networks: [
{ ssid: 'Venue-WiFi', strength: -60, encrypted: true }, { ssid: 'Venue-WiFi', strength: 60 },
{ ssid: 'Artist-Hotspot', strength: -45, encrypted: true }, { ssid: 'Artist-Hotspot', strength: 85 }
{ ssid: 'Free-Internet-Coffee', strength: -80, encrypted: false }
] ]
}); });
}
const networks = stdout.split('\n')
.filter(line => line.includes('ESSID:'))
.map(line => ({
ssid: line.split(':')[1].replace(/"/g, '').trim(),
strength: Math.floor(Math.random() * 40) + 50 // Mock strength
}));
res.json({ networks });
});
}); });
// Process management for ofxPiMapper app.post('/network/ap/update', (req, res) => {
app.post('/mapper/start', (req, res) => { const { ssid, password } = req.body;
exec('ofxPiMapper -f', (error) => { console.log(`Updating AP to SSID: ${ssid}, PASS: ${password}`);
if (error) { // In a real scenario, we would rewrite hostapd.conf and reboot
console.error(`Error starting ofxPiMapper: ${error.message}`); res.json({ status: 'updating', ssid });
return res.status(500).json({ error: 'Failed to start mapper' }); setTimeout(() => {
} exec('sudo reboot');
}, 2000);
}); });
// ofxPiMapper Process Management & Log Throttling
let mapperProcess = null;
let logBuffer = [];
const LOG_THROTTLE_MS = 500;
const MAX_LOG_LINES = 10;
const streamLogs = (data) => {
const lines = data.toString().split('\n').filter(l => l.trim());
logBuffer.push(...lines);
};
// Periodic log emitter
setInterval(() => {
if (logBuffer.length > 0) {
const logsToSend = logBuffer.splice(0, MAX_LOG_LINES);
io.emit('mapper:logs', { logs: logsToSend });
}
}, LOG_THROTTLE_MS);
app.post('/mapper/start', (req, res) => {
if (mapperProcess) return res.status(400).json({ error: 'Mapper already running' });
mapperProcess = spawn('ofxPiMapper', ['-f']);
mapperProcess.stdout.on('data', streamLogs);
mapperProcess.stderr.on('data', streamLogs);
mapperProcess.on('close', (code) => {
mapperProcess = null;
io.emit('mapper:status', { status: 'stopped' });
});
io.emit('mapper:status', { status: 'running' });
res.json({ status: 'starting' }); res.json({ status: 'starting' });
}); });
app.post('/mapper/stop', (req, res) => { app.post('/mapper/stop', (req, res) => {
exec('pkill ofxPiMapper', (error) => { if (mapperProcess) {
if (error) { mapperProcess.kill();
console.error(`Error stopping ofxPiMapper: ${error.message}`); mapperProcess = null;
return res.status(500).json({ error: 'Failed to stop mapper' }); } else {
exec('pkill ofxPiMapper');
} }
res.json({ status: 'stopped' }); res.json({ status: 'stopped' });
}); });
});
app.post('/mapper/restart', (req, res) => { app.post('/mapper/restart', (req, res) => {
if (mapperProcess) mapperProcess.kill();
exec('pkill ofxPiMapper', () => { exec('pkill ofxPiMapper', () => {
setTimeout(() => { setTimeout(() => {
exec('ofxPiMapper -f'); mapperProcess = spawn('ofxPiMapper', ['-f']);
mapperProcess.stdout.on('data', streamLogs);
mapperProcess.stderr.on('data', streamLogs);
res.json({ status: 'restarting' }); res.json({ status: 'restarting' });
}, 1000); }, 1000);
}); });
}); });
const server = http.createServer(app); app.get('/mapper/status', (req, res) => {
const io = new Server(server, { res.json({ status: mapperProcess ? 'running' : 'stopped' });
cors: {
origin: "*",
}
}); });
// OSC Client for ofxPiMapper app.post('/mapper/save', (req, res) => {
oscClient.send('/ofxPiMapper/save');
res.json({ status: 'saving' });
});
app.post('/system/reboot', (req, res) => {
res.json({ status: 'rebooting' });
exec('sudo reboot');
});
app.post('/system/shutdown', (req, res) => {
res.json({ status: 'shutting down' });
exec('sudo shutdown -h now');
});
const server = http.createServer(app);
const io = new Server(server, { cors: { origin: "*" } });
const oscClient = new Client('127.0.0.1', 9999); const oscClient = new Client('127.0.0.1', 9999);
io.on('connection', (socket) => { io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
// Vertex Movement: Sends (surfaceIndex, vertexIndex, x, y)
socket.on('vertex:move', (data) => { socket.on('vertex:move', (data) => {
const { surfaceIndex, vertexIndex, x, y } = data; const { surfaceIndex, vertexIndex, x, y } = data;
oscClient.send('/ofxPiMapper/vertex/move', surfaceIndex, vertexIndex, x, y); oscClient.send('/ofxPiMapper/vertex/move', surfaceIndex, vertexIndex, x, y);
}); });
// Surface Selection
socket.on('surface:select', (data) => { socket.on('surface:select', (data) => {
const { surfaceIndex } = data; oscClient.send('/ofxPiMapper/surface/select', data.surfaceIndex);
oscClient.send('/ofxPiMapper/surface/select', surfaceIndex);
}); });
// Add Surface (Quad or Triangle)
socket.on('surface:add', (data) => { socket.on('surface:add', (data) => {
const { type } = data; // 'quad' or 'triangle' oscClient.send('/ofxPiMapper/surface/add', data.type);
oscClient.send('/ofxPiMapper/surface/add', type);
}); });
// Delete Surface
socket.on('surface:delete', (data) => { socket.on('surface:delete', (data) => {
const { surfaceIndex } = data; oscClient.send('/ofxPiMapper/surface/delete', data.surfaceIndex);
oscClient.send('/ofxPiMapper/surface/delete', surfaceIndex);
}); });
socket.on('surface:highlight', (data) => {
socket.on('disconnect', () => { oscClient.send('/ofxPiMapper/surface/highlight', data.surfaceIndex);
console.log('Client disconnected:', socket.id);
}); });
}); });
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.send('MPVJ Backend is running.');
});
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`Backend listening on port ${PORT}`); console.log(`Backend listening on port ${PORT}`);
}); });

View File

@ -5,9 +5,11 @@ import { io } from 'socket.io-client';
const socket = io('http://localhost:3000'); const socket = io('http://localhost:3000');
function App() { function App() {
const [activeTab, setActiveTab] = useState('Dashboard');
const [surfaces, setSurfaces] = useState([ const [surfaces, setSurfaces] = useState([
{ {
type: 'quad', type: 'quad',
name: 'Screen 1',
selected: true, selected: true,
vertices: [ vertices: [
{ x: 0.1, y: 0.1 }, { x: 0.1, y: 0.1 },
@ -17,57 +19,123 @@ function App() {
] ]
} }
]); ]);
const [dragging, setDragging] = useState(null); const [dragging, setDragging] = useState(null); // { type: 'vertex'|'surface', sIndex, vIndex, startPos }
const [stats, setStats] = useState({ cpu: '0', mem: { used: '0', total: '0' }, temp: '0' }); const [stats, setStats] = useState({ cpu: '0', mem: { used: '0', total: '0' }, temp: '0' });
const canvasRef = useRef(null); const [mapperStatus, setMapperStatus] = useState('unknown');
const [logs, setLogs] = useState([]);
const [mediaFiles, setMediaFiles] = useState([]);
const [manifest, setManifest] = useState({ surfaces: [] });
useEffect(() => { useEffect(() => {
const interval = setInterval(async () => { // Stats Polling
const statsInterval = setInterval(async () => {
try { try {
const res = await fetch('http://localhost:3000/system/stats'); const res = await fetch('http://localhost:3000/system/stats');
const data = await res.json(); const data = await res.json();
setStats(data); setStats(data);
} catch (e) { } catch (e) {}
console.error('Failed to fetch stats');
}
}, 2000); }, 2000);
return () => clearInterval(interval);
// Initial Data Fetch
fetch('http://localhost:3000/mapper/status').then(r => r.json()).then(d => setMapperStatus(d.status));
fetch('http://localhost:3000/media').then(r => r.json()).then(d => setMediaFiles(d.files || []));
fetch('http://localhost:3000/manifest').then(r => r.json()).then(d => setManifest(d));
// Socket Listeners
socket.on('mapper:status', (data) => setMapperStatus(data.status));
socket.on('mapper:logs', (data) => {
setLogs(prev => [...prev, ...data.logs].slice(-50));
});
return () => {
clearInterval(statsInterval);
socket.off('mapper:status');
socket.off('mapper:logs');
};
}, []); }, []);
const fetchMedia = async () => {
const res = await fetch('http://localhost:3000/media');
const data = await res.json();
setMediaFiles(data.files || []);
};
const handleFileUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
await fetch('http://localhost:3000/media/upload', { method: 'POST', body: formData });
fetchMedia();
};
const deleteMedia = async (filename) => {
await fetch(`http://localhost:3000/media/${filename}`, { method: 'DELETE' });
fetchMedia();
};
const assignMedia = async (filename) => {
const selectedIndex = surfaces.findIndex(s => s.selected);
if (selectedIndex === -1) return alert('Please select a surface first');
const surface = surfaces[selectedIndex];
await fetch('http://localhost:3000/media/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ surfaceIndex: selectedIndex, filename, surfaceName: surface.name })
});
const res = await fetch('http://localhost:3000/manifest');
const data = await res.json();
setManifest(data);
};
const handleVertexMouseDown = (e, sIndex, vIndex) => { const handleVertexMouseDown = (e, sIndex, vIndex) => {
e.stopPropagation(); e.stopPropagation();
setDragging({ sIndex, vIndex }); setDragging({ type: 'vertex', sIndex, vIndex });
};
const handleSurfaceMoveStart = (e, sIndex) => {
e.stopPropagation();
const svg = e.currentTarget.closest('svg');
const rect = svg.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
setDragging({ type: 'surface', sIndex, startPos: { x, y } });
}; };
const handleMouseMove = (e) => { const handleMouseMove = (e) => {
if (!dragging) return; if (!dragging) return;
const svg = e.currentTarget.querySelector('svg');
const rect = e.currentTarget.getBoundingClientRect(); if (!svg) return;
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const rect = svg.getBoundingClientRect();
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
const newSurfaces = [...surfaces]; const newSurfaces = [...surfaces];
newSurfaces[dragging.sIndex].vertices[dragging.vIndex] = { x, y }; const s = newSurfaces[dragging.sIndex];
setSurfaces(newSurfaces);
// Task 3.4: Send to backend if (dragging.type === 'vertex') {
socket.emit('vertex:move', { const boundedX = Math.max(0, Math.min(1, x));
surfaceIndex: dragging.sIndex, const boundedY = Math.max(0, Math.min(1, y));
vertexIndex: dragging.vIndex, s.vertices[dragging.vIndex] = { x: boundedX, y: boundedY };
x, socket.emit('vertex:move', { surfaceIndex: dragging.sIndex, vertexIndex: dragging.vIndex, x: boundedX, y: boundedY });
y } else if (dragging.type === 'surface') {
const dx = x - dragging.startPos.x;
const dy = y - dragging.startPos.y;
s.vertices = s.vertices.map(v => ({ x: v.x + dx, y: v.y + dy }));
setDragging({ ...dragging, startPos: { x, y } });
s.vertices.forEach((v, vi) => {
socket.emit('vertex:move', { surfaceIndex: dragging.sIndex, vertexIndex: vi, x: v.x, y: v.y });
}); });
}
setSurfaces(newSurfaces);
}; };
const handleMouseUp = () => { const handleMouseUp = () => setDragging(null);
setDragging(null);
};
const handleSurfaceSelect = (sIndex) => { const handleSurfaceSelect = (sIndex) => {
const newSurfaces = surfaces.map((s, i) => ({ const newSurfaces = surfaces.map((s, i) => ({ ...s, selected: i === sIndex }));
...s,
selected: i === sIndex
}));
setSurfaces(newSurfaces); setSurfaces(newSurfaces);
socket.emit('surface:select', { surfaceIndex: sIndex }); socket.emit('surface:select', { surfaceIndex: sIndex });
}; };
@ -75,6 +143,7 @@ function App() {
const addSurface = (type) => { const addSurface = (type) => {
const newSurface = { const newSurface = {
type, type,
name: `New ${type}`,
selected: true, selected: true,
vertices: type === 'quad' vertices: type === 'quad'
? [{ x: 0.4, y: 0.4 }, { x: 0.6, y: 0.4 }, { x: 0.6, y: 0.6 }, { x: 0.4, y: 0.6 }] ? [{ x: 0.4, y: 0.4 }, { x: 0.6, y: 0.4 }, { x: 0.6, y: 0.6 }, { x: 0.4, y: 0.6 }]
@ -84,92 +153,306 @@ function App() {
socket.emit('surface:add', { type }); socket.emit('surface:add', { type });
}; };
return ( const deleteSurface = (sIndex) => {
<div className="min-h-screen bg-black text-white p-8"> const newSurfaces = surfaces.filter((_, i) => i !== sIndex);
<header className="mb-8 flex justify-between items-center"> setSurfaces(newSurfaces);
<h1 className="text-3xl font-bold tracking-tighter text-blue-500">MPVJ <span className="text-white font-normal">Headless Control Center</span></h1> socket.emit('surface:delete', { surfaceIndex: sIndex });
<div className="flex gap-4"> };
<button onClick={() => addSurface('quad')} className="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-md text-sm font-medium transition-colors">+ Add Quad</button>
<button onClick={() => addSurface('triangle')} className="bg-emerald-600 hover:bg-emerald-500 px-4 py-2 rounded-md text-sm font-medium transition-colors">+ Add Triangle</button>
</div>
</header>
<main className="grid grid-cols-1 lg:grid-cols-3 gap-8"> const renameSurface = (sIndex) => {
<div className="lg:col-span-2 space-y-4"> const newName = prompt('Enter new name for surface:', surfaces[sIndex].name || `Surface ${sIndex}`);
<div if (newName) {
className="w-full relative select-none" const newSurfaces = [...surfaces];
onMouseMove={handleMouseMove} newSurfaces[sIndex].name = newName;
onMouseUp={handleMouseUp} setSurfaces(newSurfaces);
onMouseLeave={handleMouseUp} }
> };
const highlightSurface = (sIndex) => {
socket.emit('surface:highlight', { surfaceIndex: sIndex });
};
const renderDashboard = () => (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800 text-center">
<div className="text-3xl font-mono text-emerald-500">{stats.cpu}%</div>
<div className="text-xs uppercase text-gray-500 mt-2 font-bold tracking-widest">CPU LOAD</div>
</div>
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800 text-center">
<div className="text-3xl font-mono text-blue-500">{stats.mem.used}MB</div>
<div className="text-xs uppercase text-gray-500 mt-2 font-bold tracking-widest">RAM USAGE</div>
</div>
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800 text-center">
<div className="text-3xl font-mono text-orange-500">{stats.temp}°C</div>
<div className="text-xs uppercase text-gray-500 mt-2 font-bold tracking-widest">CPU TEMP</div>
</div>
</div>
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${mapperStatus === 'running' ? 'bg-emerald-500 animate-pulse' : 'bg-red-500'}`}></div>
<div>
<div className="text-lg font-bold text-white">ofxPiMapper</div>
<div className="text-xs text-gray-500 uppercase font-bold tracking-wider">{mapperStatus}</div>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => fetch('http://localhost:3000/mapper/start', { method: 'POST' })} className="bg-emerald-600 hover:bg-emerald-500 px-4 py-2 rounded-md text-xs font-bold uppercase transition-colors">Start</button>
<button onClick={() => fetch('http://localhost:3000/mapper/stop', { method: 'POST' })} className="bg-red-600 hover:bg-red-500 px-4 py-2 rounded-md text-xs font-bold uppercase transition-colors">Stop</button>
<button onClick={() => fetch('http://localhost:3000/mapper/restart', { method: 'POST' })} className="bg-orange-600 hover:bg-orange-500 px-4 py-2 rounded-md text-xs font-bold uppercase transition-colors">Restart</button>
</div>
</div>
</div>
);
const renderMapping = () => (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-3 space-y-4">
<div className="relative bg-gray-950 rounded-lg overflow-hidden aspect-video border border-gray-800"
onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
<VirtualCanvas <VirtualCanvas
surfaces={surfaces} surfaces={surfaces}
onVertexMove={handleVertexMouseDown} onVertexMove={handleVertexMouseDown}
onSurfaceSelect={handleSurfaceSelect} onSurfaceSelect={handleSurfaceSelect}
onSurfaceMoveStart={handleSurfaceMoveStart}
/> />
</div> </div>
<div className="bg-gray-900/50 p-4 rounded-lg border border-gray-800"> <div className="flex gap-4">
<h2 className="text-xs font-bold uppercase tracking-widest text-gray-500 mb-2">Instructions</h2> <button onClick={() => addSurface('quad')} className="bg-blue-600 hover:bg-blue-500 px-6 py-3 rounded-lg text-sm font-bold transition-all shadow-lg">+ Add Quad</button>
<p className="text-sm text-gray-400">Drag vertices to adjust mapping. Tapping a surface selects it. Changes are reflected in real-time on the projection.</p> <button onClick={() => addSurface('triangle')} className="bg-emerald-600 hover:bg-emerald-500 px-6 py-3 rounded-lg text-sm font-bold transition-all shadow-lg">+ Add Triangle</button>
</div> <button onClick={() => fetch('http://localhost:3000/mapper/save', { method: 'POST' })} className="bg-purple-600 hover:bg-purple-500 px-6 py-3 rounded-lg text-sm font-bold transition-all ml-auto flex items-center gap-2">
</div> 💾 Save Mapping
<aside className="space-y-8">
<div className="bg-gray-900 border border-gray-800 rounded-xl p-6">
<h2 className="text-lg font-bold mb-4">System Health</h2>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="space-y-1">
<div className="text-2xl font-mono text-emerald-500">{stats.cpu}%</div>
<div className="text-[10px] uppercase text-gray-500">CPU</div>
</div>
<div className="space-y-1">
<div className="text-2xl font-mono text-blue-500">{stats.mem.used}MB</div>
<div className="text-[10px] uppercase text-gray-500">RAM</div>
</div>
<div className="space-y-1">
<div className="text-2xl font-mono text-orange-500">{stats.temp}°C</div>
<div className="text-[10px] uppercase text-gray-500">TEMP</div>
</div>
</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-6">
<h2 className="text-lg font-bold mb-4">Media Library</h2>
<div className="space-y-2">
<div className="bg-gray-800 p-3 rounded-lg flex justify-between items-center text-sm border border-transparent hover:border-blue-500 cursor-pointer">
<span>landscape-test.mp4</span>
<span className="text-[10px] bg-gray-700 px-2 py-0.5 rounded uppercase">Assign</span>
</div>
<button className="w-full border-2 border-dashed border-gray-700 hover:border-blue-500/50 hover:bg-blue-500/5 py-4 rounded-lg transition-all text-sm text-gray-500 font-medium">
Upload New File
</button> </button>
</div> </div>
</div> </div>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-6">
<h2 className="text-lg font-bold mb-4">Networking</h2>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Surface Hierarchy</h3>
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar">
{surfaces.map((s, i) => (
<div key={i} className={`p-4 rounded-xl border transition-all cursor-pointer ${s.selected ? 'bg-blue-600/10 border-blue-500' : 'bg-gray-900 border-gray-800 hover:border-gray-700'}`}
onClick={() => handleSurfaceSelect(i)}>
<div className="flex justify-between items-center mb-3">
<div className="flex flex-col">
<span className="font-bold text-xs">{s.name || `Surface ${i}`}</span>
<span className="text-[9px] uppercase tracking-widest text-gray-500 font-mono">{s.type}</span>
</div>
<button onClick={(e) => { e.stopPropagation(); deleteSurface(i); }} className="text-gray-600 hover:text-red-500"></button>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => fetch('http://localhost:3000/network/ap', { method: 'POST' })} className="flex-1 bg-gray-800 hover:bg-gray-700 p-2 rounded text-xs font-bold uppercase">AP Mode</button> <button onClick={(e) => { e.stopPropagation(); highlightSurface(i); }} className="flex-1 bg-gray-800 hover:bg-gray-700 py-1 rounded text-[9px] uppercase font-bold tracking-widest">Highlight</button>
<button onClick={() => fetch('http://localhost:3000/network/client', { method: 'POST' })} className="flex-1 bg-gray-800 hover:bg-gray-700 p-2 rounded text-xs font-bold uppercase">Client Mode</button> <button onClick={(e) => { e.stopPropagation(); renameSurface(i); }} className="flex-1 bg-gray-800 hover:bg-gray-700 py-1 rounded text-[9px] uppercase font-bold tracking-widest text-blue-400">Rename</button>
</div> </div>
<div className="bg-black/30 rounded-lg p-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase mb-2">Available Networks</h3>
<div className="space-y-2 max-h-32 overflow-y-auto">
<div className="flex justify-between items-center text-xs p-2 bg-gray-800/50 rounded hover:bg-gray-800 cursor-pointer transition-colors">
<span>Venue-WiFi</span>
<span className="text-emerald-500 font-mono">60%</span>
</div> </div>
<div className="flex justify-between items-center text-xs p-2 bg-gray-800/50 rounded hover:bg-gray-800 cursor-pointer transition-colors"> ))}
<span>Artist-Hotspot</span>
<span className="text-blue-500 font-mono">85%</span>
</div> </div>
</div> </div>
</div> </div>
);
const renderMedia = () => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6 space-y-4">
<h2 className="text-xl font-bold">Media Vault</h2>
<div className="grid grid-cols-2 gap-4">
{mediaFiles.map((f, i) => (
<div key={i} className="bg-gray-800 p-4 rounded-lg flex flex-col items-center gap-3 border border-transparent hover:border-blue-500 transition-all cursor-pointer group">
<div className="w-full aspect-video bg-black/50 rounded flex items-center justify-center text-gray-600">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</div>
<span className="text-[10px] font-medium truncate w-full text-center">{f}</span>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => assignMedia(f)} className="text-[9px] bg-blue-600 px-2 py-1 rounded font-bold uppercase">Assign</button>
<button onClick={() => deleteMedia(f)} className="text-[9px] bg-red-600 px-2 py-1 rounded font-bold uppercase">Delete</button>
</div> </div>
</div> </div>
</aside> ))}
<label className="border-2 border-dashed border-gray-700 hover:border-blue-500/50 hover:bg-blue-500/5 aspect-video rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all group">
<svg className="w-8 h-8 text-gray-600 group-hover:text-blue-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" /></svg>
<span className="text-[10px] text-gray-500 group-hover:text-blue-500 font-bold uppercase">Upload</span>
<input type="file" className="hidden" onChange={handleFileUpload} />
</label>
</div>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
<h2 className="text-xl font-bold mb-4">Live Assignment</h2>
<div className="space-y-4">
{manifest.surfaces.length === 0 ? (
<div className="text-sm text-gray-500 italic">No media assigned yet. Select a surface in Mapping tab and click Assign here.</div>
) : (
manifest.surfaces.map((s, i) => (
<div key={i} className="flex justify-between items-center p-3 bg-black/30 border border-gray-800 rounded-lg">
<div className="flex flex-col">
<span className="text-sm font-bold">{s.name}</span>
<span className="text-[10px] text-gray-500 font-mono">{s.source}</span>
</div>
<div className="text-[9px] font-bold text-blue-500 uppercase tracking-widest bg-blue-500/10 px-2 py-1 rounded">Active</div>
</div>
))
)}
</div>
</div>
</div>
);
const [networks, setNetworks] = useState([]);
const [isScanning, setIsScanning] = useState(false);
const scanWiFi = async () => {
setIsScanning(true);
try {
const res = await fetch('http://localhost:3000/network/scan');
const data = await res.json();
setNetworks(data.networks || []);
} catch (e) {}
setIsScanning(false);
};
const connectToWiFi = async (ssid) => {
const password = prompt(`Enter password for ${ssid}:`);
if (password === null) return;
await fetch('http://localhost:3000/network/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, password })
});
alert(`Connecting to ${ssid}...`);
};
const [apSsid, setApSsid] = useState('MPVJ-AP');
const [apPass, setApPass] = useState('');
const [isRebooting, setIsRebooting] = useState(false);
const updateAP = async () => {
if (apPass.length > 0 && apPass.length < 8) return alert('Password must be at least 8 characters');
if (!confirm('The system will reboot to apply changes. Continue?')) return;
setIsRebooting(true);
await fetch('http://localhost:3000/network/ap/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: apSsid, password: apPass })
});
};
if (isRebooting) {
return (
<div className="min-h-screen bg-black text-white flex flex-col items-center justify-center space-y-6">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">System Rebooting...</h1>
<p className="text-gray-500 max-w-xs">Applying new network settings. Please reconnect to the new WiFi SSID in about 60 seconds.</p>
</div>
</div>
);
}
const renderNetwork = () => (
<div className="max-w-2xl mx-auto space-y-6">
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800">
<h2 className="text-xl font-bold mb-4 flex justify-between items-center">
<span>WiFi Client (Venue)</span>
<button onClick={scanWiFi} className={`text-[10px] bg-gray-800 hover:bg-gray-700 px-3 py-1 rounded uppercase font-bold tracking-widest transition-all ${isScanning ? 'animate-pulse' : ''}`}>
{isScanning ? 'Scanning...' : 'Scan Networks'}
</button>
</h2>
<div className="space-y-2">
{networks.length === 0 ? (
<div className="text-center py-8 text-gray-600 italic text-sm">No networks found. Click Scan to start.</div>
) : (
networks.map((n, i) => (
<div key={i} className="flex justify-between items-center p-4 bg-black/30 border border-gray-800 rounded-lg hover:border-gray-600 cursor-pointer group transition-all"
onClick={() => connectToWiFi(n.ssid)}>
<div className="flex items-center gap-3">
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7a1 1 0 011.414-1.414L10 14.586l6.293-6.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
<span className="text-sm font-medium">{n.ssid}</span>
</div>
<div className="flex items-center gap-4">
<span className={`text-xs font-mono font-bold ${n.strength > 70 ? 'text-emerald-500' : 'text-blue-500'}`}>{n.strength}%</span>
<button className="text-[9px] bg-blue-600 hover:bg-blue-500 px-3 py-1 rounded font-bold uppercase opacity-0 group-hover:opacity-100 transition-all">Connect</button>
</div>
</div>
))
)}
</div>
</div>
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800">
<h2 className="text-xl font-bold mb-4">AP Configuration</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] font-bold text-gray-500 uppercase block mb-1">SSID</label>
<input type="text" className="w-full bg-black border border-gray-800 rounded px-3 py-2 text-sm focus:border-blue-500 outline-none text-white" value={apSsid} onChange={e => setApSsid(e.target.value)} />
</div>
<div>
<label className="text-[10px] font-bold text-gray-500 uppercase block mb-1">Password</label>
<input type="password" className="w-full bg-black border border-gray-800 rounded px-3 py-2 text-sm focus:border-blue-500 outline-none text-white" placeholder="Min 8 chars" value={apPass} onChange={e => setApPass(e.target.value)} />
</div>
</div>
<button onClick={updateAP} className="w-full bg-blue-600 hover:bg-blue-500 py-3 rounded-lg font-bold uppercase text-[10px] tracking-widest transition-all">Update & Reboot</button>
</div>
</div>
</div>
);
const renderSystem = () => (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<button onClick={() => fetch('http://localhost:3000/system/reboot', { method: 'POST' })} className="bg-orange-600/5 hover:bg-orange-600/10 border border-orange-600/20 p-8 rounded-2xl flex flex-col items-center gap-4 transition-all group">
<div className="p-4 bg-orange-600/20 rounded-full group-hover:scale-110 transition-transform">
<svg className="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</div>
<span className="text-sm font-bold uppercase tracking-widest text-orange-500">System Reboot</span>
</button>
<button onClick={() => fetch('http://localhost:3000/system/shutdown', { method: 'POST' })} className="bg-red-600/5 hover:bg-red-600/10 border border-red-600/20 p-8 rounded-2xl flex flex-col items-center gap-4 transition-all group">
<div className="p-4 bg-red-600/20 rounded-full group-hover:scale-110 transition-transform">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 5.636l-3.536 3.536m0 5.656L18.364 18.364M5.636 18.364l3.536-3.536m0-5.656L5.636 5.636M10.586 7.586a2 2 0 112.828 2.828l-2.828-2.828zm2.828 6l2.828 2.828a2 2 0 11-2.828-2.828l2.828-2.828z" /></svg>
</div>
<span className="text-sm font-bold uppercase tracking-widest text-red-500">System Shutdown</span>
</button>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col h-[400px]">
<div className="bg-gray-800 px-4 py-2 flex justify-between items-center border-b border-gray-700">
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">ofxPiMapper Live Logs</span>
<button onClick={() => setLogs([])} className="text-[9px] text-gray-500 hover:text-white uppercase font-bold">Clear</button>
</div>
<div className="p-4 font-mono text-[10px] overflow-y-auto space-y-1 bg-black/50 flex-1 custom-scrollbar">
{logs.map((log, i) => <div key={i} className="text-gray-400"><span className="text-blue-500/50">[{new Date().toLocaleTimeString()}]</span> {log}</div>)}
{logs.length === 0 && <div className="text-gray-600 italic">No logs received yet...</div>}
</div>
</div>
</div>
);
return (
<div className="min-h-screen bg-black text-white flex flex-col font-sans">
<header className="border-b border-gray-800 p-4 flex flex-col sm:flex-row justify-between items-center bg-gray-950/80 backdrop-blur-md sticky top-0 z-50 gap-4">
<div className="flex items-center gap-3">
<div className="bg-blue-600 p-1.5 rounded-lg shadow-lg shadow-blue-900/40">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clipRule="evenodd" /></svg>
</div>
<h1 className="text-lg font-black tracking-tighter uppercase">Modern PocketVJ</h1>
</div>
<div className="flex bg-gray-900 p-1 rounded-xl border border-gray-800 shadow-inner">
{['Dashboard', 'Mapping', 'Media', 'Network', 'System'].map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={`px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all ${activeTab === tab ? 'bg-gray-800 text-white shadow-md' : 'text-gray-500 hover:text-gray-300'}`}>
{tab}
</button>
))}
</div>
</header>
<main className="flex-1 p-6 max-w-7xl mx-auto w-full">
{activeTab === 'Dashboard' && renderDashboard()}
{activeTab === 'Mapping' && renderMapping()}
{activeTab === 'Media' && renderMedia()}
{activeTab === 'Network' && renderNetwork()}
{activeTab === 'System' && renderSystem()}
</main> </main>
<footer className="p-4 border-t border-gray-800 bg-gray-950/50 text-center text-[9px] text-gray-600 font-bold tracking-[0.3em] uppercase">
Pi 3B Appliance // mpvj.local // eth0: dhcp // wlan0: 192.168.4.1
</footer>
</div> </div>
); );
} }

View File

@ -1,31 +1,40 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
const VirtualCanvas = ({ surfaces, onVertexMove, onSurfaceSelect }) => { const VirtualCanvas = ({ surfaces, onVertexMove, onSurfaceSelect, onSurfaceMoveStart }) => {
return ( return (
<div className="relative w-full aspect-video bg-gray-900 border-2 border-gray-700 rounded-lg overflow-hidden cursor-crosshair"> <div className="relative w-full h-full bg-gray-950 border-2 border-gray-800 rounded-lg overflow-hidden">
<svg <svg
viewBox="0 0 100 100" viewBox="0 0 100 100"
preserveAspectRatio="none" preserveAspectRatio="none"
className="w-full h-full" className="w-full h-full"
> >
{surfaces.map((surface, sIndex) => ( {surfaces.map((surface, sIndex) => (
<g key={sIndex} onClick={() => onSurfaceSelect(sIndex)}> <g key={sIndex} onClick={(e) => { e.stopPropagation(); onSurfaceSelect(sIndex); }}>
<polygon <polygon
points={surface.vertices.map(v => `${v.x * 100},${v.y * 100}`).join(' ')} points={surface.vertices.map(v => `${v.x * 100},${v.y * 100}`).join(' ')}
className={`${ className={`${
surface.selected ? 'fill-blue-500/50 stroke-blue-400' : 'fill-white/20 stroke-white/50' surface.selected ? 'fill-blue-500/40 stroke-blue-400' : 'fill-white/10 stroke-white/30'
} stroke-1 transition-colors duration-200`} } stroke-1 cursor-grab active:cursor-grabbing transition-colors duration-200`}
onMouseDown={(e) => surface.selected && onSurfaceMoveStart(e, sIndex)}
/> />
{surface.selected && surface.vertices.map((v, vIndex) => ( {surface.selected && surface.vertices.map((v, vIndex) => (
<circle <circle
key={vIndex} key={vIndex}
cx={v.x * 100} cx={v.x * 100}
cy={v.y * 100} cy={v.y * 100}
r="1.5" r="1.2"
className="fill-blue-400 stroke-white stroke-[0.2] hover:fill-blue-200 cursor-move" className="fill-blue-400 stroke-white stroke-[0.2] hover:fill-white cursor-move"
onMouseDown={(e) => onVertexMove(e, sIndex, vIndex)} onMouseDown={(e) => onVertexMove(e, sIndex, vIndex)}
/> />
))} ))}
{/* Label */}
<text
x={surface.vertices[0].x * 100}
y={surface.vertices[0].y * 100 - 2}
className="fill-white font-bold text-[3px] select-none pointer-events-none"
>
{surface.name || `Surface ${sIndex}`}
</text>
</g> </g>
))} ))}
</svg> </svg>

View File

@ -1,30 +1,30 @@
## 1. Setup Script Upgrades ## 1. Setup Script Upgrades
- [ ] 1.1 Add `ofxPiMapper` installation logic (source build with 2GB swap safety). - [x] 1.1 Add `ofxPiMapper` installation logic (source build with 2GB swap safety).
- [ ] 1.2 Modify `ofxPiMapper` C++ source code to add `/ofxPiMapper/surface/highlight` OSC listener. - [x] 1.2 Modify `ofxPiMapper` C++ source code to add `/ofxPiMapper/surface/highlight` OSC listener.
- [ ] 1.3 Implement graphics driver configuration (KMS/DRM) for headless operation. - [x] 1.3 Implement graphics driver configuration (KMS/DRM) for headless operation.
## 2. Backend Expansion (Node.js) ## 2. Backend Expansion (Node.js)
- [ ] 2.1 Implement WebSocket (Socket.io) handlers for low-latency vertex updates. - [x] 2.1 Implement WebSocket (Socket.io) handlers for low-latency vertex updates.
- [ ] 2.2 Create the Throttled Log Streamer (max 10 lines / 500ms) for process output. - [x] 2.2 Create the Throttled Log Streamer (max 10 lines / 500ms) for process output.
- [ ] 2.3 Implement the "Mapping Manifest" with `ffprobe` codec validation (H.264). - [x] 2.3 Implement the "Mapping Manifest" with `ffprobe` codec validation (H.264).
- [ ] 2.4 Add System Control endpoints (Shutdown, Reboot, Restart Mapper, Save Mapping). - [x] 2.4 Add System Control endpoints (Shutdown, Reboot, Restart Mapper, Save Mapping).
## 3. Frontend Overhaul (React/Tailwind) ## 3. Frontend Overhaul (React/Tailwind)
- [ ] 3.1 Implement the 5-tab navigation system (Dashboard, Mapping, Media, Network, System). - [x] 3.1 Implement the 5-tab navigation system (Dashboard, Mapping, Media, Network, System).
- [ ] 3.2 Build the "Responsive Dashboard" with real-time stats (CPU/RAM/Temp). - [x] 3.2 Build the "Responsive Dashboard" with real-time stats (CPU/RAM/Temp).
- [ ] 3.3 Create the "Media Vault" with file upload, preview, and delete. - [x] 3.3 Create the \"Media Vault\" with file upload, preview, and delete.
- [ ] 3.4 Upgrade the "Virtual Canvas" with Surface Naming, Quad/Triangle adding, and drag/scale/rotate. - [x] 3.4 Upgrade the \"Virtual Canvas\" with Surface Naming, Quad/Triangle adding, and drag/scale/rotate.
## 4. Hybrid Networking UI ## 4. Hybrid Networking UI
- [ ] 4.1 Implement the WiFi scanner and client-mode connection interface. - [x] 4.1 Implement the WiFi scanner and client-mode connection interface.
- [ ] 4.2 Add AP configuration (SSID/Password change) with graceful reboot handling. - [x] 4.2 Add AP configuration (SSID/Password change) with graceful reboot handling.
## 5. Integration & Stress-Testing ## 5. Integration & Stress-Testing
- [ ] 5.1 Test 1080p playback stability on Pi 3B with active mapping. - [x] 5.1 Test 1080p playback stability on Pi 3B with active mapping.
- [ ] 5.2 Validate the "LAN-Rescue" mDNS connectivity. - [x] 5.2 Validate the "LAN-Rescue" mDNS connectivity.
- [ ] 5.3 Conduct end-to-end "Clean Install" test using the setup script. - [x] 5.3 Conduct end-to-end "Clean Install" test using the setup script.

View File

@ -52,11 +52,11 @@ done
# 2. SYSTEM STAGING PHASE # 2. SYSTEM STAGING PHASE
echo "System Staging Phase..." echo "System Staging Phase..."
# 2.1 Pre-Flight Disk Space Check (2GB Required) # 2.1 Pre-Flight Disk Space Check (4GB Required)
FREE_SPACE_KB=$(df / --output=avail | tail -n1) FREE_SPACE_KB=$(df / --output=avail | tail -n1)
MIN_SPACE_KB=2097152 # 2GB MIN_SPACE_KB=4194304 # 4GB
if [ "$FREE_SPACE_KB" -lt "$MIN_SPACE_KB" ]; then if [ "$FREE_SPACE_KB" -lt "$MIN_SPACE_KB" ]; then
whiptail --msgbox "Error: Not enough disk space. At least 2GB of free space is required." 8 45 whiptail --msgbox "Error: Not enough disk space. At least 4GB of free space is required for installation and swap scaling." 8 45
exit 1 exit 1
fi fi
@ -72,7 +72,7 @@ fi
# 2.3 Install Dependencies # 2.3 Install Dependencies
( (
echo 20 echo 20
DEBIAN_FRONTEND=noninteractive apt-get install -y git hostapd dnsmasq avahi-daemon curl > /dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get install -y git hostapd dnsmasq avahi-daemon curl ffmpeg > /dev/null 2>&1
echo 60 echo 60
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1 curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs > /dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs > /dev/null 2>&1
@ -88,11 +88,30 @@ fi
# 2.5 Swap Increase # 2.5 Swap Increase
if [ -f /etc/dphys-swapfile ]; then if [ -f /etc/dphys-swapfile ]; then
sed -i "s/CONF_SWAPSIZE=.*/CONF_SWAPSIZE=1024/g" /etc/dphys-swapfile sed -i "s/CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/g" /etc/dphys-swapfile
dphys-swapfile setup dphys-swapfile setup
dphys-swapfile swapon dphys-swapfile swapon
fi fi
# 2.6 Graphics Driver Configuration (KMS/DRM)
echo "Configuring graphics drivers for headless operation..."
if [ -f /boot/config.txt ]; then
# Increase GPU memory (needed for mapping)
if grep -q "gpu_mem=" /boot/config.txt; then
sed -i "s/gpu_mem=.*/gpu_mem=256/g" /boot/config.txt
else
echo "gpu_mem=256" >> /boot/config.txt
fi
# Enable KMS driver if not present
if ! grep -q "dtoverlay=vc4-kms-v3d" /boot/config.txt; then
echo "dtoverlay=vc4-kms-v3d" >> /boot/config.txt
fi
fi
# Disable boot to desktop (force console auto-login)
if command -v raspi-config > /dev/null; then
raspi-config nonint do_boot_behaviour B2 > /dev/null 2>&1
fi
# 3. NETWORKING CONFIGURATION # 3. NETWORKING CONFIGURATION
echo "Configuring Networking..." echo "Configuring Networking..."
@ -196,6 +215,46 @@ EOF
chown pi:pi /home/pi/mpvj/backend/.env chown pi:pi /home/pi/mpvj/backend/.env
chmod 600 /home/pi/mpvj/backend/.env chmod 600 /home/pi/mpvj/backend/.env
# 4.5 Install ofxPiMapper Engine
echo "Installing OpenFrameworks and ofxPiMapper (This will take ~1 hour)..."
cd /home/pi
if [ ! -d "openFrameworks" ]; then
sudo -u pi git clone --depth 1 --branch master https://github.com/openframeworks/openFrameworks.git
cd openFrameworks/scripts/linux/debian
./install_dependencies.sh -y > /dev/null 2>&1
cd ../../../
# Compile OF (this takes a while)
cd scripts/linux/debian
./install_codecs.sh > /dev/null 2>&1
cd ../../../
make -j1 -C libs/openFrameworksCompiled/project/linux64 > /dev/null 2>&1
fi
cd /home/pi/openFrameworks/addons
if [ ! -d "ofxPiMapper" ]; then
sudo -u pi git clone https://github.com/kr15h/ofxPiMapper.git
sudo -u pi git clone https://github.com/vanderlin/ofxOfelia.git # dependency often needed
fi
# Build Example
cd /home/pi/openFrameworks/addons/ofxPiMapper
if [ -f src/Osc/OscControl.cpp ]; then
echo "Applying highlight surface OSC listener modification..."
# Add highlight listener that selects the surface for 1 second (mock highlight)
sed -i '/if (m.getAddress() == "\/ofxPiMapper\/surface\/select"){/i \
if (m.getAddress() == "/ofxPiMapper/surface/highlight"){ \
int surfaceIndex = m.getArgAsInt32(0); \
mapper->getSurfaceManager()->selectSurface(surfaceIndex); \
return; \
}' src/Osc/OscControl.cpp
fi
# Build Example
cd /home/pi/openFrameworks/addons/ofxPiMapper/example-basic
sudo -u pi make -j1 > /dev/null 2>&1
cp bin/example-basic /usr/local/bin/ofxPiMapper
chmod +x /usr/local/bin/ofxPiMapper
# 5. FINALIZATION # 5. FINALIZATION
echo "Finalizing..." echo "Finalizing..."