diff --git a/.gitignore b/.gitignore index 50cd1f20660fcd901d8b6153b8da5c2404e798a7..d0547825f3588c87a2200678069e04bf94bae24f 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ writable/logs/* writable/session/* !writable/session/index.html +writable/temp/* +!writable/temp/index.html + writable/uploads/* !writable/uploads/index.html diff --git a/app/Libraries/MediaClipper/Config/MediaClipper.php b/app/Libraries/MediaClipper/Config/MediaClipper.php index f4274d6de256761f601f4dfc9e23fa3e43e6d661..a0df0da96865fd4cc3643dcc21cecc758d058906 100644 --- a/app/Libraries/MediaClipper/Config/MediaClipper.php +++ b/app/Libraries/MediaClipper/Config/MediaClipper.php @@ -229,8 +229,10 @@ class MediaClipper extends BaseConfig */ public array $themes = [ 'pine' => [ - // Preview must be a HSL colorscheme string + // Previews must be a HSL colorscheme string 'preview' => '174 100% 29%', + 'preview-background' => '172 100% 17%', + // arrays are rgb 'background' => [0, 86, 74], 'text' => [255, 255, 255], // subtitle hex color is BGR (Blue, Green, Red), @@ -248,6 +250,8 @@ class MediaClipper extends BaseConfig 'crimson' => [ // Preview must be a HSL colorscheme string 'preview' => '350 87% 61%', + 'preview-background' => '348 75% 40%', + // arrays are rgb 'background' => [179, 31, 57], 'text' => [255, 255, 255], // subtitle hex color is BGR (Blue, Green, Red), @@ -265,6 +269,8 @@ class MediaClipper extends BaseConfig 'lake' => [ // Preview must be a HSL colorscheme string 'preview' => '194 100% 44%', + 'preview-background' => '194 100% 22%', + // arrays are rgb 'background' => [0, 86, 113], 'text' => [255, 255, 255], // subtitle hex color is BGR (Blue, Green, Red), @@ -282,6 +288,8 @@ class MediaClipper extends BaseConfig 'amber' => [ // Preview must be a HSL colorscheme string 'preview' => '17 100% 57%', + 'preview-background' => '17 100% 35%', + // arrays are rgb 'background' => [177, 50, 0], 'text' => [255, 255, 255], // subtitle hex color is BGR (Blue, Green, Red), @@ -299,6 +307,8 @@ class MediaClipper extends BaseConfig 'jacaranda' => [ // Preview must be a HSL colorscheme string 'preview' => '254 72% 52%', + 'preview-background' => '254 73% 30%', + // arrays are rgb 'background' => [47, 21, 132], 'text' => [255, 255, 255], // subtitle hex color is BGR (Blue, Green, Red), @@ -316,6 +326,8 @@ class MediaClipper extends BaseConfig 'onyx' => [ // Preview must be a HSL colorscheme string 'preview' => '240 17% 2%', + 'preview-background' => '240 17% 2%', + // arrays are rgb 'background' => [5, 5, 7], 'text' => [255, 255, 255], // subtitle hex color is BGR (Blue, Green, Red), diff --git a/app/Libraries/MediaClipper/VideoClipper.php b/app/Libraries/MediaClipper/VideoClipper.php index a8e3230ba1f127957fad31bb60af367d55e12d11..3e7cadb821daa7291707ce91c2aff78800cdfe69 100644 --- a/app/Libraries/MediaClipper/VideoClipper.php +++ b/app/Libraries/MediaClipper/VideoClipper.php @@ -55,6 +55,8 @@ class VideoClipper protected ?string $episodeNumbering = null; + protected string $tempFileOutput; + /** * @var array<string, mixed> */ @@ -90,11 +92,22 @@ class VideoClipper $podcastFolder = media_path("podcasts/{$this->episode->podcast->handle}"); - $this->soundbiteOutput = $podcastFolder . "/{$this->episode->slug}-soundbite-{$this->start}-to-{$this->end}.mp3"; - $this->subtitlesClipOutput = $podcastFolder . "/{$this->episode->slug}-subtitles-clip-{$this->start}-to-{$this->end}.srt"; - $this->videoClipBgOutput = $podcastFolder . "/{$this->episode->slug}-clip-bg-{$this->format}-{$this->theme}.png"; $this->videoClipOutput = $podcastFolder . "/{$this->episode->slug}-clip-{$this->start}-to-{$this->end}-{$this->format}-{$this->theme}.mp4"; $this->videoClipFilePath = "podcasts/{$this->episode->podcast->handle}/{$this->episode->slug}-clip-{$this->start}-to-{$this->end}-{$this->format}-{$this->theme}.mp4"; + + // Temporary files to generate clip + $tempFile = tempnam(WRITEPATH . 'temp', "{$this->episode->slug}-soundbite-{$this->start}-to-{$this->end}"); + + if (! $tempFile) { + throw new Exception( + 'Could not create temporary files, check for permissions on your ' . WRITEPATH . 'temp folder.' + ); + } + + $this->tempFileOutput = $tempFile; + $this->soundbiteOutput = $tempFile . '.mp3'; + $this->subtitlesClipOutput = $tempFile . '.srt'; + $this->videoClipBgOutput = $tempFile . '.png'; } public function soundbite(): void @@ -178,6 +191,7 @@ class VideoClipper public function cleanTempFiles(): void { // delete generated video background image, soundbite & subtitlesClip + unlink($this->tempFileOutput); unlink($this->soundbiteOutput); unlink($this->subtitlesClipOutput); unlink($this->videoClipBgOutput); diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php index 1d9172b7df465f0728c90054ddccf3613e0dc70d..446e8ded498f7194bee6ea1cf2c9c04a476294b6 100644 --- a/app/Models/ClipModel.php +++ b/app/Models/ClipModel.php @@ -130,6 +130,20 @@ class ClipModel extends Model return $found; } + public function getRunningVideoClipsCount(): int + { + $result = $this + ->select('COUNT(*) as `running_count`') + ->where([ + 'type' => 'video', + 'status' => 'running', + ]) + ->get() + ->getResultArray(); + + return (int) $result[0]['running_count']; + } + public function deleteVideoClip(int $podcastId, int $episodeId, int $clipId): BaseResult | bool { $this->clearVideoClipCache($clipId); diff --git a/app/Resources/js/modules/VideoClipBuilder.ts b/app/Resources/js/modules/VideoClipBuilder.ts index 432d12465d83a86aba737c612ec299f398095a98..b3a0865c67c0ac1ed09bd00fb079716f7df7dafa 100644 --- a/app/Resources/js/modules/VideoClipBuilder.ts +++ b/app/Resources/js/modules/VideoClipBuilder.ts @@ -36,15 +36,16 @@ const VideoClipBuilder = (): void => { let theme = form .querySelector('input[name="theme"]:checked') - ?.parentElement?.style.getPropertyValue("--color-accent-base"); + ?.parentElement?.style.getPropertyValue("--color-background-preview"); videoClipPreviewer.setAttribute("theme", theme || ""); const watchThemeChange = (event: Event) => { theme = ( event.target as HTMLInputElement - ).parentElement?.style.getPropertyValue("--color-accent-base") ?? - theme; + ).parentElement?.style.getPropertyValue( + "--color-background-preview" + ) ?? theme; videoClipPreviewer.setAttribute("theme", theme || ""); }; for (let i = 0; i < themeOptions.length; i++) { diff --git a/app/Resources/js/modules/video-clip-previewer.ts b/app/Resources/js/modules/video-clip-previewer.ts index faa138af87553bb82d39c32ea7bb2c14cbc008ad..a28c2f2a57b2664ace7fbca992bc119aeda78fd2 100644 --- a/app/Resources/js/modules/video-clip-previewer.ts +++ b/app/Resources/js/modules/video-clip-previewer.ts @@ -31,7 +31,7 @@ export class VideoClipPreviewer extends LitElement { format: VideoFormats = VideoFormats.Portrait; @property() - theme = "173 44% 96%"; + theme = "172 100% 17%"; @property({ type: Number }) duration!: number; diff --git a/modules/Admin/Config/Admin.php b/modules/Admin/Config/Admin.php index d71f39b738b2b2eb4728d897015bfa057ccadc5f..4c289d9cd936e175d150252499573ae820ca3a23 100644 --- a/modules/Admin/Config/Admin.php +++ b/modules/Admin/Config/Admin.php @@ -15,4 +15,10 @@ class Admin extends BaseConfig * Defines a base route for all admin pages */ public string $gateway = 'cp-admin'; + + /** + * Number of maximum ffmpeg processes to spawn in parallel when generating video clips. Processes are instance wide, + * meaning that they are shared across all podcasts and episodes. + */ + public int $videoClipWorkers = 2; } diff --git a/modules/Admin/Controllers/SchedulerController.php b/modules/Admin/Controllers/SchedulerController.php index a5f0239d98ad1ee0598e7e5af417dfcf41ff328a..2a00204d1850f2807ff173f477f33a50c64a2b17 100644 --- a/modules/Admin/Controllers/SchedulerController.php +++ b/modules/Admin/Controllers/SchedulerController.php @@ -13,12 +13,20 @@ namespace Modules\Admin\Controllers; use App\Models\ClipModel; use CodeIgniter\Controller; use CodeIgniter\I18n\Time; +use Exception; use MediaClipper\VideoClipper; class SchedulerController extends Controller { public function generateVideoClips(): bool { + // get number of running clips to prevent from having too much running in parallel + // TODO: get the number of running ffmpeg processes directly from the machine? + $runningVideoClips = (new ClipModel())->getRunningVideoClipsCount(); + if ($runningVideoClips >= config('Admin')->videoClipWorkers) { + return true; + } + // get all clips that haven't been processed yet $scheduledClips = (new ClipModel())->getScheduledVideoClips(); @@ -38,40 +46,49 @@ class SchedulerController extends Controller // Loop through clips to generate them foreach ($scheduledClips as $scheduledClip) { - // set clip to pending - (new ClipModel()) - ->update($scheduledClip->id, [ - 'status' => 'running', - 'job_started_at' => Time::now(), - ]); - $clipper = new VideoClipper( - $scheduledClip->episode, - $scheduledClip->start_time, - $scheduledClip->end_time, - $scheduledClip->format, - $scheduledClip->theme['name'], - ); - $exitCode = $clipper->generate(); + try { - $clipModel = new ClipModel(); - if ($exitCode === 0) { - // success, video was generated - $scheduledClip->setMedia($clipper->videoClipFilePath); - $clipModel->update($scheduledClip->id, [ - 'media_id' => $scheduledClip->media_id, - 'status' => 'passed', - 'logs' => $clipper->logs, - 'job_ended_at' => Time::now(), - ]); - } else { - // error - $clipModel->update($scheduledClip->id, [ + // set clip to pending + (new ClipModel()) + ->update($scheduledClip->id, [ + 'status' => 'running', + 'job_started_at' => Time::now(), + ]); + $clipper = new VideoClipper( + $scheduledClip->episode, + $scheduledClip->start_time, + $scheduledClip->end_time, + $scheduledClip->format, + $scheduledClip->theme['name'], + ); + $exitCode = $clipper->generate(); + + $clipModel = new ClipModel(); + if ($exitCode === 0) { + // success, video was generated + $scheduledClip->setMedia($clipper->videoClipFilePath); + $clipModel->update($scheduledClip->id, [ + 'media_id' => $scheduledClip->media_id, + 'status' => 'passed', + 'logs' => $clipper->logs, + 'job_ended_at' => Time::now(), + ]); + } else { + // error + $clipModel->update($scheduledClip->id, [ + 'status' => 'failed', + 'logs' => $clipper->logs, + 'job_ended_at' => Time::now(), + ]); + } + $clipModel->clearVideoClipCache($scheduledClip->id); + } catch (Exception $exception) { + (new ClipModel())->update($scheduledClip->id, [ 'status' => 'failed', - 'logs' => $clipper->logs, + 'logs' => $exception, 'job_ended_at' => Time::now(), ]); } - $clipModel->clearVideoClipCache($scheduledClip->id); } return true; diff --git a/modules/Admin/Language/en/VideoClip.php b/modules/Admin/Language/en/VideoClip.php index 8e804f2277202c28e6d8faf74b54cd862fda0450..84dfe89cd1cd7f55fa77ba92b5f2650cfa9571d9 100644 --- a/modules/Admin/Language/en/VideoClip.php +++ b/modules/Admin/Language/en/VideoClip.php @@ -38,17 +38,19 @@ return [ 'createSuccess' => 'Video clip has been successfully created!', 'deleteSuccess' => 'Video clip has been successfully removed!', ], + 'format' => [ + 'landscape' => 'Landscape', + 'portrait' => 'Portrait', + 'squared' => 'Squared', + ], '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', diff --git a/modules/Admin/Language/fr/VideoClip.php b/modules/Admin/Language/fr/VideoClip.php index a0646869ef81a1ca4e07655dbe9a5a92c7920867..c81eee5eebf315f2447d687e3dc02cdd5bb74928 100644 --- a/modules/Admin/Language/fr/VideoClip.php +++ b/modules/Admin/Language/fr/VideoClip.php @@ -38,17 +38,19 @@ return [ 'createSuccess' => 'L’extrait vidéo a été créé avec succès !', 'deleteSuccess' => 'L’extrait vidéo a bien été supprimé !', ], + 'format' => [ + 'landscape' => 'Paysage', + 'portrait' => 'Portrait', + 'squared' => 'Carré', + ], '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', diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php index 6da9bc9d9f39b70137a76115c6d68793a3d38758..5af00a750be34c9d2b89240efad04036cbb53821 100644 --- a/themes/cp_admin/episode/video_clips_list.php +++ b/themes/cp_admin/episode/video_clips_list.php @@ -62,7 +62,7 @@ use CodeIgniter\I18n\Time; '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 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->title . '</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>'; + 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="' . lang('VideoClip.format.' . $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->title . '</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>'; }, ], [ diff --git a/themes/cp_admin/episode/video_clips_new.php b/themes/cp_admin/episode/video_clips_new.php index 1c9b3796a9d0dd7d36c34417aafba7a07ae94934..b8683f6efb6239d21d8643c94045975f92b15a48 100644 --- a/themes/cp_admin/episode/video_clips_new.php +++ b/themes/cp_admin/episode/video_clips_new.php @@ -39,17 +39,17 @@ name="format" isChecked="true" required="true" - hint="<?= lang('VideoClip.form.format.landscape_hint') ?>"><?= lang('VideoClip.form.format.landscape') ?></Forms.RadioButton> + hint="<?= lang('VideoClip.form.format.landscape_hint') ?>"><?= lang('VideoClip.format.landscape') ?></Forms.RadioButton> <Forms.RadioButton value="portrait" name="format" required="true" - hint="<?= lang('VideoClip.form.format.portrait_hint') ?>"><?= lang('VideoClip.form.format.portrait') ?></Forms.RadioButton> + hint="<?= lang('VideoClip.form.format.portrait_hint') ?>"><?= lang('VideoClip.format.portrait') ?></Forms.RadioButton> <Forms.RadioButton value="squared" name="format" required="true" - hint="<?= lang('VideoClip.form.format.squared_hint') ?>"><?= lang('VideoClip.form.format.squared') ?></Forms.RadioButton> + hint="<?= lang('VideoClip.form.format.squared_hint') ?>"><?= lang('VideoClip.format.squared') ?></Forms.RadioButton> </fieldset> <fieldset> <legend><?= lang('VideoClip.form.theme') ?></legend> @@ -61,7 +61,7 @@ name="theme" required="true" isChecked="<?= $themeName === 'pine' ? 'true' : 'false' ?>" - style="--color-accent-base: <?= $colors['preview']?>"><?= lang('Settings.theme.' . $themeName) ?></Forms.ColorRadioButton> + style="--color-accent-base: <?= $colors['preview']?>; --color-background-preview: <?= $colors['preview-background'] ?>"><?= lang('Settings.theme.' . $themeName) ?></Forms.ColorRadioButton> <?php endforeach; ?> </div> </fieldset> diff --git a/writable/temp/index.html b/writable/temp/index.html new file mode 100644 index 0000000000000000000000000000000000000000..eebf8ecb2b2bdf794e1a23e04bc129e3aaacaeb4 --- /dev/null +++ b/writable/temp/index.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> + <head> + <title>403 Forbidden</title> + </head> + <body> + <p>Directory access is forbidden.</p> + </body> +</html>