diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1706f3e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto +* eol=lf + +.git export-ignore +.gitattributes export-ignore +.gitignore export-ignore +vendor export-ignore +makefile export-ignore +phpunit.xml export-ignore + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8180267 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +composer.lock + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2431853 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "ajur-media/fsnews.media", + "description": "FSNews Engine Media common class", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "AJUR\\FSNews\\": "src/" + } + }, + "authors": [ + { + "name": "Karel Wintersky", + "email": "karel.wintersky@yandex.ru" + } + ], + "require": { + "ext-json": "*", + "ext-fileinfo": "*", + "psr/log": "^1.1", + "karelwintersky/arris": "^2.4", + "karelwintersky/arris.toolkit.mimetypes": "^1.0", + "ajur-media/php_gdwrapper": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5" + } +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..eeb3b1b --- /dev/null +++ b/makefile @@ -0,0 +1,22 @@ +#!/usr/bin/make + +help: + @perl -e '$(HELP_ACTION)' $(MAKEFILE_LIST) + +test: ##@test PHPUnit tests + @php ./vendor/bin/phpunit --bootstrap vendor/autoload.php --testdox tests + +# ------------------------------------------------ +# Add the following 'help' target to your makefile, add help text after each target name starting with '\#\#' +# A category can be added with @category +GREEN := $(shell tput -Txterm setaf 2) +YELLOW := $(shell tput -Txterm setaf 3) +WHITE := $(shell tput -Txterm setaf 7) +RESET := $(shell tput -Txterm sgr0) +HELP_ACTION = \ + %help; while(<>) { push @{$$help{$$2 // 'options'}}, [$$1, $$3] if /^([a-zA-Z\-_]+)\s*:.*\#\#(?:@([a-zA-Z\-]+))?\s(.*)$$/ }; \ + print "usage: make [target]\n\n"; for (sort keys %help) { print "${WHITE}$$_:${RESET}\n"; \ + for (@{$$help{$$_}}) { $$sep = " " x (32 - length $$_->[0]); print " ${YELLOW}$$_->[0]${RESET}$$sep${GREEN}$$_->[1]${RESET}\n"; }; \ + print "\n"; } + +# -eof- \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..89a0c9e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,9 @@ + + + + ./tests + + diff --git a/src/Constants/AllowedMimeTypes.php b/src/Constants/AllowedMimeTypes.php new file mode 100644 index 0000000..dea13c5 --- /dev/null +++ b/src/Constants/AllowedMimeTypes.php @@ -0,0 +1,25 @@ + "titles", + "titles" => "titles", + "photos" => "photos", + "videos" => "videos", + "audios" => "audios", + "youtube" => "youtube", + "files" => "files", + "_" => "" + ]; + + public static function getContentDir($type = 'photos'):string + { + return array_key_exists($type, self::$content_dirs) + ? self::$content_dirs[$type] + : self::$content_dirs['_']; + } + +} \ No newline at end of file diff --git a/src/Constants/ConvertSizes.php b/src/Constants/ConvertSizes.php new file mode 100644 index 0000000..83ab3bc --- /dev/null +++ b/src/Constants/ConvertSizes.php @@ -0,0 +1,166 @@ + [ + /* + * Иконка превью в списке фотографий, входящих в фоторепортаж (десктоп и мобильный) + */ + "100x100" => [ + 'maxWidth' => 100, + 'maxHeight' => 100, + 'method' => "getfixedpicture", + 'prefix' => '100x100_', + 'quality' => 80, + ], + /* + * sizes_full - для десктопного фоторепортажа в RSS-лентах + * базовый размер для фото, вставляемого в МОБИЛЬНУЮ статью через [media id=] -- (считается, что 440 - это базовая ширина мобилки) + */ + "440x300" => [ + 'maxWidth' => 440, + 'maxHeight' => 999, + 'method' => "resizepictureaspect", + 'wmFile' => "l.png", + 'wmMargin' => 10, + 'prefix' => '440x300_', + 'quality' => 80, + ], + /* + * базовый размер для фото в фоторепортаже (десктоп и мобильный) + * базовый размер для фото, вставляемого в десктопную статью через [media id=] + */ + "630x465" => [ + 'maxWidth' => 630, + 'maxHeight' => 465, + 'method' => "resizepictureaspect", + 'wmFile' => "l.png", + 'wmMargin' => 30, + 'prefix' => '630x465_', + 'quality' => 80, + ], + /* + * sizes_full - полноразмерная картинка, всплывающая при клике на вставленную в статью/страницу фото размера 'sizes' (630x465) + * sizes_full для репортажей на мобиле + * sizes_large для всех фото + * + упоминается в шаблоне site/reports/reports_list.tpl + */ + "1280x1024" => [ + 'maxWidth' => 1280, + 'maxHeight' => 1024, + 'method' => "resizeimageaspect", + 'wmFile' => "l.png", + 'wmMargin' => 30, + 'prefix' => '1280x1024_', + 'quality' => 90, + ], + ], + "videos" => [ + "100x100" => [ + /* + * Превью-иконка видео в админке + */ + 'maxWidth' => 100, + 'maxHeight' => 100, + 'method' => "getfixedpicture", + 'prefix' => '100x100_', + 'quality' => 80, + ], + /* + * Превью видео, используется + */ + "640x352" => [ + 'maxWidth' => 640, + 'maxHeight' => 360, + 'method' => "getfixedpicture", + 'prefix' => '640x352_', + 'quality' => 80, + ], + ], + + "audios" => [ + "_" => [ + 'prefix' => '', + ], + ], + + "files" => [ + "_" => [ + 'prefix' => '', + ], + ], + + "youtube" => [ + /* + * Превью в админке + */ + "100x100" => [ + 'maxWidth' => 100, + 'maxHeight' => 100, + 'method' => "getfixedpicture", + 'prefix' => '100x100_', + 'quality' => 80 + ], + /* + * Превью видео, используется + */ + "640x352" => [ + 'maxWidth' => 640, + 'maxHeight' => 360, + 'method' => "getfixedpicture", + 'prefix' => '640x325_', + 'quality' => 80 + ], + ], + + "titles" => [ + /* + * основное title изображение, (article.tpl) + * Еще оно используется в админке, в редакторе статей + * Нехай качество будет 92, разница между 90 и 92 по размеру около 2%, а качество должно различаться заметно + */ + '608x406' => [ + 'maxWidth' => 608, + 'maxHeight' => 406, + 'method' => '', + 'prefix' => '', + 'quality' => 92 + ], + /* + * "квадратные" превью тайтлов статей на главной (widget.tpl, index_tres.tpl) + */ + '300x266' => [ + 'maxWidth' => 300, + 'maxHeight' => 266, + 'method' => 'getFixedPicture', + 'prefix' => 'resize_', + 'quality' => 80 + ], + /* + * маленькие превью тайтлов статей на главной (3 в топе) + * картинки в фиде "авторский материал" на главной + */ + '205x150' => [ + 'maxWidth' => 205, + 'maxHeight' => 150, + 'method' => 'getFixedPicture', + 'prefix' => 'small_', + 'quality' => 70 + ], + ] + ]; + +} \ No newline at end of file diff --git a/src/Helpers/DTHelper.php b/src/Helpers/DTHelper.php new file mode 100644 index 0000000..0727844 --- /dev/null +++ b/src/Helpers/DTHelper.php @@ -0,0 +1,50 @@ + 'января', 2 => 'февраля', + 3 => 'марта', 4 => 'апреля', 5 => 'мая', + 6 => 'июня', 7 => 'июля', 8 => 'августа', + 9 => 'сентября', 10 => 'октября', 11 => 'ноября', + 12 => 'декабря' + ); + + const yearSuffux = 'г.'; + + /** + * Преобразует переданную дату в русифицированную дату + * + * @param string $datetime - заполненную нулями возвращает как "-", "today"|"NOW()" + * @param bool $is_show_time + * @param null $year_suffix - можно передать пустую строчку, чтобы подавить вывод годового суффикса + * @return string + */ + public static function convertDate(string $datetime, bool $is_show_time = false, $year_suffix = null):string + { + $datetime = strtoupper($datetime); + if ($datetime === "0000-00-00 00:00:00" || $datetime === "0000-00-00") { + return "-"; + } + + if ($datetime === 'TODAY' || $datetime === 'NOW()') { + $datetime = date("Y-m-d H:i:s"); + } + + if (is_null($year_suffix)) { + $year_suffix = self::yearSuffux; + } + + list( $y, $m, $d, $h, $i, $s ) = sscanf( $datetime, "%d-%d-%d %d:%d:%d" ); + + $rusdate = sprintf("%s %s %s", $d, self::ruMonths[$m], $y ? "{$y} {$year_suffix}" : ""); + + if ($is_show_time) { + $rusdate .= sprintf(" %02d:%02d", $h, $i); + } + return $rusdate; + } + +} \ No newline at end of file diff --git a/src/Helpers/MediaHelpers.php b/src/Helpers/MediaHelpers.php new file mode 100644 index 0000000..670460b --- /dev/null +++ b/src/Helpers/MediaHelpers.php @@ -0,0 +1,267 @@ +join( self::getContentDir($type) ) + ->join( date('Y', $creation_date) ) + ->join( date('m', $creation_date) ) + ->setOptions(['isAbsolute'=>true]); + + return $stringify_path ? $path->toString(true) : $path; + } + + /** + * Возвращает путь к ресурсу относительно корня STORAGE (и только путь). Начинается с /, заканчивается на / + * + * Для определения каталога к типу контента используется mapping + * + * @param $type + * @param $creation_date + * @param $stringify_path + * @return Path|string + */ + public static function getRelativeResourcePath($type = 'photos', $creation_date = 'now', $stringify_path = true) + { + $creation_date = $creation_date == 'now' ? time() : strtotime($creation_date); + + $path = Path::create( self::getContentDir($type), true ) + ->join( date('Y', $creation_date) ) + ->join( date('m', $creation_date) ); + + return $stringify_path ? $path->toString(true) : $path; + } + + /** + * Возвращает абсолютный URL к ресурсу + * + * @param $type + * @param $creation_date + * @param $stringify + * @return void + */ + public static function getAbsoluteResourceURI($type = 'photos', $creation_date = 'now', $stringify = true) + { + $creation_date = $creation_date == 'now' ? time() : strtotime($creation_date); + + //@todo ... + + + } + + /** + * Проверяет существование пути + * + * @param $path + * @return bool + */ + public static function validatePath($path) + { + if ($path instanceof Path) { + $path = $path->toString(); + } + + if (!is_dir($path) && ( !mkdir($path, 0777, true) && !is_dir($path)) ) { + throw new \RuntimeException( sprintf( 'Directory "%s" can\'t be created', $path ) ); + } + + return true; + } + + /** + * @throws \Exception + */ + public static function getRandomFilename(int $length = 20, string $suffix = '', $prefix_format = 'Ymd'):string + { + $dictionary = MediaInterface::DICTIONARY; + $dictionary_len = MediaInterface::DICTIONARY_LENGTH; + + // если суффикс не NULL, то _суффикс иначе пустая строка + $suffix = !empty($suffix) ? '_' . $suffix : ''; + + $salt = ''; + for ($i = 0; $i < $length; $i++) { + $salt .= $dictionary[random_int(0, $dictionary_len - 1)]; + } + + return (date_format(date_create(), $prefix_format)) . '_' . $salt . $suffix; + } + + /** + * Генерирует имя для нового (еще точно не существующего) файла + * + * @param $path + * @param int $length + * @param string $extension + * @return string + * @throws \Exception + */ + public static function generateNewFile($path, int $length = 20, string $extension = '.jpg'): string + { + MediaHelpers::validatePath($path); // проверяем существование пути и создаем при необходимости + do { + $newfname = MediaHelpers::getRandomFilename( $length ) . $extension; + } while (is_file( "{$path}/{$newfname}" )); + return $newfname; + } + + /** + * Генерирует картинку из видео по временнОй метке. + * + * @param $source - исходное видео, имя файла с путём + * @param $target - сгенерированное имя файда с путём для превью + * @param $timestamp - деление на 2 делается вне функции + * @param $sizes - параметры + * @param $logger + * @return string + */ + public static function makePreviewFromVideo($source, $target, $timestamp, $sizes, $logger):string + { + $tn_timestamp = sprintf( "%02d:%02d:%02d", $timestamp / 3600, ($timestamp / 60) % 60, $timestamp % 60 ); + + $logger->debug("[VIDEO] Таймштамп для превью:", [ $tn_timestamp ]); + + $w = $sizes['maxWidth'] ?? 1; + $h = $sizes['maxHeight'] ?? 1; + + $vfscale = "-vf \"scale=iw*min({$w}/iw\,{$h}/ih):ih*min({$w}/iw\,{$h}/ih), pad={$w}:{$h}:({$w}-iw*min({$w}/iw\,{$h}/ih))/2:({$h}-ih*min({$w}/iw\,{$h}/ih))/2\" "; + + $cmd = [ + 'bin' => self::$paths['exec.ffmpeg'], + '-hide_banner', + '-y', + 'source' => "-i {$source}", + "-an", + 'ss' => "-ss {$tn_timestamp}", + "-r 1", + "vframes 1", + 'sizes' => "-s {$w}x{$h}", + 'scale' => $vfscale, + 'imagetype' => "-f mjpeg", + 'target' => $target, + 'verbose' => '2>/dev/null 1>/dev/null' + ]; + + $logger->debug("[VIDEO] Файл превью: {$target}"); + $cmd = implode(' ', $cmd); + + $logger->debug("[VIDEO] FFMpeg команда для генерации превью: ", [ $cmd ]); + + shell_exec( $cmd ); + + while (!is_file( $target )) { + sleep( 1 ); + $logger->debug("[VIDEO] Секунду спустя превью не готово"); + } + + return $target; + } + + /** + * @param $source + * @param $target + * @param $params + * @param $logger + * @return \AJUR\Wrappers\GDImageInfo|false + */ + public static function resizePreview($source, $target, $params = [], $logger = null) + { + if (empty($params)) { + return false; + } + + $logger->debug("[VIDEO] Генерируем превью {$params['prefix']} ({$target}) на основе {$source}", [ $params ]); + + $generate_result = GDWrapper::getFixedPicture( + $source, + $target, + $params['maxWidth'], + $params['maxHeight'], + $params['quality'] + )->valid; + $logger->debug("[VIDEO] Результат генерации превью {$params['prefix']}: ", [ $generate_result ]); + + return $generate_result; + } + + /** + * Deserialize JSON data with default value + * + * @param $data + * @param array $default + * @return array|mixed + */ + public static function deserialize($data, array $default = []) + { + if (empty($data)) { + return $default; + } + + $decoded = json_decode($data, true); + if ($decoded === null) { + return $default; + } + return $decoded; + } + + /** + * LEGACY!!! + * Получает MIME-тип файла + * + * @param string $filepath + * @return string + */ + public static function getMimeType(string $filepath) + { + return mime_content_type($filepath); + } + + /** + * LEGACY!!! + * Получает расширение по MIME-типу + * (без точки) + * + * @param $mime + * @return mixed|string|null + */ + public static function getFileExtension($mime) + { + return MimeTypes::getExtension($mime); + } + + /** + * LEGACY!!! + * Вычисляет расширение файла по MIME-типу + * (без точки) + * + * @param $filepath + * @return mixed|string|null + */ + public static function detectFileExtension($filepath) + { + return MimeTypes::getExtension( mime_content_type($filepath) ); + } + + + +} \ No newline at end of file diff --git a/src/Media.php b/src/Media.php new file mode 100644 index 0000000..a7900b0 --- /dev/null +++ b/src/Media.php @@ -0,0 +1,500 @@ + Path::create( getenv('PATH.INSTALL'), true )->join('www')->join('i')->toString(true), + * 'path.watermarks' => Path::create( getenv('PATH.INSTALL'), true)->join('www/frontend/images/watermarks/')->toString(true) + * ], [], $logger) + */ + public static function init(array $options = [], array $content_dirs = [], LoggerInterface $logger = null) + { + if (!empty($content_dirs)) { + foreach ($content_dirs as $from => $to) { + self::$content_dirs[ $from ] = $to; + } + } + + self::$options['storage.root'] = $options['path.storage'] ?? '/'; //@required + self::$options['watermarks'] = $options['path.watermarks'] ?? ''; //@required + self::$options['exec.ffprobe'] = $options['exec.ffprobe'] ?? 'ffprobe'; + self::$options['exec.ffmpeg'] = $options['exec.ffmpeg'] ?? 'ffmpeg'; + self::$options['domain.storage'] = $options['domains.storage'] ?? ''; //@required + + + self::$logger = is_null($logger) ? new NullLogger() : $logger; + } + + /** + * upload & create thumbnails for Embedded Photo + * + * @todo: return Result + * + * @param string|Path $fn_source + * @param $watermark_corner + * @param LoggerInterface $logger + * @return string + * @throws \Exception + */ + public static function uploadImage($fn_source, $watermark_corner, LoggerInterface $logger) + { + $logger->debug('[PHOTO] Обрабатываем как фото (image/*)'); + + $path = self::getAbsoluteResourcePath('photos', 'now'); + + self::validatePath($path); + + $resource_name = self::getRandomFilename(20); + + $source_extension = MimeTypes::fromExtension( MimeTypes::fromFilename($fn_source) ); + + $radix = "{$resource_name}.{$source_extension}"; + + $logger->debug("[PHOTO] Загруженное изображение будет иметь корень имени:", [ $radix ]); + + $available_photo_sizes = self::$convert_sizes['photos']; + + foreach ($available_photo_sizes as $size => $params) { + $method = $params['method']; + $max_width = $params['maxWidth']; + $max_height = $params['maxHeight']; + $quality = $params['quality']; + $prefix = $params['prefix']; + + $fn_target = Path::create($path)->joinName("{$prefix}{$radix}")->toString(); // ПРЕФИКС УЖЕ СОДЕРЖИТ `_` + + if (!call_user_func_array($method, [ $fn_source, $fn_target, $max_width, $max_height, $quality ])) { + foreach ($available_photo_sizes as $inner_size => $inner_params) { + @unlink( Path::create($path)->joinName("{$inner_params['prefix']}{$radix}")); + } + $logger->error('[PHOTO] Не удалось сгенерировать превью с параметрами: ', [ $method, $max_width, $max_height, $quality, $fn_target ]); + throw new RuntimeException("Ошибка конвертации загруженного изображения в размер [$prefix]", -1); + } + + $logger->debug('[PHOTO] Сгенерировано превью: ', [ $method, $max_width, $max_height, $quality, $fn_target ]); + + if (!is_null($watermark_corner) && isset($params['wmFile']) && $watermark_corner > 0) { + $fn_watermark = Path::create( self::$options['watermarks'] )->joinName($params['wmFile'])->toString(); + + GDWrapper::addWaterMark($fn_target, [ + 'watermark' => $fn_watermark, + 'margin' => $params['wmMargin'] ?? 10 + ], $watermark_corner); + + $logger->debug("[PHOTO] Сгенерирована вотермарка в {$watermark_corner} углу для файла {$fn_target}"); + } + } + + // сохраняем оригинал (в конфиг?) + $fn_origin = Path::create($path)->joinName("origin_{$radix}")->toString(); + if (!move_uploaded_file($fn_source, $fn_origin)) { + $logger->error("[PHOTO] Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_origin}", [ $fn_source, $fn_origin ]); + + // но тогда нужно удалить и все превьюшки + foreach ($available_photo_sizes as $inner_size => $inner_params) { + @unlink( Path::create($path)->joinName("{$inner_params['prefix']}{$radix}")); + } + + throw new MediaException("Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_origin}", -1); + } + + $logger->debug("[PHOTO] Загруженный файл {$fn_source} сохранён как оригинал в файл {$fn_origin}: ", [ $fn_source, $fn_origin ]); + + return $radix; + } + + /** + * Upload Audio + * + * @todo: return Result + * + * @param $fn_source + * @param LoggerInterface $logger + * @return string + * @throws \Exception + */ + public static function uploadAudio($fn_source, LoggerInterface $logger) + { + $logger->debug('[AUDIO] Обрабатываем как аудио (audio/*)'); + + $path = self::getAbsoluteResourcePath('audios', 'now'); + + self::validatePath($path); + + $resource_name = self::getRandomFilename(20); + + $source_extension = MimeTypes::fromExtension( MimeTypes::fromFilename($fn_source) ); + + $radix = "{$resource_name}.{$source_extension}"; + + $logger->debug("[AUDIO] Загруженный аудиофайл будет иметь корень имени:", [ $radix ]); + + $prefix = current(self::$convert_sizes['audios'])['prefix']; + + // ничего не конвертируем, этим займется крон-скрипт + $fn_target = Path::create($path)->joinName("{$prefix}{$radix}")->toString(); // ПРЕФИКС УЖЕ СОДЕРЖИТ `_` + + if (!move_uploaded_file($fn_source, $fn_target)) { + $logger->error("[AUDIO] Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_target}", [ $fn_source, $fn_target ]); + throw new MediaException("Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_target}", -1); + } + + $logger->debug("[AUDIO] Загруженный файл {$fn_source} сохранён в файл {$fn_target}: ", [ $fn_source, $fn_target ]); + + $logger->debug('[AUDIO] Stored as', [ $fn_target ]); + $logger->debug('[AUDIO] Returned', [ $fn_target ]); + + return $radix; + } + + /** + * Upload Abstract File + * + * @todo: return Result + * + * @param $fn_source + * @param LoggerInterface $logger + * @return void + * @throws \Exception + */ + public static function uploadAnyFile($fn_source, LoggerInterface $logger) + { + $logger->debug('[FILE] Обрабатываем как аудио (audio/*)'); + + $path = self::getAbsoluteResourcePath('audios', 'now'); + + self::validatePath($path); + + $resource_name = self::getRandomFilename(20); + + $source_extension = MimeTypes::fromExtension( MimeTypes::fromFilename($fn_source) ); + + $radix = "{$resource_name}.{$source_extension}"; + + $logger->debug("[FILE] Загруженный аудиофайл будет иметь корень имени:", [ $radix ]); + + $prefix = current(self::$convert_sizes['audios'])['prefix']; + + // ничего не конвертируем, этим займется крон-скрипт + $fn_target = Path::create($path)->joinName("{$prefix}{$radix}")->toString(); // ПРЕФИКС УЖЕ СОДЕРЖИТ `_` + + + if (!move_uploaded_file($fn_source, $fn_target)) { + $logger->error("[FILE] Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_target}", [ $fn_source, $fn_target ]); + throw new MediaException("Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_target}", -1); + } + $logger->debug("[FILE] Загруженный файл {$fn_source} сохранён как оригинал в файл {$fn_target}: ", [ $fn_source, $fn_target ]); + + $logger->debug('[FILE] Stored as', [ $fn_target ]); + $logger->debug('[FILE] Returned', [ $fn_target]); + } + + /** + * Загружает видео и строит превью + * + * Можно использовать https://packagist.org/packages/php-ffmpeg/php-ffmpeg , но я предпочел нативный метод, через + * прямые вызовы shell_exec() + * + * @param $fn_source + * @param LoggerInterface $logger + * @return Result + * @throws \Exception + */ + public static function uploadVideo($fn_source, LoggerInterface $logger) + { + $logger->debug('[VIDEO] Обрабатываем как видео (video/*)'); + + $ffprobe = self::$options['exec.ffprobe']; + + $json = shell_exec("{$ffprobe} -v quiet -print_format json -show_format -show_streams {$fn_source} 2>&1"); + $json = json_decode($json, true); + + if (!array_key_exists('format', $json)) { + $message = "[VIDEO] Это не видеофайл: отсутствует секция FORMAT"; + $logger->debug($message); + throw new MediaException($message); + } + + $json_format = $json['format']; + + if (!array_key_exists('streams', $json)) { + $message = "[VIDEO] Это не видеофайл: отсутствует секция STREAMS"; + $logger->debug($message); + throw new MediaException($message); + } + + $json_stream_video = current(array_filter($json['streams'], function ($v) { + return ($v['codec_type'] == 'video'); + })); + + if (empty($json_stream_video)) { + $message = "[VIDEO] Это не видеофайл: отсутствует видеопоток"; + $logger->debug($message); + throw new MediaException($message); + } + + $video_duration = round($json_stream_video['duration']) ?: round($json_format['duration']); + $video_bitrate = round($json_stream_video['bit_rate']) ?: round($json_format['bit_rate']); + + if ($video_duration <= 0) { + throw new RuntimeException("[VIDEO] Видеофайл не содержит видеопоток или видеопоток имеет нулевую длительность"); + } + + $logger->debug("[VIDEO] Длина потока видео {$video_duration}"); + + // готовим имя основного файла + + $path = self::getAbsoluteResourcePath('videos', 'now'); + + self::validatePath($path); + + $radix = self::getRandomFilename(20); + + $source_extension = MimeTypes::fromExtension( MimeTypes::fromFilename($fn_source) ); + + $fn_original = Path::create( $path )->joinName("{$radix}.{$source_extension}")->toString(); + + if (!move_uploaded_file($fn_source, $fn_original)) { + $logger->error("[VIDEO] Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_original}", [ $fn_source, $fn_original ]); + throw new RuntimeException("Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_original}", -1); + } + + $logger->debug("[VIDEO] Загруженный файл {$fn_source} сохранён как оригинал в файл {$fn_original}", [ $fn_original ]); + + // теперь генерируем файл-превью + $params_640x352 = ConvertSizes::$convert_sizes['videos']['640x352']; + + if (!empty($params_640x352)) { + $prefix_640x352 = $params_640x352['prefix']; + $fn_preview_640x352 = Path::create($path)->joinName("{$prefix_640x352}{$radix}.jpg")->toString(); + + MediaHelpers::makePreviewFromVideo( + $fn_original, + $fn_preview_640x352, + round($video_duration / 2), + $params_640x352, + $logger + ); + } + + // генерируем малые превьюшки. Для наглядности я развернул цикл из двух итераций на два вызова: + + // 100x100 + $params = ConvertSizes::$convert_sizes['videos']['100x100']; + MediaHelpers::resizePreview( + $fn_preview_640x352, + Path::create($path)->joinName("{$params['prefix']}{$radix}.jpg")->toString(), + $params, + $logger + ); + + // 440x248 + $params = self::$convert_sizes['videos']['440x248']; + MediaHelpers::resizePreview( + $fn_preview_640x352, + Path::create($path)->joinName("{$params['prefix']}{$radix}.jpg")->toString(), + $params, + $logger + ); + + $logger->debug('[VIDEO] Превью сделаны, файл видео сохранён'); + + return new Result([ + 'filename' => "{$radix}.{$source_extension}", + 'bitrate' => $video_bitrate, + 'duration' => $video_duration, + 'status' => 'pending', + 'type' => self::MEDIA_TYPE_VIDEO + ]); + } // uploadVideo + + /** + * Загружает с ютуба название видео. Точно работает с видео, с shorts не проверялось. + * + * @param string $video_id + * @param string $default + * @return string + */ + public static function getYoutubeVideoTitle(string $video_id, string $default = ''):string + { + //@todo: curl? + $video_info = @file_get_contents("http://youtube.com/get_video_info?video_id={$video_id}"); + + if (!$video_info) { + return $default; + } + + parse_str($video_info, $vi_array); + + if (!array_key_exists('player_response', $vi_array)) { + return $default; + } + + $video_info = json_decode($vi_array['player_response']); + + if (is_null($video_info)) { + return $default; + } + + return $video_info->videoDetails->title ?: $default; + } + + /** + * Удаляет тайтловое изображение и все его превьюшки + * + * @param $radix + * @param $cdate + * @param LoggerInterface $logger + * @return int + */ + public static function unlinkStoredTitleImages($filename, $cdate, LoggerInterface $logger):int + { + $path = MediaHelpers::getAbsoluteResourcePath('titles', $cdate, false); + + $prefixes = array_map(static function($v) { + return $v['prefix']; + }, ConvertSizes::$convert_sizes['titles']); + + $deleted_count = 0; + + foreach ($prefixes as $prefix) { + $fn = $path->joinName($prefix . $filename)->toString(); + $logger->debug("[unlinkStoredTitleImage] Удалятся файл:", [ $fn, @unlink($fn) ]); + $deleted_count++; + } + + return $deleted_count; + } + + /** + * @param $row + * @param $target_is_mobile - замена строки `LegacyTemplate::$use_mobile_template` или `$CONFIG['AREA'] === "m"` + * @param $is_report + * @param $prepend_domain + * @param $domain_prefix - домен - `config('domains.storage.default')` или `global $CONFIG['domains']['storage']['default']` + * заменено на self::$options['domain.storage'] + * @return mixed + */ + public static function prepareMediaProperties($row, $target_is_mobile = false, $is_report = false, $prepend_domain = false, $domain_prefix = '') + { + if (empty($domain_prefix)) { + $domain_prefix = self::$options['domain.storage']; + } + $type = $row['type']; + + $path = self::getRelativeResourcePath($type, $row['cdate']); + if ($prepend_domain === true) { + $path = $domain_prefix . $path; + } + $row['path'] = $path; + + if (isset($row['cdate'])) { + $row['cdate_rus'] = DTHelper::convertDate($row['cdate']); + } + + $row['prefix'] = $is_report ? "440x300" : "100x100"; + $row['report_prefix'] = "590x440"; + + if ($row['type'] === "photos") { + // $this->report NEVER DECLARED (в админке уж точно) + if ($is_report) { + if ($target_is_mobile) { + $row['sizes'] = [590, 440]; // "590x440" -- mobile_report_tn + $row['sizes_full'] = [1280, 1024]; // "1280x1024" -- mobile_report_full + } else { + $row['sizes'] = [100, 100]; // "100x100" -- desktop_report_tn + $row['sizes_full'] = [440, 300]; // "440x300" -- desktop_report_full + } + } else { + if ($target_is_mobile) { + $row['sizes'] = [440, 300]; // "440x300" + $row['sizes_full'] = [1280, 1024]; // "1280x1024" + } else { + $row['sizes'] = [630, 465]; // "630x465" + $row['sizes_full'] = [1280, 1024]; // "1280x1024" + } + } + + $row['sizes_prev'] = [150, 100]; // "150x100" + $row['sizes_large'] = [1280, 1024]; // "1280x1024" + $row['orig_file'] = "590x440_" . $row['file']; // "590x440_" + } + + if ($row['type'] === "videos") { + $row['thumb'] = substr($row['file'], 0, -4) . ".jpg"; //отрезаем расширение и добавляем .jpg (@todo: переделать?) + $row['sizes'] = [640, 352]; + $row['sizes_thumb'] = "640x352"; // $k[2] + $row['sizes_video'] = array(640, 352 + 25); //minWidth, minHeight + $row['orig_file'] = "440x248_" . $row['thumb']; + } + + if ($row['type'] === "audios") { + $row['sizes'] = [440, 24]; + $row['orig_file'] = $row['file']; + } + + if ($row['type'] === "files") { + $row['orig_file'] = $row['file']; + } + + if ($row['type'] === "youtube") { + $row['thumb'] = $row['file']; + $row['orig_file'] = $row['href']; + $row['sizes'] = [640, 352]; + $matches = parse_url($row['href']); + if (preg_match("/v=([A-Za-z0-9\_\-]{11})/i", $matches["query"], $res)) { + $row['yt'] = $res[1]; + } + $row['sizes_youtube'] = [640, 360]; // да, именно так, несмотря на то, что ключ - 640x352, то есть значимы параметры, а не ключ + } + + $row['tags'] = empty($row['tags']) ? [] : MediaHelpers::deserialize($row['tags']); + + if (is_null($row['descr'])) { + $row['descr'] = ''; + } + + return $row; + } + + /* == HELPERS == */ + + + + +} \ No newline at end of file diff --git a/src/MediaException.php b/src/MediaException.php new file mode 100644 index 0000000..2d38b10 --- /dev/null +++ b/src/MediaException.php @@ -0,0 +1,5 @@ + '/srv/storage/', + 'path.watermarks' => '/var/www/47news/www/frontend/images/watermarks/' + ], []); + } + + /** + * @testdox get Absolute Resource Path + * @return void + */ + public function testGetAbsoluteResourcePath() + { + $this->assertEquals( '/srv/storage/videos/2023/05/', Media::getAbsoluteResourcePath('videos', '2023-05-05 17:11:44') ); + } + + /** + * @return void + * @testdox Get Relative Resource Path + */ + public function testGetRelativeResourcePath() + { + $this->assertEquals('/videos/2023/05/', Media::getRelativeResourcePath('videos', '2023-05-05 17:17:44')); + } + + /** + * @return void + * @testdox Deserialize correct + */ + public function testDeserialize1() + { + $this->assertEquals(['a' => 1 ], \AJUR\FSNews\Helpers\MediaHelpers::deserialize('{"a": 1}', [])); + } + +} \ No newline at end of file