Store meet recordings on Ladill servers via browser upload.
Deploy Ladill Meet / deploy (push) Successful in 53s

Capture the meeting in the host browser, upload to Ladill storage on stop, and remove the LiveKit egress placeholder job.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 17:40:44 +00:00
co-authored by Cursor
parent 5137672ef5
commit b765b5dd97
10 changed files with 322 additions and 47 deletions
+153 -5
View File
@@ -17,6 +17,8 @@ function meetRoom() {
let room = null;
let connectPromise = null;
let initialized = false;
let recordingMediaRecorder = null;
let recordingChunks = [];
return {
isMuted: true,
@@ -50,6 +52,7 @@ function meetRoom() {
breakoutModalOpen: false,
breakoutCount: '4',
endMeetingConfirmOpen: false,
activeRecordingUuid: null,
joinUrl: '',
passcode: '',
roomTitle: '',
@@ -692,13 +695,152 @@ function meetRoom() {
async toggleRecording() {
if (!this.isHost) return;
const url = this.isRecording ? this.config.recordingStopUrl : this.config.recordingStartUrl;
const res = await fetch(url, { method: 'POST', headers: this.headers() });
if (res.ok) {
this.isRecording = !this.isRecording;
if (this.isRecording) {
await this.stopServerAndUploadRecording();
} else {
await this.startServerRecording();
}
},
async startServerRecording() {
const res = await fetch(this.config.recordingStartUrl, { method: 'POST', headers: this.headers() });
if (!res.ok) return;
const data = await res.json();
this.activeRecordingUuid = data.recording_uuid;
if (!this.startClientRecording()) {
this.isRecording = false;
this.activeRecordingUuid = null;
return;
}
this.isRecording = true;
},
startClientRecording() {
const stream = this.buildRecordingStream();
if (!stream || typeof MediaRecorder === 'undefined') {
return false;
}
recordingChunks = [];
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus'
: (MediaRecorder.isTypeSupported('video/webm') ? 'video/webm' : '');
try {
recordingMediaRecorder = mimeType
? new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2_500_000 })
: new MediaRecorder(stream, { videoBitsPerSecond: 2_500_000 });
} catch {
return false;
}
recordingMediaRecorder.ondataavailable = (event) => {
if (event.data?.size > 0) {
recordingChunks.push(event.data);
}
};
recordingMediaRecorder.start(1000);
return true;
},
buildRecordingStream() {
if (!room) return null;
const screenEl = document.getElementById('screen-share');
const screenVideo = screenEl && !screenEl.classList.contains('hidden')
? screenEl.querySelector('video')
: null;
const videoSource = screenVideo || document.getElementById('video-grid');
if (!videoSource?.captureStream) {
return null;
}
const videoStream = videoSource.captureStream(25);
const tracks = [...videoStream.getVideoTracks(), ...this.collectMeetingAudioTracks()];
return tracks.length ? new MediaStream(tracks) : null;
},
collectMeetingAudioTracks() {
const tracks = [];
const addFromParticipant = (participant) => {
participant?.audioTrackPublications?.forEach((publication) => {
const mediaTrack = publication.track?.mediaStreamTrack;
if (mediaTrack && mediaTrack.readyState === 'live') {
tracks.push(mediaTrack);
}
});
};
if (room?.localParticipant) {
addFromParticipant(room.localParticipant);
}
room?.remoteParticipants?.forEach(addFromParticipant);
return tracks;
},
async stopServerAndUploadRecording() {
const res = await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers() });
const blob = await this.stopClientRecording();
this.isRecording = false;
if (!res.ok) {
this.activeRecordingUuid = null;
return;
}
const data = await res.json().catch(() => ({}));
const recordingUuid = data.recording_uuid || this.activeRecordingUuid;
if (blob && recordingUuid) {
await this.uploadRecording(blob, recordingUuid);
}
this.activeRecordingUuid = null;
},
stopClientRecording() {
return new Promise((resolve) => {
const recorder = recordingMediaRecorder;
if (!recorder || recorder.state === 'inactive') {
recordingMediaRecorder = null;
recordingChunks = [];
resolve(null);
return;
}
recorder.onstop = () => {
const blob = new Blob(recordingChunks, {
type: recorder.mimeType || 'video/webm',
});
recordingMediaRecorder = null;
recordingChunks = [];
recorder.stream?.getTracks().forEach((track) => track.stop());
resolve(blob.size > 0 ? blob : null);
};
recorder.stop();
});
},
async uploadRecording(blob, recordingUuid) {
const form = new FormData();
form.append('recording', blob, `${recordingUuid}.webm`);
await fetch(`/room/${this.config.sessionUuid}/recordings/${recordingUuid}/upload`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': this.config.csrf, Accept: 'application/json' },
body: form,
});
},
async toggleLock() {
if (!this.isHost) return;
const url = this.isLocked ? this.config.unlockUrl : this.config.lockUrl;
@@ -767,7 +909,13 @@ function meetRoom() {
},
submitEndMeeting() {
document.getElementById('meet-end-form')?.requestSubmit();
if (this.isRecording) {
this.stopServerAndUploadRecording().finally(() => {
document.getElementById('meet-end-form')?.requestSubmit();
});
} else {
document.getElementById('meet-end-form')?.requestSubmit();
}
this.endMeetingConfirmOpen = false;
},