Last active
December 30, 2022 05:34
-
-
Save denistouch/c5eb915be59f5b6aa93d34ca1b315bc6 to your computer and use it in GitHub Desktop.
Простой сервис использования инструментов ffmpeg для конвертации видео и аудио в hls формат, а так же для объединения аудио между собой с возможным добавлением тишины между ними, выполняется идемпотентно
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Model\Ffmpeg; | |
interface ChecksumContent | |
{ | |
public function getChecksum(): string; | |
public function getOutPath(): string; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Model\Ffmpeg; | |
class ConcatenateAudio implements ChecksumContent | |
{ | |
public function __construct( | |
/** | |
* $parts may contain values string = 'path/to/file' or float = 12.75f time of silence in seconds | |
*/ | |
private readonly array $parts, | |
private readonly string $outPath, | |
) { | |
} | |
public function getParts(): array | |
{ | |
return $this->parts; | |
} | |
public function getOutPath(): string | |
{ | |
return $this->outPath; | |
} | |
public function getChecksum(): string | |
{ | |
return md5(implode($this->parts)); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Model\Ffmpeg; | |
class ExportAudio implements ChecksumContent | |
{ | |
public function __construct( | |
private readonly string $inputPath, | |
private readonly int $bitrate, | |
private readonly string $outPath, | |
) { | |
} | |
public function getInputPath(): string | |
{ | |
return $this->inputPath; | |
} | |
public function getBitrate(): int | |
{ | |
return $this->bitrate; | |
} | |
public function getOutPath(): string | |
{ | |
return $this->outPath; | |
} | |
public function getChecksum(): string | |
{ | |
$receipt = [ | |
$this->inputPath, | |
$this->bitrate, | |
$this->outPath | |
]; | |
return md5(implode($receipt)); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Model\Ffmpeg; | |
class ExportVideo implements ChecksumContent | |
{ | |
public function __construct( | |
private readonly string $inputPath, | |
private readonly int $width, | |
private readonly int $height, | |
private readonly int $bitrate, | |
private readonly string $outPath, | |
) { | |
} | |
public function getInputPath(): string | |
{ | |
return $this->inputPath; | |
} | |
public function getWidth(): int | |
{ | |
return $this->width; | |
} | |
public function getHeight(): int | |
{ | |
return $this->height; | |
} | |
public function getBitrate(): int | |
{ | |
return $this->bitrate; | |
} | |
public function getOutPath(): string | |
{ | |
return $this->outPath; | |
} | |
public function getChecksum(): string | |
{ | |
$receipt = [ | |
$this->inputPath, | |
$this->width, | |
$this->height, | |
$this->bitrate, | |
$this->outPath | |
]; | |
return md5(implode($receipt)); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Service; | |
use App\Exception\FfmpegExecuteException; | |
use App\Model\Ffmpeg\ChecksumContent; | |
use App\Model\Ffmpeg\ConcatenateAudio; | |
use App\Model\Ffmpeg\ExportAudio; | |
use App\Model\Ffmpeg\ExportVideo; | |
use RuntimeException; | |
use Symfony\Component\Translation\TranslatableMessage; | |
class FfmpegService | |
{ | |
private const STATE_FILE_NAME = 'pid'; | |
private const MD5_FILE_NAME = 'md5'; | |
private const MD5_EXCLUDE_NAMES = ['.', '..', self::MD5_FILE_NAME]; | |
private const EXPORT_AUDIO_CMD_TYPE = 'export.audio'; | |
private const EXPORT_VIDEO_CMD_TYPE = 'export.video'; | |
private const CONCATENATE_AUDIO_CMD_TYPE = 'concatenate.audio'; | |
private const PROCESS_DIR = "/proc"; | |
private const EXPORT_AUDIO_CMD_TEMPLATE = <<<END | |
ffmpeg | |
-i %input% | |
-map a -c:a aac -b:a %bitrate%k -ac 2 | |
-f hls | |
-hls_time 4 | |
-hls_playlist_type vod | |
%output% | |
END; | |
private const EXPORT_VIDEO_CMD_TEMPLATE = <<<END | |
ffmpeg | |
-i %input% | |
-preset slow | |
-g 50 -sc_threshold 0 | |
-s %width%x%height% | |
-map v -c:v libx264 -b:v %bitrate%k | |
-f hls | |
-hls_time 4 | |
-hls_playlist_type vod | |
-hls_flags independent_segments | |
%output% | |
END; | |
private const CONCATENATE_AUDIO_CMD_TEMPLATE = <<<END | |
ffmpeg | |
%input% | |
-filter_complex "%concat_config%:v=0:a=1[outa]" | |
-map "[outa]" -c:a aac -b:a 320k -ac 2 | |
-f hls | |
-hls_flags discont_start | |
-hls_time 4 | |
-hls_playlist_type vod | |
%output% | |
END; | |
public function exportVideo(ExportVideo $video): void | |
{ | |
if ($this->isNeedToCreate($video)) { | |
$this->executeWithControlProcess($video, self::EXPORT_VIDEO_CMD_TYPE); | |
} | |
} | |
/** | |
* @throws FfmpegExecuteException | |
* @throws RuntimeException | |
*/ | |
public function exportAudio(ExportAudio $audio): void | |
{ | |
if ($this->isNeedToCreate($audio)) { | |
$this->executeWithControlProcess($audio, self::EXPORT_AUDIO_CMD_TYPE); | |
} | |
} | |
public function concatenateAudio(ConcatenateAudio $audio): void | |
{ | |
if ($this->isNeedToCreate($audio)) { | |
$this->executeWithControlProcess($audio, self::CONCATENATE_AUDIO_CMD_TYPE); | |
} | |
} | |
public function contentIsValid(ChecksumContent $model): bool | |
{ | |
$outDir = dirname($model->getOutPath()); | |
if (!is_dir($outDir)) { | |
return false; | |
} | |
$md5Old = $this->readMd5File($outDir); | |
$md5New = $this->createMd5FromValues($this->getDirectoryChecksum($outDir), $model->getChecksum()); | |
return $md5Old === $md5New; | |
} | |
public function getExportAudioCmd(ExportAudio $audioModel): string | |
{ | |
$replacePairs = [ | |
'%input%' => $audioModel->getInputPath(), | |
'%bitrate%' => $audioModel->getBitrate(), | |
'%output%' => $audioModel->getOutPath(), | |
]; | |
$cmd = $this->oneLineCmd(self::EXPORT_AUDIO_CMD_TEMPLATE); | |
return strtr($cmd, $replacePairs); | |
} | |
public function getExportVideoCmd(ExportVideo $videoModel): string | |
{ | |
$replacePairs = [ | |
'%input%' => $videoModel->getInputPath(), | |
'%width%' => $videoModel->getWidth(), | |
'%height%' => $videoModel->getHeight(), | |
'%bitrate%' => $videoModel->getBitrate(), | |
'%output%' => $videoModel->getOutPath(), | |
]; | |
$cmd = $this->oneLineCmd(self::EXPORT_VIDEO_CMD_TEMPLATE); | |
return strtr($cmd, $replacePairs); | |
} | |
public function getConcatenateAudioCmd(ConcatenateAudio $concatenateAudioModel): string | |
{ | |
$combinedParts = $concatenateAudioModel->getParts(); | |
$inputs = []; | |
$audioMaps = []; | |
foreach ($combinedParts as $index => $part) { | |
if (is_string($part)) { | |
$inputs[] = '-i ' . $part; | |
} else { | |
$inputs[] = '-f lavfi -t ' . $part . ' -i anullsrc'; | |
} | |
$audioMaps[] = "[$index:a]"; | |
} | |
$concatConfig = sprintf( | |
"%sconcat=n=%d", | |
implode('', $audioMaps), | |
count($combinedParts), | |
); | |
$replacePairs = [ | |
'%input%' => implode(" ", $inputs), | |
'%concat_config%' => $concatConfig, | |
'%output%' => $concatenateAudioModel->getOutPath(), | |
]; | |
$cmd = $this->oneLineCmd(self::CONCATENATE_AUDIO_CMD_TEMPLATE); | |
return strtr($cmd, $replacePairs); | |
} | |
private function isNeedToCreate(ChecksumContent $model): bool | |
{ | |
if ($this->contentIsValid($model)) { | |
return false; | |
} | |
$outDir = dirname($model->getOutPath()); | |
$processPath = $outDir . DIRECTORY_SEPARATOR . self::STATE_FILE_NAME; | |
if (is_file($processPath)) { | |
//process exist - work or error case | |
$file = fopen($processPath, 'r'); | |
if (!flock($file, LOCK_EX | LOCK_NB, $wouldBlock)) { | |
if ($wouldBlock) { | |
$pid = fgets($file); | |
if (file_exists(self::PROCESS_DIR . DIRECTORY_SEPARATOR . $pid)) { | |
//work in process | |
return false; | |
} else { | |
//error case unlock, delete file | |
fclose($file); | |
unlink($processPath); | |
} | |
} else { | |
throw new RuntimeException(new TranslatableMessage('file.error.lock')); | |
} | |
} | |
} | |
return true; | |
} | |
private function readMd5File(string $directory): string | |
{ | |
$filePath = sprintf('%s/%s', $directory, self::MD5_FILE_NAME); | |
if (is_file($filePath)) { | |
$content = file_get_contents($filePath); | |
if ($content === false) { | |
throw new RuntimeException(new TranslatableMessage('file.error.read')); | |
} | |
} else { | |
$content = ''; | |
} | |
return $content; | |
} | |
private function createMd5FromValues(...$values): string | |
{ | |
return md5(implode($values)); | |
} | |
private function getDirectoryChecksum(string $directory): string | |
{ | |
$md5Array = []; | |
$files = array_diff(scandir($directory), self::MD5_EXCLUDE_NAMES); | |
foreach ($files as $file) { | |
$md5Array[] = md5($file); | |
} | |
return md5(implode($md5Array)); | |
} | |
/** | |
* @throws FfmpegExecuteException | |
* @throws RuntimeException | |
*/ | |
private function executeWithControlProcess( | |
ExportAudio|ExportVideo|ConcatenateAudio $model, | |
string $actionType | |
): void | |
{ | |
$outDir = dirname($model->getOutPath()); | |
$processPath = $outDir . '/' . self::STATE_FILE_NAME; | |
$this->createDir($outDir); | |
$file = fopen($processPath, 'w+'); | |
flock($file, LOCK_EX); | |
fwrite($file, getmypid()); | |
$cmd = match ($actionType) { | |
self::EXPORT_AUDIO_CMD_TYPE => $this->getExportAudioCmd($model), | |
self::EXPORT_VIDEO_CMD_TYPE => $this->getExportVideoCmd($model), | |
self::CONCATENATE_AUDIO_CMD_TYPE => $this->getConcatenateAudioCmd($model), | |
default => throw new RuntimeException(new TranslatableMessage('ffmpeg.undefined.cmd')), | |
}; | |
$this->execute($cmd, $actionType); | |
fclose($file); | |
unlink($processPath); | |
$md5 = $this->createMd5FromValues($this->getDirectoryChecksum($outDir), $model->getChecksum()); | |
$this->writeMd5Value($md5, $outDir); | |
} | |
private function createDir(string $dirName): void | |
{ | |
if (!is_dir($dirName)) { | |
if (!mkdir($dirName, 0775, true)) { | |
throw new RuntimeException(new TranslatableMessage('dir.create.error') . $dirName); | |
} | |
} | |
} | |
/** | |
* @throws FfmpegExecuteException | |
*/ | |
private function execute(string $cmd, string $type): void | |
{ | |
$resultCode = 0; | |
exec($cmd, $output, $resultCode); | |
if ($resultCode !== 0) { | |
throw new FfmpegExecuteException($cmd, $type, $output); | |
} | |
} | |
private function writeMd5Value(string $value, string $directory): void | |
{ | |
$outPath = sprintf('%s/%s', $directory, self::MD5_FILE_NAME); | |
$this->writeContentToFile($value, $outPath); | |
} | |
private function writeContentToFile(string $content, string $filepath): void | |
{ | |
if (file_put_contents($filepath, $content) === false) { | |
throw new RuntimeException(new TranslatableMessage('file.error.write') . $filepath); | |
} | |
} | |
private function oneLineCmd(string $cmd): string | |
{ | |
$remove = array("\n", "\r\n", "\r"); | |
return str_replace($remove, ' ', $cmd); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Tests\Service; | |
use App\Model\Ffmpeg\ConcatenateAudio; | |
use App\Model\Ffmpeg\ExportAudio; | |
use App\Model\Ffmpeg\ExportVideo; | |
use App\Service\FfmpegService; | |
use PHPUnit\Framework\TestCase; | |
class FfmpegServiceTest extends TestCase | |
{ | |
public function testExportAudioCmd(): void | |
{ | |
$service = new FfmpegService(); | |
$inputPath = '/srv/app/public/files/a/adsd/a.mp3'; | |
$bitrate = 320; | |
$outPath = '/srv/app/public/a/adsd/a.m3u8'; | |
$model = new ExportAudio($inputPath, $bitrate, $outPath); | |
$expected = "ffmpeg -i {$inputPath} -map a -c:a aac -b:a {$bitrate}k -ac 2 -f hls -hls_time 4 -hls_playlist_type vod {$outPath}"; | |
$cmd = $service->getExportAudioCmd($model); | |
$this->assertNotEmpty($cmd); | |
$this->assertEquals($expected, $cmd); | |
} | |
public function testExportVideoCmd(): void | |
{ | |
$service = new FfmpegService(); | |
$inputPath = '/srv/app/public/files/v/vdsd/v.mp4'; | |
$bitrate = 320; | |
$width = 1920; | |
$height = 1080; | |
$outPath = '/srv/app/public/a/adsd/a.m3u8'; | |
$model = new ExportVideo($inputPath, $width, $height, $bitrate, $outPath); | |
$expected = "ffmpeg -i {$inputPath} -preset slow -g 50 -sc_threshold 0 -s {$width}x{$height} -map v -c:v libx264 -b:v {$bitrate}k -f hls -hls_time 4 -hls_playlist_type vod -hls_flags independent_segments {$outPath}"; | |
$cmd = $service->getExportVideoCmd($model); | |
$this->assertNotEmpty($cmd); | |
$this->assertEquals($expected, $cmd); | |
} | |
public function testConcatenateAudioCmd(): void | |
{ | |
$service = new FfmpegService(); | |
$parts = ['/srv/app/public/a/adsd/a.m3u8', 5.3]; | |
$outPath = '/srv/app/public/a/adsd/b.m3u8'; | |
$model = new ConcatenateAudio($parts, $outPath); | |
$expected = 'ffmpeg -i '.$parts[0].' -f lavfi -t '.$parts[1].' -i anullsrc -filter_complex "[0:a][1:a]concat=n=2:v=0:a=1[outa]" -map "[outa]" -c:a aac -b:a 320k -ac 2 -f hls -hls_flags discont_start -hls_time 4 -hls_playlist_type vod '. $outPath; | |
$cmd = $service->getConcatenateAudioCmd($model); | |
$this->assertNotEmpty($cmd); | |
$this->assertEquals($expected, $cmd); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment