Update Saqel Platform: 2026-09-09 00:24:41

This commit is contained in:
Hamza-Ayed
2026-09-09 00:24:41 +03:00
parent 956bb4a674
commit 0c239c431d
7 changed files with 275 additions and 27 deletions
+44 -16
View File
@@ -151,12 +151,13 @@ class VideoController
}
try {
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title);
// Fast direct upload: save file locally and slice HLS instantly via stream copy (-c copy)
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title, false);
// Insert lesson record with HLS references
$lessonId = Database::insert(
// Insert lesson record with HLS references and processing status
$lessonId = (int)Database::insert(
"INSERT INTO lessons (course_id, title, curriculum_key, 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')",
VALUES (?, ?, ?, ?, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'processing')",
[
$courseId,
$title,
@@ -173,21 +174,48 @@ class VideoController
Database::query('UPDATE video_upload_audits SET lesson_id = ? WHERE id = ?', [$lessonId, $auditId]);
}
// Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation (Silent Background Execution)
$aiReport = AiVideoAnalyzerService::processLessonAutonomously($lessonId);
$response->status(201)->json([
// Immediately send HTTP 201 response to Flutter client and close connection
$responsePayload = [
'status' => 'success',
'message' => 'تم رفع الفيديو وتقطيعه بتقنية HLS وتوليد الفحص السقراطي الذكي تلقائياً بنجاح!',
'message' => 'تم رفع الفيديو واعتماده مبدئياً بنجاح. تجري المزامنة السحابية والتحليل السقراطي في الخلفية.',
'data' => array_merge($uploadResult, [
'lesson_id' => $lessonId,
'title' => $title,
'course_id' => $courseId,
'ai_analysis' => $aiReport
,'preflight_report' => $preflight,
'audit_id' => $auditId,
'lesson_id' => $lessonId,
'title' => $title,
'course_id' => $courseId,
'preflight_report' => $preflight,
'audit_id' => $auditId,
])
]);
];
$response->jsonAndFinish($responsePayload, 201);
// -------------------------------------------------------------------------
// ASYNCHRONOUS BACKGROUND WORKER (PHP-FPM continues running after client exit)
// -------------------------------------------------------------------------
@ignore_user_abort(true);
@set_time_limit(600);
try {
// 1. Sync MP4, HLS segments, and thumbnail to Cloudflare R2
VideoService::syncLessonToR2(
$lessonId,
$courseId,
$uploadResult['target_path'],
$uploadResult['target_file_name'],
$uploadResult['video_uuid'],
$uploadResult['mime_type']
);
// 2. Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation
AiVideoAnalyzerService::processLessonAutonomously($lessonId);
// 3. Mark encoding as ready
Database::query("UPDATE lessons SET encoding_status = 'ready' WHERE id = ?", [$lessonId]);
} catch (\Throwable $bgError) {
error_log("Background sync/analysis error for lesson {$lessonId}: " . $bgError->getMessage());
}
exit;
} catch (\Throwable $e) {
$response->status(500)->json([
'status' => 'error',
+40
View File
@@ -64,6 +64,46 @@ class Response
exit;
}
/**
* Send JSON response, flush and disconnect client via fastcgi_finish_request,
* but keep PHP script running in background for heavy async tasks.
*
* @param mixed $data
* @param int|null $code
* @return void
*/
public function jsonAndFinish($data, ?int $code = null): void
{
if ($code !== null) {
$this->setStatusCode($code);
}
$this->setHeader('Content-Type', 'application/json; charset=utf-8');
$allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*';
$this->setHeader('Access-Control-Allow-Origin', $allowedOrigin);
$this->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$this->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
$this->setHeader('Vary', 'Origin');
$this->sendHeaders();
http_response_code($this->statusCode);
if (ob_get_length()) {
ob_clean();
}
echo json_encode($data, JSON_UNESCAPED_UNICODE);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
} else {
if (ob_get_level() > 0) {
ob_end_flush();
}
flush();
}
}
/**
* Send HTML response and terminate execution
*
+60 -6
View File
@@ -48,6 +48,11 @@ class VideoService
Database::query("ALTER TABLE lessons ADD COLUMN local_path VARCHAR(500) NULL AFTER bunny_video_id");
}
$colsR2 = Database::select("SHOW COLUMNS FROM lessons LIKE 'r2_url'");
if (empty($colsR2)) {
Database::query("ALTER TABLE lessons ADD COLUMN r2_url VARCHAR(500) NULL AFTER local_path");
}
$colsHls = Database::select("SHOW COLUMNS FROM lessons LIKE 'hls_url'");
if (empty($colsHls)) {
Database::query("ALTER TABLE lessons ADD COLUMN hls_url VARCHAR(500) NULL AFTER local_path");
@@ -121,7 +126,7 @@ class VideoService
* @param string $title
* @return array
*/
public static function handleDirectUpload(array $file, int $courseId, string $title): array
public static function handleDirectUpload(array $file, int $courseId, string $title, bool $syncR2Immediately = false): array
{
self::ensureSchema();
@@ -183,16 +188,14 @@ class VideoService
// Attempt automated Server-Side HLS Transcoding
$hlsResult = self::transcodeToHls($targetPath, $courseId, $videoUuid);
// Attempt automated sync to Cloudflare R2
// Automated sync to Cloudflare R2
$r2Config = self::getR2Config();
$r2VideoUrl = null;
if (!empty($r2Config['access_key']) && !empty($r2Config['secret_key']) && !empty($r2Config['bucket'])) {
if ($syncR2Immediately && !empty($r2Config['access_key']) && !empty($r2Config['secret_key']) && !empty($r2Config['bucket'])) {
$r2Key = "videos/{$courseId}/{$targetFileName}";
$r2VideoUrl = self::uploadToR2($targetPath, $r2Key, $mime);
// The player must receive a complete HLS package, not only the source MP4.
// Upload the playlist and every segment using the same relative layout so
// relative segment references in index.m3u8 continue to resolve on R2.
if (!empty($hlsResult['hls_url'])) {
$hlsDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid;
$r2HlsPlaylist = self::uploadHlsDirectoryToR2($hlsDir, $courseId, $videoUuid);
@@ -214,6 +217,8 @@ class VideoService
return [
'video_uuid' => $videoUuid,
'local_path' => $relativePath,
'target_path' => $targetPath,
'target_file_name'=> $targetFileName,
'r2_url' => $r2VideoUrl,
'hls_url' => $hlsResult['hls_url'] ?? null,
'thumbnail_url' => $hlsResult['thumbnail_url'] ?? null,
@@ -222,11 +227,60 @@ class VideoService
'mime_type' => $mime,
'extension' => $ext,
'storage_type' => 'api_upload',
'encoding_status' => 'ready',
'encoding_status' => $syncR2Immediately ? 'ready' : 'processing',
'stream_url' => '/api/videos/stream/' . $videoUuid
];
}
/**
* Background Pipeline: Asynchronously sync local video & HLS chunks to Cloudflare R2
*/
public static function syncLessonToR2(int $lessonId, int $courseId, string $targetPath, string $targetFileName, string $videoUuid, string $mime): array
{
$r2Config = self::getR2Config();
if (empty($r2Config['access_key']) || empty($r2Config['secret_key']) || empty($r2Config['bucket'])) {
return ['r2_url' => null, 'hls_url' => null];
}
$r2Key = "videos/{$courseId}/{$targetFileName}";
$r2VideoUrl = self::uploadToR2($targetPath, $r2Key, $mime);
$hlsDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid;
$r2HlsPlaylist = self::uploadHlsDirectoryToR2($hlsDir, $courseId, $videoUuid);
$thumbnailPath = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid . '/thumbnail.jpg';
$r2ThumbUrl = null;
if (file_exists($thumbnailPath)) {
$r2ThumbKey = "hls/{$courseId}/{$videoUuid}/thumbnail.jpg";
$r2ThumbUrl = self::uploadToR2($thumbnailPath, $r2ThumbKey, 'image/jpeg');
}
$updates = [];
$params = [];
if ($r2VideoUrl) {
$updates[] = 'r2_url = ?';
$params[] = $r2VideoUrl;
}
if ($r2HlsPlaylist) {
$updates[] = 'hls_url = ?';
$params[] = $r2HlsPlaylist;
}
if ($r2ThumbUrl) {
$updates[] = 'thumbnail_url = ?';
$params[] = $r2ThumbUrl;
}
if (!empty($updates)) {
$params[] = $lessonId;
Database::query('UPDATE lessons SET ' . implode(', ', $updates) . ' WHERE id = ?', $params);
}
return [
'r2_url' => $r2VideoUrl,
'hls_url' => $r2HlsPlaylist,
'thumbnail_url' => $r2ThumbUrl,
];
}
/**
* Transcode MP4 to HLS Chunks (.m3u8 and .ts segments) using Server FFmpeg
*/