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:
Yassine Doghri 2021-12-24 17:55:56 +00:00
parent 42538dd757
commit db0e4272bd
24 changed files with 353 additions and 90 deletions

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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,

View File

@ -49,6 +49,8 @@ class ClipModel extends Model
'logs',
'created_by',
'updated_by',
'job_started_at',
'job_ended_at',
];
/**

View 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

View 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

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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',

View File

@ -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(),
]);
}
}

View File

@ -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();
}
}

View File

@ -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',
];

View 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',
],
];

View File

@ -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',
];

View 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 dattente',
'queued_hint' => 'Lextrait est dans la file dattente.',
'pending' => 'en attente',
'pending_hint' => 'Lextrait va être généré prochainement.',
'running' => 'en cours',
'running_hint' => 'Lextrait est en cours de génération.',
'failed' => 'échec',
'failed_hint' => 'Lextrait na pas pu être généré: erreur du programme.',
'passed' => 'réussite',
'passed_hint' => 'Lextrait a été généré avec succès!',
],
'clip' => 'Extrait',
'duration' => 'Durée',
],
'title' => 'Extrait vidéo: {videoClipLabel}',
'download_clip' => 'Télécharger lextrait',
'go_to_page' => 'Aller à la page de lextrait',
'delete' => 'Supprimer lextrait',
'logs' => 'Historique dexécution',
'form' => [
'title' => 'Nouvel extrait vidéo',
'params_section_title' => 'Paramètres de lextrait vidéo',
'clip_title' => 'Titre de lextrait',
'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',
],
];

View File

@ -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'],
],
]; ?>

View File

@ -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') .

View File

@ -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() ?>

View File

@ -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>';
},
],
],

View File

@ -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>