Commit db0e4272 authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat(video-clip): generate video clips in the bg using a cron job + add video...

feat(video-clip): generate video clips in the bg using a cron job + add video clip page + tidy up UI
parent 42538dd7
......@@ -72,15 +72,19 @@ class AddClips extends Migration
'type' => 'INT',
'unsigned' => true,
],
'created_at' => [
'job_started_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'job_ended_at' => [
'type' => 'DATETIME',
'null' => true,
],
'deleted_at' => [
'created_at' => [
'type' => 'DATETIME',
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
......
......@@ -12,7 +12,6 @@ namespace App\Entities\Clip;
use App\Entities\Episode;
use App\Entities\Media\Audio;
use App\Entities\Media\BaseMedia;
use App\Entities\Media\Video;
use App\Entities\Podcast;
use App\Models\EpisodeModel;
......@@ -21,6 +20,7 @@ use App\Models\PodcastModel;
use App\Models\UserModel;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\I18n\Time;
use Modules\Auth\Entities\User;
/**
......@@ -34,21 +34,32 @@ use Modules\Auth\Entities\User;
* @property double $end_time
* @property double $duration
* @property string $type
* @property int $media_id
* @property Video|Audio $media
* @property int|null $media_id
* @property Video|Audio|null $media
* @property array|null $metadata
* @property string $status
* @property string $logs
* @property User $user
* @property int $created_by
* @property int $updated_by
* @property Time|null $job_started_at
* @property Time|null $job_ended_at
*/
class BaseClip extends Entity
{
/**
* @var BaseMedia
* @var Video|Audio|null
*/
protected $media = null;
protected $media;
protected ?int $job_duration = null;
protected ?float $end_time = null;
/**
* @var string[]
*/
protected $dates = ['created_at', 'updated_at', 'job_started_at', 'job_ended_at'];
/**
* @var array<string, string>
......@@ -75,12 +86,25 @@ class BaseClip extends Entity
public function __construct(array $data = null)
{
parent::__construct($data);
}
if ($this->start_time && $this->duration) {
public function getJobDuration(): ?int
{
if ($this->job_duration === null && $this->job_started_at && $this->job_ended_at) {
$this->job_duration = ($this->job_started_at->difference($this->job_ended_at))
->getSeconds();
}
return $this->job_duration;
}
public function getEndTime(): float
{
if ($this->end_time === null) {
$this->end_time = $this->start_time + $this->duration;
} elseif ($this->start_time && $this->end_time) {
$this->duration = $this->end_time - $this->duration;
}
return $this->end_time;
}
public function getPodcast(): ?Podcast
......@@ -128,16 +152,12 @@ class BaseClip extends Entity
return $this;
}
/**
* @noRector ReturnTypeDeclarationRector
*/
public function getMedia(): Audio | Video | null
{
if ($this->media_id !== null && $this->media === null) {
$this->media = (new MediaModel($this->type))->getMediaById($this->media_id);
}
// @phpstan-ignore-next-line
return $this->media;
}
}
......@@ -26,7 +26,7 @@ if (! function_exists('hint_tooltip')) {
$tooltip =
'<span data-tooltip="bottom" tabindex="0" title="' .
$hintText .
'" class="inline-block align-middle text-skin-muted focus:ring-accent';
'" class="inline-block align-middle opacity-75 focus:ring-accent';
if ($class !== '') {
$tooltip .= ' ' . $class;
......
......@@ -136,16 +136,20 @@ if (! function_exists('slugify')) {
if (! function_exists('format_duration')) {
/**
* Formats duration in seconds to an hh:mm:ss string. Doesn't show leading zeros if any.
* Formats duration in seconds to an hh:mm:ss string.
*
* ⚠️ This uses php's gmdate function so any duration > 86000 seconds (24 hours) will not be formatted properly.
*
* @param int $seconds seconds to format
*/
function format_duration(int $seconds): string
function format_duration(int $seconds, bool $showLeadingZeros = false): string
{
if ($showLeadingZeros) {
return gmdate('H:i:s', $seconds);
}
if ($seconds < 60) {
return '0:' . $seconds;
return '0:' . sprintf('%02d', $seconds);
}
if ($seconds < 3600) {
// < 1 hour: returns MM:SS
......@@ -153,9 +157,9 @@ if (! function_exists('format_duration')) {
}
if ($seconds < 36000) {
// < 10 hours: returns H:MM:SS
return ltrim(gmdate('h:i:s', $seconds), '0');
return ltrim(gmdate('H:i:s', $seconds), '0');
}
return gmdate('h:i:s', $seconds);
return gmdate('H:i:s', $seconds);
}
}
......
......@@ -213,7 +213,7 @@ class MediaClipper extends BaseConfig
'rescaleHeight' => 1200,
'x' => 0,
'y' => 600,
'mask' => APPPATH . 'Libraries/MediaClipper/soundwaves-mask-squared.png',
'mask' => APPPATH . 'Libraries/MediaClipper/soundwaves-mask-square.png',
],
'subtitles' => [
'fontsize' => 20,
......
......@@ -49,6 +49,8 @@ class ClipModel extends Model
'logs',
'created_by',
'updated_by',
'job_started_at',
'job_ended_at',
];
/**
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M2 11h20v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-9zm15-8h4a1 1 0 0 1 1 1v5H2V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M18.364 5.636L16.95 7.05A7 7 0 1 0 19 12h2a9 9 0 1 1-2.636-6.364z"/>
</g>
</svg>
......@@ -15,7 +15,7 @@
}
& + label {
@apply inline-block py-2 pl-8 pr-2 text-sm font-semibold rounded-lg cursor-pointer border-contrast bg-elevated border-3;
@apply inline-flex items-center py-2 pl-8 pr-2 text-sm font-semibold rounded-lg cursor-pointer border-contrast bg-elevated border-3;
color: hsl(var(--color-text-muted));
}
}
......
......@@ -8,6 +8,8 @@ class RadioButton extends FormComponent
{
protected bool $isChecked = false;
protected ?string $hint = null;
public function setIsChecked(string $value): void
{
$this->isChecked = $value === 'true';
......@@ -25,10 +27,12 @@ class RadioButton extends FormComponent
old($this->name) ? old($this->name) === $this->value : $this->isChecked,
);
$hint = $this->hint ? hint_tooltip($this->hint, 'ml-1 text-base') : '';
return <<<HTML
<div>
{$radioInput}
<label for="{$this->value}">{$this->slot}</label>
<label for="{$this->value}">{$this->slot}{$hint}</label>
</div>
HTML;
}
......
......@@ -17,6 +17,10 @@ class Pill extends Component
public ?string $icon = null;
public ?string $iconClass = '';
protected ?string $hint = null;
public function render(): string
{
$variantClasses = [
......@@ -27,10 +31,11 @@ class Pill extends Component
'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
];
$icon = $this->icon ? icon($this->icon) : '';
$icon = $this->icon ? icon($this->icon, $this->iconClass) : '';
$hint = $this->hint ? 'data-tooltip="bottom" title="' . $this->hint . '"' : '';
return <<<HTML
<span class="inline-flex items-center gap-x-1 px-1 font-semibold text-sm border rounded {$variantClasses[$this->variant]}">{$icon}{$this->slot}</span>
<span class="inline-flex items-center gap-x-1 px-1 font-semibold text-sm border rounded {$variantClasses[$this->variant]} {$this->class}" {$hint}>{$icon}{$this->slot}</span>
HTML;
}
}
* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-activities
* * * * * /usr/local/bin/php /castopod-host/public/index.php scheduled-activities
* * * * * /usr/local/bin/php /castopod-host/public/index.php scheduled-video-clips
......@@ -365,17 +365,17 @@ $routes->group(
);
$routes->get(
'video-clips/new',
'VideoClipsController::generate/$1/$2',
'VideoClipsController::create/$1/$2',
[
'as' => 'video-clips-generate',
'as' => 'video-clips-create',
'filter' => 'permission:podcast_episodes-edit',
],
);
$routes->post(
'video-clips/new',
'VideoClipsController::attemptGenerate/$1/$2',
'VideoClipsController::attemptCreate/$1/$2',
[
'as' => 'video-clips-generate',
'as' => 'video-clips-create',
'filter' => 'permission:podcast_episodes-edit',
],
);
......@@ -387,6 +387,14 @@ $routes->group(
'filter' => 'permission:podcast_episodes-edit',
],
);
$routes->get(
'video-clips/(:num)/delete',
'VideoClipsController::delete/$1/$2/$3',
[
'as' => 'video-clip-delete',
'filter' => 'permission:podcast_episodes-edit',
],
);
$routes->get(
'embed',
'EpisodeController::embed/$1/$2',
......
......@@ -12,6 +12,7 @@ namespace Modules\Admin\Controllers;
use App\Models\ClipModel;
use CodeIgniter\Controller;
use CodeIgniter\I18n\Time;
use MediaClipper\VideoClipper;
class SchedulerController extends Controller
......@@ -41,6 +42,7 @@ class SchedulerController extends Controller
(new ClipModel())
->update($scheduledClip->id, [
'status' => 'running',
'job_started_at' => Time::now(),
]);
$clipper = new VideoClipper(
$scheduledClip->episode,
......@@ -58,12 +60,14 @@ class SchedulerController extends Controller
'media_id' => $scheduledClip->media_id,
'status' => 'passed',
'logs' => $clipper->logs,
'job_ended_at' => Time::now(),
]);
} else {
// error
(new ClipModel())->update($scheduledClip->id, [
'status' => 'failed',
'logs' => $clipper->logs,
'job_ended_at' => Time::now(),
]);
}
}
......
......@@ -15,6 +15,7 @@ use App\Entities\Episode;
use App\Entities\Podcast;
use App\Models\ClipModel;
use App\Models\EpisodeModel;
use App\Models\MediaModel;
use App\Models\PodcastModel;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;
......@@ -105,7 +106,7 @@ class VideoClipsController extends BaseController
return view('episode/video_clip', $data);
}
public function generate(): string
public function create(): string
{
helper('form');
......@@ -121,12 +122,12 @@ class VideoClipsController extends BaseController
return view('episode/video_clips_new', $data);
}
public function attemptGenerate(): RedirectResponse
public function attemptCreate(): RedirectResponse
{
// TODO: add end_time greater than start_time, with minimum ?
$rules = [
'label' => 'required',
'start_time' => 'required|numeric',
'end_time' => 'required|numeric|differs[start_time]',
'duration' => 'required|greater_than[0]',
'format' => 'required|in_list[' . implode(',', array_keys(config('MediaClipper')->formats)) . ']',
'theme' => 'required|in_list[' . implode(',', array_keys(config('Colors')->themes)) . ']',
];
......@@ -147,9 +148,9 @@ class VideoClipsController extends BaseController
];
$videoClip = new VideoClip([
'label' => 'NEW CLIP',
'label' => $this->request->getPost('label'),
'start_time' => (float) $this->request->getPost('start_time'),
'end_time' => (float) $this->request->getPost('end_time',),
'duration' => (float) $this->request->getPost('duration',),
'theme' => $theme,
'format' => $this->request->getPost('format'),
'type' => 'video',
......@@ -162,9 +163,33 @@ class VideoClipsController extends BaseController
(new ClipModel())->insert($videoClip);
return redirect()->route('video-clips-generate', [$this->podcast->id, $this->episode->id])->with(
return redirect()->route('video-clips-list', [$this->podcast->id, $this->episode->id])->with(
'message',
lang('Settings.images.regenerationSuccess')
);
}
public function delete(string $videoClipId): RedirectResponse
{
$videoClip = (new ClipModel())->getVideoClipById((int) $videoClipId);
if ($videoClip === null) {
throw PageNotFoundException::forPageNotFound();
}
if ($videoClip->media === null) {
// delete Clip directly
(new ClipModel())->delete($videoClipId);
} else {
$mediaModel = new MediaModel();
if (! $mediaModel->deleteMedia($videoClip->media)) {
return redirect()
->back()
->withInput()
->with('errors', $mediaModel->errors());
}
}
return redirect()->back();
}
}
......@@ -18,5 +18,5 @@ return [
'clips' => 'Clips',
'soundbites-edit' => 'Soundbites',
'video-clips-list' => 'Video clips',
'video-clips-generate' => 'New video clip',
'video-clips-create' => 'New video clip',
];
<?php
declare(strict_types=1);
/**
* @copyright 2021 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'list' => [
'title' => 'Video clips',
'status' => [
'label' => 'Status',
'queued' => 'queued',
'queued_hint' => 'Clip is waiting to be processed.',
'pending' => 'pending',
'pending_hint' => 'Clip will be generated shortly.',
'running' => 'running',
'running_hint' => 'Clip is being generated.',
'failed' => 'failed',
'failed_hint' => 'Clip could not be generated: script failure.',
'passed' => 'passed',
'passed_hint' => 'Clip was generated successfully!',
],
'clip' => 'Clip',
'duration' => 'Duration',
],
'title' => 'Video clip: {videoClipLabel}',
'download_clip' => 'Download clip',
'go_to_page' => 'Go to clip page',
'delete' => 'Delete clip',
'logs' => 'Job logs',
'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',
'start_time' => 'Start at',
'duration' => 'Duration',
'submit' => 'Create video clip',
],
];
......@@ -18,5 +18,5 @@ return [
'clips' => 'Extraits',
'soundbites-edit' => 'Extraits sonores',
'video-clips-list' => 'Extraits video',
'video-clips-generate' => 'Nouvel extrait video',
'video-clips-create' => 'Nouvel extrait video',
];
<?php
declare(strict_types=1);
/**
* @copyright 2021 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'list' => [
'title' => 'Extraits vidéos',
'status' => [
'label' => 'Statut',
'queued' => 'en file d’attente',
'queued_hint' => 'L’extrait est dans la file d’attente.',
'pending' => 'en attente',
'pending_hint' => 'L’extrait va être généré prochainement.',
'running' => 'en cours',
'running_hint' => 'L’extrait est en cours de génération.',
'failed' => 'échec',
'failed_hint' => 'L’extrait n’a pas pu être généré : erreur du programme.',
'passed' => 'réussite',
'passed_hint' => 'L’extrait a été généré avec succès !',
],
'clip' => 'Extrait',
'duration' => 'Durée',
],
'title' => 'Extrait vidéo : {videoClipLabel}',
'download_clip' => 'Télécharger l’extrait',
'go_to_page' => 'Aller à la page de l’extrait',
'delete' => 'Supprimer l’extrait',
'logs' => 'Historique d’exécution',
'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',
'start_time' => 'Démarrer à',
'duration' => 'Durée',
'submit' => 'Créer un extrait vidéo',
],
];
......@@ -7,7 +7,7 @@ $podcastNavigation = [
],
'clips' => [
'icon' => 'clapperboard',
'items' => ['video-clips-list', 'video-clips-generate', 'soundbites-edit'],
'items' => ['video-clips-list', 'video-clips-create', 'soundbites-edit'],
],
]; ?>
......
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