Skip to content

Instantly share code, notes, and snippets.

@denistouch
Last active December 30, 2022 05:34
Show Gist options
  • Save denistouch/c5eb915be59f5b6aa93d34ca1b315bc6 to your computer and use it in GitHub Desktop.
Save denistouch/c5eb915be59f5b6aa93d34ca1b315bc6 to your computer and use it in GitHub Desktop.
Простой сервис использования инструментов ffmpeg для конвертации видео и аудио в hls формат, а так же для объединения аудио между собой с возможным добавлением тишины между ними, выполняется идемпотентно
<?php
namespace App\Model\Ffmpeg;
interface ChecksumContent
{
public function getChecksum(): string;
public function getOutPath(): string;
}
<?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));
}
}
<?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));
}
}
<?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));
}
}
<?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);
}
}
<?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