import { spawn } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; import { createUploadStream } from './s3/client.js'; const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; class CaptureManager { constructor() { this.state = { recording: false, sessionId: null, processes: {}, currentSession: {}, }; } /** * Start a new capture session * @param {Object} params - { projectId, binId, clipName, device } * @returns {Object} Session info */ async start({ projectId, binId, clipName, device }) { if (this.state.recording) { throw new Error('Capture already in progress'); } const sessionId = uuidv4(); const hiresKey = `projects/${projectId}/masters/${clipName}.mov`; const proxyKey = `projects/${projectId}/proxies/${clipName}.mp4`; const startedAt = new Date().toISOString(); // Spawn FFmpeg processes const hiresProcess = spawn('ffmpeg', [ '-f', 'decklink', '-i', device, '-c:v', 'prores_ks', '-profile:v', '3', '-c:a', 'pcm_s24le', '-f', 'mov', 'pipe:1', ], { stdio: ['ignore', 'pipe', 'pipe'], }); const proxyProcess = spawn('ffmpeg', [ '-f', 'decklink', '-i', device, '-c:v', 'libx264', '-preset', 'fast', '-b:v', '10M', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+frag_keyframe+empty_moov', '-f', 'mp4', 'pipe:1', ], { stdio: ['ignore', 'pipe', 'pipe'], }); // Start S3 uploads from FFmpeg stdout const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout); const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout); this.state.recording = true; this.state.sessionId = sessionId; this.state.processes = { hires: hiresProcess, proxy: proxyProcess, }; this.state.currentSession = { sessionId, projectId, binId, clipName, device, hiresKey, proxyKey, startedAt, duration: 0, uploads: { hires: hiresUpload, proxy: proxyUpload, }, }; // Handle process errors hiresProcess.stderr.on('data', (data) => { console.error(`[HIRES] ${data}`); }); proxyProcess.stderr.on('data', (data) => { console.error(`[PROXY] ${data}`); }); return this._formatSessionResponse(); } /** * Stop the current capture session * @param {string} sessionId - Session ID to stop * @returns {Object} Completed session info */ async stop(sessionId) { if (!this.state.recording || this.state.sessionId !== sessionId) { throw new Error('No active capture session or session ID mismatch'); } const { processes, currentSession } = this.state; // Send SIGINT to both processes if (processes.hires) { processes.hires.kill('SIGINT'); } if (processes.proxy) { processes.proxy.kill('SIGINT'); } try { // Wait for uploads to complete if (currentSession.uploads) { await Promise.all([ currentSession.uploads.hires, currentSession.uploads.proxy, ]); } } catch (error) { console.error('Error during upload completion:', error); } const stoppedAt = new Date().toISOString(); const startTime = new Date(currentSession.startedAt); const stopTime = new Date(stoppedAt); const duration = Math.round((stopTime - startTime) / 1000); // Reset state this.state.recording = false; this.state.sessionId = null; this.state.processes = {}; return { sessionId, projectId: currentSession.projectId, binId: currentSession.binId, clipName: currentSession.clipName, hiresKey: currentSession.hiresKey, proxyKey: currentSession.proxyKey, startedAt: currentSession.startedAt, stoppedAt, duration, }; } /** * Get current capture status * @returns {Object} Current state */ getStatus() { if (!this.state.recording) { return { recording: false, }; } const startTime = new Date(this.state.currentSession.startedAt); const now = new Date(); const duration = Math.round((now - startTime) / 1000); return { recording: true, sessionId: this.state.sessionId, device: this.state.currentSession.device, clipName: this.state.currentSession.clipName, projectId: this.state.currentSession.projectId, binId: this.state.currentSession.binId, duration, startedAt: this.state.currentSession.startedAt, }; } /** * Format session response * @private */ _formatSessionResponse() { const { currentSession, sessionId } = this.state; return { sessionId, projectId: currentSession.projectId, binId: currentSession.binId, clipName: currentSession.clipName, device: currentSession.device, hiresKey: currentSession.hiresKey, proxyKey: currentSession.proxyKey, startedAt: currentSession.startedAt, }; } } export default new CaptureManager();