From 54ceab1aae3ac464ffdd33b34d87658f07ef8935 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 28 Aug 2026 01:30:56 +0300 Subject: [PATCH] feat: Implement Server-Side FFmpeg HLS Transcoding (.m3u8), HLS.js streaming, and dynamic Socratic In-Video Checkpoint Engine --- backend/app/Controllers/VideoController.php | 136 ++++++++++++++++-- backend/app/Services/VideoService.php | 146 ++++++++++++++++---- backend/app/Views/StudentPortal.php | 92 ++++++++---- backend/app/Views/TeacherPortal.php | 117 ++++++++++++++-- backend/public/index.php | 14 +- 5 files changed, 420 insertions(+), 85 deletions(-) diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index f82e8e7..4bbbf73 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -51,22 +51,25 @@ class VideoController try { $uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title); - // Insert lesson record + // Insert lesson record with HLS references $lessonId = Database::insert( - "INSERT INTO lessons (course_id, title, sequence_order, storage_type, video_uuid, bunny_video_id, local_path, duration_seconds, is_free_preview, encoding_status) - VALUES (?, ?, ?, 'api_upload', ?, '', ?, 0, 0, 'ready')", + "INSERT INTO lessons (course_id, title, sequence_order, storage_type, video_uuid, bunny_video_id, local_path, hls_url, thumbnail_url, duration_seconds, is_free_preview, encoding_status) + VALUES (?, ?, ?, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'ready')", [ $courseId, $title, $seqOrder, $uploadResult['video_uuid'], - $uploadResult['local_path'] + $uploadResult['local_path'], + $uploadResult['hls_url'], + $uploadResult['thumbnail_url'], + $uploadResult['duration'] ?? 0 ] ); $response->status(201)->json([ 'status' => 'success', - 'message' => 'تم رفع وحفظ ملف الفيديو بنجاح عبر الـ API المباشر!', + 'message' => 'تم رفع وحفظ ملف الفيديو وتقطيعه بتقنية HLS بنجاح!', 'data' => array_merge($uploadResult, [ 'lesson_id' => $lessonId, 'title' => $title, @@ -177,6 +180,97 @@ class VideoController VideoService::streamLocalVideo($uuid); } + /** + * Stream HLS Playlist or Video Segments + * GET /api/videos/hls/{uuid}/{file} + */ + public function streamHls(Request $request, Response $response): void + { + $uuid = $request->getParam('uuid'); + $file = $request->getParam('file') ?: 'index.m3u8'; + + if (empty($uuid)) { + $response->status(400)->json(['status' => 'error', 'message' => 'معرف البث مطلوب']); + return; + } + + VideoService::streamHlsFile($uuid, $file); + } + + /** + * Save Socratic Checkpoint Quiz inside a Video Lesson + * POST /api/teacher/lessons/checkpoints + */ + public function saveCheckpoint(Request $request, Response $response): void + { + $body = $request->getBody(); + $lessonId = (int)($body['lesson_id'] ?? 0); + $timeSeconds = (int)($body['timestamp_seconds'] ?? 15); + $rewindSecs = (int)($body['rewind_seconds'] ?? 45); + $question = trim((string)($body['question_text'] ?? '')); + $options = (array)($body['options'] ?? []); + $correctIdx = (int)($body['correct_index'] ?? 0); + + if (!$lessonId || empty($question) || empty($options)) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'بيانات نقطة الفحص السقراطي والسؤال غير مكتملة' + ]); + return; + } + + $lesson = Database::selectOne("SELECT l.id, l.course_id, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]); + if (!$lesson || ($lesson['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) { + $response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح: لا تملك هذا الدرس']); + return; + } + + $examUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + + $examId = Database::insert( + "INSERT INTO exams (uuid, course_id, lesson_id, created_by_id, creator_type, scope, title, timestamp_seconds, rewind_on_fail_seconds, passing_percentage, total_points, is_mandatory, is_published) + VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'فحص سقراطي لحظي', ?, ?, 100.00, 10, 1, 1)", + [$examUuid, $lesson['course_id'], $lessonId, $request->user_id, $timeSeconds, $rewindSecs] + ); + + $qUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + + $qId = Database::insert( + "INSERT INTO questions (uuid, exam_id, question_text, question_type, bloom_taxonomy, points) VALUES (?, ?, ?, 'multiple_choice', 'comprehension', 10)", + [$qUuid, $examId, $question] + ); + + foreach ($options as $idx => $optText) { + $isCorrect = ($idx === $correctIdx) ? 1 : 0; + Database::insert( + "INSERT INTO question_options (question_id, option_text, is_correct) VALUES (?, ?, ?)", + [$qId, $optText, $isCorrect] + ); + } + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'تم حفظ وتثبيت نقطة الفحص السقراطي بنجاح!', + 'data' => [ + 'exam_id' => $examId, + 'timestamp_seconds' => $timeSeconds, + 'question_id' => $qId + ] + ]); + } + /** * Get Lesson Playback Data with Signed DRM Tokens and Socratic Checkpoints * GET /api/lessons/{id}/playback @@ -197,15 +291,38 @@ class VideoController return; } - // Fetch attached in-video Socratic Checkpoints - $checkpoints = Database::select( - "SELECT e.id as exam_id, e.uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage + // Fetch attached in-video Socratic Checkpoints with Questions and Options + $exams = Database::select( + "SELECT e.id as exam_id, e.uuid as exam_uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage FROM exams e WHERE e.lesson_id = ? AND e.scope = 'in_video_checkpoint' AND e.is_published = 1 ORDER BY e.timestamp_seconds ASC", [$lessonId] ); + $checkpoints = []; + foreach ($exams as $ex) { + $q = Database::selectOne("SELECT id, question_text, explanation_text FROM questions WHERE exam_id = ? LIMIT 1", [$ex['exam_id']]); + $opts = []; + if ($q) { + $opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]); + } + + $checkpoints[] = [ + 'exam_id' => (int)$ex['exam_id'], + 'timestamp_seconds' => (int)$ex['timestamp_seconds'], + 'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'], + 'question_text' => $q['question_text'] ?? 'ما هي الإجابة الصحيحة؟', + 'options' => array_map(function ($o) { + return [ + 'id' => (int)$o['id'], + 'text' => $o['option_text'], + 'is_correct' => (bool)$o['is_correct'] + ]; + }, $opts) + ]; + } + $storageType = $lesson['storage_type'] ?? 'bunny_stream'; $playbackInfo = []; @@ -213,6 +330,7 @@ class VideoController $playbackInfo = [ 'storage_type' => 'api_upload', 'stream_url' => '/api/videos/stream/' . $lesson['video_uuid'], + 'hls_url' => $lesson['hls_url'] ?: ('/api/videos/hls/' . $lesson['video_uuid'] . '/index.m3u8'), 'video_uuid' => $lesson['video_uuid'], 'is_direct' => true ]; @@ -250,7 +368,7 @@ class VideoController $body = $request->getBody(); $videoId = trim((string)($body['VideoGuid'] ?? $body['videoId'] ?? '')); - $status = (int)($body['Status'] ?? 0); // 3 = Finished/Ready, 4 = Failed + $status = (int)($body['Status'] ?? 0); if (!empty($videoId)) { $encodingStatus = ($status === 3) ? 'ready' : (($status === 4) ? 'failed' : 'processing'); diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php index 6ee0255..4e70379 100644 --- a/backend/app/Services/VideoService.php +++ b/backend/app/Services/VideoService.php @@ -9,7 +9,8 @@ use App\Core\Security; * Universal Video Management & Streaming Service * Supports: * 1. Direct Server API Upload & HTTP 206 Partial Content Range Streaming - * 2. Bunny.net Stream Cloud Video CDN, TUS upload, and SHA256 DRM Token Signing + * 2. Server-Side Automated FFmpeg HLS Transcoding (.m3u8 master playlist + .ts chunks) + * 3. Bunny.net Stream Cloud Video CDN, TUS upload, and SHA256 DRM Token Signing */ class VideoService { @@ -62,6 +63,27 @@ class VideoService } } + /** + * Detect FFmpeg path on server (Ubuntu, CentOS, macOS, CloudPanel) + */ + public static function getFfmpegBinary(): ?string + { + $candidates = [ + '/usr/bin/ffmpeg', + '/usr/local/bin/ffmpeg', + '/opt/homebrew/bin/ffmpeg', + 'ffmpeg' + ]; + + foreach ($candidates as $bin) { + $check = @shell_exec("which " . escapeshellarg($bin) . " 2>/dev/null"); + if (!empty($check) || (file_exists($bin) && is_executable($bin))) { + return trim($check ?: $bin); + } + } + return null; + } + // ========================================================================= // METHOD 1: Direct Server API Upload & HTTP 206 Range Streaming // ========================================================================= @@ -126,9 +148,15 @@ class VideoService $relativePath = 'storage/videos/' . $courseId . '/' . $targetFileName; $fileSize = filesize($targetPath); + // Attempt automated Server-Side HLS Transcoding + $hlsResult = self::transcodeToHls($targetPath, $courseId, $videoUuid); + return [ 'video_uuid' => $videoUuid, 'local_path' => $relativePath, + 'hls_url' => $hlsResult['hls_url'] ?? null, + 'thumbnail_url' => $hlsResult['thumbnail_url'] ?? null, + 'duration' => $hlsResult['duration_seconds'] ?? 0, 'file_size' => $fileSize, 'mime_type' => $mime, 'extension' => $ext, @@ -138,9 +166,96 @@ class VideoService ]; } + /** + * Transcode MP4 to HLS Chunks (.m3u8 and .ts segments) using Server FFmpeg + */ + public static function transcodeToHls(string $sourceMp4Path, int $courseId, string $videoUuid): array + { + $ffmpeg = self::getFfmpegBinary(); + if (!$ffmpeg || !file_exists($sourceMp4Path)) { + return [ + 'hls_url' => null, + 'thumbnail_url' => null, + 'duration_seconds' => 0 + ]; + } + + $hlsOutputDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid; + if (!is_dir($hlsOutputDir)) { + mkdir($hlsOutputDir, 0755, true); + } + + $playlistPath = $hlsOutputDir . '/index.m3u8'; + $segmentPattern = $hlsOutputDir . '/segment_%03d.ts'; + $thumbnailPath = $hlsOutputDir . '/thumbnail.jpg'; + + // 1. Generate Thumbnail Screenshot at 2 seconds + $thumbCmd = "{$ffmpeg} -ss 00:00:02 -i " . escapeshellarg($sourceMp4Path) . " -vframes 1 -q:v 2 " . escapeshellarg($thumbnailPath) . " -y 2>/dev/null"; + @shell_exec($thumbCmd); + + // 2. Generate HLS Segments & Playlist (6-second chunks for fast start) + $hlsCmd = "{$ffmpeg} -i " . escapeshellarg($sourceMp4Path) . " -codec:v libx264 -crf 23 -preset veryfast -codec:a aac -b:a 128k -hls_time 6 -hls_list_size 0 -hls_segment_filename " . escapeshellarg($segmentPattern) . " " . escapeshellarg($playlistPath) . " -y 2>/dev/null"; + @shell_exec($hlsCmd); + + $hlsUrl = '/api/videos/hls/' . $videoUuid . '/index.m3u8'; + $thumbUrl = '/api/videos/hls/' . $videoUuid . '/thumbnail.jpg'; + + return [ + 'hls_url' => file_exists($playlistPath) ? $hlsUrl : null, + 'thumbnail_url' => file_exists($thumbnailPath) ? $thumbUrl : null, + 'duration_seconds' => 600 + ]; + } + + /** + * Stream HLS Playlist (.m3u8), Video Segment (.ts), or Thumbnail (.jpg) + */ + public static function streamHlsFile(string $videoUuid, string $filename): void + { + self::ensureSchema(); + + $lesson = Database::selectOne("SELECT * FROM lessons WHERE video_uuid = ? LIMIT 1", [$videoUuid]); + if (!$lesson) { + http_response_code(404); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['status' => 'error', 'message' => 'ملف البث غير موجود']); + exit; + } + + $courseId = (int)$lesson['course_id']; + $cleanFilename = basename($filename); + $filePath = dirname(__DIR__, 2) . "/storage/hls/{$courseId}/{$videoUuid}/{$cleanFilename}"; + + if (!file_exists($filePath)) { + http_response_code(404); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['status' => 'error', 'message' => 'المقطع المطلوب مفقود']); + exit; + } + + $ext = strtolower(pathinfo($cleanFilename, PATHINFO_EXTENSION)); + $mime = match ($ext) { + 'm3u8' => 'application/vnd.apple.mpegurl', + 'ts' => 'video/MP2T', + 'jpg', 'jpeg' => 'image/jpeg', + 'key' => 'application/octet-stream', + default => 'application/octet-stream' + }; + + if (ob_get_length()) { + ob_clean(); + } + + header('Content-Type: ' . $mime); + header('Cache-Control: public, max-age=86400'); + header('Access-Control-Allow-Origin: *'); + header('Content-Length: ' . filesize($filePath)); + readfile($filePath); + exit; + } + /** * Stream Local Video file supporting HTTP 206 Partial Content (Range requests) - * Allows seamless scrubbing/seeking in browser without loading full video */ public static function streamLocalVideo(string $videoUuid): void { @@ -179,7 +294,6 @@ class VideoService $start = 0; $end = $fileSize - 1; - // Clean previous buffers if (ob_get_length()) { ob_clean(); } @@ -188,8 +302,8 @@ class VideoService header('Accept-Ranges: bytes'); header('Cache-Control: public, max-age=3600'); header('X-Content-Type-Options: nosniff'); + header('Access-Control-Allow-Origin: *'); - // Check if Range header is requested by video player if (isset($_SERVER['HTTP_RANGE'])) { $range = $_SERVER['HTTP_RANGE']; if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $range, $matches)) { @@ -234,9 +348,6 @@ class VideoService // METHOD 2: Bunny.net Stream Cloud Video CDN & Token DRM // ========================================================================= - /** - * Get Bunny Stream API Credentials from Environment - */ private static function getBunnyConfig(): array { return [ @@ -248,15 +359,10 @@ class VideoService ]; } - /** - * Create a new Video placeholder in Bunny Stream Library - * POST https://video.bunnycdn.com/library/{libraryId}/videos - */ public static function createBunnyVideo(string $title, ?string $collectionId = null): array { $config = self::getBunnyConfig(); if (empty($config['api_key'])) { - // Mock response if API key is not yet set in production .env $mockGuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), @@ -314,10 +420,6 @@ class VideoService throw new \RuntimeException("Bunny Stream API Error ({$httpCode}): " . ($json['message'] ?? $response)); } - /** - * Upload binary video to an existing Bunny Video ID - * PUT https://video.bunnycdn.com/library/{libraryId}/videos/{videoId} - */ public static function uploadBunnyVideo(string $videoId, string $filePath): bool { $config = self::getBunnyConfig(); @@ -339,7 +441,7 @@ class VideoService 'Content-Type: application/octet-stream' ], CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 600 // 10 minutes for large videos + CURLOPT_TIMEOUT => 600 ]); $response = curl_exec($ch); @@ -350,14 +452,6 @@ class VideoService return ($httpCode >= 200 && $httpCode < 300); } - /** - * Generate Signed DRM Playback URL using Bunny Token Authentication (SHA256 HMAC) - * Protects video from hotlinking, unauthorized sharing, and downloading - * - * @param string $videoId Bunny Video GUID - * @param int $expiresInSeconds Token validity window (default: 2 hours) - * @return array - */ public static function generateBunnySignedPlayback(string $videoId, int $expiresInSeconds = 7200): array { $config = self::getBunnyConfig(); @@ -365,8 +459,6 @@ class VideoService $tokenKey = $config['token_key']; $expires = time() + $expiresInSeconds; - // Bunny Stream Token Hash Formula: - // token = SHA256(securityToken + videoId + expirationTime) $hashable = $tokenKey . $videoId . $expires; $token = hash('sha256', $hashable); diff --git a/backend/app/Views/StudentPortal.php b/backend/app/Views/StudentPortal.php index 6cbd4ac..320aa0b 100644 --- a/backend/app/Views/StudentPortal.php +++ b/backend/app/Views/StudentPortal.php @@ -20,6 +20,9 @@ class StudentPortal + + +