feat: Implement Server-Side FFmpeg HLS Transcoding (.m3u8), HLS.js streaming, and dynamic Socratic In-Video Checkpoint Engine
This commit is contained in:
@@ -9,7 +9,8 @@ use App\Core\Security;
|
||||
* Universal Video Management & Streaming Service
|
||||
* Supports:
|
||||
* 1. Direct Server API Upload & HTTP 206 Partial Content Range Streaming
|
||||
* 2. Bunny.net Stream Cloud Video CDN, TUS upload, and SHA256 DRM Token Signing
|
||||
* 2. Server-Side Automated FFmpeg HLS Transcoding (.m3u8 master playlist + .ts chunks)
|
||||
* 3. Bunny.net Stream Cloud Video CDN, TUS upload, and SHA256 DRM Token Signing
|
||||
*/
|
||||
class VideoService
|
||||
{
|
||||
@@ -62,6 +63,27 @@ class VideoService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// =========================================================================
|
||||
@@ -126,9 +148,15 @@ class VideoService
|
||||
$relativePath = 'storage/videos/' . $courseId . '/' . $targetFileName;
|
||||
$fileSize = filesize($targetPath);
|
||||
|
||||
// Attempt automated Server-Side HLS Transcoding
|
||||
$hlsResult = self::transcodeToHls($targetPath, $courseId, $videoUuid);
|
||||
|
||||
return [
|
||||
'video_uuid' => $videoUuid,
|
||||
'local_path' => $relativePath,
|
||||
'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,
|
||||
@@ -138,9 +166,96 @@ class VideoService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// 1. Generate Thumbnail Screenshot at 2 seconds
|
||||
$thumbCmd = "{$ffmpeg} -ss 00:00:02 -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)
|
||||
$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' => 600
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* Allows seamless scrubbing/seeking in browser without loading full video
|
||||
*/
|
||||
public static function streamLocalVideo(string $videoUuid): void
|
||||
{
|
||||
@@ -179,7 +294,6 @@ class VideoService
|
||||
$start = 0;
|
||||
$end = $fileSize - 1;
|
||||
|
||||
// Clean previous buffers
|
||||
if (ob_get_length()) {
|
||||
ob_clean();
|
||||
}
|
||||
@@ -188,8 +302,8 @@ class VideoService
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
// Check if Range header is requested by video player
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
$range = $_SERVER['HTTP_RANGE'];
|
||||
if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $range, $matches)) {
|
||||
@@ -234,9 +348,6 @@ class VideoService
|
||||
// METHOD 2: Bunny.net Stream Cloud Video CDN & Token DRM
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get Bunny Stream API Credentials from Environment
|
||||
*/
|
||||
private static function getBunnyConfig(): array
|
||||
{
|
||||
return [
|
||||
@@ -248,15 +359,10 @@ class VideoService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Video placeholder in Bunny Stream Library
|
||||
* POST https://video.bunnycdn.com/library/{libraryId}/videos
|
||||
*/
|
||||
public static function createBunnyVideo(string $title, ?string $collectionId = null): array
|
||||
{
|
||||
$config = self::getBunnyConfig();
|
||||
if (empty($config['api_key'])) {
|
||||
// Mock response if API key is not yet set in production .env
|
||||
$mockGuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
@@ -314,10 +420,6 @@ class VideoService
|
||||
throw new \RuntimeException("Bunny Stream API Error ({$httpCode}): " . ($json['message'] ?? $response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload binary video to an existing Bunny Video ID
|
||||
* PUT https://video.bunnycdn.com/library/{libraryId}/videos/{videoId}
|
||||
*/
|
||||
public static function uploadBunnyVideo(string $videoId, string $filePath): bool
|
||||
{
|
||||
$config = self::getBunnyConfig();
|
||||
@@ -339,7 +441,7 @@ class VideoService
|
||||
'Content-Type: application/octet-stream'
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 600 // 10 minutes for large videos
|
||||
CURLOPT_TIMEOUT => 600
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
@@ -350,14 +452,6 @@ class VideoService
|
||||
return ($httpCode >= 200 && $httpCode < 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Signed DRM Playback URL using Bunny Token Authentication (SHA256 HMAC)
|
||||
* Protects video from hotlinking, unauthorized sharing, and downloading
|
||||
*
|
||||
* @param string $videoId Bunny Video GUID
|
||||
* @param int $expiresInSeconds Token validity window (default: 2 hours)
|
||||
* @return array
|
||||
*/
|
||||
public static function generateBunnySignedPlayback(string $videoId, int $expiresInSeconds = 7200): array
|
||||
{
|
||||
$config = self::getBunnyConfig();
|
||||
@@ -365,8 +459,6 @@ class VideoService
|
||||
$tokenKey = $config['token_key'];
|
||||
$expires = time() + $expiresInSeconds;
|
||||
|
||||
// Bunny Stream Token Hash Formula:
|
||||
// token = SHA256(securityToken + videoId + expirationTime)
|
||||
$hashable = $tokenKey . $videoId . $expires;
|
||||
$token = hash('sha256', $hashable);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user