Video transcoding extension, currently just supports changing the video container

This commit is contained in:
Matthew Barbour 2020-08-28 09:11:14 -05:00
parent 65aca09203
commit ecd860ec6d
12 changed files with 516 additions and 7 deletions

65
core/command_builder.php Normal file
View file

@ -0,0 +1,65 @@
<?php declare(strict_types=1);
// Provides mechanisms for cleanly executing command-line applications
// Was created to try to centralize a solution for whatever caused this:
// quotes are only needed if the path to convert contains a space; some other times, quotes break things, see github bug #27
class CommandBuilder
{
private $executable;
private $args = [];
public $output;
function __construct(String $executable) {
if(empty($executable)) {
throw new InvalidArgumentException("executable cannot be empty");
}
$this->executable = $executable;
}
public function add_flag(string $value): void
{
$this->args[] = $value;
}
public function add_escaped_arg(string $value): void
{
$this->args[] = escapeshellarg($value);
}
public function generate(): string
{
$command = escapeshellarg($this->executable);
if(!empty($this->args)) {
$command .= " ";
$command .= join(" ", $this->args);
}
return escapeshellcmd($command)." 2>&1";
}
public function combineOutput(string $empty_output = ""): string
{
if(empty($this->output)) {
return $empty_output;
} else {
return implode("\r\n", $this->output);
}
}
public function execute(bool $fail_on_non_zero_return = false): int
{
$cmd = $this->generate();
exec($cmd, $this->output, $ret);
$output = $this->combineOutput("nothing");
log_debug('command_builder', "Command `$cmd` returned $ret and outputted $output");
if($fail_on_non_zero_return&&(int)$ret!==(int)0) {
throw new SCoreException("Command `$cmd` failed, returning $ret and outputting $output");
}
return $ret;
}
}

View file

@ -63,6 +63,9 @@ class Image
/** @var boolean */
public $video = null;
/** @var string */
public $video_codec = null;
/** @var boolean */
public $image = null;

View file

