Instructions
-Drag vertices to adjust mapping. Tapping a surface selects it. Changes are reflected in real-time on the projection.
-diff --git a/backend/index.js b/backend/index.js index 7ab7b6e..9561fd6 100644 --- a/backend/index.js +++ b/backend/index.js @@ -3,14 +3,14 @@ const { Client } = require('node-osc'); const http = require('http'); const { Server } = require('socket.io'); -const { exec } = require('child_process'); +const { exec, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const multer = require('multer'); const app = express(); -app.use(express.json()); // Allow parsing JSON body +app.use(express.json()); app.use(express.static(path.join(__dirname, '../frontend/dist'))); const si = require('systeminformation'); @@ -52,7 +52,19 @@ const storage = multer.diskStorage({ }); 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) => { fs.readdir(MEDIA_DIR, (err, files) => { 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) => { 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); -app.delete('/media/:filename', (req, res) => { - const filePath = path.join(MEDIA_DIR, req.params.filename); - fs.unlink(filePath, (err) => { - if (err) return res.status(500).json({ error: 'Failed to delete file' }); - res.json({ status: 'deleted' }); + // 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.get('/manifest', (req, res) => { + res.json(manifest); +}); + 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); - res.json({ status: 'assigned', surfaceIndex, filename }); + res.json({ status: 'assigned', surfaceIndex, filename, manifest }); }); // Networking API @@ -95,94 +134,133 @@ app.post('/network/client', (req, res) => { }); app.get('/network/scan', (req, res) => { - // Mock scan for WiFi networks - res.json({ - networks: [ - { ssid: 'Venue-WiFi', strength: -60, encrypted: true }, - { ssid: 'Artist-Hotspot', strength: -45, encrypted: true }, - { ssid: 'Free-Internet-Coffee', strength: -80, encrypted: false } - ] + exec('iwlist wlan0 scan | grep "ESSID"', (err, stdout) => { + if (err) { + // Fallback for non-Pi or error + return res.json({ + networks: [ + { ssid: 'Venue-WiFi', strength: 60 }, + { ssid: 'Artist-Hotspot', strength: 85 } + ] + }); + } + 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) => { + const { ssid, password } = req.body; + console.log(`Updating AP to SSID: ${ssid}, PASS: ${password}`); + // In a real scenario, we would rewrite hostapd.conf and reboot + res.json({ status: 'updating', ssid }); + 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) => { - exec('ofxPiMapper -f', (error) => { - if (error) { - console.error(`Error starting ofxPiMapper: ${error.message}`); - return res.status(500).json({ error: 'Failed to start mapper' }); - } + 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' }); }); app.post('/mapper/stop', (req, res) => { - exec('pkill ofxPiMapper', (error) => { - if (error) { - console.error(`Error stopping ofxPiMapper: ${error.message}`); - return res.status(500).json({ error: 'Failed to stop mapper' }); - } - res.json({ status: 'stopped' }); - }); + if (mapperProcess) { + mapperProcess.kill(); + mapperProcess = null; + } else { + exec('pkill ofxPiMapper'); + } + res.json({ status: 'stopped' }); }); app.post('/mapper/restart', (req, res) => { + if (mapperProcess) mapperProcess.kill(); exec('pkill ofxPiMapper', () => { setTimeout(() => { - exec('ofxPiMapper -f'); + mapperProcess = spawn('ofxPiMapper', ['-f']); + mapperProcess.stdout.on('data', streamLogs); + mapperProcess.stderr.on('data', streamLogs); res.json({ status: 'restarting' }); }, 1000); }); }); -const server = http.createServer(app); -const io = new Server(server, { - cors: { - origin: "*", - } +app.get('/mapper/status', (req, res) => { + res.json({ status: mapperProcess ? 'running' : 'stopped' }); }); -// 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); io.on('connection', (socket) => { - console.log('Client connected:', socket.id); - - // Vertex Movement: Sends (surfaceIndex, vertexIndex, x, y) socket.on('vertex:move', (data) => { const { surfaceIndex, vertexIndex, x, y } = data; oscClient.send('/ofxPiMapper/vertex/move', surfaceIndex, vertexIndex, x, y); }); - - // Surface Selection socket.on('surface:select', (data) => { - const { surfaceIndex } = data; - oscClient.send('/ofxPiMapper/surface/select', surfaceIndex); + oscClient.send('/ofxPiMapper/surface/select', data.surfaceIndex); }); - - // Add Surface (Quad or Triangle) socket.on('surface:add', (data) => { - const { type } = data; // 'quad' or 'triangle' - oscClient.send('/ofxPiMapper/surface/add', type); + oscClient.send('/ofxPiMapper/surface/add', data.type); }); - - // Delete Surface socket.on('surface:delete', (data) => { - const { surfaceIndex } = data; - oscClient.send('/ofxPiMapper/surface/delete', surfaceIndex); + oscClient.send('/ofxPiMapper/surface/delete', data.surfaceIndex); }); - - socket.on('disconnect', () => { - console.log('Client disconnected:', socket.id); + socket.on('surface:highlight', (data) => { + oscClient.send('/ofxPiMapper/surface/highlight', data.surfaceIndex); }); }); const PORT = process.env.PORT || 3000; - -app.get('/', (req, res) => { - res.send('MPVJ Backend is running.'); -}); - server.listen(PORT, () => { console.log(`Backend listening on port ${PORT}`); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e1ae630..f2576bc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,9 +5,11 @@ import { io } from 'socket.io-client'; const socket = io('http://localhost:3000'); function App() { + const [activeTab, setActiveTab] = useState('Dashboard'); const [surfaces, setSurfaces] = useState([ { type: 'quad', + name: 'Screen 1', selected: true, vertices: [ { 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 canvasRef = useRef(null); + const [mapperStatus, setMapperStatus] = useState('unknown'); + const [logs, setLogs] = useState([]); + const [mediaFiles, setMediaFiles] = useState([]); + const [manifest, setManifest] = useState({ surfaces: [] }); useEffect(() => { - const interval = setInterval(async () => { + // Stats Polling + const statsInterval = setInterval(async () => { try { const res = await fetch('http://localhost:3000/system/stats'); const data = await res.json(); setStats(data); - } catch (e) { - console.error('Failed to fetch stats'); - } + } catch (e) {} }, 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) => { 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) => { if (!dragging) return; - - const rect = e.currentTarget.getBoundingClientRect(); - const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + const svg = e.currentTarget.querySelector('svg'); + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; const newSurfaces = [...surfaces]; - newSurfaces[dragging.sIndex].vertices[dragging.vIndex] = { x, y }; + const s = newSurfaces[dragging.sIndex]; + + if (dragging.type === 'vertex') { + const boundedX = Math.max(0, Math.min(1, x)); + const boundedY = Math.max(0, Math.min(1, y)); + s.vertices[dragging.vIndex] = { x: boundedX, y: boundedY }; + socket.emit('vertex:move', { surfaceIndex: dragging.sIndex, vertexIndex: dragging.vIndex, x: boundedX, y: boundedY }); + } 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); - - // Task 3.4: Send to backend - socket.emit('vertex:move', { - surfaceIndex: dragging.sIndex, - vertexIndex: dragging.vIndex, - x, - y - }); }; - const handleMouseUp = () => { - setDragging(null); - }; + const handleMouseUp = () => setDragging(null); const handleSurfaceSelect = (sIndex) => { - const newSurfaces = surfaces.map((s, i) => ({ - ...s, - selected: i === sIndex - })); + const newSurfaces = surfaces.map((s, i) => ({ ...s, selected: i === sIndex })); setSurfaces(newSurfaces); socket.emit('surface:select', { surfaceIndex: sIndex }); }; @@ -75,6 +143,7 @@ function App() { const addSurface = (type) => { const newSurface = { type, + name: `New ${type}`, selected: true, 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 }] @@ -84,92 +153,306 @@ function App() { socket.emit('surface:add', { type }); }; - return ( -
Applying new network settings. Please reconnect to the new WiFi SSID in about 60 seconds.
+Drag vertices to adjust mapping. Tapping a surface selects it. Changes are reflected in real-time on the projection.
-