feat(clips): setup clip entities and model + save video clip to have it generated in the background

This commit is contained in:
Yassine Doghri 2021-12-21 16:25:03 +00:00
parent 057559183c
commit 2f6fdf9091
12 changed files with 347 additions and 97 deletions

View File

@ -51,10 +51,15 @@ class AddClips extends Migration
'media_id' => [ 'media_id' => [
'type' => 'INT', 'type' => 'INT',
'unsigned' => true, 'unsigned' => true,
'null' => true,
],
'metadata' => [
'type' => 'JSON',
'null' => true,
], ],
'status' => [ 'status' => [
'type' => 'ENUM', 'type' => 'ENUM',
'constraint' => ['queued', 'pending', 'generating', 'passed', 'failed'], 'constraint' => ['queued', 'pending', 'running', 'passed', 'failed'],
], ],
'logs' => [ 'logs' => [
'type' => 'TEXT', 'type' => 'TEXT',

View File

@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entities;
use CodeIgniter\Entity\Entity;
class BaseEntity extends Entity
{
}

View File

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use CodeIgniter\Entity\Entity;
/**
* @property int $id
* @property int $podcast_id
* @property int $episode_id
* @property double $start_time
* @property double $duration
* @property string|null $label
* @property int $created_by
* @property int $updated_by
*/
class Clip extends Entity
{
/**
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'episode_id' => 'integer',
'start_time' => 'double',
'duration' => 'double',
'type' => 'string',
'label' => '?string',
'media_id' => 'integer',
'status' => 'string',
'logs' => 'string',
'created_by' => 'integer',
'updated_by' => 'integer',
];
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities\Clip;
use App\Entities\Episode;
use App\Entities\Media\Audio;
use App\Entities\Media\Video;
use App\Entities\Podcast;
use App\Models\EpisodeModel;
use App\Models\MediaModel;
use App\Models\PodcastModel;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
/**
* @property int $id
* @property int $podcast_id
* @property Podcast $podcast
* @property int $episode_id
* @property Episode $episode
* @property string $label
* @property double $start_time
* @property double $end_time
* @property double $duration
* @property string $type
* @property int $media_id
* @property Video|Audio $media
* @property string $status
* @property string $logs
* @property int $created_by
* @property int $updated_by
*/
class BaseClip extends Entity
{
/**
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'episode_id' => 'integer',
'label' => 'string',
'start_time' => 'double',
'duration' => 'double',
'type' => 'string',
'media_id' => '?integer',
'metadata' => 'json-array',
'status' => 'string',
'logs' => 'string',
'created_by' => 'integer',
'updated_by' => 'integer',
];
public function __construct($data)
{
parent::__construct($data);
if ($this->start_time && $this->duration) {
$this->end_time = $this->start_time + $this->duration;
} elseif ($this->start_time && $this->end_time) {
$this->duration = $this->end_time - $this->duration;
}
}
public function getPodcast(): ?Podcast
{
return (new PodcastModel())->getPodcastById($this->podcast_id);
}
public function getEpisode(): ?Episode
{
return (new EpisodeModel())->getEpisodeById($this->episode_id);
}
public function setMedia(string $filePath = null): static
{
if ($filePath === null || ($file = new File($filePath)) === null) {
return $this;
}
if ($this->media_id !== 0) {
$this->getMedia()
->setFile($file);
$this->getMedia()
->updated_by = (int) user_id();
(new MediaModel('audio'))->updateMedia($this->getMedia());
} else {
$media = new Audio([
'file_path' => $filePath,
'language_code' => $this->getPodcast()
->language_code,
'uploaded_by' => user_id(),
'updated_by' => user_id(),
]);
$media->setFile($file);
$this->attributes['media_id'] = (new MediaModel())->saveMedia($media);
}
return $this;
}
public function getMedia(): Audio | Video
{
if ($this->media_id !== null && $this->media === null) {
$this->media = (new MediaModel($this->type))->getMediaById($this->media_id);
}
return $this->media;
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities\Clip;
class Soundbite extends BaseClip
{
protected string $type = 'audio';
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities\Clip;
/**
* @property string $theme
*/
class VideoClip extends BaseClip
{
protected string $type = 'video';
public function __construct(array $data = null)
{
parent::__construct($data);
if ($this->metadata !== null) {
$this->theme = $this->metadata['theme'];
$this->format = $this->metadata['format'];
}
}
}

View File

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace App\Entities; namespace App\Entities;
use App\Entities\Clip\BaseClip;
use App\Entities\Media\Audio; use App\Entities\Media\Audio;
use App\Entities\Media\Chapters; use App\Entities\Media\Chapters;
use App\Entities\Media\Image; use App\Entities\Media\Image;
@ -74,7 +75,7 @@ use RuntimeException;
* @property Time|null $deleted_at; * @property Time|null $deleted_at;
* *
* @property Person[] $persons; * @property Person[] $persons;
* @property Clip[] $clips; * @property Soundbites[] $soundbites;
* @property string $embed_url; * @property string $embed_url;
*/ */
class Episode extends Entity class Episode extends Entity
@ -109,9 +110,9 @@ class Episode extends Entity
protected ?array $persons = null; protected ?array $persons = null;
/** /**
* @var Clip[]|null * @var Soundbites[]|null
*/ */
protected ?array $clips = null; protected ?array $soundbites = null;
/** /**
* @var Post[]|null * @var Post[]|null
@ -406,19 +407,19 @@ class Episode extends Entity
/** /**
* Returns the episodes clips * Returns the episodes clips
* *
* @return Clip[] * @return BaseClip[]|\App\Entities\Soundbites[]
*/ */
public function getClips(): array public function getSoundbites(): array
{ {
if ($this->id === null) { if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting clips.'); throw new RuntimeException('Episode must be created before getting soundbites.');
} }
if ($this->clips === null) { if ($this->soundbites === null) {
$this->clips = (new ClipModel())->getEpisodeClips($this->getPodcast() ->id, $this->id); $this->soundbites = (new ClipModel())->getEpisodeSoundbites($this->getPodcast()->id, $this->id);
} }
return $this->clips; return $this->soundbites;
} }
/** /**

View File

@ -273,11 +273,11 @@ if (! function_exists('get_rss_feed')) {
$chaptersElement->addAttribute('type', 'application/json+chapters'); $chaptersElement->addAttribute('type', 'application/json+chapters');
} }
foreach ($episode->clips as $clip) { foreach ($episode->soundbites as $soundbite) {
// TODO: differentiate video from soundbites? // TODO: differentiate video from soundbites?
$soundbiteElement = $item->addChild('soundbite', $clip->label, $podcastNamespace); $soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace);
$soundbiteElement->addAttribute('start_time', (string) $clip->start_time); $soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time);
$soundbiteElement->addAttribute('duration', (string) $clip->duration); $soundbiteElement->addAttribute('duration', (string) $soundbite->duration);
} }
foreach ($episode->persons as $person) { foreach ($episode->persons as $person) {

View File

@ -18,7 +18,7 @@ use GdImage;
* *
* @phpstan-ignore-next-line * @phpstan-ignore-next-line
*/ */
class VideoClip class VideoClipper
{ {
/** /**
* @var array<string, string> * @var array<string, string>
@ -107,7 +107,7 @@ class VideoClip
} }
} }
public function generate(): void public function generate(): string
{ {
$this->soundbite(); $this->soundbite();
$this->subtitlesClip(); $this->subtitlesClip();
@ -119,7 +119,7 @@ class VideoClip
$generateCmd = $this->getCmd(); $generateCmd = $this->getCmd();
shell_exec($generateCmd); return shell_exec($generateCmd . ' 2>&1');
} }
public function getCmd(): string public function getCmd(): string

View File

@ -12,9 +12,13 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Entities\Clip; use App\Entities\Clip\BaseClip;
use App\Entities\Clip\Soundbite;
use App\Entities\Clip\VideoClip;
use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\BaseResult;
use CodeIgniter\Database\ConnectionInterface;
use CodeIgniter\Model; use CodeIgniter\Model;
use CodeIgniter\Validation\ValidationInterface;
class ClipModel extends Model class ClipModel extends Model
{ {
@ -32,12 +36,16 @@ class ClipModel extends Model
* @var string[] * @var string[]
*/ */
protected $allowedFields = [ protected $allowedFields = [
'id',
'podcast_id', 'podcast_id',
'episode_id', 'episode_id',
'label', 'label',
'type',
'start_time', 'start_time',
'duration', 'duration',
'type',
'media_id',
'status',
'logs',
'created_by', 'created_by',
'updated_by', 'updated_by',
]; ];
@ -45,7 +53,7 @@ class ClipModel extends Model
/** /**
* @var string * @var string
*/ */
protected $returnType = Clip::class; protected $returnType = BaseClip::class;
/** /**
* @var bool * @var bool
@ -72,36 +80,93 @@ class ClipModel extends Model
*/ */
protected $beforeDelete = ['clearCache']; protected $beforeDelete = ['clearCache'];
public function deleteClip(int $podcastId, int $episodeId, int $clipId): BaseResult | bool public function __construct(
{ protected string $type = 'audio',
return $this->delete([ ConnectionInterface &$db = null,
'podcast_id' => $podcastId, ValidationInterface $validation = null
'episode_id' => $episodeId, ) {
'id' => $clipId, // @phpstan-ignore-next-line
]); switch ($type) {
case 'audio':
$this->returnType = Soundbite::class;
break;
case 'video':
$this->returnType = VideoClip::class;
break;
default:
// do nothing, keep default class
break;
}
parent::__construct($db, $validation);
} }
/** /**
* Gets all clips for an episode * Gets all clips for an episode
* *
* @return Clip[] * @return BaseClip[]
*/ */
public function getEpisodeClips(int $podcastId, int $episodeId): array public function getEpisodeSoundbites(int $podcastId, int $episodeId): array
{ {
$cacheName = "podcast#{$podcastId}_episode#{$episodeId}_clips"; $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_soundbites";
if (! ($found = cache($cacheName))) { if (! ($found = cache($cacheName))) {
$found = $this->where([ $found = $this->where([
'episode_id' => $episodeId, 'episode_id' => $episodeId,
'podcast_id' => $podcastId, 'podcast_id' => $podcastId,
'type' => 'audio',
]) ])
->orderBy('start_time') ->orderBy('start_time')
->findAll(); ->findAll();
foreach ($found as $key => $soundbite) {
$found[$key] = new Soundbite($soundbite->toArray());
}
cache() cache()
->save($cacheName, $found, DECADE); ->save($cacheName, $found, DECADE);
} }
return $found; return $found;
} }
/**
* Gets all video clips for an episode
*
* @return BaseClip[]
*/
public function getVideoClips(int $podcastId, int $episodeId): array
{
$cacheName = "podcast#{$podcastId}_episode#{$episodeId}_video-clips";
if (! ($found = cache($cacheName))) {
$found = $this->where([
'episode_id' => $episodeId,
'podcast_id' => $podcastId,
'type' => 'video',
])
->orderBy('start_time')
->findAll();
foreach ($found as $key => $videoClip) {
$found[$key] = new VideoClip($videoClip->toArray());
}
cache()
->save($cacheName, $found, DECADE);
}
return $found;
}
public function deleteSoundbite(int $podcastId, int $episodeId, int $clipId): BaseResult | bool
{
cache()
->delete("podcast#{$podcastId}_episode#{$episodeId}_soundbites");
return $this->delete([
'podcast_id' => $podcastId,
'episode_id' => $episodeId,
'id' => $clipId,
]);
}
/** /**
* @param array<string, array<string|int, mixed>> $data * @param array<string, array<string|int, mixed>> $data
* @return array<string, array<string|int, mixed>> * @return array<string, array<string|int, mixed>>
@ -114,9 +179,6 @@ class ClipModel extends Model
: $data['id']['episode_id'], : $data['id']['episode_id'],
); );
cache()
->delete("podcast#{$episode->podcast_id}_episode#{$episode->id}_clips");
// delete cache for rss feed // delete cache for rss feed
cache() cache()
->deleteMatching("podcast#{$episode->podcast_id}_feed*"); ->deleteMatching("podcast#{$episode->podcast_id}_feed*");

View File

@ -10,13 +10,16 @@ declare(strict_types=1);
namespace Modules\Admin\Controllers; namespace Modules\Admin\Controllers;
use App\Entities\Clip;
use App\Entities\Clip\VideoClip;
use App\Entities\Episode; use App\Entities\Episode;
use App\Entities\Podcast; use App\Entities\Podcast;
use App\Models\ClipModel;
use App\Models\EpisodeModel; use App\Models\EpisodeModel;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\RedirectResponse;
use MediaClipper\VideoClip; use MediaClipper\VideoClipper;
class VideoClipsController extends BaseController class VideoClipsController extends BaseController
{ {
@ -57,9 +60,19 @@ class VideoClipsController extends BaseController
public function list(): string public function list(): string
{ {
$videoClips = (new ClipModel('video'))
->where([
'podcast_id' => $this->podcast->id,
'episode_id' => $this->episode->id,
'type' => 'video',
])
->orderBy('created_at', 'desc');
$data = [ $data = [
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'episode' => $this->episode, 'episode' => $this->episode,
'videoClips' => $videoClips->paginate(10),
'pager' => $videoClips->pager,
]; ];
replace_breadcrumb_params([ replace_breadcrumb_params([
@ -102,18 +115,58 @@ class VideoClipsController extends BaseController
->with('errors', $this->validator->getErrors()); ->with('errors', $this->validator->getErrors());
} }
$clipper = new VideoClip( $videoClip = new VideoClip([
$this->episode, 'label' => 'NEW CLIP',
(float) $this->request->getPost('start_time'), 'start_time' => (float) $this->request->getPost('start_time'),
(float) $this->request->getPost('end_time',), 'end_time' => (float) $this->request->getPost('end_time',),
$this->request->getPost('format'), 'type' => 'video',
$this->request->getPost('theme'), 'status' => 'queued',
); 'podcast_id' => $this->podcast->id,
$clipper->generate(); 'episode_id' => $this->episode->id,
'created_by' => user_id(),
'updated_by' => user_id(),
]);
(new ClipModel())->insert($videoClip);
return redirect()->route('video-clips-generate', [$this->podcast->id, $this->episode->id])->with( return redirect()->route('video-clips-generate', [$this->podcast->id, $this->episode->id])->with(
'message', 'message',
lang('Settings.images.regenerationSuccess') 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,
]);
}
}
} }

View File

@ -9,5 +9,25 @@
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>
<?= data_table(
[
[
'header' => lang('Episode.list.episode'),
'cell' => function ($videoClip): string {
return $videoClip->label;
},
],
[
'header' => lang('Episode.list.visibility'),
'cell' => function ($videoClip): string {
return $videoClip->status;
},
],
],
$videoClips,
'mb-6'
) ?>
<?= $pager->links() ?>
<?= $this->endSection() ?> <?= $this->endSection() ?>