mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-19 13:01:19 +00:00
feat(video-clip): generate video clips in the bg using a cron job + add video clip page + tidy up UI
This commit is contained in:
parent
42538dd757
commit
db0e4272bd
@ -72,16 +72,20 @@ class AddClips extends Migration
|
||||
'type' => 'INT',
|
||||
'unsigned' => true,
|
||||
],
|
||||
'job_started_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'job_ended_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
],
|
||||
'deleted_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', 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) {
|
||||
$this->end_time = $this->start_time + $this->duration;
|
||||
} elseif ($this->start_time && $this->end_time) {
|
||||
$this->duration = $this->end_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;
|
||||
}
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
6
app/Resources/icons/calendar.svg
Normal file
6
app/Resources/icons/calendar.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<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>
|
After Width: | Height: | Size: 254 B |
6
app/Resources/icons/loader.svg
Normal file
6
app/Resources/icons/loader.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<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>
|
After Width: | Height: | Size: 217 B |
@ -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;
|
||||
}
|
||||
}
|
||||
|
3
crontab
3
crontab
@ -1 +1,2 @@
|
||||
* * * * * /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',
|
||||
];
|
||||
|
53
modules/Admin/Language/en/VideoClip.php
Normal file
53
modules/Admin/Language/en/VideoClip.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?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',
|
||||
];
|
||||
|
53
modules/Admin/Language/fr/VideoClip.php
Normal file
53
modules/Admin/Language/fr/VideoClip.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?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'],
|
||||
],
|
||||
]; ?>
|
||||
|
||||
|
@ -162,7 +162,7 @@
|
||||
<div class="py-2 tab-panels">
|
||||
<section id="transcript-file-upload" class="flex items-center tab-panel">
|
||||
<?php if ($episode->transcript) : ?>
|
||||
<div class="flex mb-1 gap-x-2">
|
||||
<div class="flex items-center mb-1 gap-x-2">
|
||||
<?= anchor(
|
||||
$episode->transcript->file_url,
|
||||
icon('file', 'mr-2 text-skin-muted') .
|
||||
|
@ -1,13 +1,13 @@
|
||||
<?= $this->extend('_layout') ?>
|
||||
|
||||
<?= $this->section('title') ?>
|
||||
<?= lang('Episode.video_clips.title', [
|
||||
<?= lang('VideoClip.title', [
|
||||
'videoClipLabel' => $videoClip->label,
|
||||
]) ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('pageTitle') ?>
|
||||
<?= lang('Episode.video_clips.title', [
|
||||
<?= lang('VideoClip.title', [
|
||||
'videoClipLabel' => $videoClip->label,
|
||||
]) ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
@ -1,18 +1,24 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use App\Entities\Clip\VideoClip;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
?>
|
||||
<?= $this->extend('_layout') ?>
|
||||
|
||||
<?= $this->section('title') ?>
|
||||
<?= lang('Episode.video_clips.title') ?>
|
||||
<?= lang('VideoClip.list.title') ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('pageTitle') ?>
|
||||
<?= lang('Episode.video_clips.title') ?>
|
||||
<?= lang('VideoClip.list.title') ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<?= data_table(
|
||||
[
|
||||
[
|
||||
'header' => lang('VideoClip.list.status'),
|
||||
'header' => lang('VideoClip.list.status.label'),
|
||||
'cell' => function ($videoClip): string {
|
||||
$pillVariantMap = [
|
||||
'queued' => 'default',
|
||||
@ -26,36 +32,84 @@
|
||||
$pillIconMap = [
|
||||
'queued' => 'timer',
|
||||
'pending' => 'pause',
|
||||
'running' => 'play',
|
||||
'running' => 'loader',
|
||||
'canceled' => 'forbid',
|
||||
'failed' => 'close',
|
||||
'passed' => 'check',
|
||||
];
|
||||
|
||||
return '<Pill variant="' . $pillVariantMap[$videoClip->status] . '" icon="' . $pillIconMap[$videoClip->status] . '">' . $videoClip->status . '</Pill>';
|
||||
$pillIconClassMap = [
|
||||
'queued' => '',
|
||||
'pending' => '',
|
||||
'running' => 'animate-spin',
|
||||
'canceled' => '',
|
||||
'failed' => '',
|
||||
'passed' => '',
|
||||
];
|
||||
|
||||
return '<Pill variant="' . $pillVariantMap[$videoClip->status] . '" icon="' . $pillIconMap[$videoClip->status] . '" iconClass="' . $pillIconClassMap[$videoClip->status] . '" hint="' . lang('VideoClip.list.status.' . $videoClip->status . '_hint') . '">' . lang('VideoClip.list.status.' . $videoClip->status) . '</Pill>';
|
||||
},
|
||||
],
|
||||
[
|
||||
'header' => lang('VideoClip.list.label'),
|
||||
'header' => lang('VideoClip.list.clip'),
|
||||
'cell' => function ($videoClip): string {
|
||||
$formatClass = [
|
||||
'landscape' => 'aspect-video',
|
||||
'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 font-semibold hover:underline gap-x-2 focus:ring-accent"><div class="relative"><span class="absolute block w-3 h-3 rounded-full -bottom-1 -left-1" data-tooltip="bottom" title="' . $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>' . $videoClip->label . '</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="' . $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->label . '</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>';
|
||||
},
|
||||
],
|
||||
[
|
||||
'header' => lang('VideoClip.list.clip_id'),
|
||||
'cell' => function ($videoClip): string {
|
||||
return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="font-semibold hover:underline focus:ring-accent">#' . $videoClip->id . '</a><span class="ml-1 text-sm">by ' . $videoClip->user->username . '</span>';
|
||||
'header' => lang('VideoClip.list.duration'),
|
||||
'cell' => function (VideoClip $videoClip): string {
|
||||
$duration = '';
|
||||
if ($videoClip->job_started_at !== null) {
|
||||
if ($videoClip->job_ended_at !== null) {
|
||||
$duration = '<div class="flex flex-col text-xs gap-y-1">' .
|
||||
'<div class="inline-flex items-center gap-x-1"><Icon glyph="timer" class="text-sm text-gray-400" />' . format_duration($videoClip->job_duration, true) . '</div>' .
|
||||
'<div class="inline-flex items-center gap-x-1"><Icon glyph="calendar" class="text-sm text-gray-400" />' . relative_time($videoClip->job_ended_at) . '</div>' .
|
||||
'</div>';
|
||||
} else {
|
||||
$duration = '<div class="inline-flex items-center text-xs gap-x-1"><Icon glyph="timer" class="text-sm text-gray-400" />' . format_duration(($videoClip->job_started_at->difference(Time::now()))->getSeconds(), true) . '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
return $duration;
|
||||
},
|
||||
],
|
||||
[
|
||||
'header' => lang('Common.actions'),
|
||||
'cell' => function ($videoClip): string {
|
||||
return '…';
|
||||
$downloadButton = '';
|
||||
if ($videoClip->media) {
|
||||
helper('misc');
|
||||
$filename = 'clip-' . slugify($videoClip->label) . "-{$videoClip->start_time}-{$videoClip->end_time}";
|
||||
$downloadButton = '<IconButton glyph="download" uri="' . $videoClip->media->file_url . '" download="' . $filename . '">' . lang('VideoClip.download_clip') . '</IconButton>';
|
||||
}
|
||||
|
||||
return '<div class="inline-flex items-center gap-x-2">' . $downloadButton .
|
||||
'<button id="more-dropdown-' . $videoClip->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $videoClip->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
|
||||
icon('more') .
|
||||
'</button>' .
|
||||
'<DropdownMenu id="more-dropdown-' . $videoClip->id . '-menu" labelledby="more-dropdown-' . $videoClip->id . '" offsetY="-24" items="' . esc(json_encode([
|
||||
[
|
||||
'type' => 'link',
|
||||
'title' => lang('VideoClip.go_to_page'),
|
||||
'uri' => route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id),
|
||||
],
|
||||
[
|
||||
'type' => 'separator',
|
||||
],
|
||||
[
|
||||
'type' => 'link',
|
||||
'title' => lang('VideoClip.delete'),
|
||||
'uri' => route_to('video-clip-delete', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id),
|
||||
'class' => 'font-semibold text-red-600',
|
||||
],
|
||||
])) . '" />' .
|
||||
'</div>';
|
||||
},
|
||||
],
|
||||
],
|
||||
|
@ -1,60 +1,74 @@
|
||||
<?= $this->extend('_layout') ?>
|
||||
|
||||
<?= $this->section('title') ?>
|
||||
<?= lang('Episode.video_clips.title') ?>
|
||||
<?= lang('VideoClip.form.title') ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('pageTitle') ?>
|
||||
<?= lang('Episode.video_clips.title') ?>
|
||||
<?= lang('VideoClip.form.title') ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<form action="<?= route_to('video-clips-generate', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col max-w-sm gap-y-4">
|
||||
<form action="<?= route_to('video-clips-create', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col gap-y-4">
|
||||
|
||||
<fieldset>
|
||||
<legend>Format</legend>
|
||||
<div class="mx-auto">
|
||||
<input type="radio" name="format" value="landscape" id="landscape" checked="checked"/>
|
||||
<label for="landscape">Landscape - 16:9</label>
|
||||
</div>
|
||||
<div class="mx-auto">
|
||||
<input type="radio" name="format" value="portrait" id="portrait"/>
|
||||
<label for="portrait">Portrait - 9:16</label>
|
||||
</div>
|
||||
<div class="mx-auto">
|
||||
<input type="radio" name="format" value="squared" id="square"/>
|
||||
<label for="square">Square - 1:1</label>
|
||||
</div>
|
||||
<Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" >
|
||||
|
||||
<Forms.Field
|
||||
name="label"
|
||||
label="<?= lang('VideoClip.form.clip_title') ?>"
|
||||
required="true"
|
||||
/>
|
||||
|
||||
<fieldset class="flex gap-1">
|
||||
<legend><?= lang('VideoClip.form.format.label') ?></legend>
|
||||
<Forms.RadioButton
|
||||
value="landscape"
|
||||
name="format"
|
||||
hint="<?= lang('VideoClip.form.format.landscape_hint') ?>"><?= lang('VideoClip.form.format.landscape') ?></Forms.RadioButton>
|
||||
<Forms.RadioButton
|
||||
value="portrait"
|
||||
name="format"
|
||||
hint="<?= lang('VideoClip.form.format.portrait_hint') ?>"><?= lang('VideoClip.form.format.portrait') ?></Forms.RadioButton>
|
||||
<Forms.RadioButton
|
||||
value="squared"
|
||||
name="format"
|
||||
hint="<?= lang('VideoClip.form.format.squared_hint') ?>"><?= lang('VideoClip.form.format.squared') ?></Forms.RadioButton>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><?= lang('VideoClip.form.theme') ?></legend>
|
||||
<div class="grid gap-4 grid-cols-colorButtons">
|
||||
<?php foreach (config('MediaClipper')->themes as $themeName => $colors): ?>
|
||||
<Forms.ColorRadioButton
|
||||
class="mx-auto"
|
||||
value="<?= $themeName ?>"
|
||||
name="theme"
|
||||
isChecked="<?= $themeName === 'pine' ? 'true' : 'false' ?>"
|
||||
style="--color-accent-base: <?= $colors['preview']?>"><?= lang('Settings.theme.' . $themeName) ?></Forms.ColorRadioButton>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Forms.Field
|
||||
type="number"
|
||||
name="start_time"
|
||||
label="START"
|
||||
required="true"
|
||||
value="5"
|
||||
/>
|
||||
<Forms.Field
|
||||
type="number"
|
||||
name="end_time"
|
||||
label="END"
|
||||
required="true"
|
||||
value="10"
|
||||
/>
|
||||
<div class="flex flex-col gap-x-2 gap-y-4 md:flex-row">
|
||||
<Forms.Field
|
||||
type="number"
|
||||
name="start_time"
|
||||
label="<?= lang('VideoClip.form.start_time') ?>"
|
||||
required="true"
|
||||
step="0.001"
|
||||
/>
|
||||
<Forms.Field
|
||||
type="number"
|
||||
name="duration"
|
||||
label="<?= lang('VideoClip.form.duration') ?>"
|
||||
required="true"
|
||||
step="0.001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" type="submit"><?= lang('Episode.video_clips.submit') ?></Button>
|
||||
<Button variant="primary" type="submit" iconRight="arrow-right" class="self-end"><?= lang('VideoClip.form.submit') ?></Button>
|
||||
|
||||
</Forms.Section>
|
||||
|
||||
</form>
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user