Update Saqel Platform: 2026-09-09 00:24:41
This commit is contained in:
@@ -44,9 +44,11 @@ class TeacherLessonAuditModel {
|
||||
final decisionValue = json['decision']?.toString() ?? 'pending';
|
||||
|
||||
final ai = json['ai_analysis'] is Map ? Map<String, dynamic>.from(json['ai_analysis'] as Map) : null;
|
||||
final qa = ai != null && ai['quality_assessment'] is Map ? Map<String, dynamic>.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'] ?? '',
|
||||
|
||||
@@ -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<int>, List<int>>.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({
|
||||
|
||||
@@ -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<TeacherStudioState> {
|
||||
(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<TeacherStudioState> {
|
||||
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<TeacherStudioState> {
|
||||
isAuditing: false,
|
||||
isUploading: false,
|
||||
isUploaded: false,
|
||||
uploadProgress: 0.0,
|
||||
uploadPhase: 'idle',
|
||||
auditResult: result,
|
||||
errorMessage: reason,
|
||||
));
|
||||
@@ -348,6 +373,8 @@ class TeacherStudioCubit extends Cubit<TeacherStudioState> {
|
||||
isAuditing: false,
|
||||
isUploading: false,
|
||||
isUploaded: true,
|
||||
uploadProgress: 1.0,
|
||||
uploadPhase: 'completed',
|
||||
auditResult: result,
|
||||
uploadSuccessMessage: msg,
|
||||
));
|
||||
@@ -356,6 +383,8 @@ class TeacherStudioCubit extends Cubit<TeacherStudioState> {
|
||||
emit(state.copyWith(
|
||||
isAuditing: false,
|
||||
isUploading: false,
|
||||
uploadProgress: 0.0,
|
||||
uploadPhase: 'idle',
|
||||
errorMessage: e.toString().replaceFirst('Bad state: ', '')));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -491,6 +491,72 @@ class _TeacherStudioUploadTabState extends State<TeacherStudioUploadTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
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<Color>(TeacherTheme.emeraldPrimary),
|
||||
minHeight: 8,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.errorMessage != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(state.errorMessage!,
|
||||
|
||||
@@ -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,
|
||||
'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',
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user