198 lines
5 KiB
JavaScript
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();
|