Commit 7f7c878c authored by Yassine Doghri's avatar Yassine Doghri
Browse files

fix(video-clips): create unique temporary files for resources to be deleted after generation

- tempfile uniqueness ensures that each process lives in its independent context
- add
writable/temp folder to store video clips temporary resources
- add videoClipWorkers config to
Admin for specifying the number of ffmpeg processes to run in parallel
- update video clip preview
background to better suit the end result
parent 482b47ba
Pipeline #1218 passed with stages
in 8 minutes and 34 seconds
......@@ -60,6 +60,9 @@ writable/logs/*
writable/session/*
!writable/session/index.html
writable/temp/*
!writable/temp/index.html
writable/uploads/*
!writable/uploads/index.html
......
......@@ -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),
......
......@@ -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);
......
......@@ -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);
......
......@@ -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++) {
......
......@@ -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;
......
......@@ -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;
}
......@@ -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;
......
......@@ -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',
......
......@@ -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',
......
......@@ -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>';
},
],
[
......
......@@ -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>
......
<!DOCTYPE html>
<html>
<head>
<title>403 Forbidden</title>
</head>
<body>
<p>Directory access is forbidden.</p>
</body>
</html>
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment