add services/capture/src/capture-manager.js
This commit is contained in:
parent
febf394a81
commit
75ef8a4ed8
1 changed files with 198 additions and 0 deletions
198
services/capture/src/capture-manager.js
Normal file
198
services/capture/src/capture-manager.js
Normal file
|
|
@ -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();
|
||||
Loading…
Reference in a new issue