diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js new file mode 100644 index 0000000..a55e9b6 --- /dev/null +++ b/services/capture/src/capture-manager.js @@ -0,0 +1,198 @@ +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();