diff --git a/.env.example b/.env.example index 1b6dc2e..4f69c45 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,7 @@ LIVEKIT_API_SECRET= # --- Recordings & AI --- MEET_RECORDINGS_DISK=local +MEET_RECORDING_MAX_MB=512 MEET_AI_DRIVER=openai MEET_AI_API_KEY= MEET_AI_MODEL=gpt-4o-mini diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 4621c5c..3a7446c 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Meet; use App\Http\Controllers\Controller; use App\Models\Participant; +use App\Models\Recording; use App\Models\Session; use App\Services\Meet\ChatService; use App\Services\Meet\Media\MediaProviderInterface; @@ -254,9 +255,29 @@ class MeetingRoomController extends Controller abort_unless($participant->isHost(), 403); $recording = $session->recordings()->where('status', 'recording')->firstOrFail(); - $this->recordings->stop($recording, $participant->user_ref ?? $participant->uuid); + $recording = $this->recordings->stop($recording, $participant->user_ref ?? $participant->uuid); - return response()->json(['status' => 'processing']); + return response()->json([ + 'status' => 'processing', + 'recording_uuid' => $recording->uuid, + ]); + } + + public function uploadRecording(Request $request, Session $session, Recording $recording): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + abort_unless($participant->isHost(), 403); + abort_unless($recording->session_id === $session->id, 404); + + $maxKb = max(1, (int) config('meet.recordings.max_mb', 512)) * 1024; + + $request->validate([ + 'recording' => ['required', 'file', 'mimetypes:video/webm,video/mp4,video/x-matroska,video/quicktime', 'max:'.$maxKb], + ]); + + $this->recordings->storeUpload($recording, $request->file('recording')); + + return response()->json(['status' => 'ready', 'recording_uuid' => $recording->uuid]); } public function lock(Request $request, Session $session): JsonResponse diff --git a/app/Http/Controllers/Meet/RecordingController.php b/app/Http/Controllers/Meet/RecordingController.php index 3e9b6f0..474af64 100644 --- a/app/Http/Controllers/Meet/RecordingController.php +++ b/app/Http/Controllers/Meet/RecordingController.php @@ -52,7 +52,7 @@ class RecordingController extends Controller abort_unless(app(\App\Services\Meet\MeetBillingService::class)->storageIsAccessible($recording), 402, 'Recording storage billing is overdue. Top up your Ladill wallet to download this recording.'); return Storage::disk(config('meet.recordings.disk', 'local')) - ->download($recording->storage_path, $recording->session->room->title.'.mp4'); + ->download($recording->storage_path, $recording->session->room->title.'.'.pathinfo($recording->storage_path, PATHINFO_EXTENSION)); } public function destroy(Request $request, Recording $recording): RedirectResponse diff --git a/app/Jobs/ProcessRecording.php b/app/Jobs/ProcessRecording.php deleted file mode 100644 index 32f90a5..0000000 --- a/app/Jobs/ProcessRecording.php +++ /dev/null @@ -1,27 +0,0 @@ -recordingId); - if (! $recording || $recording->status !== 'processing') { - return; - } - - $recordings->process($recording); - } -} diff --git a/app/Services/Meet/RecordingService.php b/app/Services/Meet/RecordingService.php index 50e6fcf..3ad5e67 100644 --- a/app/Services/Meet/RecordingService.php +++ b/app/Services/Meet/RecordingService.php @@ -5,8 +5,9 @@ namespace App\Services\Meet; use App\Models\Recording; use App\Models\Session; use App\Models\User; -use App\Jobs\ProcessRecording; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use RuntimeException; class RecordingService { @@ -61,26 +62,40 @@ class RecordingService $recording->id, ); - ProcessRecording::dispatch($recording->id); - return $recording->fresh(); } - public function process(Recording $recording): Recording + /** Store a browser-captured recording on Ladill storage (not LiveKit). */ + public function storeUpload(Recording $recording, UploadedFile $file): Recording { - $session = $recording->session; - $filename = 'recordings/'.$session->uuid.'/'.$recording->uuid.'.mp4'; - $disk = Storage::disk(config('meet.recordings.disk', 'local')); + if ($recording->status !== 'processing') { + return $recording; + } - // Placeholder until LiveKit Egress writes the file; create empty marker for dev. - if (! $disk->exists($filename)) { - $disk->put($filename, ''); + $session = $recording->session; + $extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'webm'); + if (! in_array($extension, ['webm', 'mp4', 'mkv'], true)) { + $extension = 'webm'; + } + + $filename = 'recordings/'.$session->uuid.'/'.$recording->uuid.'.'.$extension; + $disk = Storage::disk(config('meet.recordings.disk', 'local')); + $disk->putFileAs( + 'recordings/'.$session->uuid, + $file, + $recording->uuid.'.'.$extension, + ); + + $size = $disk->size($filename) ?: $file->getSize(); + + if ($size <= 0) { + throw new RuntimeException('Recording upload was empty.'); } $recording->update([ 'status' => 'ready', 'storage_path' => $filename, - 'file_size' => $disk->size($filename) ?: null, + 'file_size' => $size, 'thumbnail_path' => null, ]); diff --git a/config/meet.php b/config/meet.php index c5d25ed..1b8e792 100644 --- a/config/meet.php +++ b/config/meet.php @@ -101,6 +101,7 @@ return [ 'recordings' => [ 'disk' => env('MEET_RECORDINGS_DISK', 'local'), + 'max_mb' => (int) env('MEET_RECORDING_MAX_MB', 512), ], 'ai' => [ diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index be3abe0..a8aed1a 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -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; }, diff --git a/resources/views/meet/recordings/show.blade.php b/resources/views/meet/recordings/show.blade.php index 72b777b..f52a06f 100644 --- a/resources/views/meet/recordings/show.blade.php +++ b/resources/views/meet/recordings/show.blade.php @@ -8,7 +8,7 @@
Duration: {{ gmdate('H:i:s', $recording->duration_seconds) }}
@endif @if ($recording->isReady()) - Download MP4 + Download recording @elseRecording is still processing. Refresh shortly.
@endif diff --git a/routes/web.php b/routes/web.php index 45e5391..dba2462 100644 --- a/routes/web.php +++ b/routes/web.php @@ -68,6 +68,7 @@ Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReac Route::get('/room/{session}/poll', [MeetingRoomController::class, 'poll'])->name('meet.room.poll'); Route::post('/room/{session}/recording/start', [MeetingRoomController::class, 'startRecording'])->name('meet.room.recording.start'); Route::post('/room/{session}/recording/stop', [MeetingRoomController::class, 'stopRecording'])->name('meet.room.recording.stop'); +Route::post('/room/{session}/recordings/{recording}/upload', [MeetingRoomController::class, 'uploadRecording'])->name('meet.room.recording.upload'); Route::post('/room/{session}/lock', [MeetingRoomController::class, 'lock'])->name('meet.room.lock'); Route::post('/room/{session}/unlock', [MeetingRoomController::class, 'unlock'])->name('meet.room.unlock'); Route::post('/room/{session}/caption', [MeetingRoomController::class, 'caption'])->name('meet.room.caption'); diff --git a/tests/Feature/MeetRecordingUploadTest.php b/tests/Feature/MeetRecordingUploadTest.php new file mode 100644 index 0000000..6396d89 --- /dev/null +++ b/tests/Feature/MeetRecordingUploadTest.php @@ -0,0 +1,115 @@ +withoutMiddleware(EnsurePlatformSession::class); + Storage::fake('local'); + + $this->user = User::create([ + 'public_id' => 'test-user-recording', + 'name' => 'Test User', + 'email' => 'recording@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Test Org', + 'slug' => 'test-org-recording', + 'timezone' => 'UTC', + 'settings' => ['onboarded' => true], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'owner', + ]); + + $base = rtrim((string) config('billing.api_url'), '/'); + Http::fake([ + $base.'/can-afford*' => Http::response(['affordable' => true]), + $base.'/debit' => Http::response(['ok' => true]), + ]); + } + + public function test_host_can_upload_browser_recording_to_ladill_storage(): void + { + $room = Room::create([ + 'organization_id' => $this->organization->id, + 'owner_ref' => $this->user->public_id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'Recorded meeting', + 'type' => 'instant', + 'status' => 'live', + 'timezone' => 'UTC', + ]); + + $session = Session::create([ + 'room_id' => $room->id, + 'owner_ref' => $this->user->public_id, + 'media_room_name' => 'room-'.$room->uuid, + 'status' => 'live', + 'started_at' => now()->subMinutes(5), + ]); + + $participant = Participant::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'user_ref' => $this->user->public_id, + 'role' => 'host', + 'display_name' => 'Test User', + 'status' => 'joined', + 'joined_at' => now(), + ]); + + $recording = Recording::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'status' => 'processing', + 'layout' => 'gallery', + 'started_by_ref' => $this->user->public_id, + 'started_at' => now()->subMinute(), + 'ended_at' => now(), + 'duration_seconds' => 60, + ]); + + $this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) + ->actingAs($this->user) + ->post("/room/{$session->uuid}/recordings/{$recording->uuid}/upload", [ + 'recording' => UploadedFile::fake()->create('meet.webm', 128, 'video/webm'), + ]) + ->assertOk() + ->assertJson(['status' => 'ready']); + + $recording->refresh(); + $this->assertSame('ready', $recording->status); + $this->assertNotNull($recording->storage_path); + Storage::disk('local')->assertExists($recording->storage_path); + } +}