VideoEncoder.php 9.9 KB

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