...
 
Commits (2)
  • Brian Hatchet's avatar
    (feature) A transcoding endpoint that does something! · 203fc087
    Brian Hatchet authored
    Added a new endpoint to v1/media/transcoding
    It was relying on the default fall through on /media/ without matching a type. And the front end was just hard coding a response.
    
    This utilizes the AWS S3 client baked into FFMpeg.php to query the configured bucket and list the objects that match the prefix /cinemr_dir/video_guid
    
    Based on the keys that s3 returns, it then builds a TranscodingStatus object which parses the keys into statuses.
    
    A properly transcoded video should have
    * guid/source - the raw video
    * guid/{video height}.format for each of the presets configured in CONFIG->transcoder->[prefixes]
    * thumbnails
    
    If this proves slow or unreliable, the next step is have FFMPEG write status per guid to cassandra
    203fc087
  • Mark Harding's avatar
    Merge branch 'transcoding_endpoint' into 'master' · 9e3be2d9
    Mark Harding authored
    (feature) A transcoding endpoint that does something!
    
    See merge request !194
    9e3be2d9
<?php
/**
* Minds Media Albums API.
*
* @version 1
*
* @author Emi Balbuena
*/
namespace Minds\Controllers\api\v1\media;
use Minds\Core\Di\Di;
use Minds\Entities;
use Minds\Interfaces;
use Minds\Api\Factory;
use Minds\Core\Media\Services\Factory as ServiceFactory;
class transcoding implements Interfaces\Api
{
/**
* Return the transcoding status.
*
* API:: /v1/media/transcoding/guid
*/
public function get($pages)
{
$videoGUID = $pages[0] ?? null;
if (!$videoGUID) {
return Factory::response([
'status' => 'error',
'message' => 'You must supply the guid of a video',
]);
}
/** @var EntitiesBuilder $entitiesBuilder */
$entitiesBuilder = Di::_()->get('EntitiesBuilder');
$entity = $entitiesBuilder->single($videoGUID);
if (!$entity) {
return Factory::response([
'status' => 'error',
'error' => 'Cannot find that video. Please, upload again.',
]);
}
if (!$entity instanceof Entities\Video) {
return Factory::response([
'status' => 'error',
'error' => 'Not a video resource.',
]);
}
$transcoder = ServiceFactory::build('FFMpeg');
$transcodingStatus = $transcoder->verify($entity);
if (!$transcodingStatus->hasSource()) {
return Factory::response([
'transcoding' => false,
'error' => 'This video has been deleted. Please, upload again.',
]);
}
if ($transcodingStatus->isTranscodingComplete()) {
return Factory::response([
'transcoding' => false,
'message' => 'transcoding complete',
'transcode'=> $transcodingStatus->getTranscodes()
]);
}
return Factory::response([
'transcoding' => true,
'message' => 'This video is transcoding',
'transcodes'=> $transcodingStatus->getTranscodes()
]);
}
/**
* POST method.
*/
public function post($pages)
{
return Factory::response([]);
}
/**
* PUT Method.
*/
public function put($pages)
{
return Factory::response([]);
}
/**
* DELETE Method.
*/
public function delete($pages)
{
return Factory::response([]);
}
}
<?php
/**
* Minds FFMpeg
* Minds FFMpeg.
*/
namespace Minds\Core\Media\Services;
use Aws\ElasticTranscoder\ElasticTranscoderClient;
use Aws\S3\S3Client;
use FFMpeg\FFMpeg as FFMpegClient;
use FFMpeg\FFProbe as FFProbeClient;
use FFMpeg\Filters\Video\ResizeFilter;
use Minds\Core;
use Minds\Core\Config;
use Minds\Entities\Video;
use Minds\Core\Di\Di;
use Minds\Core\Media\TranscodingStatus;
class FFMpeg implements ServiceInterface
{
/** @var Queue $queue */
private $queue;
......@@ -44,21 +44,20 @@ class FFMpeg implements ServiceInterface
$ffprobe = null,
$s3 = null,
$config = null
)
{
) {
$this->config = $config ?: Di::_()->get('Config');
$this->queue = $queue ?: Core\Queue\Client::build();
$this->ffmpeg = $ffmpeg ?: FFMpegClient::create([
'ffmpeg.binaries' => '/usr/bin/ffmpeg',
'ffmpeg.binaries' => '/usr/bin/ffmpeg',
'ffprobe.binaries' => '/usr/bin/ffprobe',
'ffmpeg.threads' => $this->config->get('transcoder')['threads'],
'ffmpeg.threads' => $this->config->get('transcoder')['threads'],
]);
$this->ffprobe = $ffprobe ?: FFProbeClient::create([
'ffprobe.binaries' => '/usr/bin/ffprobe',
]);
$awsConfig = $this->config->get('aws');
$opts = [
'region' => $awsConfig['region']
'region' => $awsConfig['region'],
];
if (!isset($awsConfig['useRoles']) || !$awsConfig['useRoles']) {
......@@ -68,13 +67,14 @@ class FFMpeg implements ServiceInterface
];
}
$this->s3 = $s3 ?: new S3Client(array_merge([ 'version' => '2006-03-01' ], $opts));
$this->s3 = $s3 ?: new S3Client(array_merge(['version' => '2006-03-01'], $opts));
$this->dir = $this->config->get('transcoder')['dir'];
}
public function setKey($key)
{
$this->key = $key;
return $this;
}
......@@ -82,8 +82,7 @@ class FFMpeg implements ServiceInterface
{
try {
if (is_string($file)) {
$result = $this->s3->putObject([
$result = $this->s3->putObject([
'ACL' => 'public-read',
'Bucket' => 'cinemr',
'Key' => "$this->dir/$this->key/source",
......@@ -91,28 +90,29 @@ class FFMpeg implements ServiceInterface
//'ContentLength' => filesize($file),
'Body' => fopen($file, 'r'),
]);
return $this;
return $this;
} elseif (is_resource($file)) {
$result = $this->client->putObject([
$result = $this->client->putObject([
'ACL' => 'public-read',
'Bucket' => 'cinemr',
'Key' => "$this->dir/$this->key/source",
'ContentLength' => $_SERVER['CONTENT_LENGTH'],
'Body' => $file
'Body' => $file,
]);
return $this;
return $this;
}
} catch (\Exception $e) {
var_dump($e->getMessage()); exit;
var_dump($e->getMessage());
exit;
}
throw new \Exception('Sorry, only strings and stream resource are accepted');
}
/**
* Queue the video to be transcoded
* Queue the video to be transcoded.
*
* @return $this
*/
public function transcode()
......@@ -121,13 +121,14 @@ class FFMpeg implements ServiceInterface
$this->queue
->setQueue('Transcode')
->send([
"key" => $this->key
'key' => $this->key,
]);
return $this;
}
/**
* Called when the queue is running
* Called when the queue is running.
*/
public function onQueue()
{
......@@ -152,21 +153,21 @@ class FFMpeg implements ServiceInterface
// get video metadata
$tags = $videostream->get('tags');
} catch (\Exception $e) {
} catch (\Exception $e) {
error_log('Error getting videostream information');
}
try {
$thumbnailsDir = $sourcePath . '-thumbnails';
$thumbnailsDir = $sourcePath.'-thumbnails';
@mkdir($thumbnailsDir, 0600, true);
//create thumbnails
$length = round((int) $this->ffprobe->format($sourcePath)->get('duration'));
$secs = [ 0, 1, round($length/2), $length -1, $length ];
$secs = [0, 1, round($length / 2), $length - 1, $length];
foreach ($secs as $sec) {
$frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds($sec));
$pad = str_pad($sec, 5, '0', STR_PAD_LEFT);
$path = $thumbnailsDir . '/' . "thumbnail-$pad.png";
$path = $thumbnailsDir.'/'."thumbnail-$pad.png";
$frame->save($path);
@$this->uploadTranscodedFile($path, "thumbnail-$pad.png");
//cleanup uploaded file
......@@ -175,10 +176,10 @@ class FFMpeg implements ServiceInterface
//cleanup thumbnails director
@unlink($thumbnailsDir);
} catch (\Exception $e) {
} catch (\Exception $e) {
}
$rotated = isset($tags['rotate']) && in_array($tags['rotate'], [270,90]);
$rotated = isset($tags['rotate']) && in_array($tags['rotate'], [270, 90]);
$outputs = [];
$presets = $this->config->get('transcoder')['presets'];
......@@ -189,13 +190,13 @@ class FFMpeg implements ServiceInterface
'prefix' => null,
'width' => '720',
'height' => '480',
'formats' => [ 'mp4', 'webm' ],
'formats' => ['mp4', 'webm'],
], $opts);
if ($rotated) {
$ratio = $videostream->get('width') / $videostream->get('height');
$width = round($opts['height'] * $ratio);
$opts['width'] = $opts['height'];
$opts['width'] = $opts['height'];
$opts['height'] = $width;
}
......@@ -206,13 +207,13 @@ class FFMpeg implements ServiceInterface
$formatMap = [
'mp4' => (new \FFMpeg\Format\Video\X264())
->setAudioCodec("aac"),
->setAudioCodec('aac'),
'webm' => new \FFMpeg\Format\Video\WebM(),
];
foreach ($opts['formats'] as $format) {
$pfx = ($rotated ? $opts['width'] : $opts['height']) . "." . $format;
$path = $sourcePath . '-' . $pfx;
$pfx = ($rotated ? $opts['width'] : $opts['height']).'.'.$format;
$path = $sourcePath.'-'.$pfx;
try {
echo "\nTranscoding: $path ($this->key)";
$formatMap[$format]
......@@ -249,4 +250,20 @@ class FFMpeg implements ServiceInterface
]);
}
/**
* @param Video $entity
*
* Queries S3 in the cinemr bucket for all keys matching dir/guid
* Uses the AWS/Result object to construct a transcodingStatus based on the content of the key
*/
public function verify(Video $video)
{
$awsResult = $this->s3->listObjects([
'Bucket' => 'cinemr',
'Prefix' => "{$this->dir}/{$video->guid}",
]);
$status = new TranscodingStatus($video, $awsResult);
return $status;
}
}
<?php
namespace Minds\Core\Media;
use AWS\ResultInterface;
use Minds\Entities\Video;
use Minds\Core\Config;
use Minds\Core\Di\Di;
class TranscodingStatus
{
/** @var Config $config */
private $config;
/** @var Video $video */
private $video;
/** @var array $keys */
private $keys = [];
/** @var string $dir */
private $dir = 'cinemr_data';
/** @var array $presets */
private $presets;
/**
* @param Video video The video entity that got transcoded
* @param ResultInterface awsResult The output of the AWS S3 listObjects on the cinemr bucket
* @param Config config Mocked version of Config for testing, else DI'ed
* Builds and parses the transcoding status object and provides functions for checking the results of the transcode
*/
public function __construct(Video $video, ResultInterface $awsResult, Config $config = null)
{
$this->config = $config ?: Di::_()->get('Config');
$this->video = $video;
$this->dir = $this->config->get('transcoder')['dir'];
$this->presets = $this->config->get('transcoder')['presets'];
if (!isset($awsResult['Contents'])) {
return;
}
$s3Contents = $awsResult['Contents'];
$this->keys = array_column($s3Contents, 'Key');
}
/**
* Looks in the list of keys for the source video
* All transcodes upload a /source first
* This should always be there else something went horribly wrong / got deleted.
*
* @return bool whether or not a video has a source file
*/
public function hasSource()
{
$needle = "{$this->dir}/{$this->video->guid}/source";
return in_array($needle, $this->keys);
}
/**
* Looks in the list of keys for the different transcodes based on the transcoder presets
* Each successfully transcoded file will have their a key with a file named height.format.
*
* @return array keys to the transcoded files
*/
public function getTranscodes()
{
$transcodes = [];
foreach ($this->presets as $preset) {
// REGEX /1080\.(mp4|webm4)/
$formatGroup = '('.implode('|', $preset['formats']).')';
$pattern = "/{$preset['height']}\.{$formatGroup}/";
$transcodes = array_merge($transcodes, preg_grep($pattern, $this->keys));
}
return $transcodes;
}
/**
* Compares the number of transcodes to the expected presets
*
* @return boolean whether or not all transcodes have been generated
*/
public function isTranscodingComplete() {
$transcodes = $this->getTranscodes();
return (count($transcodes) === $this->getExpectedTranscodeCount());
}
/**
* Gets the number of expected trancodes based on the preset and their available formats
*/
public function getExpectedTranscodeCount() {
return array_reduce($this->presets, function($count, $preset) {
return $count + count($preset['formats']);
}, 0);
}
/**
* Looks in the list of keys for thumbnails
* FFMpeg generates thumbnails in key/thumbnail-00000.png.
*
* @return array keys to the transcoded files thumbnails
*/
public function getThumbnails()
{
$pattern = "/thumbnail-[0-9]+\.png/";
return preg_grep($pattern, $this->keys);
}
}
<?php
namespace Spec\Minds\Core\Media;
use Minds\Core\Media\TranscodingStatus;
use PhpSpec\ObjectBehavior;
use Aws\Result;
use Minds\Entities\Video;
use Minds\Core\Config;
class TranscodingStatusSpec extends ObjectBehavior
{
/** @var Video $video */
private $video;
/** @var Config $config */
private $config;
public function let(Config $config)
{
$this->video = new Video();
$this->video->guid = 123;
$this->config = $config;
$this->config->get('transcoder')->willReturn([
'dir' => 'test',
'presets' => [
[
'height' => 1080,
'formats' => ['mp4', 'webm']
]
]
]);
}
public function it_is_initializable()
{
$this->beConstructedWith($this->video, new Result(), $this->config);
$this->shouldHaveType(TranscodingStatus::class);
}
public function it_should_parse_empty_data()
{
$this->beConstructedWith($this->video, new Result(), $this->config);
$this->hasSource()->shouldReturn(false);
$this->getTranscodes()->shouldReturn([]);
$this->getThumbnails()->shouldReturn([]);
$this->isTranscodingComplete()->shouldReturn(false);
$this->getExpectedTranscodeCount()->shouldReturn(2);
}
public function it_should_parse_data()
{
$result = new Result(['Contents' => [
['Key' => '/test/123/1080.mp4'],
['Key' => '/test/123/1080.webm'],
['Key' => '/test/123/thumbnail-00000.png']
]]);
$this->beConstructedWith($this->video, $result, $this->config);
$this->hasSource()->shouldReturn(false);
$this->getTranscodes()->shouldReturn(['/test/123/1080.mp4', '/test/123/1080.webm']);
$this->getThumbnails()->shouldContain('/test/123/thumbnail-00000.png');
$this->isTranscodingComplete()->shouldReturn(true);
}
}