VideoEncoder.php 11 KB

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