@ -98,8 +98,7 @@ class VideoFileHandler extends DataHandlerExtension
$event->image->video_codec = $video_codec;
$event->image->audio = $audio;
if($event->image->get_mime()==MimeType::MKV &&
($event->image->video_codec == "vp9" ||
$event->image->video_codec == "vp8")) {
VideoContainers::is_video_codec_supported(VideoContainers::WEBM,$event->image->video_codec)) {
// WEBMs are MKVs with the VP9 or VP8 codec
// For browser-friendliness, we'll just change the mime type
$event->image->set_mime(MimeType::WEBM);

View file

@ -3,6 +3,7 @@
require_once "config.php";
require_once "events.php";
require_once "media_engine.php";
require_once "video_codecs.php";
/*
* This is used by the media code when there is an error

View file

@ -0,0 +1,70 @@
<?php declare(strict_types=1);
abstract class VideoContainers
{
public const WEBM = MimeType::WEBM;
public const MP4 = MimeType::MP4_VIDEO;
public const OGG = MimeType::OGG_VIDEO;
public const MKV = MimeType::MKV;
public const ALL = [
VideoContainers::WEBM,
VideoContainers::MP4,
VideoContainers::OGG,
VideoContainers::MKV,
];
public const VIDEO_CODEC_SUPPORT = [
VideoContainers::WEBM => [
VideoCodecs::VP8,
VideoCodecs::VP9,
],
VideoContainers::OGG => [
VideoCodecs::THEORA,
],
VideoContainers::MP4 => [
VideoCodecs::H264,
VideoCodecs::H265,
VideoCodecs::MPEG4,
],
VideoContainers::MKV => VideoCodecs::ALL // The one container to rule them all
];
public static function is_video_codec_supported(string $container, string $codec): bool
{
return array_key_exists($container,self::VIDEO_CODEC_SUPPORT) &&
in_array($codec,self::VIDEO_CODEC_SUPPORT[$container]);
}
}
abstract class VideoCodecs
{
public const VP9 = "vp9";
public const VP8 = "vp8";
public const H264 = "h264";
public const H265 = "h265";
public const MPEG4 = "mpeg4";
public const THEORA = "theora";
public const ALL = [
VideoCodecs::VP9,
VideoCodecs::VP8,
VideoCodecs::H264,
VideoCodecs::H265,
VideoCodecs::MPEG4,
VideoCodecs::THEORA,
];
//
// public static function is_input_supported(string $engine, string $mime): bool
// {
// return MimeType::matches_array(
// $mime,
// MediaEngine::INPUT_SUPPORT[$engine]
// );
// }
}

View file

@ -262,7 +262,7 @@ class TranscodeImage extends Extension
$engine = $config->get_string(TranscodeConfig::ENGINE);
if ($user->can(Permissions::EDIT_FILES)) {
$event->add_action(self::ACTION_BULK_TRANSCODE, "Transcode", null, "", $this->theme->get_transcode_picker_html($this->get_supported_output_mimes($engine)));
$event->add_action(self::ACTION_BULK_TRANSCODE, "Transcode Image", null, "", $this->theme->get_transcode_picker_html($this->get_supported_output_mimes($engine)));
}
}
@ -401,7 +401,7 @@ class TranscodeImage extends Extension
{
global $config;
$q = $config->get_int("transcode_quality");
$q = $config->get_int(TranscodeConfig::QUALITY);
$tmp_name = tempnam(sys_get_temp_dir(), "shimmie_transcode");
@ -453,10 +453,10 @@ class TranscodeImage extends Extension
{
global $config;
$q = $config->get_int("transcode_quality");
$q = $config->get_int(TranscodeConfig::QUALITY);
$convert = $config->get_string(MediaConfig::CONVERT_PATH);
if ($convert==null||$convert=="") {
if (empty($convert)) {
throw new ImageTranscodeException("ImageMagick path not configured");
}
$ext = Media::determine_ext($target_mime);

View file

@ -18,7 +18,7 @@ class TranscodeImageTheme extends Themelet
<input type='hidden' name='image_id' value='{$image->id}'>
<input type='hidden' id='image_lossless' name='image_lossless' value='{$image->lossless}'>
".$this->get_transcode_picker_html($options)."
<br><input id='transcodebutton' type='submit' value='Transcode'>
<br><input id='transcodebutton' type='submit' value='Transcode Image'>
</form>
";

View file

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
class TranscodeVideoConfig
{
const ENABLED = "transcode_video_enabled";
const UPLOAD_TO_NATIVE_CONTAINER = "transcode_video_upload_to_native_container";
const UPLOAD = "transcode_video_upload";
}

View file

@ -0,0 +1,13 @@
<?php declare(strict_types=1);
class TranscodeVideoInfo extends ExtensionInfo
{
public const KEY = "transcode_video";
public $key = self::KEY;
public $name = "Transcode Video";
public $authors = ["Matthew Barbour"=>"matthew@darkholme.net"];
public $license = self::LICENSE_WTFPL;
public $description = "Allows admins to automatically and manually transcode videos.";
public $documentation ="Requires ffmpeg";
}

View file

@ -0,0 +1,294 @@
<?php declare(strict_types=1);
require_once "config.php";
/*
* This is used by the image transcoding code when there is an error while transcoding
*/
class VideoTranscodeException extends SCoreException
{
}
class TranscodeVideo extends Extension
{
/** @var TranscodeVideoTheme */
protected $theme;
const ACTION_BULK_TRANSCODE = "bulk_transcode_video";
const FORMAT_NAMES = [
VideoContainers::MKV => "matroska",
VideoContainers::WEBM => "webm",
VideoContainers::OGG => "ogg",
VideoContainers::MP4 => "mp4",
];
/**
* Needs to be after upload, but before the processing extensions
*/
public function get_priority(): int
{
return 45;
}
public function onInitExt(InitExtEvent $event)
{
global $config;
$config->set_default_bool(TranscodeVideoConfig::ENABLED, true);
$config->set_default_bool(TranscodeVideoConfig::UPLOAD, false);
$config->set_default_bool(TranscodeVideoConfig::UPLOAD_TO_NATIVE_CONTAINER, false);
}
public function onImageAdminBlockBuilding(ImageAdminBlockBuildingEvent $event)
{
global $user, $config;
if ($event->image->video===true && $user->can(Permissions::EDIT_FILES)) {
$options = self::get_output_options($event->image->get_mime(), $event->image->video_codec);
if(!empty($options)&&sizeof($options)>1) {
$event->add_part($this->theme->get_transcode_html($event->image,$options));
}
}
}
public function onSetupBuilding(SetupBuildingEvent $event)
{
global $config;
$sb = new SetupBlock("Video Transcode");
$sb->start_table();
$sb->add_bool_option(TranscodeVideoConfig::ENABLED, "Allow transcoding images: ", true);
$sb->add_bool_option(TranscodeVideoConfig::UPLOAD_TO_NATIVE_CONTAINER, "Convert videos using MPEG-4 or WEBM to their native containers:", true);
$sb->end_table();
$event->panel->add_block($sb);
}
public function onDataUpload(DataUploadEvent $event)
{
// global $config;
//
// if ($config->get_bool(TranscodeVideoConfig::UPLOAD) == true) {
// $ext = strtolower($event->type);
//
// $ext = Media::normalize_format($ext);
//
// if ($event->type=="gif"&&Media::is_animated_gif($event->tmpname)) {
// return;
// }
//
// if (in_array($ext, array_values(self::INPUT_FORMATS))) {
// $target_format = $config->get_string(TranscodeVideoConfig::UPLOAD_PREFIX.$ext);
// if (empty($target_format)) {
// return;
// }
// try {
// $new_image = $this->transcode_image($event->tmpname, $ext, $target_format);
// $event->set_mime(Media::determine_ext($target_format));
// $event->set_tmpname($new_image);
// } catch (Exception $e) {
// log_error("transcode_video", "Error while performing upload transcode: ".$e->getMessage());
// // We don't want to interfere with the upload process,
// // so if something goes wrong the untranscoded image jsut continues
// }
// }
// }
}
public function onPageRequest(PageRequestEvent $event)
{
global $page, $user;
if ($event->page_matches("transcode_video") && $user->can(Permissions::EDIT_FILES)) {
if ($event->count_args() >= 1) {
$image_id = int_escape($event->get_arg(0));
} elseif (isset($_POST['image_id'])) {
$image_id = int_escape($_POST['image_id']);
} else {
throw new VideoTranscodeException("Can not transcode video: No valid ID given.");
}
$image_obj = Image::by_id($image_id);
if (is_null($image_obj)) {
$this->theme->display_error(404, "Image not found", "No image in the database has the ID #$image_id");
} else {
if (isset($_POST['transcode_format'])) {
try {
$this->transcode_and_replace_video($image_obj, $_POST['transcode_format']);
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("post/view/".$image_id));
} catch (VideoTranscodeException $e) {
$this->theme->display_transcode_error($page, "Error Transcoding", $e->getMessage());
}
}
}
}
}
public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event)
{
global $user, $config;
if ($user->can(Permissions::EDIT_FILES)) {
$event->add_action(
self::ACTION_BULK_TRANSCODE,
"Transcode Video",
null,
"",
$this->theme->get_transcode_picker_html(self::get_output_options())
);
}
}
public function onBulkAction(BulkActionEvent $event)
{
global $user, $database, $page;
switch ($event->action) {
case self::ACTION_BULK_TRANSCODE:
if (!isset($_POST['transcode_format'])) {
return;
}
if ($user->can(Permissions::EDIT_FILES)) {
$format = $_POST['transcode_format'];
$total = 0;
foreach ($event->items as $image) {
try {
$database->begin_transaction();
$output_image = $this->transcode_and_replace_video($image, $format);
// If a subsequent transcode fails, the database needs to have everything about the previous
// transcodes recorded already, otherwise the image entries will be stuck pointing to
// missing image files
$database->commit();
if($output_image!=$image) {
$total++;
}
} catch (Exception $e) {
log_error("transcode_video", "Error while bulk transcode on item {$image->id} to $format: ".$e->getMessage());
try {
$database->rollback();
} catch (Exception $e) {
// is this safe? o.o
}
}
}
$page->flash("Transcoded $total items");
}
break;
}
}
private static function get_output_options(?String $starting_container = null, ?String $starting_codec = null): array
{
$output = ["" => ""];
foreach (VideoContainers::ALL as $container) {
if($starting_container==$container) {
continue;
}
if(!empty($starting_codec)&&
!VideoContainers::is_video_codec_supported($container,$starting_codec)) {
continue;
}
$description = MimeMap::get_name_for_mime($container);
$output[$description] = $container;
}
return $output;
}
private function transcode_and_replace_video(Image $image, String $target_mime): Image
{
if($image->get_mime()==$target_mime) {
return $image;
}
if($image->video==null||($image->video===true && empty($image->video_codec))) {
// If image predates the media system, or the video codec support, run a media check
send_event(new MediaCheckPropertiesEvent($image));
$image->save_to_db();
}
if(empty($image->video_codec)) {
throw new VideoTranscodeException("Cannot transcode item $image->id because its video codec is not known");
}
$original_file = warehouse_path(Image::IMAGE_DIR, $image->hash);
$tmp_filename = tempnam(sys_get_temp_dir(), "shimmie_transcode_video");
try {
$tmp_filename = $this->transcode_video($original_file, $image->video_codec, $target_mime, $tmp_filename);
$new_image = new Image();
$new_image->hash = md5_file($tmp_filename);
$new_image->filesize = filesize($tmp_filename);
$new_image->filename = $image->filename;
$new_image->width = $image->width;
$new_image->height = $image->height;
/* Move the new image into the main storage location */
$target = warehouse_path(Image::IMAGE_DIR, $new_image->hash);
if (!@copy($tmp_filename, $target)) {
throw new VideoTranscodeException("Failed to copy new image file from temporary location ({$tmp_filename}) to archive ($target)");
}
send_event(new ImageReplaceEvent($image->id, $new_image));
return $new_image;
} finally {
/* Remove temporary file */
@unlink($tmp_filename);
}
}
private function transcode_video(String $source_file, String $source_video_codec, String $target_mime, string $target_file): string
{
global $config;
if(empty($source_video_codec)) {
throw new VideoTranscodeException("Cannot transcode item because it's video codec is not known");
}
$ffmpeg = $config->get_string(MediaConfig::FFMPEG_PATH);
if (empty($ffmpeg)) {
throw new VideoTranscodeException("ffmpeg path not configured");
}
$ext = Media::determine_ext($target_mime);
$command = new CommandBuilder($ffmpeg);
$command->add_flag("-y"); // Bypass y/n prompts
$command->add_flag("-i");
$command->add_escaped_arg($source_file);
if(!VideoContainers::is_video_codec_supported($target_mime, $source_video_codec)) {
throw new VideoTranscodeException("Cannot transcode item to $target_mime because it does not support the video codec $source_video_codec");
}
// TODO: Implement transcoding the codec as well. This will be much more advanced than just picking a container.
$command->add_flag("-c");
$command->add_flag("copy");
$command->add_flag("-map"); // Copies all streams
$command->add_flag("0");
$file_extension = FileExtension::get_for_mime($target_mime);
$command->add_flag("-f");
$format = self::FORMAT_NAMES[$target_mime];
$command->add_flag($format);
$command->add_escaped_arg($target_file);
$command->execute(true);
return $target_file;
}
}

