Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release v0.6.0 #79

Merged
merged 1 commit into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ TRANSMORPHER_STORE_DERIVATIVES=true
TRANSMORPHER_DISK_ORIGINALS=localOriginals
TRANSMORPHER_DISK_IMAGE_DERIVATIVES=localImageDerivatives
TRANSMORPHER_DISK_VIDEO_DERIVATIVES=localVideoDerivatives
TRANSMORPHER_SIGNING_KEYPAIR=
#TRANSMORPHER_SIGNING_KEYPAIR=
TRANSMORPHER_OPTIMIZER_TIMEOUT=10
# More information: https://github.com/cybex-gmbh/transmorpher/tree/release/v0#configuration-options
VIDEO_TRANSCODING_WORKERS_AMOUNT=1
#TRANSMORPHER_VIDEO_ENCODER=cpu-hevc
#TRANSMORPHER_VIDEO_ENCODER_BITRATE=9000k
#CACHE_INVALIDATION_COUNTER_FILE_PATH="cacheInvalidationCounter"

# AWS
Expand Down
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,78 @@ To publicly access a video, the client name, the identifier and a format have to

The [Laravel Transmorpher Client](https://github.com/cybex-gmbh/laravel-transmorpher-client) will receive this information and store it.

### Bit rate

The bit rate for video transcoding can be set in the `.env` file in kilobits:

```dotenv
TRANSMORPHER_VIDEO_ENCODER_BITRATE=9000k
```

This setting will be ignored for the DASH/HLS streaming formats because they are calculated automatically.
For suitable bit rates, see: https://help.twitch.tv/s/article/broadcast-guidelines#recommended

### Streaming Codec

To encode the DASH and HLS formats with either HEVC or h264, set the following environment variable.

```dotenv
TRANSMORPHER_VIDEO_ENCODER=cpu-hevc
```
or
```dotenv
TRANSMORPHER_VIDEO_ENCODER=cpu-h264
```

For the MP4 fallback file, h264 is always used because
- FFmpeg doesn't support HEVC in MP4 files when encoding with a CPU.
- h264 is the most widely supported codec, and this file is to be used when a client does not support HLS or DASH.

### GPU Acceleration

Videos may be transcoded using a machine's nVidia GPU.
This requires the according hardware and driver setup on the host machine.
- https://trac.ffmpeg.org/wiki/HWAccelIntro#NVENC
- https://docs.nvidia.com/video-technologies/video-codec-sdk/pdf/Using_FFmpeg_with_NVIDIA_GPU_Hardware_Acceleration.pdf

The following steps are necessary on a docker host:
- Install nvidia drivers
- Install nvidia container toolkit
- Configure the docker nvidia runtime (note difference for rootless docker)
- Add gpu capabilities and nvidia runtime to according compose.yml files
- Restart docker and according containers

To use GPU encoding with HEVC or h264, set the following environment variable.
This controls the codec used when transcoding videos to HLS and DASH, as well as the device used.

```dotenv
TRANSMORPHER_VIDEO_ENCODER=nvidia-hevc
```
or
```dotenv
TRANSMORPHER_VIDEO_ENCODER=nvidia-h264
```

The nVidia encoders have different presets available.
Higher preset numbers are higher quality and slower.
For encoder specific lists of presets see:

```bash
ffmpeg -h encoder=h264_nvenc
ffmpeg -h encoder=hevc_nvenc
```

The default preset is `p4`. To set the high quality preset, use the following environment variable:

```dotenv
TRANSMORPHER_VIDEO_ENCODER_NVIDIA_PRESET=p6
```

Each encoder has its own configuration file in the `config/encoder` folder, containing FFmpeg parameters.

Note that the optional GPU video decoding setting is experimental and unstable.
By default, videos are decoded using the CPU.

## Interchangeability

### Content Delivery Network
Expand Down
15 changes: 15 additions & 0 deletions app/Enums/Decoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\Enums;

enum Decoder: string
{
//
case CPU = 'cpu';
case NVIDIA_CUDA = 'nvidia-cuda';

public function getInitialParameters(): array
{
return config(sprintf('decoder.%s', $this->value), []);
}
}
39 changes: 39 additions & 0 deletions app/Enums/Encoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace App\Enums;

enum Encoder: string
{
case CPU_H264 = 'cpu-h264';
case CPU_HEVC = 'cpu-hevc';
case NVIDIA_H264 = 'nvidia-h264';
case NVIDIA_HEVC = 'nvidia-hevc';

public function getAdditionalParameters(bool $forMp4Fallback = false): array
{
$configuredParameters = config(sprintf('encoder.%s', $this->value), []);

if ($forMp4Fallback) {
$enumParameters = match ($this) {
Encoder::NVIDIA_H264, Encoder::NVIDIA_HEVC => ['-c:v', 'h264_nvenc', '-b:v', env('TRANSMORPHER_VIDEO_ENCODER_BITRATE', '6000k')],
default => ['-b:v', env('TRANSMORPHER_VIDEO_ENCODER_BITRATE', '6000k')],
};
} else {
$enumParameters = match ($this) {
Encoder::NVIDIA_H264 => ['-c:v', 'h264_nvenc'],
Encoder::NVIDIA_HEVC => ['-c:v', 'hevc_nvenc'],
default => [],
};
}

return array_merge($enumParameters, $configuredParameters);
}

public function getStreamingCodec(): string
{
return match ($this) {
Encoder::CPU_H264, Encoder::NVIDIA_H264 => 'x264',
Encoder::CPU_HEVC, Encoder::NVIDIA_HEVC => 'hevc',
};
}
}
7 changes: 6 additions & 1 deletion app/Enums/MediaStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ enum MediaStorage: string
*/
public function getDisk(): Filesystem
{
return Storage::disk(config(sprintf('transmorpher.disks.%s', $this->value)));
return Storage::disk($this->getDiskName());
}

public function getDiskName(): string
{
return config(sprintf('transmorpher.disks.%s', $this->value));
}
}
8 changes: 5 additions & 3 deletions app/Enums/StreamingFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ enum StreamingFormat: string

