feat(clips): setup clip entities and model + save video clip to have it generated in the background
This commit is contained in:
parent
057559183c
commit
2f6fdf9091
|
@ -51,10 +51,15 @@ class AddClips extends Migration
|
|||
'media_id' => [
|
||||
'type' => 'INT',
|
||||
'unsigned' => true,
|
||||
'null' => true,
|
||||
],
|
||||
'metadata' => [
|
||||
'type' => 'JSON',
|
||||
'null' => true,
|
||||
],
|
||||
'status' => [
|
||||
'type' => 'ENUM',
|
||||
'constraint' => ['queued', 'pending', 'generating', 'passed', 'failed'],
|
||||
'constraint' => ['queued', 'pending', 'running', 'passed', 'failed'],
|
||||
],
|
||||
'logs' => [
|
||||
'type' => 'TEXT',
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entities;
|
||||
|
||||
use CodeIgniter\Entity\Entity;
|
||||
|
||||
class BaseEntity extends Entity
|
||||
{
|
||||
}
|
|
@ -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',
|
||||
];
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Entities;
|
||||
|
||||
use App\Entities\Clip\BaseClip;
|
||||
use App\Entities\Media\Audio;
|
||||
use App\Entities\Media\Chapters;
|
||||
use App\Entities\Media\Image;
|
||||
|
@ -74,7 +75,7 @@ use RuntimeException;
|
|||
* @property Time|null $deleted_at;
|
||||
*
|
||||
* @property Person[] $persons;
|
||||
* @property Clip[] $clips;
|
||||
* @property Soundbites[] $soundbites;
|
||||
* @property string $embed_url;
|
||||
*/
|
||||
class Episode extends Entity
|
||||
|
@ -109,9 +110,9 @@ class Episode extends Entity
|
|||
protected ?array $persons = null;
|
||||
|
||||
/**
|
||||
* @var Clip[]|null
|
||||
* @var Soundbites[]|null
|
||||
*/
|
||||
protected ?array $clips = null;
|
||||
protected ?array $soundbites = null;
|
||||
|
||||
/**
|
||||
* @var Post[]|null
|
||||
|
@ -406,19 +407,19 @@ class Episode extends Entity
|
|||
/**
|
||||
* Returns the episode’s clips
|
||||
*
|
||||
* @return Clip[]
|
||||
* @return BaseClip[]|\App\Entities\Soundbites[]
|
||||
*/
|
||||
public function getClips(): array
|
||||
public function getSoundbites(): array
|
||||
{
|
||||
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) {
|
||||
$this->clips = (new ClipModel())->getEpisodeClips($this->getPodcast() ->id, $this->id);
|
||||
if ($this->soundbites === null) {
|
||||
$this->soundbites = (new ClipModel())->getEpisodeSoundbites($this->getPodcast()->id, $this->id);
|
||||
}
|
||||
|
||||
return $this->clips;
|
||||
return $this->soundbites;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -273,11 +273,11 @@ if (! function_exists('get_rss_feed')) {
|
|||
$chaptersElement->addAttribute('type', 'application/json+chapters');
|
||||
}
|
||||
|
||||
foreach ($episode->clips as $clip) {
|
||||
foreach ($episode->soundbites as $soundbite) {
|
||||
// TODO: differentiate video from soundbites?
|
||||
$soundbiteElement = $item->addChild('soundbite', $clip->label, $podcastNamespace);
|
||||
$soundbiteElement->addAttribute('start_time', (string) $clip->start_time);
|
||||
$soundbiteElement->addAttribute('duration', (string) $clip->duration);
|
||||
$soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace);
|
||||
$soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time);
|
||||
$soundbiteElement->addAttribute('duration', (string) $soundbite->duration);
|
||||
}
|
||||
|
||||
foreach ($episode->persons as $person) {
|
||||
|
|
|
@ -18,7 +18,7 @@ use GdImage;
|
|||
*
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
class VideoClip
|
||||
class VideoClipper
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
|
@ -107,7 +107,7 @@ class VideoClip
|
|||
}
|
||||
}
|
||||
|
||||
public function generate(): void
|
||||
public function generate(): string
|
||||
{
|
||||
$this->soundbite();
|
||||
$this->subtitlesClip();
|
||||
|
@ -119,7 +119,7 @@ class VideoClip
|
|||
|
||||
$generateCmd = $this->getCmd();
|
||||
|
||||
shell_exec($generateCmd);
|
||||
return shell_exec($generateCmd . ' 2>&1');
|
||||
}
|
||||
|
||||
public function getCmd(): string
|
|
@ -12,9 +12,13 @@ declare(strict_types=1);
|
|||
|
||||
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\ConnectionInterface;
|
||||
use CodeIgniter\Model;
|
||||
use CodeIgniter\Validation\ValidationInterface;
|
||||
|
||||
class ClipModel extends Model
|
||||
{
|
||||
|
@ -32,12 +36,16 @@ class ClipModel extends Model
|
|||
* @var string[]
|
||||
*/
|
||||
protected $allowedFields = [
|
||||
'id',
|
||||
'podcast_id',
|
||||
'episode_id',
|
||||
'label',
|
||||
'type',
|
||||
'start_time',
|
||||
'duration',
|
||||
'type',
|
||||
'media_id',
|
||||
'status',
|
||||
'logs',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
@ -45,7 +53,7 @@ class ClipModel extends Model
|
|||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $returnType = Clip::class;
|
||||
protected $returnType = BaseClip::class;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
|
@ -72,36 +80,93 @@ class ClipModel extends Model
|
|||
*/
|
||||
protected $beforeDelete = ['clearCache'];
|
||||
|
||||
public function deleteClip(int $podcastId, int $episodeId, int $clipId): BaseResult | bool
|
||||
{
|
||||
return $this->delete([
|
||||
'podcast_id' => $podcastId,
|
||||
'episode_id' => $episodeId,
|
||||
'id' => $clipId,
|
||||
]);
|
||||
public function __construct(
|
||||
protected string $type = 'audio',
|
||||
ConnectionInterface &$db = null,
|
||||
ValidationInterface $validation = null
|
||||
) {
|
||||
// @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
|
||||
*
|
||||
* @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))) {
|
||||
$found = $this->where([
|
||||
'episode_id' => $episodeId,
|
||||
'podcast_id' => $podcastId,
|
||||
'type' => 'audio',
|
||||
])
|
||||
->orderBy('start_time')
|
||||
->findAll();
|
||||
|
||||
foreach ($found as $key => $soundbite) {
|
||||
$found[$key] = new Soundbite($soundbite->toArray());
|
||||
}
|
||||
|
||||
cache()
|
||||
->save($cacheName, $found, DECADE);
|
||||
}
|
||||
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
|
||||
* @return array<string, array<string|int, mixed>>
|
||||
|
@ -114,9 +179,6 @@ class ClipModel extends Model
|
|||
: $data['id']['episode_id'],
|
||||
);
|
||||
|
||||
cache()
|
||||
->delete("podcast#{$episode->podcast_id}_episode#{$episode->id}_clips");
|
||||
|
||||
// delete cache for rss feed
|
||||
cache()
|
||||
->deleteMatching("podcast#{$episode->podcast_id}_feed*");
|
||||
|
|
|
@ -10,13 +10,16 @@ declare(strict_types=1);
|
|||
|
||||
namespace Modules\Admin\Controllers;
|
||||
|
||||
use App\Entities\Clip;
|
||||
use App\Entities\Clip\VideoClip;
|
||||
use App\Entities\Episode;
|
||||
use App\Entities\Podcast;
|
||||
use App\Models\ClipModel;
|
||||
use App\Models\EpisodeModel;
|
||||
use App\Models\PodcastModel;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use MediaClipper\VideoClip;
|
||||
use MediaClipper\VideoClipper;
|
||||
|
||||
class VideoClipsController extends BaseController
|
||||
{
|
||||
|
@ -57,9 +60,19 @@ class VideoClipsController extends BaseController
|
|||
|
||||
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 = [
|
||||
'podcast' => $this->podcast,
|
||||
'episode' => $this->episode,
|
||||
'videoClips' => $videoClips->paginate(10),
|
||||
'pager' => $videoClips->pager,
|
||||
];
|
||||
|
||||
replace_breadcrumb_params([
|
||||
|
@ -102,18 +115,58 @@ class VideoClipsController extends BaseController
|
|||
->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$clipper = new VideoClip(
|
||||
$this->episode,
|
||||
(float) $this->request->getPost('start_time'),
|
||||
(float) $this->request->getPost('end_time',),
|
||||
$this->request->getPost('format'),
|
||||
$this->request->getPost('theme'),
|
||||
);
|
||||
$clipper->generate();
|
||||
$videoClip = new VideoClip([
|
||||
'label' => 'NEW CLIP',
|
||||
'start_time' => (float) $this->request->getPost('start_time'),
|
||||
'end_time' => (float) $this->request->getPost('end_time',),
|
||||
'type' => 'video',
|
||||
'status' => 'queued',
|
||||
'podcast_id' => $this->podcast->id,
|
||||
'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(
|
||||
'message',
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,5 +9,25 @@
|
|||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $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() ?>
|
||||
|
|
Loading…
Reference in New Issue