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 8e1f7104..0c42a922 100644 --- a/app/Database/Migrations/2021-12-09-130000_add_clips.php +++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php @@ -72,16 +72,20 @@ class AddClips extends Migration 'type' => 'INT', 'unsigned' => true, ], + 'job_started_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'job_ended_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], 'created_at' => [ 'type' => 'DATETIME', ], 'updated_at' => [ 'type' => 'DATETIME', ], - 'deleted_at' => [ - 'type' => 'DATETIME', - 'null' => true, - ], ]); $this->forge->addKey('id', true); diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php index 77d4c0da..00057c2e 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 @@ -75,12 +86,25 @@ class BaseClip extends Entity public function __construct(array $data = null) { parent::__construct($data); + } - if ($this->start_time && $this->duration) { - $this->end_time = $this->start_time + $this->duration; - } elseif ($this->start_time && $this->end_time) { - $this->duration = $this->end_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; + } + + 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 a608e03b..89c5a121 100644 --- a/app/Helpers/components_helper.php +++ b/app/Helpers/components_helper.php @@ -26,7 +26,7 @@ if (! function_exists('hint_tooltip')) { $tooltip = ' + + + + + diff --git a/app/Resources/icons/loader.svg b/app/Resources/icons/loader.svg new file mode 100644 index 00000000..55da7bdb --- /dev/null +++ b/app/Resources/icons/loader.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Resources/styles/radioBtn.css b/app/Resources/styles/radioBtn.css index 509ec4c6..5a044cd9 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 656375d9..f7d7015a 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 << {$radioInput} - + HTML; } diff --git a/app/Views/Components/Pill.php b/app/Views/Components/Pill.php index b5927e65..a6ff6ffa 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 <<{$icon}{$this->slot} + {$icon}{$this->slot} HTML; } } diff --git a/crontab b/crontab index fc44ff63..3ad6aeb4 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 b32a8174..a41bdb5d 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 04345179..e3f9cfd5 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 f75849bd..ea6a329c 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 8e2df5be..6511ff5c 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 00000000..fd13c061 --- /dev/null +++ b/modules/Admin/Language/en/VideoClip.php @@ -0,0 +1,53 @@ + [ + '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 288b9203..b8c82ff7 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 00000000..bf686ed3 --- /dev/null +++ b/modules/Admin/Language/fr/VideoClip.php @@ -0,0 +1,53 @@ + [ + '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 9fbcf493..10c0affe 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 6e621563..3b1e6873 100644 --- a/themes/cp_admin/episode/edit.php +++ b/themes/cp_admin/episode/edit.php @@ -162,7 +162,7 @@
transcript) : ?> -
+
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 dad7a8ce..0504104c 100644 --- a/themes/cp_admin/episode/video_clip.php +++ b/themes/cp_admin/episode/video_clip.php @@ -1,13 +1,13 @@ extend('_layout') ?> section('title') ?> - $videoClip->label, ]) ?> endSection() ?> section('pageTitle') ?> - $videoClip->label, ]) ?> endSection() ?> diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php index 71a5d0f6..c718c393 100644 --- a/themes/cp_admin/episode/video_clips_list.php +++ b/themes/cp_admin/episode/video_clips_list.php @@ -1,18 +1,24 @@ + extend('_layout') ?> section('title') ?> - + endSection() ?> section('pageTitle') ?> - + endSection() ?> section('content') ?> 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 '' . $videoClip->status . ''; + $pillIconClassMap = [ + 'queued' => '', + 'pending' => '', + 'running' => 'animate-spin', + 'canceled' => '', + 'failed' => '', + 'passed' => '', + ]; + + return '' . lang('VideoClip.list.status.' . $videoClip->status) . ''; }, ], [ - '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 '
' . $videoClip->label . '
'; + return '
#' . $videoClip->id . ' – ' . $videoClip->label . 'by ' . $videoClip->user->username . '
' . format_duration((int) $videoClip->duration) . '
'; }, ], [ - 'header' => lang('VideoClip.list.clip_id'), - 'cell' => function ($videoClip): string { - return '#' . $videoClip->id . 'by ' . $videoClip->user->username . ''; + 'header' => lang('VideoClip.list.duration'), + 'cell' => function (VideoClip $videoClip): string { + $duration = ''; + if ($videoClip->job_started_at !== null) { + if ($videoClip->job_ended_at !== null) { + $duration = '
' . + '
' . format_duration($videoClip->job_duration, true) . '
' . + '
' . relative_time($videoClip->job_ended_at) . '
' . + '
'; + } else { + $duration = '
' . format_duration(($videoClip->job_started_at->difference(Time::now()))->getSeconds(), true) . '
'; + } + } + + 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 = '' . lang('VideoClip.download_clip') . ''; + } + + return '
' . $downloadButton . + '' . + '' . + '
'; }, ], ], diff --git a/themes/cp_admin/episode/video_clips_new.php b/themes/cp_admin/episode/video_clips_new.php index 93ce42a0..442df6f4 100644 --- a/themes/cp_admin/episode/video_clips_new.php +++ b/themes/cp_admin/episode/video_clips_new.php @@ -1,60 +1,74 @@ extend('_layout') ?> section('title') ?> - + endSection() ?> section('pageTitle') ?> - + endSection() ?> section('content') ?> -
+ -
-Format -
- - -
-
- - -
-
- - -
+ + + + +
+ + + +
+
+
themes as $themeName => $colors): ?>
+
- - +
+ + +
- + + +