mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-19 13:01:19 +00:00
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
This commit is contained in:
parent
482b47ba6b
commit
7f7c878cb6
3
.gitignore
vendored
3
.gitignore
vendored
@ -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>
|
||||
|
9
writable/temp/index.html
Normal file
9
writable/temp/index.html
Normal file
@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>403 Forbidden</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Directory access is forbidden.</p>
|
||||
</body>
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user