/**
* @param StreamingMedia $video
* @param Encoder $encoder
*
* @return Streaming The video configured with the streaming format, codec and representations.
*/
public function configure(StreamingMedia $video): Streaming
public function configure(StreamingMedia $video, Encoder $encoder): Streaming
{
$format = $this->value;
$codec = config('transmorpher.video_codec');
$codec = $encoder->getStreamingCodec();

// GPU accelerated encoding cannot be set via $codec('h264_nvenc'). It may be set through the additional params.
return $video->$format()
->$codec()
->autoGenerateRepresentations(config('transmorpher.representations'))
->setAdditionalParams(config('transmorpher.additional_transcoding_parameters'));
->setAdditionalParams($encoder->getAdditionalParameters());
}
}
81 changes: 40 additions & 41 deletions app/Jobs/TranscodeVideo.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace App\Jobs;

use App\Enums\Decoder;
use App\Enums\Encoder;
use App\Enums\MediaStorage;
use App\Enums\ResponseState;
use App\Enums\StreamingFormat;
Expand All @@ -19,7 +21,6 @@
use Storage;
use Streaming\FFMpeg;
use Streaming\Media as StreamingMedia;
use Streaming\Streaming;
use Throwable;
use Transcode;

Expand All @@ -44,7 +45,8 @@ class TranscodeVideo implements ShouldQueue
protected Filesystem $originalsDisk;
protected Filesystem $derivativesDisk;
protected Filesystem $localDisk;

protected Decoder $decoder;
protected Encoder $encoder;
protected string $originalFilePath;
protected string $uploadToken;
// Videos stored in the cloud have to be downloaded for transcoding.
Expand All @@ -69,6 +71,8 @@ public function __construct(
\Log::info(sprintf('Constructing job for media %s and version %s with uploadToken %s.', $version->Media->identifier, $version->getKey(), $uploadSlot->token));
$this->originalFilePath = $version->originalFilePath();
$this->uploadToken = $this->uploadSlot->token;
$this->decoder = Decoder::from(config('transmorpher.decoder'));
$this->encoder = Encoder::from(config('transmorpher.encoder'));
}

