Spaces:
Running
Running
import type { | |
ProducerSensorDriver, | |
ConnectionStatus, | |
SensorFrame, | |
SensorStream, | |
VideoStreamConfig, | |
MediaRecorderProducerConfig, | |
FrameCallback, | |
StreamUpdateCallback, | |
StatusChangeCallback, | |
UnsubscribeFn | |
} from "../types/index.js"; | |
/** | |
* MediaRecorder Producer Driver | |
* | |
* Captures video/audio from browser MediaDevices using MediaRecorder API. | |
* Simplified with best practices - uses WebM format and optimized settings. | |
*/ | |
export class MediaRecorderProducer implements ProducerSensorDriver { | |
readonly type = "producer" as const; | |
readonly id: string; | |
readonly name: string; | |
private _status: ConnectionStatus = { isConnected: false }; | |
private config: MediaRecorderProducerConfig; | |
// MediaRecorder state | |
private mediaStream: MediaStream | null = null; | |
private mediaRecorder: MediaRecorder | null = null; | |
private recordingDataChunks: Blob[] = []; | |
// Stream management | |
private activeStreams = new Map<string, SensorStream>(); | |
// Event callbacks | |
private frameCallbacks: FrameCallback[] = []; | |
private streamUpdateCallbacks: StreamUpdateCallback[] = []; | |
private statusCallbacks: StatusChangeCallback[] = []; | |
constructor(config: MediaRecorderProducerConfig) { | |
this.config = config; | |
this.id = `media-recorder-${Date.now()}`; | |
this.name = "MediaRecorder Producer"; | |
console.log("π₯ Created MediaRecorder producer driver"); | |
} | |
get status(): ConnectionStatus { | |
return this._status; | |
} | |
async connect(): Promise<void> { | |
console.log("π₯ Connecting MediaRecorder producer..."); | |
try { | |
// Check if browser supports MediaRecorder | |
if (!MediaRecorder.isTypeSupported) { | |
throw new Error("MediaRecorder not supported in this browser"); | |
} | |
// Test basic media access | |
const testStream = await navigator.mediaDevices.getUserMedia({ | |
video: true, | |
audio: true | |
}); | |
// Close test stream immediately | |
testStream.getTracks().forEach(track => track.stop()); | |
this._status = { | |
isConnected: true, | |
lastConnected: new Date(), | |
error: undefined | |
}; | |
this.notifyStatusChange(); | |
console.log("β MediaRecorder producer connected successfully"); | |
} catch (error) { | |
this._status = { | |
isConnected: false, | |
error: `Connection failed: ${error}` | |
}; | |
this.notifyStatusChange(); | |
throw error; | |
} | |
} | |
async disconnect(): Promise<void> { | |
console.log("π₯ Disconnecting MediaRecorder producer..."); | |
// Stop all active streams | |
for (const streamId of this.activeStreams.keys()) { | |
await this.stopStream(streamId); | |
} | |
this._status = { isConnected: false }; | |
this.notifyStatusChange(); | |
console.log("β MediaRecorder producer disconnected"); | |
} | |
async startStream(config: VideoStreamConfig): Promise<SensorStream> { | |
if (!this._status.isConnected) { | |
throw new Error("Cannot start stream: producer not connected"); | |
} | |
console.log("π₯ Starting MediaRecorder stream...", config); | |
try { | |
// Prepare media constraints with best practices | |
const constraints: MediaStreamConstraints = { | |
video: { | |
width: config.width || 1280, | |
height: config.height || 720, | |
frameRate: config.frameRate || 30, | |
facingMode: config.facingMode || "user", | |
...(config.deviceId && { deviceId: config.deviceId }) | |
}, | |
audio: true, | |
...this.config.constraints | |
}; | |
// Get media stream | |
this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints); | |
// Create MediaRecorder with optimized WebM settings | |
const mimeType = this.getBestWebMType(); | |
this.mediaRecorder = new MediaRecorder(this.mediaStream, { | |
mimeType, | |
videoBitsPerSecond: this.config.videoBitsPerSecond || 2500000, | |
audioBitsPerSecond: this.config.audioBitsPerSecond || 128000 | |
}); | |
// Create stream object | |
const stream: SensorStream = { | |
id: `stream-${Date.now()}`, | |
name: `MediaRecorder Stream ${config.width}x${config.height}`, | |
type: "video", | |
config, | |
active: true, | |
startTime: new Date(), | |
totalFrames: 0 | |
}; | |
this.activeStreams.set(stream.id, stream); | |
// Set up MediaRecorder event handlers | |
this.setupMediaRecorderEvents(stream); | |
// Start recording with optimized interval | |
const recordingInterval = this.config.recordingInterval || 100; | |
this.mediaRecorder.start(recordingInterval); | |
// Update status with stream info | |
this._status.frameRate = config.frameRate; | |
this._status.bitrate = this.config.videoBitsPerSecond; | |
this.notifyStatusChange(); | |
this.notifyStreamUpdate(stream); | |
console.log(`β MediaRecorder stream started: ${stream.id}`); | |
return stream; | |
} catch (error) { | |
console.error("β Failed to start MediaRecorder stream:", error); | |
throw error; | |
} | |
} | |
async stopStream(streamId: string): Promise<void> { | |
console.log(`π₯ Stopping MediaRecorder stream: ${streamId}`); | |
const stream = this.activeStreams.get(streamId); | |
if (!stream) { | |
throw new Error(`Stream not found: ${streamId}`); | |
} | |
try { | |
// Stop MediaRecorder | |
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") { | |
this.mediaRecorder.stop(); | |
} | |
// Stop media stream tracks | |
if (this.mediaStream) { | |
this.mediaStream.getTracks().forEach(track => track.stop()); | |
this.mediaStream = null; | |
} | |
// Update stream | |
stream.active = false; | |
stream.endTime = new Date(); | |
this.activeStreams.delete(streamId); | |
this.notifyStreamUpdate(stream); | |
console.log(`β MediaRecorder stream stopped: ${streamId}`); | |
} catch (error) { | |
console.error(`β Failed to stop stream ${streamId}:`, error); | |
throw error; | |
} | |
} | |
async pauseStream(streamId: string): Promise<void> { | |
console.log(`βΈοΈ Pausing MediaRecorder stream: ${streamId}`); | |
const stream = this.activeStreams.get(streamId); | |
if (!stream) { | |
throw new Error(`Stream not found: ${streamId}`); | |
} | |
if (this.mediaRecorder && this.mediaRecorder.state === "recording") { | |
this.mediaRecorder.pause(); | |
this.notifyStreamUpdate(stream); | |
} | |
} | |
async resumeStream(streamId: string): Promise<void> { | |
console.log(`βΆοΈ Resuming MediaRecorder stream: ${streamId}`); | |
const stream = this.activeStreams.get(streamId); | |
if (!stream) { | |
throw new Error(`Stream not found: ${streamId}`); | |
} | |
if (this.mediaRecorder && this.mediaRecorder.state === "paused") { | |
this.mediaRecorder.resume(); | |
this.notifyStreamUpdate(stream); | |
} | |
} | |
getActiveStreams(): SensorStream[] { | |
return Array.from(this.activeStreams.values()); | |
} | |
// Event subscription methods | |
onFrame(callback: FrameCallback): UnsubscribeFn { | |
this.frameCallbacks.push(callback); | |
return () => { | |
const index = this.frameCallbacks.indexOf(callback); | |
if (index >= 0) { | |
this.frameCallbacks.splice(index, 1); | |
} | |
}; | |
} | |
onStreamUpdate(callback: StreamUpdateCallback): UnsubscribeFn { | |
this.streamUpdateCallbacks.push(callback); | |
return () => { | |
const index = this.streamUpdateCallbacks.indexOf(callback); | |
if (index >= 0) { | |
this.streamUpdateCallbacks.splice(index, 1); | |
} | |
}; | |
} | |
onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { | |
this.statusCallbacks.push(callback); | |
return () => { | |
const index = this.statusCallbacks.indexOf(callback); | |
if (index >= 0) { | |
this.statusCallbacks.splice(index, 1); | |
} | |
}; | |
} | |
// Private helper methods | |
private setupMediaRecorderEvents(stream: SensorStream): void { | |
if (!this.mediaRecorder) return; | |
this.mediaRecorder.ondataavailable = (event) => { | |
if (event.data && event.data.size > 0) { | |
this.recordingDataChunks.push(event.data); | |
// Create frame from chunk | |
const frame: SensorFrame = { | |
timestamp: Date.now(), | |
type: "video", | |
data: event.data, | |
metadata: { | |
width: stream.config.width, | |
height: stream.config.height, | |
frameRate: stream.config.frameRate, | |
codec: "webm", | |
bitrate: this.config.videoBitsPerSecond | |
} | |
}; | |
// Update stream stats | |
stream.totalFrames = (stream.totalFrames || 0) + 1; | |
// Notify frame callbacks | |
this.notifyFrame(frame); | |
} | |
}; | |
this.mediaRecorder.onstop = () => { | |
console.log("π₯ MediaRecorder stopped"); | |
// Create final frame with complete recording | |
if (this.recordingDataChunks.length > 0) { | |
const finalBlob = new Blob(this.recordingDataChunks, { | |
type: "video/webm" | |
}); | |
const finalFrame: SensorFrame = { | |
timestamp: Date.now(), | |
type: "video", | |
data: finalBlob, | |
metadata: { | |
width: stream.config.width, | |
height: stream.config.height, | |
codec: "webm", | |
isComplete: true, | |
totalSize: finalBlob.size | |
} | |
}; | |
this.notifyFrame(finalFrame); | |
} | |
// Clear chunks | |
this.recordingDataChunks = []; | |
}; | |
this.mediaRecorder.onerror = (event) => { | |
console.error("β MediaRecorder error:", event); | |
this._status.error = "Recording error occurred"; | |
this.notifyStatusChange(); | |
}; | |
} | |
private getBestWebMType(): string { | |
// Best WebM types in order of preference | |
const types = [ | |
"video/webm;codecs=vp9,opus", | |
"video/webm;codecs=vp8,opus", | |
"video/webm" | |
]; | |
for (const type of types) { | |
if (MediaRecorder.isTypeSupported(type)) { | |
return type; | |
} | |
} | |
return "video/webm"; // Fallback | |
} | |
private notifyFrame(frame: SensorFrame): void { | |
this.frameCallbacks.forEach((callback) => { | |
try { | |
callback(frame); | |
} catch (error) { | |
console.error("Error in frame callback:", error); | |
} | |
}); | |
} | |
private notifyStreamUpdate(stream: SensorStream): void { | |
this.streamUpdateCallbacks.forEach((callback) => { | |
try { | |
callback(stream); | |
} catch (error) { | |
console.error("Error in stream update callback:", error); | |
} | |
}); | |
} | |
private notifyStatusChange(): void { | |
this.statusCallbacks.forEach((callback) => { | |
try { | |
callback(this._status); | |
} catch (error) { | |
console.error("Error in status change callback:", error); | |
} | |
}); | |
} | |
} |