HLSExporter::prepareSaving()   A
last analyzed

Complexity

Conditions 3
Paths 2

Size

Total Lines 32
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 19
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 32
rs 9.6333
1
<?php
2
3
namespace ProtoneMedia\LaravelFFMpeg\Exporters;
4
5
use Closure;
6
use FFMpeg\Format\FormatInterface;
7
use FFMpeg\Format\Video\DefaultVideo;
8
use FFMpeg\Format\VideoInterface;
9
use Illuminate\Support\Collection;
10
use ProtoneMedia\LaravelFFMpeg\Filesystem\Disk;
11
use ProtoneMedia\LaravelFFMpeg\Filesystem\Media;
12
use ProtoneMedia\LaravelFFMpeg\MediaOpener;
13
14
class HLSExporter extends MediaExporter
15
{
16
    use EncryptsHLSSegments;
17
18
    const HLS_KEY_INFO_FILENAME = 'hls_encryption.keyinfo';
19
    const ENCRYPTION_LISTENER   = "listen-encryption-key";
20
21
    /**
22
     * @var integer
23
     */
24
    private $segmentLength = 10;
25
26
    /**
27
    * @var integer
28
    */
29
    private $keyFrameInterval = 48;
30
31
    /**
32
     * @var \Illuminate\Support\Collection
33
     */
34
    private $pendingFormats;
35
36
    /**
37
     * @var \ProtoneMedia\LaravelFFMpeg\Exporters\PlaylistGenerator
38
     */
39
    private $playlistGenerator;
40
41
    /**
42
     * @var \Closure
43
     */
44
    private $segmentFilenameGenerator = null;
45
46
    /**
47
     * Setter for the segment length
48
     *
49
     * @param integer $length
50
     * @return self
51
     */
52
    public function setSegmentLength(int $length): self
53
    {
54
        $this->segmentLength = $length;
55
56
        return $this;
57
    }
58
59
    /**
60
     * Setter for the Key Frame interval
61
     *
62
     * @param integer $interval
63
     * @return self
64
     */
65
    public function setKeyFrameInterval(int $interval): self
66
    {
67
        $this->keyFrameInterval = $interval;
68
69
        return $this;
70
    }
71
72
    /**
73
     * Method to set a different playlist generator than
74
     * the default HLSPlaylistGenerator.
75
     *
76
     * @param \ProtoneMedia\LaravelFFMpeg\Exporters\PlaylistGenerator $playlistGenerator
77
     * @return self
78
     */
79
    public function withPlaylistGenerator(PlaylistGenerator $playlistGenerator): self
80
    {
81
        $this->playlistGenerator = $playlistGenerator;
82
83
        return $this;
84
    }
85
86
    private function getPlaylistGenerator(): PlaylistGenerator
87
    {
88
        return $this->playlistGenerator ?: new HLSPlaylistGenerator;
89
    }
90
91
    /**
92
     * Setter for a callback that generates a segment filename.
93
     *
94
     * @param Closure $callback
95
     * @return self
96
     */
97
    public function useSegmentFilenameGenerator(Closure $callback): self
98
    {
99
        $this->segmentFilenameGenerator = $callback;
100
101
        return $this;
102
    }
103
104
    /**
105
     * Returns a default generator if none is set.
106
     *
107
     * @return callable
108
     */
109
    private function getSegmentFilenameGenerator(): callable
110
    {
111
        return $this->segmentFilenameGenerator ?: function ($name, $format, $key, $segments, $playlist) {
112
            $segments("{$name}_{$key}_{$format->getKiloBitrate()}_%05d.ts");
113
            $playlist("{$name}_{$key}_{$format->getKiloBitrate()}.m3u8");
114
        };
115
    }
116
117
    /**
118
     * Calls the generator with the path (without extension), format and key.
119
     *
120
     * @param string $baseName
121
     * @param \FFMpeg\Format\VideoInterface $format
122
     * @param integer $key
123
     * @return array
124
     */
125
    private function getSegmentPatternAndFormatPlaylistPath(string $baseName, VideoInterface $format, int $key): array
126
    {
127
        $segmentsPattern    = null;
128
        $formatPlaylistPath = null;
129
130
        call_user_func(
131
            $this->getSegmentFilenameGenerator(),
132
            $baseName,
133
            $format,
134
            $key,
135
            function ($path) use (&$segmentsPattern) {
136
                $segmentsPattern = $path;
137
            },
138
            function ($path) use (&$formatPlaylistPath) {
139
                $formatPlaylistPath = $path;
140
            }
141
        );
142
143
        return [$segmentsPattern, $formatPlaylistPath];
144
    }
145
146
    /**
147
     * Merges the HLS parameters to the given format.
148
     *
149
     * @param \FFMpeg\Format\Video\DefaultVideo $format
150
     * @param string $segmentsPattern
151
     * @param \ProtoneMedia\LaravelFFMpeg\Filesystem\Disk $disk
152
     * @param integer $key
153
     * @return array
154
     */
155
    private function addHLSParametersToFormat(DefaultVideo $format, string $segmentsPattern, Disk $disk, int $key): array
156
    {
157
        $format->setAdditionalParameters(array_merge(
158
            $format->getAdditionalParameters() ?: [],
159
            $hlsParameters = [
160
                '-sc_threshold',
161
                '0',
162
                '-g',
163
                $this->keyFrameInterval,
164
                '-hls_playlist_type',
165
                'vod',
166
                '-hls_time',
167
                $this->segmentLength,
168
                '-hls_segment_filename',
169
                $disk->makeMedia($segmentsPattern)->getLocalPath(),
170
                '-master_pl_name',
171
                $this->generateTemporarySegmentPlaylistFilename($key),
172
            ],
173
            $this->getEncrypedHLSParameters()
174
        ));
175
176
        return $hlsParameters;
177
    }
178
179
    /**
180
     * Gives the callback an HLSVideoFilters object that provides addFilter(),
181
     * addLegacyFilter(), addWatermark() and resize() helper methods. It
182
     * returns a mapping for the video and (optional) audio stream.
183
     *
184
     * @param callable $filtersCallback
185
     * @param integer $formatKey
186
     * @return array
187
     */
188
    private function applyFiltersCallback(callable $filtersCallback, int $formatKey): array
189
    {
190
        $filtersCallback(
191
            $hlsVideoFilters = new HLSVideoFilters($this->driver, $formatKey)
192
        );
193
194
        $filterCount = $hlsVideoFilters->count();
195
196
        $outs = [$filterCount ? HLSVideoFilters::glue($formatKey, $filterCount) : '0:v'];
197
198
        if ($this->getAudioStream()) {
0 ignored issues
show
Bug introduced by
The method getAudioStream() does not exist on ProtoneMedia\LaravelFFMpeg\Exporters\HLSExporter. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

198
        if ($this->/** @scrutinizer ignore-call */ getAudioStream()) {
Loading history...
199
            $outs[] = '0:a';
200
        }
201
202
        return $outs;
203
    }
204
205
    /**
206
     * Returns the filename of a segment playlist by its key. We let FFmpeg generate a playlist
207
     * for each added format so we don't have to detect the bitrate and codec ourselves.
208
     * We use this as a reference so when can generate our own main playlist.
209
     *
210
     * @param int $key
211
     * @return string
212
     */
213
    public static function generateTemporarySegmentPlaylistFilename(int $key): string
214
    {
215
        return "temporary_segment_playlist_{$key}.m3u8";
216
    }
217
218
    /**
219
     * Loops through each added format and then deletes the temporary
220
     * segment playlist, which we generate manually using the
221
     * HLSPlaylistGenerator.
222
     *
223
     * @param \ProtoneMedia\LaravelFFMpeg\Filesystem\Media $media
224
     * @return self
225
     */
226
    private function cleanupSegmentPlaylistGuides(Media $media): self
227
    {
228
        $disk      = $media->getDisk();
229
        $directory = $media->getDirectory();
230
231
        $this->pendingFormats->map(function ($formatAndCallback, $key) use ($disk, $directory) {
232
            $disk->delete($directory . static::generateTemporarySegmentPlaylistFilename($key));
0 ignored issues
show
Bug introduced by
The method delete() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

232
            $disk->/** @scrutinizer ignore-call */ 
233
                   delete($directory . static::generateTemporarySegmentPlaylistFilename($key));
Loading history...
233
        });
234
235
        return $this;
236
    }
237
238
    /**
239
     * Adds a mapping for each added format and automatically handles the mapping
240
     * for filters. Adds a handler to rotate the encryption key (optional).
241
     * Returns a media collection of all segment playlists.
242
     *
243
     * @param string $path
244
     * @throws \ProtoneMedia\LaravelFFMpeg\Exporters\NoFormatException
245
     * @return \Illuminate\Support\Collection
246
     */
247
    private function prepareSaving(string $path = null): Collection
248
    {
249
        if (!$this->pendingFormats) {
250
            throw new NoFormatException;
251
        }
252
253
        $media = $this->getDisk()->makeMedia($path);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $path of ProtoneMedia\LaravelFFMp...ystem\Disk::makeMedia() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

253
        $media = $this->getDisk()->makeMedia(/** @scrutinizer ignore-type */ $path);
Loading history...
254
255
        $baseName = $media->getDirectory() . $media->getFilenameWithoutExtension();
256
257
        return $this->pendingFormats->map(function (array $formatAndCallback, $key) use ($baseName) {
258
            [$format, $filtersCallback] = $formatAndCallback;
259
260
            [$segmentsPattern, $formatPlaylistPath] = $this->getSegmentPatternAndFormatPlaylistPath(
261
                $baseName,
262
                $format,
263
                $key
264
            );
265
266
            $disk = $this->getDisk()->clone();
267
268
            $this->addHLSParametersToFormat($format, $segmentsPattern, $disk, $key);
269
270
            if ($filtersCallback) {
271
                $outs = $this->applyFiltersCallback($filtersCallback, $key);
272
            }
273
            $formatPlaylistOutput = $disk->makeMedia($formatPlaylistPath);
274
            $this->addFormatOutputMapping($format, $formatPlaylistOutput, $outs ?? ['0']);
275
276
            return $formatPlaylistOutput;
277
        })->tap(function () {
278
            $this->addHandlerToRotateEncryptionKey();
279
        });
280
    }
281
282
    /**
283
     * Prepares the saves command but returns the command instead.
284
     *
285
     * @param string $path
286
     * @return mixed
287
     */
288
    public function getCommand(string $path = null)
289
    {
290
        $this->prepareSaving($path);
291
292
        return parent::getCommand(null);
293
    }
294
295
    /**
296
     * Runs the export, generates the main playlist, and cleans up the
297
     * segment playlist guides and temporary HLS encryption keys.
298
     *
299
     * @param string $path
300
     * @return \ProtoneMedia\LaravelFFMpeg\MediaOpener
301
     */
302
    public function save(string $mainPlaylistPath = null): MediaOpener
303
    {
304
        return $this->prepareSaving($mainPlaylistPath)->pipe(function ($segmentPlaylists) use ($mainPlaylistPath) {
305
            $result = parent::save();
306
307
            $playlist = $this->getPlaylistGenerator()->get(
308
                $segmentPlaylists->all(),
309
                $this->driver->fresh()
310
            );
311
312
            $this->getDisk()->put($mainPlaylistPath, $playlist);
0 ignored issues
show
Bug introduced by
The method put() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

312
            $this->getDisk()->/** @scrutinizer ignore-call */ put($mainPlaylistPath, $playlist);
Loading history...
313
314
            $this->replaceAbsolutePathsHLSEncryption($segmentPlaylists)
315
                ->cleanupSegmentPlaylistGuides($segmentPlaylists->first())
316
                ->cleanupHLSEncryption()
317
                ->removeHandlerThatRotatesEncryptionKey();
318
319
            return $result;
320
        });
321
    }
322
323
    /**
324
     * Initializes the $pendingFormats property when needed and adds the format
325
     * with the optional callback to add filters.
326
     *
327
     * @param \FFMpeg\Format\FormatInterface $format
328
     * @param callable $filtersCallback
329
     * @return self
330
     */
331
    public function addFormat(FormatInterface $format, callable $filtersCallback = null): self
332
    {
333
        if (!$this->pendingFormats) {
334
            $this->pendingFormats = new Collection;
335
        }
336
337
        $this->pendingFormats->push([$format, $filtersCallback]);
338
339
        return $this;
340
    }
341
}
342