Update Saqel Platform: 2026-08-29 22:05:41
This commit is contained in:
@@ -260,48 +260,60 @@ class CurriculumController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$uploadDir = __DIR__ . '/../../public/uploads/curriculum_videos';
|
|
||||||
if (!is_dir($uploadDir)) {
|
|
||||||
mkdir($uploadDir, 0777, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$videoTmp = $_FILES['video_file']['tmp_name'];
|
|
||||||
$ext = strtolower(pathinfo($_FILES['video_file']['name'], PATHINFO_EXTENSION)) ?: 'mp4';
|
|
||||||
|
|
||||||
// Semantic, human-readable file naming (e.g. video_grade_10_math_10_unit_01_lesson_01.mp4)
|
|
||||||
$cleanBase = preg_replace('/[^a-zA-Z0-9_-]+/', '_', str_replace(['.md', '/'], ['', '_'], $file));
|
|
||||||
$cleanBase = trim($cleanBase, '_');
|
|
||||||
$safeName = 'video_' . $cleanBase . '_' . time() . '.' . $ext;
|
|
||||||
$destPath = $uploadDir . '/' . $safeName;
|
|
||||||
|
|
||||||
if (!move_uploaded_file($videoTmp, $destPath)) {
|
|
||||||
$response->status(500)->json(['status' => 'error', 'message' => 'تعذر رفع وحفظ ملف الفيديو على السيرفر']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$videoUrl = '/uploads/curriculum_videos/' . $safeName;
|
|
||||||
|
|
||||||
// 1. Update Lesson AI assets in manifest.json & filesystem
|
|
||||||
$currentAssets = CurriculumService::getLessonAiAssets($file);
|
|
||||||
$currentAssets['ai_video_url'] = $videoUrl;
|
|
||||||
CurriculumService::saveLessonAiAssets($file, $currentAssets);
|
|
||||||
|
|
||||||
// 2. Direct Sync to MySQL Database Table `lessons`
|
|
||||||
try {
|
try {
|
||||||
$filename = basename($file, '.md');
|
$courseId = 0; // System course ID for curriculum AI videos
|
||||||
\App\Core\Database::query(
|
$title = basename($file, '.md');
|
||||||
"UPDATE lessons SET ai_video_url = ? WHERE title LIKE ? OR markdown_content LIKE ?",
|
|
||||||
[$videoUrl, "%{$filename}%", "%{$file}%"]
|
|
||||||
);
|
|
||||||
} catch (\Throwable $dbEx) {
|
|
||||||
error_log("Video DB sync note: " . $dbEx->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->json([
|
// 1. Upload & Transcode to HLS & Push to R2 (handled by VideoService)
|
||||||
'status' => 'success',
|
$uploadResult = \App\Services\VideoService::handleDirectUpload($_FILES['video_file'], $courseId, $title);
|
||||||
'message' => 'تم رفع وتثبيت فيديو الشرح وربطه بالدرس وقاعدة البيانات بنجاح!',
|
|
||||||
'video_url' => $videoUrl,
|
$videoUrl = $uploadResult['r2_url'] ?? $uploadResult['hls_url'];
|
||||||
'file_name' => $safeName
|
|
||||||
]);
|
// 2. Insert into lessons table as AI generated version
|
||||||
|
$lessonId = \App\Core\Database::insert(
|
||||||
|
"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, ai_video_url)
|
||||||
|
VALUES (?, ?, 1, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'ready', ?)",
|
||||||
|
[
|
||||||
|
$courseId,
|
||||||
|
$title,
|
||||||
|
$uploadResult['video_uuid'],
|
||||||
|
$uploadResult['local_path'],
|
||||||
|
$uploadResult['hls_url'],
|
||||||
|
$uploadResult['thumbnail_url'],
|
||||||
|
$uploadResult['duration'] ?? 0,
|
||||||
|
$videoUrl
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Trigger Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation
|
||||||
|
\App\Services\AiVideoAnalyzerService::processLessonAutonomously($lessonId);
|
||||||
|
|
||||||
|
// 4. Update Lesson AI assets in manifest.json & filesystem
|
||||||
|
$currentAssets = CurriculumService::getLessonAiAssets($file);
|
||||||
|
$currentAssets['ai_video_url'] = $videoUrl;
|
||||||
|
CurriculumService::saveLessonAiAssets($file, $currentAssets);
|
||||||
|
|
||||||
|
// 5. Update any existing curriculum lesson rows with this video
|
||||||
|
try {
|
||||||
|
\App\Core\Database::query(
|
||||||
|
"UPDATE lessons SET ai_video_url = ? WHERE title LIKE ? OR markdown_content LIKE ?",
|
||||||
|
[$videoUrl, "%{$title}%", "%{$file}%"]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $dbEx) {
|
||||||
|
error_log("Video DB sync note: " . $dbEx->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->status(201)->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'تم رفع وتثبيت فيديو الشرح ومعالجته HLS وربطه بالذكاء الاصطناعي بنجاح!',
|
||||||
|
'video_url' => $videoUrl,
|
||||||
|
'data' => $uploadResult
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->status(500)->json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -415,6 +415,64 @@ class VideoController
|
|||||||
|
|
||||||
$chapters = !empty($lesson['timeline_chapters_json']) ? json_decode($lesson['timeline_chapters_json'], true) : [];
|
$chapters = !empty($lesson['timeline_chapters_json']) ? json_decode($lesson['timeline_chapters_json'], true) : [];
|
||||||
|
|
||||||
|
// Find available versions (AI vs Teacher specific)
|
||||||
|
$lessonTitle = $lesson['title'];
|
||||||
|
$versionsRaw = Database::select(
|
||||||
|
"SELECT l.id, l.course_id, l.title, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id,
|
||||||
|
c.teacher_id, u.full_name as teacher_name, u.school_name
|
||||||
|
FROM lessons l
|
||||||
|
LEFT JOIN courses c ON l.course_id = c.id
|
||||||
|
LEFT JOIN users u ON c.teacher_id = u.id
|
||||||
|
WHERE l.title = ? AND l.encoding_status = 'ready'",
|
||||||
|
[$lessonTitle]
|
||||||
|
);
|
||||||
|
|
||||||
|
$availableVersions = [];
|
||||||
|
$studentSchool = $request->user['school_name'] ?? ''; // if student's school is in the token
|
||||||
|
|
||||||
|
foreach ($versionsRaw as $ver) {
|
||||||
|
$isAi = ($ver['course_id'] == 0 || $ver['course_id'] == null);
|
||||||
|
$vPlayback = [];
|
||||||
|
|
||||||
|
if ($ver['storage_type'] === 'api_upload') {
|
||||||
|
$vPlayback = [
|
||||||
|
'storage_type' => 'api_upload',
|
||||||
|
'stream_url' => '/api/videos/stream/' . $ver['video_uuid'],
|
||||||
|
'hls_url' => $ver['hls_url'] ?: ('/api/videos/hls/' . $ver['video_uuid'] . '/index.m3u8'),
|
||||||
|
'video_url' => $ver['ai_video_url'] ?: ($ver['hls_url'] ?: ('/api/videos/stream/' . $ver['video_uuid']))
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$bId = $ver['bunny_video_id'] ?: 'mock-bunny-guid-2026';
|
||||||
|
$signed = VideoService::generateBunnySignedPlayback($bId, 10800);
|
||||||
|
$vPlayback = array_merge(['storage_type' => 'bunny_stream', 'video_url' => $signed['hls_url']], $signed);
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $isAi ? 'فيديو الذكاء الاصطناعي الأساسي 🤖' : 'شرح الأستاذ ' . $ver['teacher_name'];
|
||||||
|
$isRecommended = (!$isAi && !empty($studentSchool) && $ver['school_name'] === $studentSchool);
|
||||||
|
if ($isRecommended) {
|
||||||
|
$label .= ' (مدرستك 🏫)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$availableVersions[] = [
|
||||||
|
'lesson_id' => (int)$ver['id'],
|
||||||
|
'is_ai' => $isAi,
|
||||||
|
'teacher_name' => $ver['teacher_name'],
|
||||||
|
'school_name' => $ver['school_name'],
|
||||||
|
'label' => $label,
|
||||||
|
'is_recommended' => $isRecommended,
|
||||||
|
'playback' => $vPlayback
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: Recommended first, then AI, then others
|
||||||
|
usort($availableVersions, function($a, $b) {
|
||||||
|
if ($a['is_recommended'] && !$b['is_recommended']) return -1;
|
||||||
|
if (!$a['is_recommended'] && $b['is_recommended']) return 1;
|
||||||
|
if ($a['is_ai'] && !$b['is_ai']) return -1;
|
||||||
|
if (!$a['is_ai'] && $b['is_ai']) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
$response->json([
|
$response->json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'data' => [
|
'data' => [
|
||||||
@@ -426,9 +484,10 @@ class VideoController
|
|||||||
'is_free_preview' => (bool)$lesson['is_free_preview'],
|
'is_free_preview' => (bool)$lesson['is_free_preview'],
|
||||||
'storage_type' => $storageType
|
'storage_type' => $storageType
|
||||||
],
|
],
|
||||||
'playback' => $playbackInfo,
|
'playback' => $playbackInfo,
|
||||||
'chapters' => $chapters ?: [],
|
'available_versions' => $availableVersions,
|
||||||
'checkpoints' => $checkpoints ?: []
|
'chapters' => $chapters ?: [],
|
||||||
|
'checkpoints' => $checkpoints ?: []
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -789,6 +789,13 @@ class StudentPortal
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- NEW: Video Version Selector -->
|
||||||
|
<div id="video_version_selector_container" style="display: none; margin-bottom: 16px; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 12px; padding: 12px;">
|
||||||
|
<label style="font-size: 12px; font-weight: 700; color: var(--text-secondary); margin-bottom: 8px; display: block;">اختر مصدر الشرح (المعلم):</label>
|
||||||
|
<select id="video_version_select" onchange="switchVideoVersion(this.value)" style="width: 100%; background: var(--bg-card); color: #FFF; border: 1px solid var(--border); padding: 8px 12px; border-radius: 8px; outline: none; font-family: inherit; font-size: 13px;">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="video-container">
|
<div class="video-container">
|
||||||
<video id="lesson_video_player" class="video-element" controls poster="/assets/images/saqel_logo.jpg" preload="metadata">
|
<video id="lesson_video_player" class="video-element" controls poster="/assets/images/saqel_logo.jpg" preload="metadata">
|
||||||
<?php if ($activeLesson && !empty($activeLesson['video_uuid'])): ?>
|
<?php if ($activeLesson && !empty($activeLesson['video_uuid'])): ?>
|
||||||
@@ -1250,6 +1257,19 @@ class StudentPortal
|
|||||||
badge.textContent = `الفحص السقراطي الذكي نشط (${checkpoints.length} نقاط فحص) 🧠`;
|
badge.textContent = `الفحص السقراطي الذكي نشط (${checkpoints.length} نقاط فحص) 🧠`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate Video Versions
|
||||||
|
const versions = data.data.available_versions || [];
|
||||||
|
const selContainer = document.getElementById('video_version_selector_container');
|
||||||
|
const selElement = document.getElementById('video_version_select');
|
||||||
|
window.currentAvailableVersions = versions;
|
||||||
|
|
||||||
|
if (versions.length > 1) {
|
||||||
|
selContainer.style.display = 'block';
|
||||||
|
selElement.innerHTML = versions.map((v, idx) => `<option value="${idx}">${v.label}</option>`).join('');
|
||||||
|
} else {
|
||||||
|
selContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Update AI Video Button
|
// Update AI Video Button
|
||||||
const aiAssetsContainer = document.getElementById('ai_assets_container');
|
const aiAssetsContainer = document.getElementById('ai_assets_container');
|
||||||
if (aiAssetsContainer && les) {
|
if (aiAssetsContainer && les) {
|
||||||
@@ -1311,6 +1331,32 @@ class StudentPortal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchVideoVersion(idx) {
|
||||||
|
const version = window.currentAvailableVersions[idx];
|
||||||
|
if (!version || !version.playback) return;
|
||||||
|
const pb = version.playback;
|
||||||
|
|
||||||
|
const video = document.getElementById('lesson_video_player');
|
||||||
|
if (video && pb) {
|
||||||
|
video.pause();
|
||||||
|
video.removeAttribute('src');
|
||||||
|
video.load();
|
||||||
|
if (pb.hls_url && window.Hls && Hls.isSupported()) {
|
||||||
|
if (hlsInstance) hlsInstance.destroy();
|
||||||
|
hlsInstance = new Hls({ enableWorker: true });
|
||||||
|
hlsInstance.loadSource(pb.hls_url);
|
||||||
|
hlsInstance.attachMedia(video);
|
||||||
|
video.play().catch(e => console.log(e));
|
||||||
|
} else if (pb.stream_url) {
|
||||||
|
video.src = pb.stream_url;
|
||||||
|
video.play().catch(e => console.log(e));
|
||||||
|
} else if (pb.video_url) {
|
||||||
|
video.src = pb.video_url;
|
||||||
|
video.play().catch(e => console.log(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(secs) {
|
function formatTime(secs) {
|
||||||
const m = Math.floor(secs / 60).toString().padStart(2, '0');
|
const m = Math.floor(secs / 60).toString().padStart(2, '0');
|
||||||
const s = (secs % 60).toString().padStart(2, '0');
|
const s = (secs % 60).toString().padStart(2, '0');
|
||||||
|
|||||||
Reference in New Issue
Block a user