feat: Implement Server-Side FFmpeg HLS Transcoding (.m3u8), HLS.js streaming, and dynamic Socratic In-Video Checkpoint Engine

This commit is contained in:
Hamza-Ayed
2026-08-28 01:30:56 +03:00
parent 8044f86798
commit 54ceab1aae
5 changed files with 420 additions and 85 deletions
+127 -9
View File
@@ -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');