VideoEncoder.php 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. <?php
  2. declare(strict_types = 1);
  3. // {{{ License
  4. // This file is part of GNU social - https://www.gnu.org/software/social
  5. //
  6. // GNU social is free software: you can redistribute it and/or modify
  7. // it under the terms of the GNU Affero General Public License as published by
  8. // the Free Software Foundation, either version 3 of the License, or
  9. // (at your option) any later version.
  10. //
  11. // GNU social is distributed in the hope that it will be useful,
  12. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. // GNU Affero General Public License for more details.
  15. //
  16. // You should have received a copy of the GNU Affero General Public License
  17. // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
  18. // }}}
  19. /**
  20. * Animated GIF resize support via PHP-FFMpeg
  21. *
  22. * @package GNUsocial
  23. *
  24. * @author Bruno Casteleiro <up201505347@fc.up.pt>
  25. * @author Diogo Peralta Cordeiro <mail@diogo.site>
  26. * @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
  27. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  28. *
  29. * @see http://www.gnu.org/software/social/
  30. */
  31. namespace Plugin\VideoEncoder;
  32. use App\Core\Event;
  33. use App\Core\GSFile;
  34. use function App\Core\I18n\_m;
  35. use App\Core\Log;
  36. use App\Core\Modules\Plugin;
  37. use App\Util\Exception\ServerException;
  38. use App\Util\Exception\TemporaryFileException;
  39. use App\Util\Formatting;
  40. use App\Util\TemporaryFile;
  41. use EventResult;
  42. use Exception;
  43. use FFMpeg\FFMpeg as ffmpeg;
  44. use FFMpeg\FFProbe as ffprobe;
  45. use SplFileInfo;
  46. class VideoEncoder extends Plugin
  47. {
  48. public static function version(): string
  49. {
  50. return '1.0.0';
  51. }
  52. public static function shouldHandle(string $mimetype): bool
  53. {
  54. return GSFile::mimetypeMajor($mimetype) === 'video' || $mimetype === 'image/gif';
  55. }
  56. public function onFileMetaAvailable(array &$event_map, string $mimetype): EventResult
  57. {
  58. if (!self::shouldHandle($mimetype)) {
  59. return Event::next;
  60. }
  61. $event_map['video'][] = [$this, 'fileMeta'];
  62. $event_map['image/gif'][] = [$this, 'fileMeta'];
  63. return Event::next;
  64. }
  65. public function onFileSanitizerAvailable(array &$event_map, string $mimetype): EventResult
  66. {
  67. if ($mimetype !== 'image/gif') {
  68. return Event::next;
  69. }
  70. $event_map['video'][] = [$this, 'fileMeta'];
  71. $event_map['image/gif'][] = [$this, 'fileMeta'];
  72. return Event::next;
  73. }
  74. public function onFileResizerAvailable(array &$event_map, string $mimetype): EventResult
  75. {
  76. if ($mimetype !== 'image/gif') {
  77. return Event::next;
  78. }
  79. $event_map['video'][] = [$this, 'resizeVideoPath'];
  80. $event_map['image/gif'][] = [$this, 'resizeVideoPath'];
  81. return Event::next;
  82. }
  83. /**
  84. * Adds width and height metadata to gifs
  85. *
  86. * @param null|string $mimetype in/out
  87. * @param null|int $width out
  88. * @param null|int $height out
  89. *
  90. * @return bool true if metadata filled
  91. */
  92. public function fileMeta(SplFileInfo &$file, ?string &$mimetype, ?int &$width, ?int &$height): bool
  93. {
  94. // Create FFProbe instance
  95. // Need to explicitly tell the drivers' location, or it won't find them
  96. $ffprobe = ffprobe::create([
  97. 'ffmpeg.binaries' => exec('which ffmpeg'),
  98. 'ffprobe.binaries' => exec('which ffprobe'),
  99. ]);
  100. $metadata = $ffprobe->streams($file->getRealPath()) // extracts streams informations
  101. ->videos() // filters video streams
  102. ->first(); // returns the first video stream
  103. if (!\is_null($metadata)) {
  104. $width = $metadata->get('width');
  105. $height = $metadata->get('height');
  106. }
  107. return true;
  108. }
  109. /**
  110. * Resizes GIF files.
  111. *
  112. * @throws TemporaryFileException
  113. */
  114. public function resizeVideoPath(string $source, ?TemporaryFile &$destination, int &$width, int &$height, bool $smart_crop, ?string &$mimetype): bool
  115. {
  116. switch ($mimetype) {
  117. case 'image/gif':
  118. // resize only if an animated GIF
  119. if ($this->isAnimatedGif($source)) {
  120. return $this->resizeImageFileAnimatedGif($source, $destination, $width, $height, $smart_crop, $mimetype);
  121. }
  122. break;
  123. }
  124. return false;
  125. }
  126. /**
  127. * Generates the view for attachments of type Video
  128. */
  129. public function onViewAttachment(array $vars, array &$res): EventResult
  130. {
  131. if ($vars['attachment']->getMimetypeMajor() !== 'video') {
  132. return Event::next;
  133. }
  134. $res[] = Formatting::twigRenderFile(
  135. 'videoEncoder/videoEncoderView.html.twig',
  136. [
  137. 'attachment' => $vars['attachment'],
  138. 'note' => $vars['note'],
  139. 'title' => $vars['title'],
  140. ],
  141. );
  142. return Event::stop;
  143. }
  144. /**
  145. * Animated GIF test, courtesy of frank at huddler dot com et al:
  146. * http://php.net/manual/en/function.imagecreatefromgif.php#104473
  147. * Modified so avoid landing inside of a header (and thus not matching our regexp).
  148. */
  149. public function isAnimatedGif(string $filepath): bool
  150. {
  151. if (!($fh = @fopen($filepath, 'rb'))) {
  152. return false;
  153. }
  154. $count = 0;
  155. //an animated gif contains multiple "frames", with each frame having a
  156. //header made up of:
  157. // * a static 4-byte sequence (\x00\x21\xF9\x04)
  158. // * 4 variable bytes
  159. // * a static 2-byte sequence (\x00\x2C)
  160. // In total the header is maximum 10 bytes.
  161. // We read through the file til we reach the end of the file, or we've found
  162. // at least 2 frame headers
  163. while (!feof($fh) && $count < 2) {
  164. $chunk = fread($fh, 1024 * 100); //read 100kb at a time
  165. $count += preg_match_all('#\x00\x21\xF9\x04.{4}\x00\x2C#s', $chunk, $matches);
  166. // rewind in case we ended up in the middle of the header, but avoid
  167. // infinite loop (i.e. don't rewind if we're already in the end).
  168. if (!feof($fh) && ftell($fh) >= 9) {
  169. fseek($fh, -9, \SEEK_CUR);
  170. }
  171. }
  172. fclose($fh);
  173. return $count >= 1; // number of animated frames apart from the original image
  174. }
  175. /**
  176. * High quality GIF conversion.
  177. *
  178. * @see http://blog.pkh.me/p/21-high-quality-gif-with-ffmpeg.html
  179. * @see https://github.com/PHP-FFMpeg/PHP-FFMpeg/pull/592
  180. *
  181. * @throws TemporaryFileException
  182. */
  183. public function resizeImageFileAnimatedGif(string $source, ?TemporaryFile &$destination, int &$width, int &$height, bool $smart_crop, ?string &$mimetype): bool
  184. {
  185. // Create FFMpeg instance
  186. // Need to explicitly tell the drivers' location, or it won't find them
  187. $ffmpeg = ffmpeg::create([
  188. 'ffmpeg.binaries' => exec('which ffmpeg'),
  189. 'ffprobe.binaries' => exec('which ffprobe'),
  190. ]);
  191. // FFmpeg can't edit existing files in place,
  192. // generate temporary output file to avoid that
  193. $destination ??= new TemporaryFile(['prefix' => 'video']);
  194. // Generate palette file. FFmpeg explicitly needs to be told the
  195. // extension for PNG files outputs
  196. $palette = $this->tempnam_sfx(sys_get_temp_dir(), '.png');
  197. // Build filters
  198. $filters = 'fps=30';
  199. // if ($crop) {
  200. // $filters .= ",crop={$width}:{$height}:{$x}:{$y}";
  201. // }
  202. $filters .= ",scale={$width}:{$height}:flags=lanczos";
  203. // Assemble commands for palette generation
  204. $commands[] = $commands_2[] = '-f';
  205. $commands[] = $commands_2[] = 'gif';
  206. $commands[] = $commands_2[] = '-i';
  207. $commands[] = $commands_2[] = $source;
  208. $commands[] = '-vf';
  209. $commands[] = $filters . ',palettegen';
  210. $commands[] = '-y';
  211. $commands[] = $palette;
  212. // Assemble commands for GIF generation
  213. $commands_2[] = '-i';
  214. $commands_2[] = $palette;
  215. $commands_2[] = '-lavfi';
  216. $commands_2[] = $filters . ' [x]; [x][1:v] paletteuse';
  217. $commands_2[] = '-f';
  218. $commands_2[] = 'gif';
  219. $commands_2[] = '-y';
  220. $commands_2[] = $destination->getRealPath();
  221. $success = true;
  222. // Generate the palette image
  223. try {
  224. $ffmpeg->getFFMpegDriver()->command($commands);
  225. } catch (Exception $e) {
  226. Log::error('Unable to generate the palette image');
  227. $success = false;
  228. }
  229. // Generate GIF
  230. try {
  231. if ($success) {
  232. $ffmpeg->getFFMpegDriver()->command($commands_2);
  233. }
  234. } catch (Exception $e) {
  235. Log::error('Unable to generate the GIF image');
  236. $success = false;
  237. }
  238. @unlink($palette);
  239. $mimetype = 'image/gif';
  240. return $success;
  241. }
  242. /**
  243. * Suffix version of tempnam.
  244. * Courtesy of tomas at slax dot org:
  245. *
  246. * @see https://www.php.net/manual/en/function.tempnam.php#98232
  247. */
  248. private function tempnam_sfx(string $dir, string $suffix): string
  249. {
  250. do {
  251. $file = $dir . '/' . mt_rand() . $suffix;
  252. $fp = @fopen($file, 'x');
  253. } while (!$fp);
  254. fclose($fp);
  255. return $file;
  256. }
  257. /**
  258. * @throws ServerException
  259. */
  260. public function onPluginVersion(array &$versions): EventResult
  261. {
  262. $versions[] = ['name' => 'FFmpeg',
  263. 'version' => self::version(),
  264. 'author' => 'Bruno Casteleiro, Diogo Peralta Cordeiro',
  265. 'homepage' => 'https://notabug.org/diogo/gnu-social/src/nightly/plugins/FFmpeg',
  266. 'rawdescription', // TRANS: Plugin description. => _m('Use PHP-FFMpeg for some more video support.'),
  267. ];
  268. return Event::next;
  269. }
  270. }