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 e3319fb99a7241735e694ea81ab64a65411aeb47..8e1f710404c7c7ee31bf74f000f85117dfb1e14d 100644 --- a/app/Database/Migrations/2021-12-09-130000_add_clips.php +++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php @@ -85,7 +85,6 @@ class AddClips extends Migration ]); $this->forge->addKey('id', true); - $this->forge->addUniqueKey(['episode_id', 'start_time', 'duration', 'type']); $this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE'); $this->forge->addForeignKey('episode_id', 'episodes', 'id', '', 'CASCADE'); $this->forge->addForeignKey('media_id', 'media', 'id', '', 'CASCADE'); diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php index c5d0e1a770670b9a1bba4f9ca1d57d8fb1abca92..402bddd0d48d87f5c4e18dc15973421107c855cb 100644 --- a/app/Entities/Clip/BaseClip.php +++ b/app/Entities/Clip/BaseClip.php @@ -17,8 +17,10 @@ use App\Entities\Podcast; use App\Models\EpisodeModel; use App\Models\MediaModel; use App\Models\PodcastModel; +use App\Models\UserModel; use CodeIgniter\Entity\Entity; use CodeIgniter\Files\File; +use Modules\Auth\Entities\User; /** * @property int $id @@ -33,8 +35,10 @@ use CodeIgniter\Files\File; * @property string $type * @property int $media_id * @property Video|Audio $media + * @property array|null $metadata * @property string $status * @property string $logs + * @property User $user * @property int $created_by * @property int $updated_by */ @@ -52,14 +56,17 @@ class BaseClip extends Entity 'duration' => 'double', 'type' => 'string', 'media_id' => '?integer', - 'metadata' => 'json-array', + 'metadata' => '?json-array', 'status' => 'string', 'logs' => 'string', 'created_by' => 'integer', 'updated_by' => 'integer', ]; - public function __construct($data) + /** + * @param array<string, mixed>|null $data + */ + public function __construct(array $data = null) { parent::__construct($data); @@ -80,13 +87,20 @@ class BaseClip extends Entity return (new EpisodeModel())->getEpisodeById($this->episode_id); } + public function getUser(): ?User + { + return (new UserModel())->find($this->created_by); + } + public function setMedia(string $filePath = null): static { - if ($filePath === null || ($file = new File($filePath)) === null) { + if ($filePath === null) { return $this; } - if ($this->media_id !== 0) { + $file = new File($filePath); + + if ($this->media_id !== null) { $this->getMedia() ->setFile($file); $this->getMedia() @@ -97,8 +111,8 @@ class BaseClip extends Entity 'file_path' => $filePath, 'language_code' => $this->getPodcast() ->language_code, - 'uploaded_by' => user_id(), - 'updated_by' => user_id(), + 'uploaded_by' => $this->attributes['created_by'], + 'updated_by' => $this->attributes['created_by'], ]); $media->setFile($file); diff --git a/app/Entities/Clip/VideoClip.php b/app/Entities/Clip/VideoClip.php index a39b759f64b87375231541d778153df9c8960c89..0e5fa5a2861e9d760c70a31bfa9f9c0d1e0cfc98 100644 --- a/app/Entities/Clip/VideoClip.php +++ b/app/Entities/Clip/VideoClip.php @@ -10,20 +10,78 @@ declare(strict_types=1); namespace App\Entities\Clip; +use App\Entities\Media\Video; +use App\Models\MediaModel; +use CodeIgniter\Files\File; + /** - * @property string $theme + * @property array $theme + * @property string $format */ class VideoClip extends BaseClip { protected string $type = 'video'; + /** + * @param array<string, mixed>|null $data + */ public function __construct(array $data = null) { parent::__construct($data); - if ($this->metadata !== null) { + if ($this->metadata !== null && $this->metadata !== []) { $this->theme = $this->metadata['theme']; $this->format = $this->metadata['format']; } } + + /** + * @param array<string, string> $theme + */ + public function setTheme(array $theme): self + { + // TODO: change? + $this->attributes['metadata'] = json_decode($this->attributes['metadata'] ?? '[]', true); + + $this->attributes['theme'] = $theme; + $this->attributes['metadata']['theme'] = $theme; + + $this->attributes['metadata'] = json_encode($this->attributes['metadata']); + + return $this; + } + + public function setFormat(string $format): self + { + $this->attributes['metadata'] = json_decode($this->attributes['metadata'], true); + + $this->attributes['format'] = $format; + $this->attributes['metadata']['format'] = $format; + + $this->attributes['metadata'] = json_encode($this->attributes['metadata']); + + return $this; + } + + public function setMedia(string $filePath = null): static + { + if ($filePath === null) { + return $this; + } + + $file = new File($filePath); + + $video = new Video([ + 'file_path' => $filePath, + 'language_code' => $this->getPodcast() + ->language_code, + 'uploaded_by' => $this->attributes['created_by'], + 'updated_by' => $this->attributes['created_by'], + ]); + $video->setFile($file); + + $this->attributes['media_id'] = (new MediaModel())->saveMedia($video); + + return $this; + } } diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 2bc3fc784c1b0e82501b9c8ae22f25e1c8efa35f..57c4e672b8381cd6759e7626d31c06b651265758 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace App\Entities; -use App\Entities\Clip\BaseClip; +use App\Entities\Clip\Soundbite; use App\Entities\Media\Audio; use App\Entities\Media\Chapters; use App\Entities\Media\Image; @@ -75,7 +75,7 @@ use RuntimeException; * @property Time|null $deleted_at; * * @property Person[] $persons; - * @property Soundbites[] $soundbites; + * @property Soundbite[] $soundbites; * @property string $embed_url; */ class Episode extends Entity @@ -110,7 +110,7 @@ class Episode extends Entity protected ?array $persons = null; /** - * @var Soundbites[]|null + * @var Soundbite[]|null */ protected ?array $soundbites = null; @@ -407,7 +407,7 @@ class Episode extends Entity /** * Returns the episode’s clips * - * @return BaseClip[]|\App\Entities\Soundbites[] + * @return Soundbite[] */ public function getSoundbites(): array { diff --git a/app/Libraries/MediaClipper/VideoClipper.php b/app/Libraries/MediaClipper/VideoClipper.php index 6e223de294d607bdff0e5c6f169928eb76e5a7e0..9614c6f1b4e5e1ee74b910e41e5911d8f7e89203 100644 --- a/app/Libraries/MediaClipper/VideoClipper.php +++ b/app/Libraries/MediaClipper/VideoClipper.php @@ -31,6 +31,12 @@ class VideoClipper 'timestamp' => 'NotoSansMono-Regular.ttf', ]; + public ?string $logs = null; + + public bool $error = false; + + public string $videoClipOutput; + protected float $duration; protected string $audioInput; @@ -45,8 +51,6 @@ class VideoClipper protected string $videoClipBgOutput; - protected string $videoClipOutput; - protected ?string $episodeNumbering = null; /** @@ -107,7 +111,10 @@ class VideoClipper } } - public function generate(): string + /** + * @return int 0 for success, else error + */ + public function generate(): int { $this->soundbite(); $this->subtitlesClip(); @@ -119,7 +126,7 @@ class VideoClipper $generateCmd = $this->getCmd(); - return shell_exec($generateCmd . ' 2>&1'); + return $this->cmd_exec($generateCmd); } public function getCmd(): string @@ -205,7 +212,7 @@ class VideoClipper return false; } - $episodeCover = imagecreatefromjpeg($this->episodeCoverPath); + $episodeCover = $this->createCoverImage(); if (! $episodeCover) { return false; } @@ -340,6 +347,41 @@ class VideoClipper return true; } + /** + * @return int 0 (success), 1 - 2 - 254 - 255 (error) + */ + private function cmd_exec(string $cmd): int + { + $outFile = tempnam(WRITEPATH . 'logs', 'cmd-out-'); + + if (! $outFile) { + return 254; + } + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['file', $outFile, 'w'], + // FFmpeg outputs to stderr by default + ]; + $proc = proc_open($cmd, $descriptorSpec, $pipes); + + if (! is_resource($proc)) { + return 255; + } + + fclose($pipes[0]); //Don't really want to give any input + + $exit = proc_close($proc); + + $this->logs = (string) file_get_contents($outFile); + + // remove temporary files + unlink($outFile); + + return $exit; + } + private function getFont(string $name): string { return config('MediaClipper')->fontsFolder . self::FONTS[$name]; @@ -364,6 +406,15 @@ class VideoClipper return $background; } + private function createCoverImage(): GdImage | false + { + return match ($this->episode->cover->file_mimetype) { + 'image/jpeg' => imagecreatefromjpeg($this->episodeCoverPath), + 'image/png' => imagecreatefrompng($this->episodeCoverPath), + default => imagecreate(1400, 1400), + }; + } + private function scaleImage(GdImage $image, int $width, int $height): GdImage | false { return imagescale($image, $width, $height); diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php index 6ce42cc9cdb1f9e1162571c648246a355dde2e47..93cd2fb29a629a9805d403739210121a5a8cd2be 100644 --- a/app/Models/ClipModel.php +++ b/app/Models/ClipModel.php @@ -44,6 +44,7 @@ class ClipModel extends Model 'duration', 'type', 'media_id', + 'metadata', 'status', 'logs', 'created_by', @@ -65,21 +66,6 @@ class ClipModel extends Model */ protected $useTimestamps = true; - /** - * @var string[] - */ - protected $afterInsert = ['clearCache']; - - /** - * @var string[] - */ - protected $afterUpdate = ['clearCache']; - - /** - * @var string[] - */ - protected $beforeDelete = ['clearCache']; - public function __construct( protected string $type = 'audio', ConnectionInterface &$db = null, @@ -104,7 +90,7 @@ class ClipModel extends Model /** * Gets all clips for an episode * - * @return BaseClip[] + * @return Soundbite[] */ public function getEpisodeSoundbites(int $podcastId, int $episodeId): array { @@ -155,6 +141,27 @@ class ClipModel extends Model return $found; } + /** + * Gets scheduled video clips for an episode + * + * @return VideoClip[] + */ + public function getScheduledVideoClips(): array + { + $found = $this->where([ + 'type' => 'video', + 'status' => 'queued', + ]) + ->orderBy('created_at') + ->findAll(); + + foreach ($found as $key => $videoClip) { + $found[$key] = new VideoClip($videoClip->toArray()); + } + + return $found; + } + public function deleteSoundbite(int $podcastId, int $episodeId, int $clipId): BaseResult | bool { cache() @@ -167,25 +174,6 @@ class ClipModel extends Model ]); } - /** - * @param array<string, array<string|int, mixed>> $data - * @return array<string, array<string|int, mixed>> - */ - public function clearCache(array $data): array - { - $episode = (new EpisodeModel())->find( - isset($data['data']) - ? $data['data']['episode_id'] - : $data['id']['episode_id'], - ); - - // delete cache for rss feed - cache() - ->deleteMatching("podcast#{$episode->podcast_id}_feed*"); - - cache() - ->deleteMatching("page_podcast#{$episode->podcast_id}_episode#{$episode->id}_*"); - - return $data; - } + // cache() + // ->deleteMatching("page_podcast#{$clip->podcast_id}_episode#{$clip->episode_id}_*"); } diff --git a/app/Resources/icons/forbid.svg b/app/Resources/icons/forbid.svg new file mode 100644 index 0000000000000000000000000000000000000000..dbc2632cd2e2704d512f34da87b5faa1b9504c0b --- /dev/null +++ b/app/Resources/icons/forbid.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="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zM8.523 7.109A6.04 6.04 0 0 0 7.11 8.523l8.368 8.368a6.04 6.04 0 0 0 1.414-1.414L8.523 7.109z"/> + </g> +</svg> diff --git a/app/Views/Components/Pill.php b/app/Views/Components/Pill.php new file mode 100644 index 0000000000000000000000000000000000000000..ead392253538a70685a0935254cda64354d19b24 --- /dev/null +++ b/app/Views/Components/Pill.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace App\Views\Components; + +use ViewComponents\Component; + +class Pill extends Component +{ + /** + * @var 'small'|'base' + */ + public string $size = 'base'; + + public string $variant = 'default'; + + public ?string $icon = null; + + public function render(): string + { + $variantClasses = [ + 'default' => 'text-gray-800 bg-gray-100 border-gray-300', + 'primary' => 'text-accent-contrast bg-accent-base border-accent-base', + 'success' => 'text-pine-900 bg-pine-100 border-castopod-300', + 'danger' => 'text-red-900 bg-red-100 border-red-300', + 'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300', + ]; + + $icon = $this->icon ? icon($this->icon) : ''; + + return <<<HTML + <span class="inline-flex items-center gap-x-1 px-1 font-semibold border rounded {$variantClasses[$this->variant]}">{$icon}{$this->slot}</span> + HTML; + } +} diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index 59233f6198b2a6d47e4b8fcd4ca0dad74b4eba1d..66bb739b8071be924a2583181ac82fd773124489 100644 --- a/modules/Admin/Config/Routes.php +++ b/modules/Admin/Config/Routes.php @@ -6,6 +6,11 @@ namespace Modules\Admin\Config; $routes = service('routes'); +// video-clips scheduler +$routes->add('scheduled-video-clips', 'SchedulerController::generateVideoClips', [ + 'namespace' => 'Modules\Admin\Controllers', +]); + // Admin area routes $routes->group( config('Admin') diff --git a/modules/Admin/Controllers/SchedulerController.php b/modules/Admin/Controllers/SchedulerController.php new file mode 100644 index 0000000000000000000000000000000000000000..7ff4d0d3a90419976ad29d27a1bb89cff07daf9a --- /dev/null +++ b/modules/Admin/Controllers/SchedulerController.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\Admin\Controllers; + +use App\Models\ClipModel; +use CodeIgniter\Controller; +use MediaClipper\VideoClipper; + +class SchedulerController extends Controller +{ + public function generateVideoClips(): bool + { + // get all clips that haven't been processed yet + $scheduledClips = (new ClipModel())->getScheduledVideoClips(); + + if ($scheduledClips === []) { + return true; + } + + $data = []; + foreach ($scheduledClips as $scheduledClip) { + $data[] = [ + 'id' => $scheduledClip->id, + 'status' => 'pending', + ]; + } + + (new ClipModel())->updateBatch($data, 'id'); + + // 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['name'], + ); + $exitCode = $clipper->generate(); + + if ($exitCode === 0) { + // success, video was generated + $scheduledClip->setMedia($clipper->videoClipOutput); + (new ClipModel())->update($scheduledClip->id, [ + 'media_id' => $scheduledClip->media_id, + 'status' => 'passed', + 'logs' => $clipper->logs, + ]); + } else { + // error + (new ClipModel())->update($scheduledClip->id, [ + 'status' => 'failed', + 'logs' => $clipper->logs, + ]); + } + } + + return true; + } +} diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php index 82be57b06d5f3dbbedd08b78b9be31fb0aad1db9..4c70f72cfe2c4b2b68a22a0762eb73f1950ea160 100644 --- a/modules/Admin/Controllers/VideoClipsController.php +++ b/modules/Admin/Controllers/VideoClipsController.php @@ -10,7 +10,6 @@ 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; @@ -19,7 +18,6 @@ use App\Models\EpisodeModel; use App\Models\PodcastModel; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\RedirectResponse; -use MediaClipper\VideoClipper; class VideoClipsController extends BaseController { @@ -60,7 +58,7 @@ class VideoClipsController extends BaseController public function list(): string { - $videoClips = (new ClipModel('video')) + $videoClipsBuilder = (new ClipModel('video')) ->where([ 'podcast_id' => $this->podcast->id, 'episode_id' => $this->episode->id, @@ -68,11 +66,18 @@ class VideoClipsController extends BaseController ]) ->orderBy('created_at', 'desc'); + $clips = $videoClipsBuilder->paginate(10); + + $videoClips = []; + foreach ($clips as $clip) { + $videoClips[] = new VideoClip($clip->toArray()); + } + $data = [ 'podcast' => $this->podcast, 'episode' => $this->episode, - 'videoClips' => $videoClips->paginate(10), - 'pager' => $videoClips->pager, + 'videoClips' => $videoClips, + 'pager' => $videoClipsBuilder->pager, ]; replace_breadcrumb_params([ @@ -115,10 +120,20 @@ class VideoClipsController extends BaseController ->with('errors', $this->validator->getErrors()); } + $themeName = $this->request->getPost('theme'); + $themeColors = config('MediaClipper') + ->themes[$themeName]; + $theme = [ + 'name' => $themeName, + 'preview' => $themeColors['preview'], + ]; + $videoClip = new VideoClip([ 'label' => 'NEW CLIP', 'start_time' => (float) $this->request->getPost('start_time'), 'end_time' => (float) $this->request->getPost('end_time',), + 'theme' => $theme, + 'format' => $this->request->getPost('format'), 'type' => 'video', 'status' => 'queued', 'podcast_id' => $this->podcast->id, @@ -134,39 +149,4 @@ class VideoClipsController extends BaseController 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 ee3ac24cdded74bd7af492508ab935db8241a327..75a98bb513bd1777d7f2da1ad66fc5748ff665cd 100644 --- a/themes/cp_admin/episode/video_clips_list.php +++ b/themes/cp_admin/episode/video_clips_list.php @@ -12,15 +12,50 @@ <?= data_table( [ [ - 'header' => lang('Episode.list.episode'), + 'header' => lang('VideoClip.list.status'), 'cell' => function ($videoClip): string { - return $videoClip->label; + $pillVariantMap = [ + 'queued' => 'default', + 'pending' => 'warning', + 'running' => 'primary', + 'canceled' => 'default', + 'failed' => 'danger', + 'passed' => 'success', + ]; + + $pillIconMap = [ + 'queued' => 'timer', + 'pending' => 'pause', + 'running' => 'play', + 'canceled' => 'forbid', + 'failed' => 'close', + 'passed' => 'check', + ]; + + return '<Pill variant="' . $pillVariantMap[$videoClip->status] . '" icon="' . $pillIconMap[$videoClip->status] . '">' . $videoClip->status . '</Pill>'; + }, + ], + [ + 'header' => lang('VideoClip.list.label'), + 'cell' => function ($videoClip): string { + $formatClass = [ + 'landscape' => 'aspect-video h-4', + 'portrait' => 'aspect-[9/16] w-4', + 'squared' => 'aspect-square h-6', + ]; + return '<a href="#" class="inline-flex items-center w-full hover:underline gap-x-2"><span class="block w-3 h-3 rounded-full" data-tooltip="bottom" title="' . $videoClip->theme['name'] . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><span class="flex items-center justify-center text-white bg-gray-400 rounded-sm ' . $formatClass[$videoClip->format] . '" data-tooltip="bottom" title="' . $videoClip->format . '"><Icon glyph="play"/></span>' . $videoClip->label . '</a>'; + }, + ], + [ + 'header' => lang('VideoClip.list.clip_id'), + 'cell' => function ($videoClip): string { + return '#' . $videoClip->id . ' by ' . $videoClip->user->username; }, ], [ - 'header' => lang('Episode.list.visibility'), + 'header' => lang('Common.actions'), 'cell' => function ($videoClip): string { - return $videoClip->status; + return '…'; }, ], ],