diff --git a/core/command_builder.php b/core/command_builder.php new file mode 100644 index 00000000..5bde66e2 --- /dev/null +++ b/core/command_builder.php @@ -0,0 +1,65 @@ +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; + } +} diff --git a/core/imageboard/image.php b/core/imageboard/image.php index 161179a6..b65dc8ef 100644 --- a/core/imageboard/image.php +++ b/core/imageboard/image.php @@ -63,6 +63,9 @@ class Image /** @var boolean */ public $video = null; + /** @var string */ + public $video_codec = null; + /** @var boolean */ public $image = null; @@ -439,7 +442,7 @@ class Image $database->execute( "UPDATE images SET ". "lossless = :lossless, ". - "video = :video, audio = :audio,image = :image, ". + "video = :video, video_codec = :video_codec, audio = :audio,image = :image, ". "height = :height, width = :width, ". "length = :length WHERE id = :id", [ @@ -448,6 +451,7 @@ class Image "height" => $this->height ?? 0, "lossless" => $database->scoresql_value_prepare($this->lossless), "video" => $database->scoresql_value_prepare($this->video), + "video_codec" => $database->scoresql_value_prepare($this->video_codec), "image" => $database->scoresql_value_prepare($this->image), "audio" => $database->scoresql_value_prepare($this->audio), "length" => $this->length diff --git a/ext/handle_video/main.php b/ext/handle_video/main.php index b5b53465..403cedd3 100644 --- a/ext/handle_video/main.php +++ b/ext/handle_video/main.php @@ -67,6 +67,7 @@ class VideoFileHandler extends DataHandlerExtension if (array_key_exists("streams", $data)) { $video = false; $audio = true; + $video_codec = null; $streams = $data["streams"]; if (is_array($streams)) { foreach ($streams as $stream) { @@ -79,6 +80,7 @@ class VideoFileHandler extends DataHandlerExtension break; case "video": $video = true; + $video_codec = $stream["codec_name"]; break; } } @@ -93,7 +95,14 @@ class VideoFileHandler extends DataHandlerExtension } } $event->image->video = $video; + $event->image->video_codec = $video_codec; $event->image->audio = $audio; + if($event->image->get_mime()==MimeType::MKV && + 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); + } } } if (array_key_exists("format", $data)&& is_array($data["format"])) { diff --git a/ext/media/main.php b/ext/media/main.php index 20162caa..1255057d 100644 --- a/ext/media/main.php +++ b/ext/media/main.php @@ -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 @@ -944,6 +945,13 @@ class Media extends Extension $database->begin_transaction(); } + + if ($this->get_version(MediaConfig::VERSION) < 3) { + $database->execute($database->scoreql_to_sql( + "ALTER TABLE images ADD COLUMN video_codec varchar(512) NULL" + )); + $this->set_version(MediaConfig::VERSION, 3); + } } public static function hex_color_allocate($im, $hex) diff --git a/ext/media/video_codecs.php b/ext/media/video_codecs.php new file mode 100644 index 00000000..55731b62 --- /dev/null +++ b/ext/media/video_codecs.php @@ -0,0 +1,70 @@ + [ + 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] +// ); +// } +} diff --git a/ext/transcode/main.php b/ext/transcode/main.php index 6171e943..8eb8b016 100644 --- a/ext/transcode/main.php +++ b/ext/transcode/main.php @@ -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); diff --git a/ext/transcode/theme.php b/ext/transcode/theme.php index d6c0e5f0..56bf1755 100644 --- a/ext/transcode/theme.php +++ b/ext/transcode/theme.php @@ -18,7 +18,7 @@ class TranscodeImageTheme extends Themelet ".$this->get_transcode_picker_html($options)." -
+
"; diff --git a/ext/transcode_video/config.php b/ext/transcode_video/config.php new file mode 100644 index 00000000..8d32fb23 --- /dev/null +++ b/ext/transcode_video/config.php @@ -0,0 +1,8 @@ +"matthew@darkholme.net"]; + public $license = self::LICENSE_WTFPL; + public $description = "Allows admins to automatically and manually transcode videos."; + public $documentation ="Requires ffmpeg"; +} diff --git a/ext/transcode_video/main.php b/ext/transcode_video/main.php new file mode 100644 index 00000000..d8f11cb1 --- /dev/null +++ b/ext/transcode_video/main.php @@ -0,0 +1,294 @@ + "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; + } + + +} diff --git a/ext/transcode_video/script.js b/ext/transcode_video/script.js new file mode 100644 index 00000000..6f78ac33 --- /dev/null +++ b/ext/transcode_video/script.js @@ -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?'); + } + } +} \ No newline at end of file diff --git a/ext/transcode_video/theme.php b/ext/transcode_video/theme.php new file mode 100644 index 00000000..340d2398 --- /dev/null +++ b/ext/transcode_video/theme.php @@ -0,0 +1,45 @@ +id}"), + 'POST', + false, + "", + //"return transcodeSubmit()" + )." + + + ".$this->get_transcode_picker_html($options)." +
+ + "; + + return $html; + } + + public function get_transcode_picker_html(array $options) + { + $html = ""; + } + + 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)); + } +} diff --git a/themes/lite/view.theme.php b/themes/lite/view.theme.php index fa25386e..93db16d3 100644 --- a/themes/lite/view.theme.php +++ b/themes/lite/view.theme.php @@ -34,6 +34,9 @@ class CustomViewImageTheme extends ViewImageTheme
Filesize: $h_filesize
Type: ".$h_type." "; + if($image->video_codec!=null) { + $html .= "
Video Codec: $image->video_codec"; + } if ($image->length!=null) { $h_length = format_milliseconds($image->length); $html .= "
Length: $h_length";