diff --git a/README.md b/README.md index a0e6d18..0da3018 100644 --- a/README.md +++ b/README.md @@ -166,17 +166,17 @@ See **[HLS section](https://video.aminyazdanpanah.com/start?r=hls#hls)** in the #### Encryption(DRM) The encryption process requires some kind of secret (key) together with an encryption algorithm. HLS uses AES in cipher block chaining (CBC) mode. This means each block is encrypted using the ciphertext of the preceding block. [Learn more](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation) -You must specify a path to save a random key to your local machine and also a URL(or a path) to access the key on your website(the key you will save must be accessible from your website). You must pass both these parameters to the `encryption` method: +You must specify a path to save a random key to your local machine and also specify an URL(or a path) to access the key on your website(the key you will save must be accessible from your website). You must pass both these parameters to the `encryption` method: ##### Single Key The following code generates a key for all segment files. ```php //A path you want to save a random key to your local machine -$save_to = '/home/public_html/"PATH TO THE KEY DIRECTORY"/key' +$save_to = '/home/public_html/"PATH TO THE KEY DIRECTORY"/key'; -//A URL (or a path) to access the key on your website -$url = 'https://www.aminyazdanpanah.com/?"PATH TO THE KEY DIRECTORY"/key' +//An URL (or a path) to access the key on your website +$url = 'https://www.aminyazdanpanah.com/?"PATH TO THE KEY DIRECTORY"/key'; // or $url = '/"PATH TO THE KEY DIRECTORY"/key'; $video->hls() @@ -198,6 +198,26 @@ However FFmpeg supports AES encryption for HLS packaging, which you can encrypt **Besides [Apple's FairPlay](https://developer.apple.com/streaming/fps/)** DRM system, you can also use other DRM systems such as **[Microsoft's PlayReady](https://www.microsoft.com/playready/overview/)** and **[Google's Widevine](https://www.widevine.com/)**. +#### Subtitles +You can add subtitles to a HLS stream using `subtitle` method. +```php +use Streaming\HLSSubtitle; + +$persian = new HLSSubtitle('/var/subtitles/subtitles_fa.vtt', 'فارسی', 'fa'); +$persian->default(); +$english = new HLSSubtitle('/var/subtitles/subtitles_en.vtt', 'english', 'en'); +$german = new HLSSubtitle('/var/subtitles/subtitles_de.vtt', 'Deutsch', 'de'); +$chinese = new HLSSubtitle('/var/subtitles/subtitles_zh.vtt', '中文', 'zh'); +$spanish = new HLSSubtitle('/var/subtitles/subtitles_es.vtt', 'Español', 'es'); + +$video->hls() + ->subtitles([$persian, $english, $german, $chinese, $spanish]) + ->x264() + ->autoGenerateRepresentations([1080, 720]) + ->save('/var/media/hls-stream.m3u8'); +``` +**NOTE:** All m3u8 files will be generated using rules based on **[RFC 8216](https://tools.ietf.org/html/rfc8216#section-3.5)**. Only **[WebVTT](https://www.w3.org/TR/webvtt1/)** files are acceptable for now. + ### Transcoding A format can also extend `FFMpeg\Format\ProgressableInterface` to get realtime information about the transcoding. ```php diff --git a/src/File.php b/src/File.php index 2427e99..efc9bc6 100644 --- a/src/File.php +++ b/src/File.php @@ -127,6 +127,16 @@ public static function move(string $src, string $dst): void static::remove($src); } + /** + * @param string $src + * @param string $dst + * @param bool $force + */ + public static function copy(string $src, string $dst, bool $force = true): void + { + static::filesystem('copy', [$src, $dst, $force]); + } + /** * @param $dir */ diff --git a/src/HLS.php b/src/HLS.php index 5ff6bd9..6ebb8e2 100644 --- a/src/HLS.php +++ b/src/HLS.php @@ -53,6 +53,9 @@ class HLS extends Streaming /** @var array */ private $flags = []; + /** @var array */ + private $subtitles = []; + /** * @return string */ @@ -141,6 +144,22 @@ public function encryption(string $save_to, string $url, int $key_rotation_perio return $this; } + public function subtitle(HLSSubtitle $subtitle) + { + array_push($this->subtitles, $subtitle); + return $this; + } + + /** + * @param array $subtitles + * @return HLS + */ + public function subtitles(array $subtitles): HLS + { + array_walk($subtitles, [$this, 'subtitle']); + return $this; + } + /** * @return string */ @@ -277,6 +296,10 @@ protected function getPath(): string $path = $this->getFilePath(); $reps = $this->getRepresentations(); + if(!empty($this->subtitles)){ + $this->generateSubs($path); + } + $this->savePlaylist($path . ".m3u8"); return $path . "_" . $reps->end()->getHeight() . "p.m3u8"; @@ -291,6 +314,34 @@ public function savePlaylist(string $path): void $mater_playlist->save($this->master_playlist ?? $path, $this->stream_des); } + /** + * @param string $path + */ + private function generateSubs(string $path) + { + $this->stream_des = array_merge($this->stream_des, [PHP_EOL]); + + foreach ($this->subtitles as $subtitle) { + if($subtitle instanceof HLSSubtitle){ + $subtitle->generateM3U8File("{$path}_subtitles_{$subtitle->getLanguageCode()}.m3u8", $this->getDuration()); + array_push($this->stream_des, (string)$subtitle); + } + } + array_push($this->stream_des, PHP_EOL); + + $this->getRepresentations()->map(function (Representation $rep){ + return $rep->setHlsStreamInfo(["SUBTITLES" => "\"" . $this->subtitles[0]->getGroupId() . "\""]); + }); + } + + /** + * @return float + */ + private function getDuration():float + { + return $this->getMedia()->getFormat()->get("duration", 0); + } + /** * Clear key info file if is a temp file */ diff --git a/src/HLSPlaylist.php b/src/HLSPlaylist.php index fbbd6af..7b92442 100644 --- a/src/HLSPlaylist.php +++ b/src/HLSPlaylist.php @@ -12,12 +12,14 @@ namespace Streaming; +use FFMpeg\Exception\ExceptionInterface; + class HLSPlaylist { /** @var HLS */ private $hls; - private const DEFAULT_AUDIO_BITRATE = 131072; + private const DEFAULT_AUDIO_BITRATE = 0; //131072; /** * HLSPlaylist constructor. @@ -104,11 +106,15 @@ private function getAudioBitrate(Representation $rep): int */ private function getOriginalAudioBitrate(): int { - return $this->hls - ->getMedia() - ->getStreams() - ->audios() - ->first() - ->get('bit_rate', static::DEFAULT_AUDIO_BITRATE); + try { + return $this->hls + ->getMedia() + ->getStreams() + ->audios() + ->first() + ->get('bit_rate', static::DEFAULT_AUDIO_BITRATE); + } catch (ExceptionInterface $e) { + return static::DEFAULT_AUDIO_BITRATE; + } } } \ No newline at end of file diff --git a/src/HLSSubtitle.php b/src/HLSSubtitle.php new file mode 100644 index 0000000..fb45eb4 --- /dev/null +++ b/src/HLSSubtitle.php @@ -0,0 +1,276 @@ +path = $path; + $this->language_name = $language_name; + $this->language_code = $language_code; + + if(!in_array(pathinfo($path, PATHINFO_EXTENSION), static::SUPPORTED_EXTS)){ + throw new InvalidArgumentException(sprintf("Unsupported input! Only %s files are acceptable.", implode(",", static::SUPPORTED_EXTS))); + } + } + + /** + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * @return string + */ + public function getLanguageName(): string + { + return $this->language_name; + } + + /** + * @return string + */ + public function getLanguageCode(): string + { + return $this->language_code; + } + /** + * @return string|null + */ + public function getUri(): string + { + return $this->uri ?? $this->getBaseName(); + } + + /** + * @return string + */ + private function getFileName(): string + { + return pathinfo($this->path, PATHINFO_FILENAME); + } + + /** + * @return string + */ + private function getBaseName(): string + { + return pathinfo($this->path, PATHINFO_BASENAME); + } + + /** + * @param string|null $uri + */ + public function setUri(string $uri): void + { + $this->uri = $uri; + } + + /** + * @return bool + */ + public function isDefault(): bool + { + return $this->default; + } + + /** + * @param bool $default + */ + public function default(bool $default = true): void + { + $this->default = $default; + } + + /** + * @return bool + */ + public function isAutoSelect(): bool + { + return $this->auto_select; + } + + /** + * @param bool $auto_select + */ + public function autoSelect(bool $auto_select = true): void + { + $this->auto_select = $auto_select; + } + + /** + * @return bool + */ + public function isForce(): bool + { + return $this->force; + } + + /** + * @param bool $force + */ + public function force(bool $force = true): void + { + $this->force = $force; + } + + + /** + * @param int $hls_version + */ + public function setHlsVersion(int $hls_version): void + { + $this->hls_version = $hls_version; + } + + /** + * @param int $media_sequence + */ + public function setMediaSequence(int $media_sequence): void + { + $this->media_sequence = $media_sequence; + } + + /** + * @param string $media_type + */ + public function setMediaType(string $media_type): void + { + $this->media_type = $media_type; + } + + /** + * @return string + */ + public function getGroupId(): string + { + return $this->group_id; + } + + /** + * @param string $group_id + */ + public function setGroupId(string $group_id): void + { + $this->group_id = $group_id; + } + + /** + * @return string + */ + public function getM3u8Uri(): string + { + return $this->m3u8_uri; + } + + /** + * @param string $m3u8_uri + */ + public function setM3u8Uri(string $m3u8_uri): void + { + $this->m3u8_uri = $m3u8_uri; + } + + /** + * @return string + */ + public function __toString() + { + $s_ext = "#EXT-X-MEDIA:"; + + $ext = [ + "TYPE" => "SUBTITLES", + "GROUP-ID" => "\"" . $this->group_id . "\"", + "NAME" => "\"" . $this->language_name . "\"", + "DEFAULT" => Utiles::convertBooleanToYesNo($this->isDefault()), + "AUTOSELECT" => Utiles::convertBooleanToYesNo($this->isAutoSelect()), + "FORCED" => Utiles::convertBooleanToYesNo($this->isForce()), + "LANGUAGE" => "\"" . $this->language_code . "\"", + "URI" => "\"" . $this->getM3u8Uri(). "\"", + ]; + Utiles::concatKeyValue($ext, "="); + + return $s_ext . implode(",", $ext); + } + + public function generateM3U8File(string $path, float $duration, array $description = [], array $info = []): void + { + $ext_x = array_merge($description, [ + "#EXT-X-TARGETDURATION" => intval($duration), + "#EXT-X-VERSION" => $this->hls_version, + "#EXT-X-MEDIA-SEQUENCE" => $this->media_sequence, + "#EXT-X-PLAYLIST-TYPE" => $this->media_type, + "#EXTINF" => implode(",", array_merge([number_format($duration, 1, '.', '')], $info)) + ]); + + Utiles::concatKeyValue($ext_x, ":"); + File::put($path, implode(PHP_EOL, array_merge([static::S_TAG], $ext_x, [$this->getUri(), static::E_TAG]))); + + if(!$this->m3u8_uri){ + $this->setM3u8Uri(pathinfo($path, PATHINFO_BASENAME)); + } + + if(!$this->uri){ + File::copy($this->path, implode(DIRECTORY_SEPARATOR, [dirname($path), $this->getBaseName()])); + } + } +} \ No newline at end of file diff --git a/src/RepsCollection.php b/src/RepsCollection.php index 81e8f5c..ada2ef3 100644 --- a/src/RepsCollection.php +++ b/src/RepsCollection.php @@ -79,6 +79,14 @@ public function get($key, $default = null) return $this->representations[$key] ?? $default; } + /** + * @param callable $func + */ + public function map(callable $func) + { + $this->representations = array_map($func, $this->representations); + } + /** * count of representations */ diff --git a/src/Utiles.php b/src/Utiles.php index 668ee1b..4bb96c5 100644 --- a/src/Utiles.php +++ b/src/Utiles.php @@ -71,4 +71,13 @@ public static function getOS(): string return "unknown"; } } + + /** + * @param bool $isAutoSelect + * @return string + */ + public static function convertBooleanToYesNo(bool $isAutoSelect): string + { + return $isAutoSelect ? "YES" : "NO"; + } } \ No newline at end of file