dragonflight/services/capture/src/capture-manager.js

198 lines
5 KiB
JavaScript

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();