From 2f6fdf9091d52ca49709fc82621ba1c6dd0e817d Mon Sep 17 00:00:00 2001 From: Yassine Doghri <yassine@doghri.fr> Date: Tue, 21 Dec 2021 16:25:03 +0000 Subject: [PATCH] feat(clips): setup clip entities and model + save video clip to have it generated in the background --- .../2021-12-09-130000_add_clips.php | 7 +- app/Entities/BaseEntity.php | 11 -- app/Entities/Clip.php | 44 ------- app/Entities/Clip/BaseClip.php | 119 ++++++++++++++++++ app/Entities/Clip/Soundbite.php | 16 +++ app/Entities/Clip/VideoClip.php | 29 +++++ app/Entities/Episode.php | 19 +-- app/Helpers/rss_helper.php | 8 +- .../{VideoClip.php => VideoClipper.php} | 6 +- app/Models/ClipModel.php | 94 +++++++++++--- .../Controllers/VideoClipsController.php | 71 +++++++++-- themes/cp_admin/episode/video_clips_list.php | 20 +++ 12 files changed, 347 insertions(+), 97 deletions(-) delete mode 100644 app/Entities/BaseEntity.php delete mode 100644 app/Entities/Clip.php create mode 100644 app/Entities/Clip/BaseClip.php create mode 100644 app/Entities/Clip/Soundbite.php create mode 100644 app/Entities/Clip/VideoClip.php rename app/Libraries/MediaClipper/{VideoClip.php => VideoClipper.php} (99%) 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 068c66b39d..e3319fb99a 100644 --- a/app/Database/Migrations/2021-12-09-130000_add_clips.php +++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php @@ -51,10 +51,15 @@ class AddClips extends Migration 'media_id' => [ 'type' => 'INT', 'unsigned' => true, + 'null' => true, + ], + 'metadata' => [ + 'type' => 'JSON', + 'null' => true, ], 'status' => [ 'type' => 'ENUM', - 'constraint' => ['queued', 'pending', 'generating', 'passed', 'failed'], + 'constraint' => ['queued', 'pending', 'running', 'passed', 'failed'], ], 'logs' => [ 'type' => 'TEXT', diff --git a/app/Entities/BaseEntity.php b/app/Entities/BaseEntity.php deleted file mode 100644 index b9f888a463..0000000000 --- a/app/Entities/BaseEntity.php +++ /dev/null @@ -1,11 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Entities; - -use CodeIgniter\Entity\Entity; - -class BaseEntity extends Entity -{ -} diff --git a/app/Entities/Clip.php b/app/Entities/Clip.php deleted file mode 100644 index 550cf40391..0000000000 --- a/app/Entities/Clip.php +++ /dev/null @@ -1,44 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright 2020 Podlibre - * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 - * @link https://castopod.org/ - */ - -namespace App\Entities; - -use CodeIgniter\Entity\Entity; - -/** - * @property int $id - * @property int $podcast_id - * @property int $episode_id - * @property double $start_time - * @property double $duration - * @property string|null $label - * @property int $created_by - * @property int $updated_by - */ -class Clip extends Entity -{ - /** - * @var array<string, string> - */ - protected $casts = [ - 'id' => 'integer', - 'podcast_id' => 'integer', - 'episode_id' => 'integer', - 'start_time' => 'double', - 'duration' => 'double', - 'type' => 'string', - 'label' => '?string', - 'media_id' => 'integer', - 'status' => 'string', - 'logs' => 'string', - 'created_by' => 'integer', - 'updated_by' => 'integer', - ]; -} diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php new file mode 100644 index 0000000000..c5d0e1a770 --- /dev/null +++ b/app/Entities/Clip/BaseClip.php @@ -0,0 +1,119 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities\Clip; + +use App\Entities\Episode; +use App\Entities\Media\Audio; +use App\Entities\Media\Video; +use App\Entities\Podcast; +use App\Models\EpisodeModel; +use App\Models\MediaModel; +use App\Models\PodcastModel; +use CodeIgniter\Entity\Entity; +use CodeIgniter\Files\File; + +/** + * @property int $id + * @property int $podcast_id + * @property Podcast $podcast + * @property int $episode_id + * @property Episode $episode + * @property string $label + * @property double $start_time + * @property double $end_time + * @property double $duration + * @property string $type + * @property int $media_id + * @property Video|Audio $media + * @property string $status + * @property string $logs + * @property int $created_by + * @property int $updated_by + */ +class BaseClip extends Entity +{ + /** + * @var array<string, string> + */ + protected $casts = [ + 'id' => 'integer', + 'podcast_id' => 'integer', + 'episode_id' => 'integer', + 'label' => 'string', + 'start_time' => 'double', + 'duration' => 'double', + 'type' => 'string', + 'media_id' => '?integer', + 'metadata' => 'json-array', + 'status' => 'string', + 'logs' => 'string', + 'created_by' => 'integer', + 'updated_by' => 'integer', + ]; + + public function __construct($data) + { + 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 getPodcast(): ?Podcast + { + return (new PodcastModel())->getPodcastById($this->podcast_id); + } + + public function getEpisode(): ?Episode + { + return (new EpisodeModel())->getEpisodeById($this->episode_id); + } + + public function setMedia(string $filePath = null): static + { + if ($filePath === null || ($file = new File($filePath)) === null) { + return $this; + } + + if ($this->media_id !== 0) { + $this->getMedia() + ->setFile($file); + $this->getMedia() + ->updated_by = (int) user_id(); + (new MediaModel('audio'))->updateMedia($this->getMedia()); + } else { + $media = new Audio([ + 'file_path' => $filePath, + 'language_code' => $this->getPodcast() + ->language_code, + 'uploaded_by' => user_id(), + 'updated_by' => user_id(), + ]); + $media->setFile($file); + + $this->attributes['media_id'] = (new MediaModel())->saveMedia($media); + } + + return $this; + } + + public function getMedia(): Audio | Video + { + if ($this->media_id !== null && $this->media === null) { + $this->media = (new MediaModel($this->type))->getMediaById($this->media_id); + } + + return $this->media; + } +} diff --git a/app/Entities/Clip/Soundbite.php b/app/Entities/Clip/Soundbite.php new file mode 100644 index 0000000000..fea08e0915 --- /dev/null +++ b/app/Entities/Clip/Soundbite.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities\Clip; + +class Soundbite extends BaseClip +{ + protected string $type = 'audio'; +} diff --git a/app/Entities/Clip/VideoClip.php b/app/Entities/Clip/VideoClip.php new file mode 100644 index 0000000000..a39b759f64 --- /dev/null +++ b/app/Entities/Clip/VideoClip.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities\Clip; + +/** + * @property string $theme + */ +class VideoClip extends BaseClip +{ + protected string $type = 'video'; + + public function __construct(array $data = null) + { + parent::__construct($data); + + if ($this->metadata !== null) { + $this->theme = $this->metadata['theme']; + $this->format = $this->metadata['format']; + } + } +} diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index ca577f431b..2bc3fc784c 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace App\Entities; +use App\Entities\Clip\BaseClip; use App\Entities\Media\Audio; use App\Entities\Media\Chapters; use App\Entities\Media\Image; @@ -74,7 +75,7 @@ use RuntimeException; * @property Time|null $deleted_at; * * @property Person[] $persons; - * @property Clip[] $clips; + * @property Soundbites[] $soundbites; * @property string $embed_url; */ class Episode extends Entity @@ -109,9 +110,9 @@ class Episode extends Entity protected ?array $persons = null; /** - * @var Clip[]|null + * @var Soundbites[]|null */ - protected ?array $clips = null; + protected ?array $soundbites = null; /** * @var Post[]|null @@ -406,19 +407,19 @@ class Episode extends Entity /** * Returns the episode’s clips * - * @return Clip[] + * @return BaseClip[]|\App\Entities\Soundbites[] */ - public function getClips(): array + public function getSoundbites(): array { if ($this->id === null) { - throw new RuntimeException('Episode must be created before getting clips.'); + throw new RuntimeException('Episode must be created before getting soundbites.'); } - if ($this->clips === null) { - $this->clips = (new ClipModel())->getEpisodeClips($this->getPodcast() ->id, $this->id); + if ($this->soundbites === null) { + $this->soundbites = (new ClipModel())->getEpisodeSoundbites($this->getPodcast()->id, $this->id); } - return $this->clips; + return $this->soundbites; } /** diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index 076296b505..bee957f962 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -273,11 +273,11 @@ if (! function_exists('get_rss_feed')) { $chaptersElement->addAttribute('type', 'application/json+chapters'); } - foreach ($episode->clips as $clip) { + foreach ($episode->soundbites as $soundbite) { // TODO: differentiate video from soundbites? - $soundbiteElement = $item->addChild('soundbite', $clip->label, $podcastNamespace); - $soundbiteElement->addAttribute('start_time', (string) $clip->start_time); - $soundbiteElement->addAttribute('duration', (string) $clip->duration); + $soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace); + $soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time); + $soundbiteElement->addAttribute('duration', (string) $soundbite->duration); } foreach ($episode->persons as $person) { diff --git a/app/Libraries/MediaClipper/VideoClip.php b/app/Libraries/MediaClipper/VideoClipper.php similarity index 99% rename from app/Libraries/MediaClipper/VideoClip.php rename to app/Libraries/MediaClipper/VideoClipper.php index ad309feffd..6e223de294 100644 --- a/app/Libraries/MediaClipper/VideoClip.php +++ b/app/Libraries/MediaClipper/VideoClipper.php @@ -18,7 +18,7 @@ use GdImage; * * @phpstan-ignore-next-line */ -class VideoClip +class VideoClipper { /** * @var array<string, string> @@ -107,7 +107,7 @@ class VideoClip } } - public function generate(): void + public function generate(): string { $this->soundbite(); $this->subtitlesClip(); @@ -119,7 +119,7 @@ class VideoClip $generateCmd = $this->getCmd(); - shell_exec($generateCmd); + return shell_exec($generateCmd . ' 2>&1'); } public function getCmd(): string diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php index 3a1b8b2526..6ce42cc9cd 100644 --- a/app/Models/ClipModel.php +++ b/app/Models/ClipModel.php @@ -12,9 +12,13 @@ declare(strict_types=1); namespace App\Models; -use App\Entities\Clip; +use App\Entities\Clip\BaseClip; +use App\Entities\Clip\Soundbite; +use App\Entities\Clip\VideoClip; use CodeIgniter\Database\BaseResult; +use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Model; +use CodeIgniter\Validation\ValidationInterface; class ClipModel extends Model { @@ -32,12 +36,16 @@ class ClipModel extends Model * @var string[] */ protected $allowedFields = [ + 'id', 'podcast_id', 'episode_id', 'label', - 'type', 'start_time', 'duration', + 'type', + 'media_id', + 'status', + 'logs', 'created_by', 'updated_by', ]; @@ -45,7 +53,7 @@ class ClipModel extends Model /** * @var string */ - protected $returnType = Clip::class; + protected $returnType = BaseClip::class; /** * @var bool @@ -72,36 +80,93 @@ class ClipModel extends Model */ protected $beforeDelete = ['clearCache']; - public function deleteClip(int $podcastId, int $episodeId, int $clipId): BaseResult | bool - { - return $this->delete([ - 'podcast_id' => $podcastId, - 'episode_id' => $episodeId, - 'id' => $clipId, - ]); + public function __construct( + protected string $type = 'audio', + ConnectionInterface &$db = null, + ValidationInterface $validation = null + ) { + // @phpstan-ignore-next-line + switch ($type) { + case 'audio': + $this->returnType = Soundbite::class; + break; + case 'video': + $this->returnType = VideoClip::class; + break; + default: + // do nothing, keep default class + break; + } + + parent::__construct($db, $validation); } /** * Gets all clips for an episode * - * @return Clip[] + * @return BaseClip[] */ - public function getEpisodeClips(int $podcastId, int $episodeId): array + public function getEpisodeSoundbites(int $podcastId, int $episodeId): array { - $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_clips"; + $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_soundbites"; if (! ($found = cache($cacheName))) { $found = $this->where([ 'episode_id' => $episodeId, 'podcast_id' => $podcastId, + 'type' => 'audio', ]) ->orderBy('start_time') ->findAll(); + + foreach ($found as $key => $soundbite) { + $found[$key] = new Soundbite($soundbite->toArray()); + } + cache() ->save($cacheName, $found, DECADE); } return $found; } + /** + * Gets all video clips for an episode + * + * @return BaseClip[] + */ + public function getVideoClips(int $podcastId, int $episodeId): array + { + $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_video-clips"; + if (! ($found = cache($cacheName))) { + $found = $this->where([ + 'episode_id' => $episodeId, + 'podcast_id' => $podcastId, + 'type' => 'video', + ]) + ->orderBy('start_time') + ->findAll(); + + foreach ($found as $key => $videoClip) { + $found[$key] = new VideoClip($videoClip->toArray()); + } + + cache() + ->save($cacheName, $found, DECADE); + } + return $found; + } + + public function deleteSoundbite(int $podcastId, int $episodeId, int $clipId): BaseResult | bool + { + cache() + ->delete("podcast#{$podcastId}_episode#{$episodeId}_soundbites"); + + return $this->delete([ + 'podcast_id' => $podcastId, + 'episode_id' => $episodeId, + 'id' => $clipId, + ]); + } + /** * @param array<string, array<string|int, mixed>> $data * @return array<string, array<string|int, mixed>> @@ -114,9 +179,6 @@ class ClipModel extends Model : $data['id']['episode_id'], ); - cache() - ->delete("podcast#{$episode->podcast_id}_episode#{$episode->id}_clips"); - // delete cache for rss feed cache() ->deleteMatching("podcast#{$episode->podcast_id}_feed*"); diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php index 3520e2862d..82be57b06d 100644 --- a/modules/Admin/Controllers/VideoClipsController.php +++ b/modules/Admin/Controllers/VideoClipsController.php @@ -10,13 +10,16 @@ declare(strict_types=1); namespace Modules\Admin\Controllers; +use App\Entities\Clip; +use App\Entities\Clip\VideoClip; use App\Entities\Episode; use App\Entities\Podcast; +use App\Models\ClipModel; use App\Models\EpisodeModel; use App\Models\PodcastModel; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\RedirectResponse; -use MediaClipper\VideoClip; +use MediaClipper\VideoClipper; class VideoClipsController extends BaseController { @@ -57,9 +60,19 @@ class VideoClipsController extends BaseController public function list(): string { + $videoClips = (new ClipModel('video')) + ->where([ + 'podcast_id' => $this->podcast->id, + 'episode_id' => $this->episode->id, + 'type' => 'video', + ]) + ->orderBy('created_at', 'desc'); + $data = [ 'podcast' => $this->podcast, 'episode' => $this->episode, + 'videoClips' => $videoClips->paginate(10), + 'pager' => $videoClips->pager, ]; replace_breadcrumb_params([ @@ -102,18 +115,58 @@ class VideoClipsController extends BaseController ->with('errors', $this->validator->getErrors()); } - $clipper = new VideoClip( - $this->episode, - (float) $this->request->getPost('start_time'), - (float) $this->request->getPost('end_time',), - $this->request->getPost('format'), - $this->request->getPost('theme'), - ); - $clipper->generate(); + $videoClip = new VideoClip([ + 'label' => 'NEW CLIP', + 'start_time' => (float) $this->request->getPost('start_time'), + 'end_time' => (float) $this->request->getPost('end_time',), + 'type' => 'video', + 'status' => 'queued', + 'podcast_id' => $this->podcast->id, + 'episode_id' => $this->episode->id, + 'created_by' => user_id(), + 'updated_by' => user_id(), + ]); + + (new ClipModel())->insert($videoClip); return redirect()->route('video-clips-generate', [$this->podcast->id, $this->episode->id])->with( 'message', lang('Settings.images.regenerationSuccess') ); } + + public function scheduleClips(): void + { + // get all clips that haven't been generated + $scheduledClips = (new ClipModel())->getScheduledVideoClips(); + + foreach ($scheduledClips as $scheduledClip) { + $scheduledClip->status = 'pending'; + } + + (new ClipModel())->updateBatch($scheduledClips); + + // Loop through clips to generate them + foreach ($scheduledClips as $scheduledClip) { + // set clip to pending + (new ClipModel()) + ->update($scheduledClip->id, [ + 'status' => 'running', + ]); + $clipper = new VideoClipper( + $scheduledClip->episode, + $scheduledClip->start_time, + $scheduledClip->end_time, + $scheduledClip->format, + $scheduledClip->theme, + ); + $output = $clipper->generate(); + $scheduledClip->setMedia($clipper->videoClipOutput); + + (new ClipModel())->update($scheduledClip->id, [ + 'status' => 'passed', + 'logs' => $output, + ]); + } + } } diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php index f357a62267..ee3ac24cdd 100644 --- a/themes/cp_admin/episode/video_clips_list.php +++ b/themes/cp_admin/episode/video_clips_list.php @@ -9,5 +9,25 @@ <?= $this->endSection() ?> <?= $this->section('content') ?> +<?= data_table( + [ + [ + 'header' => lang('Episode.list.episode'), + 'cell' => function ($videoClip): string { + return $videoClip->label; + }, + ], + [ + 'header' => lang('Episode.list.visibility'), + 'cell' => function ($videoClip): string { + return $videoClip->status; + }, + ], + ], + $videoClips, + 'mb-6' +) ?> + +<?= $pager->links() ?> <?= $this->endSection() ?> -- GitLab