From db0e4272bd6d307c562e1f961d2747cb62de0f35 Mon Sep 17 00:00:00 2001 From: Yassine Doghri <yassine@doghri.fr> Date: Fri, 24 Dec 2021 17:55:56 +0000 Subject: [PATCH] feat(video-clip): generate video clips in the bg using a cron job + add video clip page + tidy up UI --- .../2021-12-09-130000_add_clips.php | 12 ++- app/Entities/Clip/BaseClip.php | 44 +++++++--- app/Helpers/components_helper.php | 2 +- app/Helpers/misc_helper.php | 14 ++-- .../MediaClipper/Config/MediaClipper.php | 2 +- app/Models/ClipModel.php | 2 + app/Resources/icons/calendar.svg | 6 ++ app/Resources/icons/loader.svg | 6 ++ app/Resources/styles/radioBtn.css | 2 +- app/Views/Components/Forms/RadioButton.php | 6 +- app/Views/Components/Pill.php | 9 ++- crontab | 3 +- modules/Admin/Config/Routes.php | 16 +++- .../Admin/Controllers/SchedulerController.php | 4 + .../Controllers/VideoClipsController.php | 39 +++++++-- .../Admin/Language/en/EpisodeNavigation.php | 2 +- modules/Admin/Language/en/VideoClip.php | 53 ++++++++++++ .../Admin/Language/fr/EpisodeNavigation.php | 2 +- modules/Admin/Language/fr/VideoClip.php | 53 ++++++++++++ themes/cp_admin/episode/_sidebar.php | 2 +- themes/cp_admin/episode/edit.php | 2 +- themes/cp_admin/episode/video_clip.php | 4 +- themes/cp_admin/episode/video_clips_list.php | 76 +++++++++++++++--- themes/cp_admin/episode/video_clips_new.php | 80 +++++++++++-------- 24 files changed, 352 insertions(+), 89 deletions(-) create mode 100644 app/Resources/icons/calendar.svg create mode 100644 app/Resources/icons/loader.svg create mode 100644 modules/Admin/Language/en/VideoClip.php create mode 100644 modules/Admin/Language/fr/VideoClip.php diff --git a/app/Database/Migrations/2021-12-09-130000_add_clips.php b/app/Database/Migrations/2021-12-09-130000_add_clips.php index 8e1f710404..0c42a92299 100644 --- a/app/Database/Migrations/2021-12-09-130000_add_clips.php +++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php @@ -72,15 +72,19 @@ class AddClips extends Migration 'type' => 'INT', 'unsigned' => true, ], - 'created_at' => [ + 'job_started_at' => [ 'type' => 'DATETIME', + 'null' => true, ], - 'updated_at' => [ + 'job_ended_at' => [ 'type' => 'DATETIME', + 'null' => true, ], - 'deleted_at' => [ + 'created_at' => [ + 'type' => 'DATETIME', + ], + 'updated_at' => [ 'type' => 'DATETIME', - 'null' => true, ], ]); diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php index 77d4c0dae2..00057c2ed2 100644 --- a/app/Entities/Clip/BaseClip.php +++ b/app/Entities/Clip/BaseClip.php @@ -12,7 +12,6 @@ namespace App\Entities\Clip; use App\Entities\Episode; use App\Entities\Media\Audio; -use App\Entities\Media\BaseMedia; use App\Entities\Media\Video; use App\Entities\Podcast; use App\Models\EpisodeModel; @@ -21,6 +20,7 @@ use App\Models\PodcastModel; use App\Models\UserModel; use CodeIgniter\Entity\Entity; use CodeIgniter\Files\File; +use CodeIgniter\I18n\Time; use Modules\Auth\Entities\User; /** @@ -34,21 +34,32 @@ use Modules\Auth\Entities\User; * @property double $end_time * @property double $duration * @property string $type - * @property int $media_id - * @property Video|Audio $media + * @property int|null $media_id + * @property Video|Audio|null $media * @property array|null $metadata * @property string $status * @property string $logs * @property User $user * @property int $created_by * @property int $updated_by + * @property Time|null $job_started_at + * @property Time|null $job_ended_at */ class BaseClip extends Entity { /** - * @var BaseMedia + * @var Video|Audio|null */ - protected $media = null; + protected $media; + + protected ?int $job_duration = null; + + protected ?float $end_time = null; + + /** + * @var string[] + */ + protected $dates = ['created_at', 'updated_at', 'job_started_at', 'job_ended_at']; /** * @var array<string, string> @@ -75,12 +86,25 @@ class BaseClip extends Entity public function __construct(array $data = null) { parent::__construct($data); + } - if ($this->start_time && $this->duration) { + public function getJobDuration(): ?int + { + if ($this->job_duration === null && $this->job_started_at && $this->job_ended_at) { + $this->job_duration = ($this->job_started_at->difference($this->job_ended_at)) + ->getSeconds(); + } + + return $this->job_duration; + } + + public function getEndTime(): float + { + if ($this->end_time === null) { $this->end_time = $this->start_time + $this->duration; - } elseif ($this->start_time && $this->end_time) { - $this->duration = $this->end_time - $this->duration; } + + return $this->end_time; } public function getPodcast(): ?Podcast @@ -128,16 +152,12 @@ class BaseClip extends Entity return $this; } - /** - * @noRector ReturnTypeDeclarationRector - */ public function getMedia(): Audio | Video | null { if ($this->media_id !== null && $this->media === null) { $this->media = (new MediaModel($this->type))->getMediaById($this->media_id); } - // @phpstan-ignore-next-line return $this->media; } } diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php index a608e03ba5..89c5a121a7 100644 --- a/app/Helpers/components_helper.php +++ b/app/Helpers/components_helper.php @@ -26,7 +26,7 @@ if (! function_exists('hint_tooltip')) { $tooltip = '<span data-tooltip="bottom" tabindex="0" title="' . $hintText . - '" class="inline-block align-middle text-skin-muted focus:ring-accent'; + '" class="inline-block align-middle opacity-75 focus:ring-accent'; if ($class !== '') { $tooltip .= ' ' . $class; diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php index 99a7a75b08..a270890745 100644 --- a/app/Helpers/misc_helper.php +++ b/app/Helpers/misc_helper.php @@ -136,16 +136,20 @@ if (! function_exists('slugify')) { if (! function_exists('format_duration')) { /** - * Formats duration in seconds to an hh:mm:ss string. Doesn't show leading zeros if any. + * Formats duration in seconds to an hh:mm:ss string. * * âš ï¸ This uses php's gmdate function so any duration > 86000 seconds (24 hours) will not be formatted properly. * * @param int $seconds seconds to format */ - function format_duration(int $seconds): string + function format_duration(int $seconds, bool $showLeadingZeros = false): string { + if ($showLeadingZeros) { + return gmdate('H:i:s', $seconds); + } + if ($seconds < 60) { - return '0:' . $seconds; + return '0:' . sprintf('%02d', $seconds); } if ($seconds < 3600) { // < 1 hour: returns MM:SS @@ -153,9 +157,9 @@ if (! function_exists('format_duration')) { } if ($seconds < 36000) { // < 10 hours: returns H:MM:SS - return ltrim(gmdate('h:i:s', $seconds), '0'); + return ltrim(gmdate('H:i:s', $seconds), '0'); } - return gmdate('h:i:s', $seconds); + return gmdate('H:i:s', $seconds); } } diff --git a/app/Libraries/MediaClipper/Config/MediaClipper.php b/app/Libraries/MediaClipper/Config/MediaClipper.php index f4274d6de2..e300f24db0 100644 --- a/app/Libraries/MediaClipper/Config/MediaClipper.php +++ b/app/Libraries/MediaClipper/Config/MediaClipper.php @@ -213,7 +213,7 @@ class MediaClipper extends BaseConfig 'rescaleHeight' => 1200, 'x' => 0, 'y' => 600, - 'mask' => APPPATH . 'Libraries/MediaClipper/soundwaves-mask-squared.png', + 'mask' => APPPATH . 'Libraries/MediaClipper/soundwaves-mask-square.png', ], 'subtitles' => [ 'fontsize' => 20, diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php index e61e6fc5f4..94364cd209 100644 --- a/app/Models/ClipModel.php +++ b/app/Models/ClipModel.php @@ -49,6 +49,8 @@ class ClipModel extends Model 'logs', 'created_by', 'updated_by', + 'job_started_at', + 'job_ended_at', ]; /** diff --git a/app/Resources/icons/calendar.svg b/app/Resources/icons/calendar.svg new file mode 100644 index 0000000000..fe9bf8d9fb --- /dev/null +++ b/app/Resources/icons/calendar.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M2 11h20v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-9zm15-8h4a1 1 0 0 1 1 1v5H2V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2z"/> + </g> +</svg> diff --git a/app/Resources/icons/loader.svg b/app/Resources/icons/loader.svg new file mode 100644 index 0000000000..55da7bdb03 --- /dev/null +++ b/app/Resources/icons/loader.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M18.364 5.636L16.95 7.05A7 7 0 1 0 19 12h2a9 9 0 1 1-2.636-6.364z"/> + </g> +</svg> diff --git a/app/Resources/styles/radioBtn.css b/app/Resources/styles/radioBtn.css index 509ec4c650..5a044cd986 100644 --- a/app/Resources/styles/radioBtn.css +++ b/app/Resources/styles/radioBtn.css @@ -15,7 +15,7 @@ } & + label { - @apply inline-block py-2 pl-8 pr-2 text-sm font-semibold rounded-lg cursor-pointer border-contrast bg-elevated border-3; + @apply inline-flex items-center py-2 pl-8 pr-2 text-sm font-semibold rounded-lg cursor-pointer border-contrast bg-elevated border-3; color: hsl(var(--color-text-muted)); } } diff --git a/app/Views/Components/Forms/RadioButton.php b/app/Views/Components/Forms/RadioButton.php index 656375d984..f7d7015a11 100644 --- a/app/Views/Components/Forms/RadioButton.php +++ b/app/Views/Components/Forms/RadioButton.php @@ -8,6 +8,8 @@ class RadioButton extends FormComponent { protected bool $isChecked = false; + protected ?string $hint = null; + public function setIsChecked(string $value): void { $this->isChecked = $value === 'true'; @@ -25,10 +27,12 @@ class RadioButton extends FormComponent old($this->name) ? old($this->name) === $this->value : $this->isChecked, ); + $hint = $this->hint ? hint_tooltip($this->hint, 'ml-1 text-base') : ''; + return <<<HTML <div> {$radioInput} - <label for="{$this->value}">{$this->slot}</label> + <label for="{$this->value}">{$this->slot}{$hint}</label> </div> HTML; } diff --git a/app/Views/Components/Pill.php b/app/Views/Components/Pill.php index b5927e658e..a6ff6ffa40 100644 --- a/app/Views/Components/Pill.php +++ b/app/Views/Components/Pill.php @@ -17,6 +17,10 @@ class Pill extends Component public ?string $icon = null; + public ?string $iconClass = ''; + + protected ?string $hint = null; + public function render(): string { $variantClasses = [ @@ -27,10 +31,11 @@ class Pill extends Component 'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300', ]; - $icon = $this->icon ? icon($this->icon) : ''; + $icon = $this->icon ? icon($this->icon, $this->iconClass) : ''; + $hint = $this->hint ? 'data-tooltip="bottom" title="' . $this->hint . '"' : ''; return <<<HTML - <span class="inline-flex items-center gap-x-1 px-1 font-semibold text-sm border rounded {$variantClasses[$this->variant]}">{$icon}{$this->slot}</span> + <span class="inline-flex items-center gap-x-1 px-1 font-semibold text-sm border rounded {$variantClasses[$this->variant]} {$this->class}" {$hint}>{$icon}{$this->slot}</span> HTML; } } diff --git a/crontab b/crontab index fc44ff63d3..3ad6aeb4c3 100644 --- a/crontab +++ b/crontab @@ -1 +1,2 @@ -* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-activities +* * * * * /usr/local/bin/php /castopod-host/public/index.php scheduled-activities +* * * * * /usr/local/bin/php /castopod-host/public/index.php scheduled-video-clips diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index b32a81748d..a41bdb5d0a 100644 --- a/modules/Admin/Config/Routes.php +++ b/modules/Admin/Config/Routes.php @@ -365,17 +365,17 @@ $routes->group( ); $routes->get( 'video-clips/new', - 'VideoClipsController::generate/$1/$2', + 'VideoClipsController::create/$1/$2', [ - 'as' => 'video-clips-generate', + 'as' => 'video-clips-create', 'filter' => 'permission:podcast_episodes-edit', ], ); $routes->post( 'video-clips/new', - 'VideoClipsController::attemptGenerate/$1/$2', + 'VideoClipsController::attemptCreate/$1/$2', [ - 'as' => 'video-clips-generate', + 'as' => 'video-clips-create', 'filter' => 'permission:podcast_episodes-edit', ], ); @@ -387,6 +387,14 @@ $routes->group( 'filter' => 'permission:podcast_episodes-edit', ], ); + $routes->get( + 'video-clips/(:num)/delete', + 'VideoClipsController::delete/$1/$2/$3', + [ + 'as' => 'video-clip-delete', + 'filter' => 'permission:podcast_episodes-edit', + ], + ); $routes->get( 'embed', 'EpisodeController::embed/$1/$2', diff --git a/modules/Admin/Controllers/SchedulerController.php b/modules/Admin/Controllers/SchedulerController.php index 0434517985..e3f9cfd599 100644 --- a/modules/Admin/Controllers/SchedulerController.php +++ b/modules/Admin/Controllers/SchedulerController.php @@ -12,6 +12,7 @@ namespace Modules\Admin\Controllers; use App\Models\ClipModel; use CodeIgniter\Controller; +use CodeIgniter\I18n\Time; use MediaClipper\VideoClipper; class SchedulerController extends Controller @@ -41,6 +42,7 @@ class SchedulerController extends Controller (new ClipModel()) ->update($scheduledClip->id, [ 'status' => 'running', + 'job_started_at' => Time::now(), ]); $clipper = new VideoClipper( $scheduledClip->episode, @@ -58,12 +60,14 @@ class SchedulerController extends Controller 'media_id' => $scheduledClip->media_id, 'status' => 'passed', 'logs' => $clipper->logs, + 'job_ended_at' => Time::now(), ]); } else { // error (new ClipModel())->update($scheduledClip->id, [ 'status' => 'failed', 'logs' => $clipper->logs, + 'job_ended_at' => Time::now(), ]); } } diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php index f75849bdea..ea6a329c7e 100644 --- a/modules/Admin/Controllers/VideoClipsController.php +++ b/modules/Admin/Controllers/VideoClipsController.php @@ -15,6 +15,7 @@ use App\Entities\Episode; use App\Entities\Podcast; use App\Models\ClipModel; use App\Models\EpisodeModel; +use App\Models\MediaModel; use App\Models\PodcastModel; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\RedirectResponse; @@ -105,7 +106,7 @@ class VideoClipsController extends BaseController return view('episode/video_clip', $data); } - public function generate(): string + public function create(): string { helper('form'); @@ -121,12 +122,12 @@ class VideoClipsController extends BaseController return view('episode/video_clips_new', $data); } - public function attemptGenerate(): RedirectResponse + public function attemptCreate(): RedirectResponse { - // TODO: add end_time greater than start_time, with minimum ? $rules = [ + 'label' => 'required', 'start_time' => 'required|numeric', - 'end_time' => 'required|numeric|differs[start_time]', + 'duration' => 'required|greater_than[0]', 'format' => 'required|in_list[' . implode(',', array_keys(config('MediaClipper')->formats)) . ']', 'theme' => 'required|in_list[' . implode(',', array_keys(config('Colors')->themes)) . ']', ]; @@ -147,9 +148,9 @@ class VideoClipsController extends BaseController ]; $videoClip = new VideoClip([ - 'label' => 'NEW CLIP', + 'label' => $this->request->getPost('label'), 'start_time' => (float) $this->request->getPost('start_time'), - 'end_time' => (float) $this->request->getPost('end_time',), + 'duration' => (float) $this->request->getPost('duration',), 'theme' => $theme, 'format' => $this->request->getPost('format'), 'type' => 'video', @@ -162,9 +163,33 @@ class VideoClipsController extends BaseController (new ClipModel())->insert($videoClip); - return redirect()->route('video-clips-generate', [$this->podcast->id, $this->episode->id])->with( + return redirect()->route('video-clips-list', [$this->podcast->id, $this->episode->id])->with( 'message', lang('Settings.images.regenerationSuccess') ); } + + public function delete(string $videoClipId): RedirectResponse + { + $videoClip = (new ClipModel())->getVideoClipById((int) $videoClipId); + + if ($videoClip === null) { + throw PageNotFoundException::forPageNotFound(); + } + + if ($videoClip->media === null) { + // delete Clip directly + (new ClipModel())->delete($videoClipId); + } else { + $mediaModel = new MediaModel(); + if (! $mediaModel->deleteMedia($videoClip->media)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $mediaModel->errors()); + } + } + + return redirect()->back(); + } } diff --git a/modules/Admin/Language/en/EpisodeNavigation.php b/modules/Admin/Language/en/EpisodeNavigation.php index 8e2df5beb4..6511ff5c00 100644 --- a/modules/Admin/Language/en/EpisodeNavigation.php +++ b/modules/Admin/Language/en/EpisodeNavigation.php @@ -18,5 +18,5 @@ return [ 'clips' => 'Clips', 'soundbites-edit' => 'Soundbites', 'video-clips-list' => 'Video clips', - 'video-clips-generate' => 'New video clip', + 'video-clips-create' => 'New video clip', ]; diff --git a/modules/Admin/Language/en/VideoClip.php b/modules/Admin/Language/en/VideoClip.php new file mode 100644 index 0000000000..fd13c06183 --- /dev/null +++ b/modules/Admin/Language/en/VideoClip.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'list' => [ + 'title' => 'Video clips', + 'status' => [ + 'label' => 'Status', + 'queued' => 'queued', + 'queued_hint' => 'Clip is waiting to be processed.', + 'pending' => 'pending', + 'pending_hint' => 'Clip will be generated shortly.', + 'running' => 'running', + 'running_hint' => 'Clip is being generated.', + 'failed' => 'failed', + 'failed_hint' => 'Clip could not be generated: script failure.', + 'passed' => 'passed', + 'passed_hint' => 'Clip was generated successfully!', + ], + 'clip' => 'Clip', + 'duration' => 'Duration', + ], + 'title' => 'Video clip: {videoClipLabel}', + 'download_clip' => 'Download clip', + 'go_to_page' => 'Go to clip page', + 'delete' => 'Delete clip', + 'logs' => 'Job logs', + 'form' => [ + 'title' => 'New video clip', + 'params_section_title' => 'Video clip parameters', + 'clip_title' => 'Clip title', + 'format' => [ + 'label' => 'Choose a format', + 'landscape' => 'Landscape', + 'landscape_hint' => 'With a 16:9 ratio, landscape videos are great for PeerTube, Youtube and Vimeo.', + 'portrait' => 'Portrait', + 'portrait_hint' => 'With a 9:16 ratio, portrait videos are great for TikTok, Youtube shorts and Instagram stories.', + 'squared' => 'Squared', + 'squared_hint' => 'With a 1:1 ratio, squared videos are great for Mastodon, Facebook, Twitter and LinkedIn.', + ], + 'theme' => 'Select a theme', + 'start_time' => 'Start at', + 'duration' => 'Duration', + 'submit' => 'Create video clip', + ], +]; diff --git a/modules/Admin/Language/fr/EpisodeNavigation.php b/modules/Admin/Language/fr/EpisodeNavigation.php index 288b9203dd..b8c82ff7ad 100644 --- a/modules/Admin/Language/fr/EpisodeNavigation.php +++ b/modules/Admin/Language/fr/EpisodeNavigation.php @@ -18,5 +18,5 @@ return [ 'clips' => 'Extraits', 'soundbites-edit' => 'Extraits sonores', 'video-clips-list' => 'Extraits video', - 'video-clips-generate' => 'Nouvel extrait video', + 'video-clips-create' => 'Nouvel extrait video', ]; diff --git a/modules/Admin/Language/fr/VideoClip.php b/modules/Admin/Language/fr/VideoClip.php new file mode 100644 index 0000000000..bf686ed316 --- /dev/null +++ b/modules/Admin/Language/fr/VideoClip.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'list' => [ + 'title' => 'Extraits vidéos', + 'status' => [ + 'label' => 'Statut', + 'queued' => 'en file d’attente', + 'queued_hint' => 'L’extrait est dans la file d’attente.', + 'pending' => 'en attente', + 'pending_hint' => 'L’extrait va être généré prochainement.', + 'running' => 'en cours', + 'running_hint' => 'L’extrait est en cours de génération.', + 'failed' => 'échec', + 'failed_hint' => 'L’extrait n’a pas pu être généré : erreur du programme.', + 'passed' => 'réussite', + 'passed_hint' => 'L’extrait a été généré avec succès !', + ], + 'clip' => 'Extrait', + 'duration' => 'Durée', + ], + 'title' => 'Extrait vidéo : {videoClipLabel}', + 'download_clip' => 'Télécharger l’extrait', + 'go_to_page' => 'Aller à la page de l’extrait', + 'delete' => 'Supprimer l’extrait', + 'logs' => 'Historique d’exécution', + 'form' => [ + 'title' => 'Nouvel extrait vidéo', + 'params_section_title' => 'Paramètres de l’extrait vidéo', + 'clip_title' => 'Titre de l’extrait', + 'format' => [ + 'label' => 'Choisissez un format', + 'landscape' => 'Paysage', + 'landscape_hint' => 'Avec un ratio de 16/9, les vidéos en paysage sont adaptées pour PeerTube, Youtube et Vimeo.', + 'portrait' => 'Portrait', + 'portrait_hint' => 'Avec un ratio de 9/16, les vidéos en portrait sont adaptées pour TikTok, les Youtube shorts and les stories Instagram.', + 'squared' => 'Carré', + 'squared_hint' => 'Avec un ratio de 1/1, les vidéos carrées sont adaptées pour Mastodon, Facebook, Twitter et LinkedIn.', + ], + 'theme' => 'Sélectionnez un thème', + 'start_time' => 'Démarrer à ', + 'duration' => 'Durée', + 'submit' => 'Créer un extrait vidéo', + ], +]; diff --git a/themes/cp_admin/episode/_sidebar.php b/themes/cp_admin/episode/_sidebar.php index 9fbcf49333..10c0affe9d 100644 --- a/themes/cp_admin/episode/_sidebar.php +++ b/themes/cp_admin/episode/_sidebar.php @@ -7,7 +7,7 @@ $podcastNavigation = [ ], 'clips' => [ 'icon' => 'clapperboard', - 'items' => ['video-clips-list', 'video-clips-generate', 'soundbites-edit'], + 'items' => ['video-clips-list', 'video-clips-create', 'soundbites-edit'], ], ]; ?> diff --git a/themes/cp_admin/episode/edit.php b/themes/cp_admin/episode/edit.php index 6e62156348..3b1e6873e0 100644 --- a/themes/cp_admin/episode/edit.php +++ b/themes/cp_admin/episode/edit.php @@ -162,7 +162,7 @@ <div class="py-2 tab-panels"> <section id="transcript-file-upload" class="flex items-center tab-panel"> <?php if ($episode->transcript) : ?> - <div class="flex mb-1 gap-x-2"> + <div class="flex items-center mb-1 gap-x-2"> <?= anchor( $episode->transcript->file_url, icon('file', 'mr-2 text-skin-muted') . diff --git a/themes/cp_admin/episode/video_clip.php b/themes/cp_admin/episode/video_clip.php index dad7a8ce75..0504104c25 100644 --- a/themes/cp_admin/episode/video_clip.php +++ b/themes/cp_admin/episode/video_clip.php @@ -1,13 +1,13 @@ <?= $this->extend('_layout') ?> <?= $this->section('title') ?> -<?= lang('Episode.video_clips.title', [ +<?= lang('VideoClip.title', [ 'videoClipLabel' => $videoClip->label, ]) ?> <?= $this->endSection() ?> <?= $this->section('pageTitle') ?> -<?= lang('Episode.video_clips.title', [ +<?= lang('VideoClip.title', [ 'videoClipLabel' => $videoClip->label, ]) ?> <?= $this->endSection() ?> diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php index 71a5d0f65d..c718c3930f 100644 --- a/themes/cp_admin/episode/video_clips_list.php +++ b/themes/cp_admin/episode/video_clips_list.php @@ -1,18 +1,24 @@ +<?php declare(strict_types=1); + +use App\Entities\Clip\VideoClip; +use CodeIgniter\I18n\Time; + +?> <?= $this->extend('_layout') ?> <?= $this->section('title') ?> -<?= lang('Episode.video_clips.title') ?> +<?= lang('VideoClip.list.title') ?> <?= $this->endSection() ?> <?= $this->section('pageTitle') ?> -<?= lang('Episode.video_clips.title') ?> +<?= lang('VideoClip.list.title') ?> <?= $this->endSection() ?> <?= $this->section('content') ?> <?= data_table( [ [ - 'header' => lang('VideoClip.list.status'), + 'header' => lang('VideoClip.list.status.label'), 'cell' => function ($videoClip): string { $pillVariantMap = [ 'queued' => 'default', @@ -26,36 +32,84 @@ $pillIconMap = [ 'queued' => 'timer', 'pending' => 'pause', - 'running' => 'play', + 'running' => 'loader', 'canceled' => 'forbid', 'failed' => 'close', 'passed' => 'check', ]; - return '<Pill variant="' . $pillVariantMap[$videoClip->status] . '" icon="' . $pillIconMap[$videoClip->status] . '">' . $videoClip->status . '</Pill>'; + $pillIconClassMap = [ + 'queued' => '', + 'pending' => '', + 'running' => 'animate-spin', + 'canceled' => '', + 'failed' => '', + 'passed' => '', + ]; + + return '<Pill variant="' . $pillVariantMap[$videoClip->status] . '" icon="' . $pillIconMap[$videoClip->status] . '" iconClass="' . $pillIconClassMap[$videoClip->status] . '" hint="' . lang('VideoClip.list.status.' . $videoClip->status . '_hint') . '">' . lang('VideoClip.list.status.' . $videoClip->status) . '</Pill>'; }, ], [ - 'header' => lang('VideoClip.list.label'), + 'header' => lang('VideoClip.list.clip'), 'cell' => function ($videoClip): string { $formatClass = [ 'landscape' => 'aspect-video', 'portrait' => 'aspect-[9/16]', 'squared' => 'aspect-square', ]; - return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="inline-flex items-center font-semibold hover:underline gap-x-2 focus:ring-accent"><div class="relative"><span class="absolute block w-3 h-3 rounded-full -bottom-1 -left-1" data-tooltip="bottom" title="' . $videoClip->theme['name'] . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><div class="flex items-center justify-center h-6 overflow-hidden bg-black rounded-sm aspect-video" data-tooltip="bottom" title="' . $videoClip->format . '"><span class="flex items-center justify-center h-full text-white bg-gray-400 ' . $formatClass[$videoClip->format] . '"><Icon glyph="play"/></span></div></div>' . $videoClip->label . '</a>'; + return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="inline-flex items-center w-full group gap-x-2 focus:ring-accent"><div class="relative"><span class="absolute block w-3 h-3 rounded-full ring-2 ring-white -bottom-1 -left-1" data-tooltip="bottom" title="' . lang('Settings.theme.' . $videoClip->theme['name']) . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><div class="flex items-center justify-center h-6 overflow-hidden bg-black rounded-sm aspect-video" data-tooltip="bottom" title="' . $videoClip->format . '"><span class="flex items-center justify-center h-full text-white bg-gray-400 ' . $formatClass[$videoClip->format] . '"><Icon glyph="play"/></span></div></div><div class="flex flex-col"><div class="text-sm">#' . $videoClip->id . ' – <span class="font-semibold group-hover:underline">' . $videoClip->label . '</span><span class="ml-1 text-sm">by ' . $videoClip->user->username . '</span></div><span class="text-xs">' . format_duration((int) $videoClip->duration) . '</span></div></a>'; }, ], [ - 'header' => lang('VideoClip.list.clip_id'), - 'cell' => function ($videoClip): string { - return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="font-semibold hover:underline focus:ring-accent">#' . $videoClip->id . '</a><span class="ml-1 text-sm">by ' . $videoClip->user->username . '</span>'; + 'header' => lang('VideoClip.list.duration'), + 'cell' => function (VideoClip $videoClip): string { + $duration = ''; + if ($videoClip->job_started_at !== null) { + if ($videoClip->job_ended_at !== null) { + $duration = '<div class="flex flex-col text-xs gap-y-1">' . + '<div class="inline-flex items-center gap-x-1"><Icon glyph="timer" class="text-sm text-gray-400" />' . format_duration($videoClip->job_duration, true) . '</div>' . + '<div class="inline-flex items-center gap-x-1"><Icon glyph="calendar" class="text-sm text-gray-400" />' . relative_time($videoClip->job_ended_at) . '</div>' . + '</div>'; + } else { + $duration = '<div class="inline-flex items-center text-xs gap-x-1"><Icon glyph="timer" class="text-sm text-gray-400" />' . format_duration(($videoClip->job_started_at->difference(Time::now()))->getSeconds(), true) . '</div>'; + } + } + + return $duration; }, ], [ 'header' => lang('Common.actions'), 'cell' => function ($videoClip): string { - return '…'; + $downloadButton = ''; + if ($videoClip->media) { + helper('misc'); + $filename = 'clip-' . slugify($videoClip->label) . "-{$videoClip->start_time}-{$videoClip->end_time}"; + $downloadButton = '<IconButton glyph="download" uri="' . $videoClip->media->file_url . '" download="' . $filename . '">' . lang('VideoClip.download_clip') . '</IconButton>'; + } + + return '<div class="inline-flex items-center gap-x-2">' . $downloadButton . + '<button id="more-dropdown-' . $videoClip->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $videoClip->id . '-menu" aria-haspopup="true" aria-expanded="false">' . + icon('more') . + '</button>' . + '<DropdownMenu id="more-dropdown-' . $videoClip->id . '-menu" labelledby="more-dropdown-' . $videoClip->id . '" offsetY="-24" items="' . esc(json_encode([ + [ + 'type' => 'link', + 'title' => lang('VideoClip.go_to_page'), + 'uri' => route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id), + ], + [ + 'type' => 'separator', + ], + [ + 'type' => 'link', + 'title' => lang('VideoClip.delete'), + 'uri' => route_to('video-clip-delete', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id), + 'class' => 'font-semibold text-red-600', + ], + ])) . '" />' . + '</div>'; }, ], ], diff --git a/themes/cp_admin/episode/video_clips_new.php b/themes/cp_admin/episode/video_clips_new.php index 93ce42a0f2..442df6f465 100644 --- a/themes/cp_admin/episode/video_clips_new.php +++ b/themes/cp_admin/episode/video_clips_new.php @@ -1,60 +1,74 @@ <?= $this->extend('_layout') ?> <?= $this->section('title') ?> -<?= lang('Episode.video_clips.title') ?> +<?= lang('VideoClip.form.title') ?> <?= $this->endSection() ?> <?= $this->section('pageTitle') ?> -<?= lang('Episode.video_clips.title') ?> +<?= lang('VideoClip.form.title') ?> <?= $this->endSection() ?> <?= $this->section('content') ?> -<form action="<?= route_to('video-clips-generate', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col max-w-sm gap-y-4"> +<form action="<?= route_to('video-clips-create', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col gap-y-4"> -<fieldset> -<legend>Format</legend> -<div class="mx-auto"> - <input type="radio" name="format" value="landscape" id="landscape" checked="checked"/> - <label for="landscape">Landscape - 16:9</label> -</div> -<div class="mx-auto"> - <input type="radio" name="format" value="portrait" id="portrait"/> - <label for="portrait">Portrait - 9:16</label> -</div> -<div class="mx-auto"> - <input type="radio" name="format" value="squared" id="square"/> - <label for="square">Square - 1:1</label> -</div> +<Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" > + +<Forms.Field + name="label" + label="<?= lang('VideoClip.form.clip_title') ?>" + required="true" +/> + +<fieldset class="flex gap-1"> +<legend><?= lang('VideoClip.form.format.label') ?></legend> +<Forms.RadioButton + value="landscape" + name="format" + hint="<?= lang('VideoClip.form.format.landscape_hint') ?>"><?= lang('VideoClip.form.format.landscape') ?></Forms.RadioButton> +<Forms.RadioButton + value="portrait" + name="format" + hint="<?= lang('VideoClip.form.format.portrait_hint') ?>"><?= lang('VideoClip.form.format.portrait') ?></Forms.RadioButton> +<Forms.RadioButton + value="squared" + name="format" + hint="<?= lang('VideoClip.form.format.squared_hint') ?>"><?= lang('VideoClip.form.format.squared') ?></Forms.RadioButton> </fieldset> +<fieldset> +<legend><?= lang('VideoClip.form.theme') ?></legend> <div class="grid gap-4 grid-cols-colorButtons"> <?php foreach (config('MediaClipper')->themes as $themeName => $colors): ?> <Forms.ColorRadioButton class="mx-auto" value="<?= $themeName ?>" name="theme" - isChecked="<?= $themeName === 'pine' ? 'true' : 'false' ?>" style="--color-accent-base: <?= $colors['preview']?>"><?= lang('Settings.theme.' . $themeName) ?></Forms.ColorRadioButton> <?php endforeach; ?> </div> +</fieldset> -<Forms.Field - type="number" - name="start_time" - label="START" - required="true" - value="5" -/> -<Forms.Field - type="number" - name="end_time" - label="END" - required="true" - value="10" -/> +<div class="flex flex-col gap-x-2 gap-y-4 md:flex-row"> + <Forms.Field + type="number" + name="start_time" + label="<?= lang('VideoClip.form.start_time') ?>" + required="true" + step="0.001" + /> + <Forms.Field + type="number" + name="duration" + label="<?= lang('VideoClip.form.duration') ?>" + required="true" + step="0.001" + /> +</div> + +<Button variant="primary" type="submit" iconRight="arrow-right" class="self-end"><?= lang('VideoClip.form.submit') ?></Button> -<Button variant="primary" type="submit"><?= lang('Episode.video_clips.submit') ?></Button> +</Forms.Section> </form> -- GitLab