View file

@ -0,0 +1,11 @@
function transcodeSubmit(e) {
var format = document.getElementById('transcode_format').value;
if(format!="webp-lossless" && format != "png") {
var lossless = document.getElementById('image_lossless');
if(lossless!=null && lossless.value=='1') {
return confirm('You are about to transcode from a lossless format to a lossy format. Lossless formats compress with no quality loss, but converting to a lossy format always results in quality loss, and it will lose more quality every time it is done again on the same image. Are you sure you want to perform this transcode?');
} else {
return confirm('Converting to a lossy format always results in quality loss, and it will lose more quality every time it is done again on the same image. Are you sure you want to perform this transcode?');
}
}
}

View file

@ -0,0 +1,45 @@
<?php declare(strict_types=1);
class TranscodeVideoTheme extends Themelet
{
/*
* Display a link to resize an image
*/
public function get_transcode_html(Image $image, array $options)
{
$html = "
".make_form(
make_link("transcode_video/{$image->id}"),
'POST',
false,
"",
//"return transcodeSubmit()"
)."
<input type='hidden' name='image_id' value='{$image->id}'>
<input type='hidden' name='codec' value='{$image->video_codec}'>
".$this->get_transcode_picker_html($options)."
<br><input id='transcodebutton' type='submit' value='Transcode Video'>
</form>
";
return $html;
}
public function get_transcode_picker_html(array $options)
{
$html = "<select id='transcode_format' name='transcode_format' required='required' >";
foreach ($options as $display=>$value) {
$html .= "<option value='$value'>$display</option>";
}
return $html."</select>";
}
public function display_transcode_error(Page $page, string $title, string $message)
{
$page->set_title("Transcode Video");
$page->set_heading("Transcode Video");
$page->add_block(new NavBlock());
$page->add_block(new Block($title, $message));
}
}