mirror of
https://code.castopod.org/adaures/castopod.git
synced 2024-09-27 20:21:59 +02:00
feat(video-clip): add video-clip page with video preview + logs
This commit is contained in:
parent
2065ebbee5
commit
42538dd757
@ -12,6 +12,7 @@ namespace App\Entities\Clip;
|
|||||||
|
|
||||||
use App\Entities\Episode;
|
use App\Entities\Episode;
|
||||||
use App\Entities\Media\Audio;
|
use App\Entities\Media\Audio;
|
||||||
|
use App\Entities\Media\BaseMedia;
|
||||||
use App\Entities\Media\Video;
|
use App\Entities\Media\Video;
|
||||||
use App\Entities\Podcast;
|
use App\Entities\Podcast;
|
||||||
use App\Models\EpisodeModel;
|
use App\Models\EpisodeModel;
|
||||||
@ -44,6 +45,11 @@ use Modules\Auth\Entities\User;
|
|||||||
*/
|
*/
|
||||||
class BaseClip extends Entity
|
class BaseClip extends Entity
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var BaseMedia
|
||||||
|
*/
|
||||||
|
protected $media = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, string>
|
* @var array<string, string>
|
||||||
*/
|
*/
|
||||||
@ -122,12 +128,16 @@ class BaseClip extends Entity
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMedia(): Audio | Video
|
/**
|
||||||
|
* @noRector ReturnTypeDeclarationRector
|
||||||
|
*/
|
||||||
|
public function getMedia(): Audio | Video | null
|
||||||
{
|
{
|
||||||
if ($this->media_id !== null && $this->media === null) {
|
if ($this->media_id !== null && $this->media === null) {
|
||||||
$this->media = (new MediaModel($this->type))->getMediaById($this->media_id);
|
$this->media = (new MediaModel($this->type))->getMediaById($this->media_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
return $this->media;
|
return $this->media;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -69,7 +69,8 @@ class VideoClip extends BaseClip
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
$file = new File($filePath);
|
helper('media');
|
||||||
|
$file = new File(media_path($filePath));
|
||||||
|
|
||||||
$video = new Video([
|
$video = new Video([
|
||||||
'file_path' => $filePath,
|
'file_path' => $filePath,
|
||||||
|
@ -35,7 +35,9 @@ class VideoClipper
|
|||||||
|
|
||||||
public bool $error = false;
|
public bool $error = false;
|
||||||
|
|
||||||
public string $videoClipOutput;
|
public string $videoClipFilePath;
|
||||||
|
|
||||||
|
protected string $videoClipOutput;
|
||||||
|
|
||||||
protected float $duration;
|
protected float $duration;
|
||||||
|
|
||||||
@ -95,6 +97,7 @@ class VideoClipper
|
|||||||
$this->subtitlesClipOutput = $podcastFolder . "/{$this->episode->slug}-subtitles-clip-{$this->start}-to-{$this->end}.srt";
|
$this->subtitlesClipOutput = $podcastFolder . "/{$this->episode->slug}-subtitles-clip-{$this->start}-to-{$this->end}.srt";
|
||||||
$this->videoClipBgOutput = $podcastFolder . "/{$this->episode->slug}-clip-bg-{$this->format}-{$this->theme}.png";
|
$this->videoClipBgOutput = $podcastFolder . "/{$this->episode->slug}-clip-bg-{$this->format}-{$this->theme}.png";
|
||||||
$this->videoClipOutput = $podcastFolder . "/{$this->episode->slug}-clip-{$this->start}-to-{$this->end}-{$this->format}-{$this->theme}.mp4";
|
$this->videoClipOutput = $podcastFolder . "/{$this->episode->slug}-clip-{$this->start}-to-{$this->end}-{$this->format}-{$this->theme}.mp4";
|
||||||
|
$this->videoClipFilePath = "podcasts/{$this->episode->podcast->handle}/{$this->episode->slug}-clip-{$this->start}-to-{$this->end}-{$this->format}-{$this->theme}.mp4";
|
||||||
}
|
}
|
||||||
|
|
||||||
public function soundbite(): void
|
public function soundbite(): void
|
||||||
@ -152,6 +155,7 @@ class VideoClipper
|
|||||||
"color=0x{$this->colors['watermarkBg']}:{$this->dimensions['watermark']['width']}x{$this->dimensions['watermark']['height']}[over]",
|
"color=0x{$this->colors['watermarkBg']}:{$this->dimensions['watermark']['width']}x{$this->dimensions['watermark']['height']}[over]",
|
||||||
'[over][watermark]overlay=x=0:y=0:shortest=1[watermark_box]',
|
'[over][watermark]overlay=x=0:y=0:shortest=1[watermark_box]',
|
||||||
"[outv][watermark_box]overlay=x={$this->dimensions['watermark']['x']}:y={$this->dimensions['watermark']['y']}:shortest=1[watermarked]",
|
"[outv][watermark_box]overlay=x={$this->dimensions['watermark']['x']}:y={$this->dimensions['watermark']['y']}:shortest=1[watermarked]",
|
||||||
|
'[watermarked]scale=w=-1:h=-1:out_color_matrix=bt709[outfinal]',
|
||||||
];
|
];
|
||||||
|
|
||||||
$watermark = config('MediaClipper')
|
$watermark = config('MediaClipper')
|
||||||
@ -167,10 +171,10 @@ class VideoClipper
|
|||||||
"-f lavfi -i color=white:{$this->dimensions['width']}x{$this->dimensions['height']}",
|
"-f lavfi -i color=white:{$this->dimensions['width']}x{$this->dimensions['height']}",
|
||||||
"-loop 1 -framerate 1 -i {$watermark}",
|
"-loop 1 -framerate 1 -i {$watermark}",
|
||||||
'-filter_complex "' . implode(';', $filters) . '"',
|
'-filter_complex "' . implode(';', $filters) . '"',
|
||||||
'-map "[watermarked]"',
|
'-map "[outfinal]"',
|
||||||
'-map 0:a',
|
'-map 0:a',
|
||||||
'-acodec copy',
|
'-acodec copy',
|
||||||
'-vcodec libx264rgb',
|
'-vcodec libx264 -pix_fmt yuv420p',
|
||||||
"{$this->videoClipOutput}",
|
"{$this->videoClipOutput}",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -114,6 +114,26 @@ class ClipModel extends Model
|
|||||||
return $found;
|
return $found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getVideoClipById(int $videoClipId): ?VideoClip
|
||||||
|
{
|
||||||
|
$cacheName = "video-clip#{$videoClipId}";
|
||||||
|
if (! ($found = cache($cacheName))) {
|
||||||
|
$clip = $this->find($videoClipId);
|
||||||
|
|
||||||
|
if ($clip === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
$found = new VideoClip($clip->toArray());
|
||||||
|
|
||||||
|
cache()
|
||||||
|
->save($cacheName, $found, DECADE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets all video clips for an episode
|
* Gets all video clips for an episode
|
||||||
*
|
*
|
||||||
|
@ -21,7 +21,7 @@ class Alert extends Component
|
|||||||
{
|
{
|
||||||
$variantClasses = [
|
$variantClasses = [
|
||||||
'default' => 'text-gray-800 bg-gray-100 border-gray-300',
|
'default' => 'text-gray-800 bg-gray-100 border-gray-300',
|
||||||
'success' => 'text-pine-900 bg-pine-100 border-castopod-300',
|
'success' => 'text-pine-900 bg-pine-100 border-pine-300',
|
||||||
'danger' => 'text-red-900 bg-red-100 border-red-300',
|
'danger' => 'text-red-900 bg-red-100 border-red-300',
|
||||||
'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
|
'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
|
||||||
];
|
];
|
||||||
|
@ -22,7 +22,7 @@ class Pill extends Component
|
|||||||
$variantClasses = [
|
$variantClasses = [
|
||||||
'default' => 'text-gray-800 bg-gray-100 border-gray-300',
|
'default' => 'text-gray-800 bg-gray-100 border-gray-300',
|
||||||
'primary' => 'text-accent-contrast bg-accent-base border-accent-base',
|
'primary' => 'text-accent-contrast bg-accent-base border-accent-base',
|
||||||
'success' => 'text-pine-900 bg-pine-100 border-castopod-300',
|
'success' => 'text-pine-900 bg-pine-100 border-pine-300',
|
||||||
'danger' => 'text-red-900 bg-red-100 border-red-300',
|
'danger' => 'text-red-900 bg-red-100 border-red-300',
|
||||||
'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
|
'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
|
||||||
];
|
];
|
||||||
@ -30,7 +30,7 @@ class Pill extends Component
|
|||||||
$icon = $this->icon ? icon($this->icon) : '';
|
$icon = $this->icon ? icon($this->icon) : '';
|
||||||
|
|
||||||
return <<<HTML
|
return <<<HTML
|
||||||
<span class="inline-flex items-center gap-x-1 px-1 font-semibold 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]}">{$icon}{$this->slot}</span>
|
||||||
HTML;
|
HTML;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -379,6 +379,14 @@ $routes->group(
|
|||||||
'filter' => 'permission:podcast_episodes-edit',
|
'filter' => 'permission:podcast_episodes-edit',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
$routes->get(
|
||||||
|
'video-clips/(:num)',
|
||||||
|
'VideoClipsController::view/$1/$2/$3',
|
||||||
|
[
|
||||||
|
'as' => 'video-clip',
|
||||||
|
'filter' => 'permission:podcast_episodes-edit',
|
||||||
|
],
|
||||||
|
);
|
||||||
$routes->get(
|
$routes->get(
|
||||||
'embed',
|
'embed',
|
||||||
'EpisodeController::embed/$1/$2',
|
'EpisodeController::embed/$1/$2',
|
||||||
|
@ -53,7 +53,7 @@ class SchedulerController extends Controller
|
|||||||
|
|
||||||
if ($exitCode === 0) {
|
if ($exitCode === 0) {
|
||||||
// success, video was generated
|
// success, video was generated
|
||||||
$scheduledClip->setMedia($clipper->videoClipOutput);
|
$scheduledClip->setMedia($clipper->videoClipFilePath);
|
||||||
(new ClipModel())->update($scheduledClip->id, [
|
(new ClipModel())->update($scheduledClip->id, [
|
||||||
'media_id' => $scheduledClip->media_id,
|
'media_id' => $scheduledClip->media_id,
|
||||||
'status' => 'passed',
|
'status' => 'passed',
|
||||||
|
@ -87,6 +87,24 @@ class VideoClipsController extends BaseController
|
|||||||
return view('episode/video_clips_list', $data);
|
return view('episode/video_clips_list', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function view($videoClipId): string
|
||||||
|
{
|
||||||
|
$videoClip = (new ClipModel())->getVideoClipById((int) $videoClipId);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'podcast' => $this->podcast,
|
||||||
|
'episode' => $this->episode,
|
||||||
|
'videoClip' => $videoClip,
|
||||||
|
];
|
||||||
|
|
||||||
|
replace_breadcrumb_params([
|
||||||
|
0 => $this->podcast->title,
|
||||||
|
1 => $this->episode->title,
|
||||||
|
2 => $videoClip->label,
|
||||||
|
]);
|
||||||
|
return view('episode/video_clip', $data);
|
||||||
|
}
|
||||||
|
|
||||||
public function generate(): string
|
public function generate(): string
|
||||||
{
|
{
|
||||||
helper('form');
|
helper('form');
|
||||||
|
31
themes/cp_admin/episode/video_clip.php
Normal file
31
themes/cp_admin/episode/video_clip.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?= $this->extend('_layout') ?>
|
||||||
|
|
||||||
|
<?= $this->section('title') ?>
|
||||||
|
<?= lang('Episode.video_clips.title', [
|
||||||
|
'videoClipLabel' => $videoClip->label,
|
||||||
|
]) ?>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('pageTitle') ?>
|
||||||
|
<?= lang('Episode.video_clips.title', [
|
||||||
|
'videoClipLabel' => $videoClip->label,
|
||||||
|
]) ?>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<?php if ($videoClip->media): ?>
|
||||||
|
<video controls class="bg-black h-80 aspect-video">
|
||||||
|
<source src="<?= $videoClip->media->file_url ?>" type="<?= $videoClip->media->file_mimetype ?>">
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($videoClip->logs): ?>
|
||||||
|
<details class="w-full mt-8 overflow-hidden text-white bg-black border rounded shadow-sm">
|
||||||
|
<summary class="px-4 py-2 font-semibold text-black bg-white"><?= lang('VideoClip.logs') ?></summary>
|
||||||
|
<pre class="p-4 text-sm whitespace-pre-wrap"><?= $videoClip->logs ?></pre>
|
||||||
|
</details>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
@ -39,17 +39,17 @@
|
|||||||
'header' => lang('VideoClip.list.label'),
|
'header' => lang('VideoClip.list.label'),
|
||||||
'cell' => function ($videoClip): string {
|
'cell' => function ($videoClip): string {
|
||||||
$formatClass = [
|
$formatClass = [
|
||||||
'landscape' => 'aspect-video h-4',
|
'landscape' => 'aspect-video',
|
||||||
'portrait' => 'aspect-[9/16] w-4',
|
'portrait' => 'aspect-[9/16]',
|
||||||
'squared' => 'aspect-square h-6',
|
'squared' => 'aspect-square',
|
||||||
];
|
];
|
||||||
return '<a href="#" class="inline-flex items-center w-full hover:underline gap-x-2"><span class="block w-3 h-3 rounded-full" data-tooltip="bottom" title="' . $videoClip->theme['name'] . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><span class="flex items-center justify-center text-white bg-gray-400 rounded-sm ' . $formatClass[$videoClip->format] . '" data-tooltip="bottom" title="' . $videoClip->format . '"><Icon glyph="play"/></span>' . $videoClip->label . '</a>';
|
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>';
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'header' => lang('VideoClip.list.clip_id'),
|
'header' => lang('VideoClip.list.clip_id'),
|
||||||
'cell' => function ($videoClip): string {
|
'cell' => function ($videoClip): string {
|
||||||
return '#' . $videoClip->id . ' by ' . $videoClip->user->username;
|
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>';
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
Loading…
Reference in New Issue
Block a user