getMessage()); } } /** * Detect FFmpeg path on server (Ubuntu, CentOS, macOS, CloudPanel) */ public static function getFfmpegBinary(): ?string { $candidates = [ '/usr/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/opt/homebrew/bin/ffmpeg', 'ffmpeg' ]; foreach ($candidates as $bin) { $check = @shell_exec("which " . escapeshellarg($bin) . " 2>/dev/null"); if (!empty($check) || (file_exists($bin) && is_executable($bin))) { return trim($check ?: $bin); } } return null; } // ========================================================================= // METHOD 1: Direct Server API Upload & HTTP 206 Range Streaming // ========================================================================= /** * Handles direct file upload from multipart request * * @param array $file $_FILES['video'] * @param int $courseId * @param string $title * @return array */ public static function handleDirectUpload(array $file, int $courseId, string $title, bool $syncR2Immediately = false): array { self::ensureSchema(); if (empty($file) || $file['error'] !== UPLOAD_ERR_OK) { $errorMsg = match ($file['error'] ?? -1) { UPLOAD_ERR_INI_SIZE => 'حجم الفيديو يتجاوز الحد المسموح في إعدادات السيرفر (upload_max_filesize)', UPLOAD_ERR_FORM_SIZE => 'حجم الفيديو يتجاوز الحد المسموح في النموذج', UPLOAD_ERR_PARTIAL => 'تم رفع جزء من الملف فقط، يرجى إعادة المحاولة', UPLOAD_ERR_NO_FILE => 'لم يتم تحديد أي ملف فيديو', default => 'حدث خطأ أثناء استلام ملف الفيديو على السيرفر' }; throw new \RuntimeException($errorMsg); } // Validate Extension & Mime $allowedMimes = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-matroska', 'video/ogg']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $allowedExts = ['mp4', 'webm', 'mov', 'mkv', 'ogg']; if (!in_array($ext, $allowedExts) || !in_array($mime, $allowedMimes)) { throw new \InvalidArgumentException('نوع الملف غير مدعوم. الصيغ المدعومة هي: MP4, WebM, MOV, MKV.'); } // Generate UUID & Target Directory $videoUuid = 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) ); $storageBase = dirname(__DIR__, 2) . '/storage/videos/' . $courseId; if (!is_dir($storageBase)) { mkdir($storageBase, 0755, true); } $targetFileName = $videoUuid . '.' . $ext; $targetPath = $storageBase . '/' . $targetFileName; $moved = is_uploaded_file($file['tmp_name']) ? move_uploaded_file($file['tmp_name'], $targetPath) : @rename($file['tmp_name'], $targetPath); if (!$moved && is_file($file['tmp_name'])) { $moved = @copy($file['tmp_name'], $targetPath); if ($moved) @unlink($file['tmp_name']); } if (!$moved) { throw new \RuntimeException('فشل حفظ ملف الفيديو على السيرفر، يرجى فحص أذونات المجلد.'); } $relativePath = 'storage/videos/' . $courseId . '/' . $targetFileName; $fileSize = filesize($targetPath); // Attempt automated Server-Side HLS Transcoding $hlsResult = self::transcodeToHls($targetPath, $courseId, $videoUuid); // Automated sync to Cloudflare R2 $r2Config = self::getR2Config(); $r2VideoUrl = null; 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. if (!empty($hlsResult['hls_url'])) { $hlsDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid; $r2HlsPlaylist = self::uploadHlsDirectoryToR2($hlsDir, $courseId, $videoUuid); if ($r2HlsPlaylist) { $hlsResult['hls_url'] = $r2HlsPlaylist; } } $thumbnailPath = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid . '/thumbnail.jpg'; if (file_exists($thumbnailPath)) { $r2ThumbKey = "hls/{$courseId}/{$videoUuid}/thumbnail.jpg"; $r2ThumbUrl = self::uploadToR2($thumbnailPath, $r2ThumbKey, 'image/jpeg'); if ($r2ThumbUrl) { $hlsResult['thumbnail_url'] = $r2ThumbUrl; } } } 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, 'duration' => $hlsResult['duration_seconds'] ?? 0, 'file_size' => $fileSize, 'mime_type' => $mime, 'extension' => $ext, 'storage_type' => 'api_upload', '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 */ public static function transcodeToHls(string $sourceMp4Path, int $courseId, string $videoUuid): array { $ffmpeg = self::getFfmpegBinary(); if (!$ffmpeg || !file_exists($sourceMp4Path)) { return [ 'hls_url' => null, 'thumbnail_url' => null, 'duration_seconds' => 0 ]; } $hlsOutputDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid; if (!is_dir($hlsOutputDir)) { mkdir($hlsOutputDir, 0755, true); } $playlistPath = $hlsOutputDir . '/index.m3u8'; $segmentPattern = $hlsOutputDir . '/segment_%03d.ts'; $thumbnailPath = $hlsOutputDir . '/thumbnail.jpg'; // Measure real video duration from input file $durationSeconds = 10; $probeCmd = "{$ffmpeg} -i " . escapeshellarg($sourceMp4Path) . " 2>&1"; $probeOutput = @shell_exec($probeCmd); if ($probeOutput && preg_match('/Duration:\s*(\d{2}):(\d{2}):(\d{2})/i', $probeOutput, $m)) { $durationSeconds = ($m[1] * 3600) + ($m[2] * 60) + (int)$m[3]; } if ($durationSeconds <= 0) { $durationSeconds = 10; } // 1. Generate Thumbnail Screenshot at 1 second (or 00:00:01) $thumbTime = ($durationSeconds > 2) ? '00:00:02' : '00:00:01'; $thumbCmd = "{$ffmpeg} -ss {$thumbTime} -i " . escapeshellarg($sourceMp4Path) . " -vframes 1 -q:v 2 " . escapeshellarg($thumbnailPath) . " -y 2>/dev/null"; @shell_exec($thumbCmd); // 2. Generate HLS Segments & Playlist (6-second chunks for fast start) // Try ultra-fast stream copy first (takes 2-4s instead of 4 minutes of CPU encoding) $hlsFastCmd = "{$ffmpeg} -i " . escapeshellarg($sourceMp4Path) . " -c copy -hls_time 6 -hls_list_size 0 -hls_segment_filename " . escapeshellarg($segmentPattern) . " " . escapeshellarg($playlistPath) . " -y 2>/dev/null"; @shell_exec($hlsFastCmd); // Fallback to transcoding only if stream copy failed to produce playlist if (!file_exists($playlistPath)) { $hlsCmd = "{$ffmpeg} -i " . escapeshellarg($sourceMp4Path) . " -codec:v libx264 -crf 23 -preset veryfast -codec:a aac -b:a 128k -hls_time 6 -hls_list_size 0 -hls_segment_filename " . escapeshellarg($segmentPattern) . " " . escapeshellarg($playlistPath) . " -y 2>/dev/null"; @shell_exec($hlsCmd); } $hlsUrl = '/api/videos/hls/' . $videoUuid . '/index.m3u8'; $thumbUrl = '/api/videos/hls/' . $videoUuid . '/thumbnail.jpg'; return [ 'hls_url' => file_exists($playlistPath) ? $hlsUrl : null, 'thumbnail_url' => file_exists($thumbnailPath) ? $thumbUrl : null, 'duration_seconds' => $durationSeconds ]; } /** * Stream HLS Playlist (.m3u8), Video Segment (.ts), or Thumbnail (.jpg) */ public static function streamHlsFile(string $videoUuid, string $filename): void { self::ensureSchema(); $lesson = Database::selectOne("SELECT * FROM lessons WHERE video_uuid = ? LIMIT 1", [$videoUuid]); if (!$lesson) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['status' => 'error', 'message' => 'ملف البث غير موجود']); exit; } $courseId = (int)$lesson['course_id']; $cleanFilename = basename($filename); $filePath = dirname(__DIR__, 2) . "/storage/hls/{$courseId}/{$videoUuid}/{$cleanFilename}"; if (!file_exists($filePath)) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['status' => 'error', 'message' => 'المقطع المطلوب مفقود']); exit; } $ext = strtolower(pathinfo($cleanFilename, PATHINFO_EXTENSION)); $mime = match ($ext) { 'm3u8' => 'application/vnd.apple.mpegurl', 'ts' => 'video/MP2T', 'jpg', 'jpeg' => 'image/jpeg', 'key' => 'application/octet-stream', default => 'application/octet-stream' }; if (ob_get_length()) { ob_clean(); } header('Content-Type: ' . $mime); header('Cache-Control: public, max-age=86400'); header('Access-Control-Allow-Origin: *'); header('Content-Length: ' . filesize($filePath)); readfile($filePath); exit; } /** * Stream Local Video file supporting HTTP 206 Partial Content (Range requests) */ public static function streamLocalVideo(string $videoUuid): void { self::ensureSchema(); $lesson = Database::selectOne("SELECT * FROM lessons WHERE video_uuid = ? LIMIT 1", [$videoUuid]); if (!$lesson || empty($lesson['local_path'])) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['status' => 'error', 'message' => 'ملف الفيديو غير موجود']); exit; } $fullPath = dirname(__DIR__, 2) . '/' . ltrim($lesson['local_path'], '/'); if (!file_exists($fullPath)) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['status' => 'error', 'message' => 'الملف المحفوظ مفقود على القرص']); exit; } $fileSize = filesize($fullPath); $fp = @fopen($fullPath, 'rb'); if (!$fp) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => 'تعذر فتح ملف الفيديو']); exit; } $mime = 'video/mp4'; $ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)); if ($ext === 'webm') $mime = 'video/webm'; if ($ext === 'mov') $mime = 'video/quicktime'; if ($ext === 'mkv') $mime = 'video/x-matroska'; $start = 0; $end = $fileSize - 1; if (ob_get_length()) { ob_clean(); } header('Content-Type: ' . $mime); header('Accept-Ranges: bytes'); header('Cache-Control: public, max-age=3600'); header('X-Content-Type-Options: nosniff'); header('Access-Control-Allow-Origin: *'); if (isset($_SERVER['HTTP_RANGE'])) { $range = $_SERVER['HTTP_RANGE']; if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $range, $matches)) { $start = (int)$matches[1]; if (!empty($matches[2])) { $end = (int)$matches[2]; } } if ($start > $end || $start >= $fileSize) { http_response_code(416); header("Content-Range: bytes */{$fileSize}"); fclose($fp); exit; } http_response_code(206); header("Content-Range: bytes {$start}-{$end}/{$fileSize}"); $length = ($end - $start) + 1; header("Content-Length: {$length}"); } else { http_response_code(200); header("Content-Length: {$fileSize}"); } fseek($fp, $start); $bufferSize = 1024 * 128; // 128KB chunk stream while (!feof($fp) && ($pos = ftell($fp)) <= $end) { if ($pos + $bufferSize > $end) { $bufferSize = $end - $pos + 1; } if ($bufferSize <= 0) break; echo fread($fp, $bufferSize); flush(); } fclose($fp); exit; } // ========================================================================= // METHOD 2: Bunny.net Stream Cloud Video CDN & Token DRM // ========================================================================= private static function getBunnyConfig(): array { return [ 'library_id' => getenv('BUNNY_STREAM_LIBRARY_ID') ?: '', 'api_key' => getenv('BUNNY_STREAM_API_KEY') ?: '', 'token_key' => getenv('BUNNY_STREAM_TOKEN_KEY') ?: getenv('JWT_SECRET') ?: '', 'pull_zone_host' => getenv('BUNNY_STREAM_HOST') ?: '', 'embed_host' => 'iframe.mediadelivery.net' ]; } public static function createBunnyVideo(string $title, ?string $collectionId = null): array { $config = self::getBunnyConfig(); if (empty($config['api_key'])) { throw new \RuntimeException('Bunny Stream غير مهيأ: أضف مفاتيح الوصول قبل إنشاء فيديو سحابي.'); /* $mockGuid = 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) ); return [ 'video_id' => $mockGuid, 'library_id' => $config['library_id'], 'title' => $title, 'status' => 'created', 'is_mock' => true, 'direct_embed' => "https://{$config['embed_host']}/embed/{$config['library_id']}/{$mockGuid}", 'hls_playlist' => "https://{$config['pull_zone_host']}/{$mockGuid}/playlist.m3u8" ]; */ } $url = "https://video.bunnycdn.com/library/{$config['library_id']}/videos"; $data = ['title' => $title]; if ($collectionId) { $data['collectionId'] = $collectionId; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => [ 'AccessKey: ' . $config['api_key'], 'Content-Type: application/json', 'Accept: application/json' ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15 ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $json = json_decode($response, true); if ($httpCode >= 200 && $httpCode < 300 && !empty($json['guid'])) { $guid = $json['guid']; return [ 'video_id' => $guid, 'library_id' => $config['library_id'], 'title' => $json['title'] ?? $title, 'status' => 'created', 'direct_embed' => "https://{$config['embed_host']}/embed/{$config['library_id']}/{$guid}", 'hls_playlist' => "https://{$config['pull_zone_host']}/{$guid}/playlist.m3u8" ]; } throw new \RuntimeException("Bunny Stream API Error ({$httpCode}): " . ($json['message'] ?? $response)); } public static function uploadBunnyVideo(string $videoId, string $filePath): bool { $config = self::getBunnyConfig(); if (empty($config['api_key']) || !file_exists($filePath)) { return false; } $url = "https://video.bunnycdn.com/library/{$config['library_id']}/videos/{$videoId}"; $fp = fopen($filePath, 'r'); $fileSize = filesize($filePath); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_PUT => true, CURLOPT_INFILE => $fp, CURLOPT_INFILESIZE => $fileSize, CURLOPT_HTTPHEADER => [ 'AccessKey: ' . $config['api_key'], 'Content-Type: application/octet-stream' ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 600 ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); fclose($fp); curl_close($ch); return ($httpCode >= 200 && $httpCode < 300); } public static function generateBunnySignedPlayback(string $videoId, int $expiresInSeconds = 7200): array { $config = self::getBunnyConfig(); $libraryId = $config['library_id']; $tokenKey = $config['token_key']; $expires = time() + $expiresInSeconds; $hashable = $tokenKey . $videoId . $expires; $token = hash('sha256', $hashable); $embedUrl = "https://{$config['embed_host']}/embed/{$libraryId}/{$videoId}?token={$token}&expires={$expires}"; $hlsUrl = "https://{$config['pull_zone_host']}/{$videoId}/playlist.m3u8?token={$token}&expires={$expires}"; $thumbUrl = "https://{$config['pull_zone_host']}/{$videoId}/thumbnail.jpg?token={$token}&expires={$expires}"; return [ 'video_id' => $videoId, 'library_id' => $libraryId, 'embed_url' => $embedUrl, 'hls_url' => $hlsUrl, 'thumb_url' => $thumbUrl, 'token' => $token, 'expires_at' => $expires ]; } // ========================================== // Cloudflare R2 Object Storage & CDN Engine // ========================================== public static function getR2Config(): array { return [ 'account_id' => getenv('CLOUDFLARE_R2_ACCOUNT_ID') ?: '', 'access_key' => getenv('CLOUDFLARE_R2_ACCESS_KEY') ?: '', 'secret_key' => getenv('CLOUDFLARE_R2_SECRET_KEY') ?: '', 'endpoint' => getenv('CLOUDFLARE_R2_ENDPOINT') ?: '', 'bucket' => getenv('CLOUDFLARE_R2_BUCKET') ?: '', 'public_url' => getenv('CLOUDFLARE_R2_PUBLIC_URL') ?: '' ]; } /** * Upload any file (video, segment, pdf, thumbnail) to Cloudflare R2 with AWS SigV4 */ public static function uploadToR2(string $localFilePath, string $r2Key, string $contentType = 'application/octet-stream'): ?string { if (!file_exists($localFilePath)) { return null; } $config = self::getR2Config(); $bucket = $config['bucket']; $host = "{$bucket}.{$config['account_id']}.r2.cloudflarestorage.com"; $endpoint = "https://{$host}/" . ltrim($r2Key, '/'); $payload = file_get_contents($localFilePath); $payloadHash = hash('sha256', $payload); $date = gmdate('Ymd\THis\Z'); $shortDate = gmdate('Ymd'); $region = 'auto'; $service = 's3'; // AWS SigV4 Canonical Headers $canonicalHeaders = "host:{$host}\nx-amz-content-sha256:{$payloadHash}\nx-amz-date:{$date}\n"; $signedHeaders = 'host;x-amz-content-sha256;x-amz-date'; $canonicalRequest = "PUT\n/" . ltrim($r2Key, '/') . "\n\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}"; $stringToSign = "AWS4-HMAC-SHA256\n{$date}\n{$shortDate}/{$region}/{$service}/aws4_request\n" . hash('sha256', $canonicalRequest); // Calculate Signing Key $kSecret = 'AWS4' . $config['secret_key']; $kDate = hash_hmac('sha256', $shortDate, $kSecret, true); $kRegion = hash_hmac('sha256', $region, $kDate, true); $kService = hash_hmac('sha256', $service, $kRegion, true); $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true); $signature = hash_hmac('sha256', $stringToSign, $kSigning); $authHeader = "AWS4-HMAC-SHA256 Credential={$config['access_key']}/{$shortDate}/{$region}/{$service}/aws4_request, SignedHeaders={$signedHeaders}, Signature={$signature}"; $ch = curl_init($endpoint); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => [ "Host: {$host}", "x-amz-date: {$date}", "x-amz-content-sha256: {$payloadHash}", "Authorization: {$authHeader}", "Content-Type: {$contentType}", "Content-Length: " . strlen($payload) ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 300 ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode >= 200 && $httpCode < 300 && !empty($config['public_url'])) { return rtrim($config['public_url'], '/') . '/' . ltrim($r2Key, '/'); } return null; } /** Upload a complete HLS directory and return the public playlist URL. */ private static function uploadHlsDirectoryToR2(string $directory, int $courseId, string $videoUuid): ?string { if (!is_dir($directory)) return null; $playlistUrl = null; foreach (glob($directory . '/*') ?: [] as $filePath) { if (!is_file($filePath)) continue; $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $contentType = match ($extension) { 'm3u8' => 'application/vnd.apple.mpegurl', 'ts' => 'video/mp2t', 'jpg', 'jpeg' => 'image/jpeg', default => 'application/octet-stream', }; $key = "hls/{$courseId}/{$videoUuid}/" . basename($filePath); $url = self::uploadToR2($filePath, $key, $contentType); if ($extension === 'm3u8' && $url) $playlistUrl = $url; } return $playlistUrl; } }