Commit 03e19eb0 authored by Mark Harding's avatar Mark Harding

(feat): Initial workings of the new transcoder

parent 1359daf0
No related merge requests found
Pipeline #100597392 failed with stages
in 2 minutes and 55 seconds
<?php
/**
* Minds FFMpeg.
* Minds FFMpeg. (This now deprecated in favour of Core/Media/Video/Transcoder/Manager)
*/
namespace Minds\Core\Media\Services;
......
<?php
namespace Minds\Core\Media\Video\Transcoder\Delegates;
use Minds\Core\Di\Di;
use Minds\Core\Media\Video\Transcoder\Transcode;
use Minds\Core\Queue\Interfaces\QueueClient;
class QueueDelegate
{
/** @var QueueClient */
private $queueClient;
public function __construct($queueClient = null)
{
$this->queueClient = $queueClient ?? Di::_()->get('Queue\SQS');
}
/**
* Add a transcode to the queue
* @param Transcode $transcode
* @return void
*/
public function onAdd(Transcode $transcode): void
{
$this->queueClient
->setQueue('Transcode')
->send([
'transcode' => serialize($transcode),
]);
}
}
<?php
/**
* Transcoder manager
*/
namespace Minds\Core\Media\Video\Transcoder;
use Minds\Core\Media\Video\Transcoder\Delegates\QueueDelegate;
use Minds\Entities\Video;
use Minds\Traits\MagicAttributes;
class Manager
{
/** @var TranscodeProfileInterface[] */
const TRANSCODE_PROFILES = [
TranscodeProfiles\Thumbnails::class,
TranscodeProfiles\X264_360p::class,
TranscodeProfiles\X264_720p::class,
TranscodeProfiles\X264_1080p::class,
TranscodeProfiles\WebM_360p::class,
TranscodeProfiles\WebM_720p::class,
TranscodeProfiles\WebM_1080p::class,
];
/** @var Repository */
private $repository;
/** @var QueueDelegate */
private $queueDelegate;
/** @var TranscodeStorage\TranscodeStorageInterface */
private $transcodeStorage;
/** @var TranscodeExecutors\TranscodeExecutorInterfsce */
private $transcodeExecutor;
public function __construct($repository = null, $queueDelegate = null, $transcodeStorage = null, $transcodeExecutor = null)
{
$this->repository = $repository ?? new Repository();
$this->queueDelegate = $queueDelegate ?? new QueueDelegate();
$this->transcodeStorage = $transcodeStorage ?? new TranscodeStorage\S3Storage();
$this->transcodeExecutor = $transcodeExecutor ?? new TranscodeExecutors\FFMpegExecutor();
}
/**
* Return a list of transcodes
* @return Response
*/
public function getList($opts): Response
{
$opts = array_merge([
'guid' => null,
'profileId' => null,
'status' => null,
], $opts);
return $this->repository->getList($opts);
}
/**
* Return transcodes for a video by urn
* @param string $urn
* @return Transcodes[]
*/
public function getTranscodesByUrn(string $urn): array
{
return [];
}
/**
* Upload the source file to storage
* Note: This does not register any transcodes. createTranscodes should be called
* @param Video $video
* @param string $path
* @return bool
*/
public function uploadSource(Video $video, string $path): bool
{
// Upload the source file to storage
$source = new Transcode();
$source
->setVideo($video)
->setProfile(new TranscodeProfiles\Source());
return (bool) $this->transcodeStorage->add($source, $path);
}
/**
* Create the transcodes from from
* @param Video $video
* @return void
*/
public function createTranscodes(Video $video): void
{
foreach (self::TRANSCODE_PROFILES as $profile) {
try {
$transcode = new Transcode();
$transcode
->setVideo($video)
->setProfile(new $profile);
// Add the transcode to database and queue
$this->add($transcode);
} catch (TranscodeProfiles\UnavailableTranscodeProfileException $e) {
continue; // Silently fail and just skip
}
}
}
/**
* Add transcode to the queue
* @param Transcode $transcode
* @return void
*/
public function add(Transcode $transcode): void
{
// Add to repository
$this->repository->add($transcode);
// Notify the background queue
$this->queueDelegate->onAdd($transcode);
}
/**
* Update the transcode entity
* @param Transcode $transcode
* @param array $dirty
* @return bool
*/
public function update(Transcode $transcode, array $dirty = []): bool
{
return $this->repository->update($transcode, $dirty);
}
/**
* Run the transcoder (this is called from Core\QueueRunner\Transcode hook)
* @param Transcode $transcode
* @return void
*/
public function transcode(Transcode $transcode): void
{
// Update the background so everyone knows this is inprogress
$transcode->setStatus('transcoding');
$this->update($transcode, [ 'status' ]);
// Perform the transcode
try {
$ref = $this;
$success = $this->transcodeExecutor->transcode($transcode, function ($progress) use ($ref) {
$transcode->setProgress($pct);
$this->update($transcode, 'progress');
});
if (!$success) { // This is actually unkown as an exception should have been thrown
throw new TranscodeExecutors\FailedTranscodeException();
}
$transcode->setStatus('completed');
} catch (TranscodeExecutors\FailedTranscodeException $e) {
$transcode->setStatus('failed');
} finally {
$this->update($transcode, [ 'progress', 'status' ]);
}
// Was this the last transcode to complete?
// if ($this->isLastToTrancode($transcode)) {
// // Sent a notification to the user saying the transcode is completed
// }
}
// protected function isLastToTrancode(Transcode $transcode): bool
// {
// }
}
<?php
/**
* Transcoder manager
*/
namespace Minds\Core\Media\Video\Transcoder;
use Minds\Core\Media\Video\Transcode\Delegates\QueueDelegate;
use Minds\Entities\Video;
use Minds\Traits\MagicAttributes;
use Minds\Common\Repository\Response;
use Minds\Common\Urn;
use Minds\Core\Di\Di;
use Minds\Core\Data\Cassandra\Client;
use Minds\Core\Data\Cassandra\Prepared\Custom;
use Cassandra\Bigint;
use Cassandra\Timestamp;
class Repository
{
/** @var Client */
private $db;
public function __construct($db = null)
{
$this->db = $db ?? Di::_()->get('Database\Cassandra\Cql');
}
/**
* Return a list of transcodes
* @param array $opts
* @return Response
*/
public function getList(array $opts): Response
{
$opts = array_merge([
'guid' => null,
'profileId' => null,
'status' => null,
], $opts);
$statement = "SELECT * FROM video_transcodes";
$where = [];
$values = [];
if ($opts['guid']) {
$where[] = "guid = ?";
$values[] = new Bigint($opts['guid']);
}
if ($opts['profileId']) {
$where[] = "profile_id = ?";
$values[] = $opts['profile_id'];
}
if ($opts['status']) {
$where[] = "status = ?";
$values[] = $opts['status'];
}
$statement .= " WHERE " . implode(' AND ', $where);
$prepared = new Custom();
$prepared->query($statement, $values);
try {
$result = $this->db->request($prepared);
} catch (\Exception $e) {
return new Response(); // TODO: make sure error is attached to response
}
$response = new Response;
foreach ($result as $row) {
$response[] = $this->buildTranscodeFromRow($row);
}
return $response;
}
/**
* Return a single transcode
* @param string $urn
* @return Transcode
*/
public function get(string $urn): ?Transcode
{
$urn = Urn::_($urn);
list($guid, $profile) = explode('-', $urn->getNss());
$statement = "SELECT * FROM video_transcodes
WHERE guid = ?
AND profile = ?";
$values = [
new Bigint($guid),
$profile
];
$prepared = new Custom();
$prepared->query($statement, $values);
try {
$result = $this->db->request($prepared);
} catch (\Exception $e) {
return null;
}
$row = $result[0];
$transcode = $this->buildTranscodeFromRow($row);
return $transcode;
}
/**
* Add a transcode to the database
* @param Transcode $transcode
* @return bool
*/
public function add(Transcode $transcode): bool
{
$statement = "INSERT INTO video_transcodes (guid, profile_id) VALUES (?, ?)";
$values = [
new Bigint($transcode->getGuid()),
$transcode->getProfile()->getId(),
];
$prepared = new Custom();
$prepared->query($statement, $values);
try {
$this->db->request($prepared);
} catch (\Exception $e) {
return false;
}
return true;
}
/**
* Update the transcode
* @param Transcode $transcode
* @param array $dirty - list of fields that have changed
* @return bool
*/
public function update(Transcode $transcode, array $dirty = []): bool
{
// Always update lastEventTimestampMs
$transcode->setLastEventTimestampMs(round(microtime(true) * 1000));
$dirty[] = 'lastEventTimestampMs';
$statement = "UPDATE video_transcodes";
$set = [];
foreach ($dirty as $field) {
switch ($field) {
case 'progress':
$set['progress'] = (int) $transcode->getProgress(); // This is a percentage basedf off 100
break;
case 'status':
$set['status'] = (string) $transcode->getStatus();
break;
case 'lastEventTimestampMs':
$set['last_event_timestamp_ms'] = new Timestamp($transcode->getLastEventTimestampMs() / 1000);
break;
case 'lengthSecs':
$set['length_secs'] = (int) $transcode->getLengthSecs();
break;
case 'bytes':
$set['bytes'] = (int) $transcode->getBytes();
break;
}
}
// Convert our $set to statement
$statement .= " SET " . implode(' ', array_map(function ($field) {
return "$field = ?";
}, $set));
// Move to values array
$values = array_values($set);
// Say what we are updating
$statement .= " WHERE guid = ? AND profile_id = ?";
$values[] = new Bigint($transcode->getGuid());
$values[] = (string) $transcode->getProfile()->getId();
// Prepared statement
$prepared = new Custom();
$prepared->query($statement, $values);
try {
$this->db->request($prepared);
} catch (\Exception $e) {
return false;
}
return true;
}
/**
* Delete a transcode
* @param Transcode $transcode
* @return bool
*/
public function delete(Transcode $transcode): bool
{
$statement = "DELETE FROM video_transcodes WHERE guid = ? and profile_id = ?";
$values = [
new Bigint($transcode->getGuid()),
(string) $transcode->getProfile()->getId(),
];
// Prepared statement
$prepared = new Custom();
$prepared->query($statement, $values);
try {
$this->db->request($prepared);
} catch (\Exception $e) {
return false;
}
return true;
}
/**
* Build transcode from an array of data
* @param array $row
* @return Transcode
*/
protected function buildTranscodeFromRow(array $row): Transcode
{
$transcode = new Transcode();
$transcode->setGuid((string) $row['guid'])
->setProfile(TranscodeProfiles\Factory::build((string) $row['profile_id']))
->setProgress($row['progress']->value())
->setLastEventTimestampMs(round($row['last_event_timestamp_ms']->microtime(true) * 1000))
->setLength($row['length_secs']->value())
->setBytes($row['bytes']->value());
return $transcode;
}
}
<?php
/**
* Transcode model
*/
namespace Minds\Core\Media\Video\Transcoder;
use Minds\Entities\Video;
use Minds\Traits\MagicAttributes;
/**
* @method Transcode setGuid(string $guid)
* @method string getGuid()
* @method Transcode setVideo(Video $video)
* @method Video getVideo()
* @method Transcode setProgress(int $progress)
* @method int getProgress()
* @method Transcode setStatus(string $status)
* @method string getStatus()
* @method TranscodeProfiles\TranscodeProfileInterface getProfile()
* @method int getLastEventTimestampMs()
* @method Transcode setLastEventTimestampMs(int $lastEventTimestampMs)
* @method Transcode setLengthSecs(int $secs)
* @method int getLengthSecs()
* @method Transcode setBytes(int $bytes)
* @method int getBytes()
*/
class Transcode
{
use MagicAttributes;
/** @var string */
const TRANSCODE_STATES = [
'created',
'transcoding',
'failed',
'completed',
];
/** @var string */
private $guid;
/** @var Video */
private $video;
/** @var TranscodeProfiles\TranscodeProfileInterface */
private $profile;
/** @var int */
private $progress = 0;
/** @var string */
protected $status;
/** @var int */
private $lastEventTimestampMs;
/** @var int */
private $lengthSecs;
/** @var int */
private $bytes;
/**
* @param Video $video
* @return self
*/
public function setVideo(Video $video): self
{
$this->video = $video;
$this->guid = $video->getGuid();
return $this;
}
/**
* Set the profile
* @param TranscodeProfiles\TranscodeProfileInterface $profile
* @throws TranscodeProfiles\UnavailableTranscodeProfileException
* @return self
*/
public function setProfile(TranscodeProfiles\TranscodeProfileInterface $profile): self
{
if ($profile->isProOnly() && !$this->video->getOwnerEntity()->isPro()) {
throw new TranscodeProfiles\UnavailableTranscodeProfileException();
}
$this->profile = $profile;
return $this;
}
/**
* Export
* @param array $extras
* @return array
*/
public function export($extras = []): array
{
return [
'guid' => $this->guid,
'profile' => $this->profile->export(),
'progress' => (int) $this->progress,
'completed' => (bool) $this->completed,
'last_event_timestamp_ms' => (int) $this->lastEventTimestampMs,
'length_secs' => (int) $this->lengthSecs,
'bytes' => (int) $this->bytes,
];
}
}
<?php
/**
* Minds FFMpeg.
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeExecutors;
use FFMpeg\FFMpeg as FFMpegClient;
use FFMpeg\FFProbe as FFProbeClient;
use FFMpeg\Media\Video as FFMpegVideo;
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;
use Minds\Core\Media\Video\Transcoder\Transcode;
use Minds\Core\Media\Video\Transcoder\TranscodeStorage\TranscodeStorageInterface;
use Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class FFMpegExecutor implements TranscodeExecutorInterface
{
/** @var Config */
private $config;
/** @var FFMpeg */
private $ffmpeg;
/** @var FFProbe */
private $ffprobe;
/** @var TranscodeStorageInterface */
private $transcodeStorage;
public function __construct(
$config = null,
$ffmpeg = null,
$ffprobe = null,
$transcodeStorage = null
) {
$this->config = $config ?: Di::_()->get('Config');
$this->ffmpeg = $ffmpeg ?: FFMpegClient::create([
'ffmpeg.binaries' => '/usr/bin/ffmpeg',
'ffprobe.binaries' => '/usr/bin/ffprobe',
'ffmpeg.threads' => $this->config->get('transcoder')['threads'],
'timeout' => 0,
]);
$this->ffprobe = $ffprobe ?: FFProbeClient::create([
'ffprobe.binaries' => '/usr/bin/ffprobe',
]);
$this->transcodeStorage = $transcodeStorage ?? Di::_()->get('Media\Video\Transcode\TranscodeStorage');
}
/**
* Transcode the video
* @param Transcode $transcode (pass by reference)
* @param callable $progressCallback
* @return bool
*/
public function transcode(Transcode &$transcode, callable $progressCallback): bool
{
// This is the profile that will be used for the transcode
$transcodeProfiler = $transcode->getProfile();
// Prepare the source of this transcode
$source = new Transcode();
$source->setProfile(new TranscodeProfiles\Source()); // Simply change the source
// Download the source
$sourcePath = $this->transcodeStorage->downloadToTmp($source);
// Open the resource
/** @var \FFMpeg\Media\Video; */
$video = $this->ffmpeg->open($sourcePath);
if (!$video) {
throw new FailedTranscodeException("Source error");
}
$tags = null;
try {
$videostream = $this->ffprobe
->streams($sourcePath)
->videos()
->first();
// get video metadata
$tags = $videostream->get('tags');
} catch (\Exception $e) {
error_log('Error getting videostream information');
}
// Thumbnails are treated differently to other transcodes
if ($transcode->getProfile() instanceof TranscodeProfiles\Thumbnails) {
return $this->transcodeThumbnails($transcode, $sourcePath, $video);
}
// Target height
$width = $transcodeProfiler->getWidth();
$height = $transcodeProfiler->getHeight();
// Logic for rotated videos
$rotated = isset($tags['rotate']) && in_array($tags['rotate'], [270, 90], true);
if ($rotated) {
$ratio = $videostream->get('width') / $videostream->get('height');
// Invert width and height
$width = $height;
$height = round($height * $ratio);
}
// Resize the video
$video->filters()
->resize(
new \FFMpeg\Coordinate\Dimension($width, $height),
$rotated ? ResizeFilter::RESIZEMODE_FIT : ResizeFilter::RESIZEMODE_SCALE_WIDTH
)
->synchronize();
$pfx = $transcodeProfiler->getStorageName();
$path = $sourcePath.'-'.$pfx;
$format = $transcodeProfiler->getFormat();
$formatMap = [
'video/mp4' => (new \FFMpeg\Format\Video\X264())
->setAudioCodec('aac'),
'video/webm' => new \FFMpeg\Format\Video\WebM(),
];
try {
// $this->logger->info("Transcoding: $path ({$transcode->getGuid()})");
// Update our progress
$formatMap[$format]->on('progress', function ($a, $b, $pct) {
// $this->logger->info("$pct% transcoded");
$progressCallback($pct);
});
$formatMap[$format]
->setKiloBitRate($transcodeProfiler->getBitrate())
->setAudioKiloBitrate($transcodeProfiler->getAudioBitrate());
// Run the transcode
$video->save($formatMap[$format], $path);
// Save to storage
$this->transcodeStorage->add($transcode, $path);
// Completed!
// $this->logger->info("Completed: $path ({$transcode->getGuid()})");
$transcode->setStatus('completed');
} catch (\Exception $e) {
// $this->logger->out("Failed {$e->getMessage()}");
$transcode->setStatus('failed');
// TODO: Should we also save the failure reason in the db?
// Throw a new 'failed' exception
throw new FailedTranscodeException($e->getMessage());
} finally {
// Cleanup our path
@unlink($path);
}
// Cleanup our sourcefile
@unlink($sourcePath);
return true;
}
/**
* Thumbnail transcodes are treated differently as they we extract frames
* @param Transcode $transcode
* @return bool
*/
protected function transcodeThumbnails(Transcode &$transcode, string $sourcePath, FFMpegVideo $video): bool
{
try {
// Create a temporary directory for out 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];
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";
$frame->save($path);
// Hack the profile storage name, as there are multiple thumbnails
$transcode->getProfile()->setStorageName("thumbnail-$pad.png");
// Upload to filestore
$this->transcodeStorage->add($transcode, $path);
// Cleanup tmp
@unlink($path);
}
$transcode->setStatus('completed');
} catch (\Exception $e) {
$transcode->setStatus('failed');
// TODO: Should we also save the failure reason in the db?
// Throw a new 'failed' exception
throw new FailedTranscodeException($e->getMessage());
} finally {
// Cleanup the temporary directory we made
@unlink($thumbnailsDir);
}
return true;
}
}
<?php
namespace Minds\Core\Media\Video\Transcoder\TranscodeExecutors;
class FailedTranscodeException extends \Exception
{
}
<?php
namespace Minds\Core\Media\Video\Transcoder\TranscodeExecutors;
use Minds\Core\Media\Video\Transcoder\Transcode;
interface TranscodeExecutorInterface
{
/**
* @param Transcode $transcode
* @return bool
*/
public function transcode(Transcode &$transcode, callable $progressCallback): bool;
}
<?php
/**
* Abstract class for transcode profiles
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
use Minds\Traits\MagicAttributes;
/**
* @method string getFormat()
* @method int getWidth()
* @method int getHeight()
* @method int getBitrate()
* @method int getAudioBitrate()
* @method bool isProOnly()
* @method string getStorageName()
* @method TranscodeProfileInterface setStorageName(string $storageName)
*/
abstract class AbstractTranscodeProfile implements TranscodeProfileInterface
{
use MagicAttributes;
/** @var string */
protected $format;
/** @var int */
protected $width;
/** @var int */
protected $height;
/** @var int */
protected $bitrate;
/** @var int */
protected $audioBitrate;
/** @var bool */
protected $proOnly = false;
/** @var string */
protected $storageName;
/**
* Returns the ID of the transcode (this will usually be the classname)
* @return string
*/
public function getId(): string
{
$path = explode('\\', __CLASS__);
return array_pop($path);
}
/**
* Export the profile
* @param array $extras
* @return array
*/
public function export($extras = []): array
{
return [
'id' => $this->getId(),
'format' => $this->format,
'width' => (int) $this->width,
'height' => (int) $this->height,
'bitrate' => (int) $this->bitrate,
'audio_bitrate' => (int) $this->audioBitrarte,
];
}
}
<?php
/**
* Source. This is the source file.
* It will not be transcoded but will be stored on the filestore
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class Factory
{
/**
* Build a TranscodeProfileInterface from ID
* @param string $profileId
* @return TranscodeProfile
* @throws TranscodeProfileNotFoundException
*/
public static function build(string $profileId): TranscodeProfileInterface
{
$class = "Minds\\Core\\Media\\Video\\Transcoder\\TranscodeProfiles\\$profileId";
if (class_exists($class)) {
return new $class;
}
throw new TranscodeProfileNotFoundException("$profileId does not have a valid profile instance");
}
}
<?php
/**
* Source. This is the source file.
* It will not be transcoded but will be stored on the filestore
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class Source extends AbstractTranscodeProfile
{
/** @var string */
protected $format = 'video/*';
/** @var int */
protected $width = 0;
/** @var int */
protected $height = 0;
/** @var string */
protected $storageName = 'source';
}
<?php
/**
* Thumbnails
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class Thumbnails extends AbstractTranscodeProfile
{
/** @var string */
protected $format = 'image/png';
/** @var int */
protected $width = 1920;
/** @var int */
protected $height = 1080;
}
<?php
/**
* Transcode Profile Interface
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
interface TranscodeProfileInterface
{
}
<?php
/**
* Unavailable Transcode Profile Exceptioon
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class TranscodeProfileNotFoundException extends \Exception
{
}
<?php
/**
* Unavailable Transcode Profile Exceptioon
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class UnavailableTranscodeProfileException extends \Exception
{
/** @var string */
protected $message = 'This transcode profile is not available';
}
<?php
/**
* 1080p Webm (pro only)
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class Webm_1080p extends AbstractTranscodeProfile
{
/** @var string */
protected $format = 'video/webm';
/** @var int */
protected $width = 1920;
/** @var int */
protected $height = 1080;
/** @var int */
protected $bitrate = 2000;
/** @var int */
protected $audioBitrate = 128;
/** @var bool */
protected $proOnly = true;
/** @var string */
protected $storageName = '1080p.webm';
}
<?php
/**
* 360p Webm
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class Webm_360p extends AbstractTranscodeProfile
{
/** @var string */
protected $format = 'video/webm';
/** @var int */
protected $width = 640;
/** @var int */
protected $height = 360;
/** @var int */
protected $bitrate = 500;
/** @var int */
protected $audioBitrate = 80;
/** @var bool */
protected $proOnly = false;
/** @var string */
protected $storageName = '360p.webm';
}
<?php
/**
* 720p Webm
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class Webm_720p extends AbstractTranscodeProfile
{
/** @var string */
protected $format = 'video/webm';
/** @var int */
protected $width = 1280;
/** @var int */
protected $height = 720;
/** @var int */
protected $bitrate = 1000;
/** @var int */
protected $audioBitrate = 128;
/** @var bool */
protected $proOnly = false;
/** @var string */
protected $storageName = '720p.webm';
}
<?php
/**
* 1080p MP4 (pro only)
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class X264_1080p extends AbstractTranscodeProfile
{
/** @var string */
protected $format = 'video/mp4';
/** @var int */
protected $width = 1920;
/** @var int */
protected $height = 1080;
/** @var int */
protected $bitrate = 2000;
/** @var int */
protected $audioBitrate = 128;
/** @var bool */
protected $proOnly = true;
/** @var string */
protected $storageName = '1080p.mp4';
}
<?php
/**
* 360p MP4
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class X264_360p extends AbstractTranscodeProfile
{
/** @var string */
protected $format = 'video/mp4';
/** @var int */
protected $width = 640;
/** @var int */
protected $height = 360;
/** @var int */
protected $bitrate = 500;
/** @var int */
protected $audioBitrate = 80;
/** @var bool */
protected $proOnly = false;
/** @var string */
protected $storageName = '360p.mp4';
}
<?php
/**
* 720p MP4
*/
namespace Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
class X264_720p extends AbstractTranscodeProfile
{
/** @var string */
protected $format = 'video/mp4';
/** @var int */
protected $width = 1280;
/** @var int */
protected $height = 720;
/** @var int */
protected $bitrate = 1000;
/** @var int */
protected $audioBitrate = 128;
/** @var bool */
protected $proOnly = false;
/** @var string */
protected $storageName = '720p.mp4';
}
<?php
namespace Minds\Core\Media\Video\Transcoder\TranscodeStorage;
use Aws\S3\S3Client;
use Minds\Core\Config;
use Minds\Core\Di\Di;
use Minds\Core\Media\Video\Transcoder\Transcode;
class S3Storage implements TranscodeStorageInterface
{
/** @var string */
private $dir = 'cinemr_data';
/** @var Config */
private $config;
/** @var S3Client */
private $s3;
public function __construct($config = null, $s3 = null)
{
$this->config = $config ?? Di::_()->get('Config');
$this->dir = $this->config->get('transcoder')['dir'];
$awsConfig = $this->config->get('aws');
$opts = [
'region' => $awsConfig['region'],
];
if (!isset($awsConfig['useRoles']) || !$awsConfig['useRoles']) {
$opts['credentials'] = [
'key' => $awsConfig['key'],
'secret' => $awsConfig['secret'],
];
}
$this->s3 = $s3 ?: new S3Client(array_merge(['version' => '2006-03-01'], $opts));
}
/**
* Add a transcode to storage
* @param Transcode $transcode
* @param string $path
* @return bool
*/
public function add(Transcode $transcode, string $path): bool
{
return (bool) $this->s3->putObject([
'ACL' => 'public-read',
'Bucket' => 'cinemr',
'Key' => "$this->dir/{$transcode->getGuid()}/{$transcode->getProfile()->getStorageName()}",
'Body' => fopen($path, 'r'),
]);
}
/**
* @param Transcode $transcode
* @return string
*/
public function downloadToTmp(Transcode $transcode): string
{
// Create a temporary file where our source file will go
$sourcePath = tempnam(sys_get_temp_dir(), "{$transcode->getGuid()}-{$transcode->getProfile()->getStorageName()}");
// Grab from S3
$this->s3->getObject([
'Bucket' => 'cinemr',
'Key' => "$this->dir/{$transcode->getGuid()}/{$transcode->getProfile()->getStorageName()}",
'SaveAs' => $sourcePath,
]);
return $sourcePath;
}
}
<?php
namespace Minds\Core\Media\Video\Transcoder\TranscodeStorage;
use Minds\Core\Media\Video\Transcoder\Transcode;
interface TranscodeStorageInterface
{
/**
* @param Transcode $transcode
* @param string $path
* @return bool
*/
public function add(Transcode $transcode, string $path): bool;
/**
* @param Transcode $transcode
* @return string
*/
public function downloadToTmp(Transcode $transcode): string;
}
<?php
/**
* Transcode model
*/
namespace Minds\Core\Media\Video\Transcoder;
use Minds\Traits\MagicAttributes;
class Transcodes
{
use MagicAttributes;
/** @var string */
private $guid;
/** @var Transcode[] */
private $transcodes;
public function export($extras = []): array
{
return [
'guid' => $this->guid,
'transcodes' => $this->transcodes ? array_map(function ($transcode) {
return $transcode->export();
}, $this->transcodes) : null
];
}
}
......@@ -14,11 +14,12 @@ class Transcode implements Interfaces\QueueRunner
$client->setQueue("Transcode")
->receive(function ($data) {
$d = $data->getData();
$transcode = unserialize($d['transcode']);
echo "Received a transcode request \n";
$transcoder = new Core\Media\Services\FFMpeg();
$transcoder->setKey($d['key']);
$transcoder->setFullHD($d['full_hd']);
$transcoder->onQueue();
$transcoderManeger = Di::_()->get('Media\Video\Transcoder\Manager');
$transcoderManager->transcode($transcode);
}, [ 'max_messages' => 1 ]);
}
}
......@@ -51,14 +51,15 @@ class Video extends MindsObject
public function upload($filepath)
{
// TODO: Confirm why this is still here
$this->generateGuid();
$transcoder = ServiceFactory::build('FFMpeg');
$transcoder->setKey($this->getGuid())
->setFullHD($this->getFlag('full_hd'))
->saveToFilestore($filepath)
->transcode();
// Upload the source and start the transcoder pipeline
$transcoderManager = Di::_()->get('Media\Video\Transcoder\Manager');
$transcoderManager->uploadSource($this, $filepath)
->createTranscodes($this);
// Legacy support
$this->cinemr_guid = $this->getGuid();
}
......
<?php
namespace Spec\Minds\Core\Media\Video\Transcoder\Delegates;
use Minds\Core\Media\Video\Transcoder\Delegates\QueueDelegate;
use Minds\Core\Queue\Interfaces\QueueClient;
use Minds\Core\Media\Video\Transcoder\Transcode;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class QueueDelegateSpec extends ObjectBehavior
{
private $queueClient;
public function let(QueueClient $queueClient)
{
$this->beConstructedWith($queueClient);
$this->queueClient = $queueClient;
}
public function it_is_initializable()
{
$this->shouldHaveType(QueueDelegate::class);
}
public function it_should_add_to_queue()
{
$transcode = new Transcode();
$this->queueClient->setQueue('Transcode')
->shouldBeCalled()
->willReturn($this->queueClient);
$this->queueClient->send(Argument::that(function ($message) use ($transcode) {
return unserialize($message['transcode']) == $transcode;
}))
->shouldBeCalled();
$this->onAdd($transcode);
}
}
<?php
namespace Spec\Minds\Core\Media\Video\Transcoder;
use Minds\Core\Media\Video\Transcoder\Manager;
use Minds\Core\Media\Video\Transcoder\Repository;
use Minds\Core\Media\Video\Transcoder\Delegates\QueueDelegate;
use Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
use Minds\Core\Media\Video\Transcoder\TranscodeStorage\TranscodeStorageInterface;
use Minds\Core\Media\Video\Transcoder\TranscodeExecutors\TranscodeExecutorInterface;
use Minds\Core\Media\Video\Transcoder\Transcode;
use Minds\Entities\Video;
use Minds\Entities\User;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class ManagerSpec extends ObjectBehavior
{
private $repository;
private $queueDelegate;
private $transcodeStorage;
private $transcodeExecutor;
public function let(Repository $repository, QueueDelegate $queueDelegate, TranscodeStorageInterface $transcodeStorage, TranscodeExecutorInterface $transcodeExecutor)
{
$this->beConstructedWith($repository, $queueDelegate, $transcodeStorage, $transcodeExecutor);
$this->repository = $repository;
$this->queueDelegate = $queueDelegate;
$this->transcodeStorage = $transcodeStorage;
$this->transcodeExecutor = $transcodeExecutor;
}
public function it_is_initializable()
{
$this->shouldHaveType(Manager::class);
}
public function it_should_upload_source_file_to_transcoder_storage()
{
$video = new Video();
$video->guid = 123;
$this->transcodeStorage->add(Argument::that(function ($transcode) use ($video) {
return $transcode->getProfile() instanceof TranscodeProfiles\Source
&& $transcode->getVideo()->getGuid() === $video->getGuid();
}), '/tmp/my-fake-video')
->shouldBeCalled()
->willReturn(true);
$this->uploadSource($video, '/tmp/my-fake-video')
->shouldReturn(true);
}
public function it_should_create_transcodes_from_video()
{
$video = new Video();
$video->guid = 123;
$user = new User();
$user->pro = 0;
$video->set('owner', $user);
foreach ([
TranscodeProfiles\Thumbnails::class,
TranscodeProfiles\X264_360p::class,
TranscodeProfiles\X264_720p::class,
TranscodeProfiles\WebM_360p::class,
TranscodeProfiles\WebM_720p::class,
] as $i => $profile) {
// Should be added to repo
$this->repository->add(Argument::that(function ($transcode) use ($video, $profile) {
return $transcode->getProfile() instanceof $profile
&& $transcode->getGuid() === $video->getGuid();
}))
->shouldBeCalled();
// And queue
$this->queueDelegate->onAdd(Argument::that(function ($transcode) use ($video, $profile) {
return $transcode->getProfile() instanceof $profile
&& $transcode->getGuid() === $video->getGuid();
}))
->shouldBeCalled();
}
// Should not add 1080p
$this->repository->add(Argument::that(function ($transcode) use ($video, $profile) {
return $transcode->getProfile() instanceof TranscodeProfiles\X264_1080p
&& $transcode->getGuid() === $video->getGuid();
}))
->shouldNotBeCalled();
$this->createTranscodes($video);
}
public function it_should_add_transcode()
{
$transcode = new Transcode();
$this->repository->add($transcode)
->shouldBeCalled();
$this->queueDelegate->onAdd($transcode)
->shouldBeCalled();
$this->add($transcode);
}
public function it_should_update()
{
$transcode = new Transcode();
$this->repository->update($transcode, [ 'myDirtyField' ])
->shouldBeCalled();
$this->update($transcode, [ 'myDirtyField' ]);
}
public function it_should_execute_the_transcode()
{
$transcode = new Transcode();
$this->repository->update(Argument::that(function ($transcode) {
//return true;
return $transcode->getStatus() === 'transcoding';
}), [ 'status' ])
->shouldBeCalled();
$this->transcodeExecutor->transcode($transcode, Argument::any())
->shouldBeCalled()
->willReturn(true);
// $this->repository->update(Argument::type(Transcode::class), [ 'progress' ])
// ->shouldBeCalled();
$this->repository->update(Argument::that(function ($transcode) {
return $transcode->getStatus() === 'completed';
}), [ 'progress', 'status' ])
->shouldBeCalled();
$this->transcode($transcode);
}
}
<?php
namespace Spec\Minds\Core\Media\Video\Transcoder;
use Minds\Core\Media\Video\Transcoder\Repository;
use Minds\Core\Media\Video\Transcoder\Transcode;
use Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
use Minds\Core\Data\Cassandra\Client;
use Spec\Minds\Mocks\Cassandra\Rows;
use Cassandra\Bigint;
use Cassandra\Timestamp;
use Cassandra\Varint;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class RepositorySpec extends ObjectBehavior
{
private $db;
public function let(Client $db)
{
$this->beConstructedWith($db);
$this->db = $db;
}
public function it_is_initializable()
{
$this->shouldHaveType(Repository::class);
}
public function it_should_add(Transcode $transcode)
{
$transcode->getGuid()
->shouldBeCalled()
->willReturn("123");
$transcode->getProfile()
->shouldBeCalled()
->willReturn(new TranscodeProfiles\X264_360p());
$this->db->request(Argument::that(function ($prepared) {
return true;
}))
->shouldBeCalled();
$this->add($transcode)
->shouldReturn(true);
}
public function it_should_get_single()
{
$this->db->request(Argument::that(function ($prepared) {
return true;
}))
->shouldBeCalled()
->willReturn(new Rows([
[
'guid' => new Bigint(123),
'profile_id' => 'X264_360p',
'last_event_timestamp_ms' => new Timestamp(microtime(true)),
'progress' => new Varint(0),
'length_secs' => new Varint(0),
'bytes' => new Varint(0),
]
], null));
$transcode = $this->get("urn:transcode:123-X264_360p");
$transcode->getGuid()
->shouldBe("123");
}
public function it_should_get_list()
{
$this->db->request(Argument::that(function ($prepared) {
return true;
}))
->shouldBeCalled()
->willReturn(new Rows([
[
'guid' => new Bigint(123),
'profile_id' => 'X264_360p',
'last_event_timestamp_ms' => new Timestamp(microtime(true)),
'progress' => new Varint(0),
'length_secs' => new Varint(0),
'bytes' => new Varint(0),
],
[
'guid' => new Bigint(456),
'profile_id' => 'X264_720p',
'last_event_timestamp_ms' => new Timestamp(microtime(true)),
'progress' => new Varint(0),
'length_secs' => new Varint(0),
'bytes' => new Varint(0),
]
], null));
$rows = $this->getList([]);
$X264_360Transcode = $rows[0];
$X264_360Transcode->getGuid()
->shouldBe("123");
$X264_720Transcode = $rows[1];
$X264_720Transcode->getGuid()
->shouldBe("456");
}
public function it_should_update(Transcode $transcode)
{
$transcode->getGuid()
->shouldBeCalled()
->willReturn("123");
$transcode->getProfile()
->shouldBeCalled()
->willReturn(new TranscodeProfiles\X264_360p());
$transcode->setLastEventTimestampMs(Argument::approximate(round(microtime(true) * 1000), 7))
->shouldBeCalled();
$transcode->getLastEventTimestampMs()
->shouldBeCalled()
->willReturn(round(microtime(true) * 1000));
$this->db->request(Argument::that(function ($prepared) {
return true;
}))
->shouldBeCalled();
$this->update($transcode, [])
->shouldReturn(true);
}
public function it_should_delete(Transcode $transcode)
{
$transcode->getGuid()
->shouldBeCalled()
->willReturn("123");
$transcode->getProfile()
->shouldBeCalled()
->willReturn(new TranscodeProfiles\X264_360p());
$this->db->request(Argument::that(function ($prepared) {
return true;
}))
->shouldBeCalled();
$this->delete($transcode)
->shouldReturn(true);
}
}
<?php
namespace Spec\Minds\Core\Media\Video\Transcoder\TranscodeExecutors;
use Minds\Core\Media\Video\Transcoder\TranscodeExecutors\FFMpegExecutor;
use Minds\Core\Media\Video\Transcoder\TranscodeStorage\TranscodeStorageInterface;
use Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
use Minds\Core\Media\Video\Transcoder\Transcode;
use Minds\Core\Media\Video\Transcoder\TranscodeExecutors\FailedTranscodeException;
use FFMpeg\FFMpeg as FFMpegClient;
use FFMpeg\FFProbe as FFProbeClient;
use FFMpeg\Filters\Video\ResizeFilter;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class FFMpegExecutorSpec extends ObjectBehavior
{
private $ffmpeg;
private $ffprobe;
private $transcodeStorage;
public function let(FFMpegClient $ffmpeg, FFProbeClient $ffprobe, TranscodeStorageInterface $transcodeStorage)
{
$this->beConstructedWith(null, $ffmpeg, $ffprobe, $transcodeStorage);
$this->ffmpeg = $ffmpeg;
$this->ffprobe = $ffprobe;
$this->transcodeStorage = $transcodeStorage;
}
public function it_is_initializable()
{
$this->shouldHaveType(FFMpegExecutor::class);
}
public function it_should_transcode_thumbnails(
Transcode $transcode,
\FFMpeg\Media\Video $ffmpegVideo,
\FFMpeg\FFProbe\DataMapping\Format $ffprobeFormat,
\FFMpeg\Media\Frame $ffmpegFrame
) {
//$transcode = new Transcode();
$transcode->getProfile()
->shouldBeCalled()
->willReturn(new TranscodeProfiles\Thumbnails());
$this->transcodeStorage->downloadToTmp(Argument::type(Transcode::class))
->willReturn('/tmp/fake-path-for-source');
$this->ffmpeg->open('/tmp/fake-path-for-source')
->shouldBeCalled()
->willReturn($ffmpegVideo);
$this->ffprobe->streams('/tmp/fake-path-for-source')
->shouldBeCalled()
->willReturn($this->ffprobe);
$this->ffprobe->format('/tmp/fake-path-for-source')
->shouldBeCalled()
->willReturn($ffprobeFormat);
$ffprobeFormat->get('duration')
->willReturn(120);
$ffmpegVideo->frame(Argument::any())
->shouldBeCalled()
->willReturn($ffmpegFrame);
$ffmpegFrame->save(Argument::any())
->shouldBeCalled();
// These are all the thumbnails thast should have been called
$this->transcodeStorage->add($transcode, '/tmp/fake-path-for-source-thumbnails/thumbnail-00000.png')
->shouldBeCalled();
$this->transcodeStorage->add($transcode, '/tmp/fake-path-for-source-thumbnails/thumbnail-00001.png')
->shouldBeCalled();
$this->transcodeStorage->add($transcode, '/tmp/fake-path-for-source-thumbnails/thumbnail-00060.png')
->shouldBeCalled();
$this->transcodeStorage->add($transcode, '/tmp/fake-path-for-source-thumbnails/thumbnail-00119.png')
->shouldBeCalled();
$this->transcodeStorage->add($transcode, '/tmp/fake-path-for-source-thumbnails/thumbnail-00120.png')
->shouldBeCalled();
$transcode->setStatus('completed')
->shouldBeCalled();
$wrapped = $transcode->getWrappedObject();
$this->getWrappedObject()->transcode($wrapped, function ($progress) {
});
}
public function it_should_transcode_video(
Transcode $transcode,
\FFMpeg\Media\Video $ffmpegVideo,
\FFMpeg\FFProbe\DataMapping\Format $ffprobeFormat,
\FFMpeg\Filters\Video\VideoFilters $ffmpegVideoFilters
) {
//$transcode = new Transcode();
$transcode->getProfile()
->shouldBeCalled()
->willReturn(new TranscodeProfiles\X264_360p());
$this->transcodeStorage->downloadToTmp(Argument::type(Transcode::class))
->willReturn('/tmp/fake-path-for-source');
$this->ffmpeg->open('/tmp/fake-path-for-source')
->shouldBeCalled()
->willReturn($ffmpegVideo);
$this->ffprobe->streams('/tmp/fake-path-for-source')
->shouldBeCalled()
->willReturn($this->ffprobe);
$ffmpegVideo->filters()
->shouldBeCalled()
->willReturn($ffmpegVideoFilters);
$ffmpegVideoFilters->resize(Argument::any(), ResizeFilter::RESIZEMODE_SCALE_WIDTH)
->shouldBeCalled()
->willReturn($ffmpegVideoFilters);
$ffmpegVideoFilters->synchronize()
->shouldBeCalled();
$ffmpegVideo->save(Argument::that(function ($format) {
return $format->getKiloBitRate() === 500
&& $format->getAudioKiloBitrate() === 80;
}), '/tmp/fake-path-for-source-360p.mp4')
->shouldBeCalled();
$this->transcodeStorage->add($transcode, '/tmp/fake-path-for-source-360p.mp4')
->shouldBeCalled();
$transcode->setStatus('completed')
->shouldBeCalled();
$wrapped = $transcode->getWrappedObject();
$this->getWrappedObject()->transcode($wrapped, function ($progress) {
});
}
public function it_should_transcode_video_but_register_failure(
Transcode $transcode,
\FFMpeg\Media\Video $ffmpegVideo,
\FFMpeg\FFProbe\DataMapping\Format $ffprobeFormat,
\FFMpeg\Filters\Video\VideoFilters $ffmpegVideoFilters
) {
//$transcode = new Transcode();
$transcode->getProfile()
->shouldBeCalled()
->willReturn(new TranscodeProfiles\X264_360p());
$this->transcodeStorage->downloadToTmp(Argument::type(Transcode::class))
->willReturn('/tmp/fake-path-for-source');
$this->ffmpeg->open('/tmp/fake-path-for-source')
->shouldBeCalled()
->willReturn($ffmpegVideo);
$this->ffprobe->streams('/tmp/fake-path-for-source')
->shouldBeCalled()
->willReturn($this->ffprobe);
$ffmpegVideo->filters()
->shouldBeCalled()
->willReturn($ffmpegVideoFilters);
$ffmpegVideoFilters->resize(Argument::any(), ResizeFilter::RESIZEMODE_SCALE_WIDTH)
->shouldBeCalled()
->willReturn($ffmpegVideoFilters);
$ffmpegVideoFilters->synchronize()
->shouldBeCalled();
$ffmpegVideo->save(Argument::that(function ($format) {
return $format->getKiloBitRate() === 500
&& $format->getAudioKiloBitrate() === 80;
}), '/tmp/fake-path-for-source-360p.mp4')
->shouldBeCalled()
->willThrow(new \FFMpeg\Exception\RuntimeException());
$this->transcodeStorage->add($transcode, '/tmp/fake-path-for-source-360p.mp4')
->shouldNotBeCalled();
$transcode->setStatus('failed')
->shouldBeCalled();
$wrapped = $transcode->getWrappedObject();
try {
$this->getWrappedObject()->transcode($wrapped, function ($progress) {
});
throw new \Exception("An exception should have been thrown doe failed transcode");
} catch (FailedTranscodeException $e) {
// We throw a new exception above if the one we are expecting isn't called
}
}
}
<?php
namespace Spec\Minds\Core\Media\Video\Transcoder\TranscodeStorage;
use Minds\Core\Media\Video\Transcoder\TranscodeStorage\S3Storage;
use Minds\Core\Media\Video\Transcoder\Transcode;
use Minds\Core\Media\Video\Transcoder\TranscodeProfiles;
use Aws\S3\S3Client;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class S3StorageSpec extends ObjectBehavior
{
private $s3;
public function let(S3Client $s3)
{
$this->beConstructedWith(null, $s3);
$this->s3 = $s3;
}
public function it_is_initializable()
{
$this->shouldHaveType(S3Storage::class);
}
public function it_should_upload_file(Transcode $transcode)
{
$transcode->getGuid()
->willReturn(123);
$transcode->getProfile()
->willReturn(new TranscodeProfiles\X264_360p());
$this->s3->putObject(Argument::that(function ($args) {
return true;
}))
->shouldBeCalled()
->willReturn(true);
$this->add($transcode, tempnam(sys_get_temp_dir(), 'my-fake-path'));
}
public function it_should_download_file(Transcode $transcode)
{
$transcode->getGuid()
->willReturn(123);
$transcode->getProfile()
->willReturn(new TranscodeProfiles\Source());
$this->downloadToTmp($transcode)
->shouldContain("123-source");
}
}
......@@ -114,6 +114,11 @@ class Mock
return (int) $this->a;
}
public function microtime()
{
return (int) $this->a;
}
public function toInt()
{
return (int) $this->a;
......
Please register or to comment