/**
Expand All @@ -86,7 +90,7 @@ public function handle(): void
$this->localDisk = Storage::disk('local');
$this->tempOriginalFilename = $this->getTempOriginalFilename();

$this->transcodeVideo();
$this->transcode();
} else {
$this->responseState = ResponseState::TRANSCODING_ABORTED;
}
Expand Down Expand Up @@ -136,7 +140,7 @@ public function failed(?Throwable $exception): void
*
* @return void
*/
protected function transcodeVideo(): void
protected function transcode(): void
{
$ffmpeg = FFMpeg::create([
'timeout' => $this->timeout,
Expand All @@ -146,16 +150,10 @@ protected function transcodeVideo(): void
\Log::info(sprintf('Downloading video for media %s and version %s.', $this->version->Media->identifier, $this->version->getKey()));
$video = $this->loadVideo($ffmpeg);

// Set the necessary file path information.
$this->setFilePaths();


// Generate MP4.
$this->generateMp4($video);
// Generate HLS
$this->saveVideo(StreamingFormat::HLS->configure($video), StreamingFormat::HLS->value);
// Generate DASH
$this->saveVideo(StreamingFormat::DASH->configure($video), StreamingFormat::DASH->value);
$this->saveVideo($video, StreamingFormat::HLS);
$this->saveVideo($video, StreamingFormat::DASH);

$this->localDisk->delete($this->tempOriginalFilename);
$this->localDisk->deleteDirectory($this->getFfmpegTempDirectory());
Expand All @@ -174,9 +172,14 @@ protected function transcodeVideo(): void
*/
protected function loadVideo(FFMpeg $ffmpeg): StreamingMedia
{
return $this->isLocalFilesystem($this->originalsDisk) ?
$ffmpeg->open($this->originalsDisk->path($this->originalFilePath))
: $this->openFromCloud($ffmpeg);
$localPath = $this->originalsDisk->path($this->originalFilePath);

if (!$this->isLocalFilesystem($this->originalsDisk)) {
$this->localDisk->writeStream($this->tempOriginalFilename, $this->originalsDisk->readStream($this->originalFilePath));
$localPath = $this->localDisk->path($this->tempOriginalFilename);
}

return $ffmpeg->customInput($localPath, $this->decoder->getInitialParameters());
}

/**
Expand All @@ -191,19 +194,6 @@ protected function isLocalFilesystem(Filesystem $disk): bool
return $disk->getAdapter() instanceof LocalFilesystemAdapter;
}

/**
* The original needs to be available locally, since transcoding to MP4 uses the basic PHP-FFmpeg package, which cannot access cloud storages.
*
* @param FFMpeg $ffmpeg
* @return StreamingMedia
*/
protected function openFromCloud(FFMpeg $ffmpeg): StreamingMedia
{
$this->localDisk->writeStream($this->tempOriginalFilename, $this->originalsDisk->readStream($this->originalFilePath));

return $ffmpeg->open($this->localDisk->path($this->tempOriginalFilename));
}

/**
* Sets the file name and file paths which are needed for the transcoding process.
*
Expand All @@ -217,27 +207,28 @@ protected function setFilePaths(): void
/**
* Saves the transcoded video to storage.
*
* @param Streaming $video
* @param string $format
* @param StreamingMedia $media
* @param StreamingFormat $streamingFormat
*
* @return void
*/
protected function saveVideo(Streaming $video, string $format): void
protected function saveVideo(StreamingMedia $media, StreamingFormat $streamingFormat): void
{
$tempDerivativeFilePath = $this->getTempDerivativeFilePath($format);
$configuredMedia = $streamingFormat->configure($media, $this->encoder);
$tempDerivativeFilePath = $this->getTempDerivativeFilePath($streamingFormat->value);

\Log::info(sprintf('Generating %s for media %s and version %s.', $format, $this->version->Media->identifier, $this->version->getKey()));
\Log::info(sprintf('Generating %s for media %s and version %s. Using: %s -> %s', strtoupper($streamingFormat->value), $this->version->Media->identifier, $this->version->getKey(), $this->decoder->name, $this->encoder->name));
// Save to temporary folder first, to prevent race conditions when multiple versions are uploaded simultaneously.
if ($this->isLocalFilesystem($this->derivativesDisk)) {
$video->save($this->derivativesDisk->path($tempDerivativeFilePath));
$configuredMedia->save($this->derivativesDisk->path($tempDerivativeFilePath));
} else {
// When using cloud storage, we save to local storage first and then upload manually,
// because the php-ffmpeg-video-streaming package direct upload functionality led to S3 disconnects for large files.
$video->save($this->localDisk->path($tempDerivativeFilePath));
$configuredMedia->save($this->localDisk->path($tempDerivativeFilePath));

$tempDerivativesFormatDirectoryPath = $this->getTempDerivativesFormatDirectoryPath($format);
$tempDerivativesFormatDirectoryPath = $this->getTempDerivativesFormatDirectoryPath($streamingFormat->value);

\Log::info(sprintf('Writing %s to S3 for media %s and version %s.', $format, $this->version->Media->identifier, $this->version->getKey()));
\Log::info(sprintf('Writing %s to S3 for media %s and version %s.', $streamingFormat->value, $this->version->Media->identifier, $this->version->getKey()));
foreach ($this->localDisk->allFiles($tempDerivativesFormatDirectoryPath) as $filePath) {
$this->derivativesDisk->writeStream(
$filePath,
Expand All @@ -260,10 +251,16 @@ protected function saveVideo(Streaming $video, string $format): void
protected function generateMp4(StreamingMedia $video): void
{
$tempMp4Filename = $this->getTempMp4Filename();
\Log::info(sprintf('Generating MP4 for media %s and version %s.', $this->version->Media->identifier, $this->version->getKey()));
$video->save((new X264())->setAdditionalParameters(config('transmorpher.additional_transcoding_parameters')), $this->localDisk->path($tempMp4Filename));
\Log::info(sprintf('Generating MP4 for media %s and version %s. Using: %s -> %s', $this->version->Media->identifier, $this->version->getKey(), $this->decoder->name, $this->encoder->name));
// GPU accelerated encoding cannot be set via setVideoCodec(). h264_nvenc may be set through the additional params.
$video->save(
(new X264())
->setInitialParameters($this->decoder->getInitialParameters())
->setAdditionalParameters($this->encoder->getAdditionalParameters(forMp4Fallback: true)),
$this->localDisk->path($tempMp4Filename)
);

\Log::info(sprintf('Writing MP4 to S3 for media %s and version %s.', $this->version->Media->identifier, $this->version->getKey()));
\Log::info(sprintf('Writing MP4 to %s for media %s and version %s.', MediaStorage::VIDEO_DERIVATIVES->getDiskName(), $this->version->Media->identifier, $this->version->getKey()));
$this->derivativesDisk->writeStream(
sprintf('%s.%s', $this->getTempDerivativeFilePath('mp4'), 'mp4'),
$this->localDisk->readStream($tempMp4Filename)
Expand All @@ -283,7 +280,6 @@ protected function moveDerivativesToDestinationPath(): void
if ($this->isMostRecentVersion()) {
// This will make sure we can invalidate the cache before the current derivative gets deleted.
// If this fails, the job will stop and cleanup will be done in the 'failed()'-method.
\Log::info(sprintf('Invalidating CDN cache for media %s and version %s.', $this->version->Media->identifier, $this->version->getKey()));
$this->invalidateCdnCache();

$this->derivativesDisk->deleteDirectory($this->derivativesDestinationPath);
Expand Down Expand Up @@ -350,8 +346,11 @@ protected function moveFromCloudTempDirectory(): void
protected function invalidateCdnCache(): void
{
if (CdnHelper::isConfigured()) {
\Log::info(sprintf('Invalidating CDN cache for media %s and version %s.', $this->version->Media->identifier, $this->version->getKey()));
// If this fails, the 'failed()'-method will handle the cleanup.
CdnHelper::invalidateMedia($this->version->Media->type, $this->derivativesDestinationPath);
} else {
\Log::info('Skipping CDN invalidation. CDN is not configured.');
}
}

Expand Down
Loading