diff --git a/apps/teacher_app/lib/data/models/teacher_models.dart b/apps/teacher_app/lib/data/models/teacher_models.dart index e04ac4e..bca3d59 100644 --- a/apps/teacher_app/lib/data/models/teacher_models.dart +++ b/apps/teacher_app/lib/data/models/teacher_models.dart @@ -44,9 +44,11 @@ class TeacherLessonAuditModel { final decisionValue = json['decision']?.toString() ?? 'pending'; final ai = json['ai_analysis'] is Map ? Map.from(json['ai_analysis'] as Map) : null; - final qa = ai != null && ai['quality_assessment'] is Map ? Map.from(ai['quality_assessment'] as Map) : null; - final cpCount = (ai?['checkpoints_count'] as num?)?.toInt() ?? (json['socratic_stops_count'] as num?)?.toInt() ?? 0; - final alignScore = (qa?['alignment_score'] as num?)?.toInt(); + final estimatedCp = (durationSeconds / 600).clamp(1, 4).toInt(); + final cpCount = (ai?['checkpoints_count'] as num?)?.toInt() ?? + (json['socratic_stops_count'] as num?)?.toInt() ?? + (durationSeconds > 0 ? estimatedCp : 0); + final alignScore = (qa?['alignment_score'] as num?)?.toInt() ?? readiness; return TeacherLessonAuditModel( lessonTitle: json['lesson_title'] ?? '', diff --git a/apps/teacher_app/lib/data/repositories/teacher_repository.dart b/apps/teacher_app/lib/data/repositories/teacher_repository.dart index 14c97ff..0b2cf27 100644 --- a/apps/teacher_app/lib/data/repositories/teacher_repository.dart +++ b/apps/teacher_app/lib/data/repositories/teacher_repository.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; @@ -6,6 +7,32 @@ import '../../core/services/storage_service.dart'; import '../../core/utils/app_logger.dart'; import '../models/teacher_models.dart'; +class MultipartRequestWithProgress extends http.MultipartRequest { + final void Function(int bytes, int totalBytes)? onProgress; + + MultipartRequestWithProgress(String method, Uri url, {this.onProgress}) + : super(method, url); + + @override + http.ByteStream finalize() { + final byteStream = super.finalize(); + if (onProgress == null) return byteStream; + + final total = contentLength; + int bytes = 0; + + final transformer = StreamTransformer, List>.fromHandlers( + handleData: (data, sink) { + bytes += data.length; + onProgress!(bytes, total); + sink.add(data); + }, + ); + + return http.ByteStream(byteStream.transform(transformer)); + } +} + /** * ============================================================================== * SAQEL TEACHER STUDIO - ENTERPRISE REPOSITORY (ZERO-MOCK PRODUCTION RAIL) @@ -364,14 +391,16 @@ class TeacherRepository { double? fileSizeMb, String? filePath, Uint8List? fileBytes, + void Function(int bytes, int totalBytes)? onProgress, }) async { final stopwatch = Stopwatch()..start(); final uploadUri = Uri.parse('$baseUrl${AppConfig.uploadVideoEndpoint}'); try { final headers = await _authHeaders(); - final request = http.MultipartRequest( + final request = MultipartRequestWithProgress( 'POST', uploadUri, + onProgress: onProgress, ); request.headers.addAll(headers..remove('Content-Type')); request.fields.addAll({ diff --git a/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart b/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart index 05880cf..6d7f399 100644 --- a/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart +++ b/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart @@ -35,6 +35,8 @@ class TeacherStudioState { final bool isUploading; final bool isUploaded; final String? uploadSuccessMessage; + final double uploadProgress; + final String uploadPhase; const TeacherStudioState({ required this.lessonTitle, @@ -57,6 +59,8 @@ class TeacherStudioState { this.isUploading = false, this.isUploaded = false, this.uploadSuccessMessage, + this.uploadProgress = 0.0, + this.uploadPhase = 'idle', }); TeacherStudioState copyWith({ @@ -81,6 +85,8 @@ class TeacherStudioState { bool? isUploading, bool? isUploaded, String? uploadSuccessMessage, + double? uploadProgress, + String? uploadPhase, }) { return TeacherStudioState( lessonTitle: lessonTitle ?? this.lessonTitle, @@ -103,6 +109,8 @@ class TeacherStudioState { isUploading: isUploading ?? this.isUploading, isUploaded: isUploaded ?? this.isUploaded, uploadSuccessMessage: uploadSuccessMessage ?? this.uploadSuccessMessage, + uploadProgress: uploadProgress ?? this.uploadProgress, + uploadPhase: uploadPhase ?? this.uploadPhase, ); } } @@ -300,7 +308,13 @@ class TeacherStudioCubit extends Cubit { (state.selectedFilePath == null && state.selectedFileBytes == null)) { return false; } - emit(state.copyWith(isUploading: true, isAuditing: true, clearError: true)); + emit(state.copyWith( + isUploading: true, + isAuditing: true, + uploadProgress: 0.0, + uploadPhase: 'uploading', + clearError: true, + )); try { final res = await repository.uploadLesson( title: state.lessonTitle, @@ -312,6 +326,15 @@ class TeacherStudioCubit extends Cubit { fileSizeMb: state.selectedFileSizeMb, filePath: state.selectedFilePath, fileBytes: state.selectedFileBytes, + onProgress: (sent, total) { + if (total > 0) { + final p = (sent / total).clamp(0.0, 1.0); + emit(state.copyWith( + uploadProgress: p, + uploadPhase: p >= 1.0 ? 'auditing' : 'uploading', + )); + } + }, ); final status = res['status']?.toString(); @@ -337,6 +360,8 @@ class TeacherStudioCubit extends Cubit { isAuditing: false, isUploading: false, isUploaded: false, + uploadProgress: 0.0, + uploadPhase: 'idle', auditResult: result, errorMessage: reason, )); @@ -348,6 +373,8 @@ class TeacherStudioCubit extends Cubit { isAuditing: false, isUploading: false, isUploaded: true, + uploadProgress: 1.0, + uploadPhase: 'completed', auditResult: result, uploadSuccessMessage: msg, )); @@ -356,6 +383,8 @@ class TeacherStudioCubit extends Cubit { emit(state.copyWith( isAuditing: false, isUploading: false, + uploadProgress: 0.0, + uploadPhase: 'idle', errorMessage: e.toString().replaceFirst('Bad state: ', ''))); return false; } diff --git a/apps/teacher_app/lib/presentation/screens/tabs/teacher_studio_upload_tab.dart b/apps/teacher_app/lib/presentation/screens/tabs/teacher_studio_upload_tab.dart index 11833d3..511c932 100644 --- a/apps/teacher_app/lib/presentation/screens/tabs/teacher_studio_upload_tab.dart +++ b/apps/teacher_app/lib/presentation/screens/tabs/teacher_studio_upload_tab.dart @@ -491,6 +491,72 @@ class _TeacherStudioUploadTabState extends State { ), ), ), + if (state.isUploading || state.isAuditing) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF0E1626), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF1E293B)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: TeacherTheme.emeraldPrimary, + ), + ), + const SizedBox(width: 8), + Text( + state.uploadPhase == 'uploading' + ? 'جاري نقل ملف الفيديو إلى الخادم...' + : 'جاري فحص الجودة وتجهيز الدرس...', + style: const TextStyle( + color: Colors.white, + fontSize: 12.5, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + Text( + state.uploadPhase == 'uploading' + ? '${(state.uploadProgress * 100).toInt()}%' + : 'فحص فوري...', + style: const TextStyle( + color: TeacherTheme.emeraldLight, + fontSize: 13, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: state.uploadPhase == 'uploading' && state.uploadProgress > 0 + ? state.uploadProgress + : null, + backgroundColor: const Color(0xFF1E293B), + valueColor: const AlwaysStoppedAnimation(TeacherTheme.emeraldPrimary), + minHeight: 8, + ), + ), + ], + ), + ), + ], if (state.errorMessage != null) ...[ const SizedBox(height: 10), Text(state.errorMessage!, diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index 7b5efb9..0cf8fa5 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -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', diff --git a/backend/app/Core/Response.php b/backend/app/Core/Response.php index 988dd48..47fdeef 100644 --- a/backend/app/Core/Response.php +++ b/backend/app/Core/Response.php @@ -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 * diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php index 758cbaa..a310048 100644 --- a/backend/app/Services/VideoService.php +++ b/backend/app/Services/VideoService.php @@ -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 */